├── .buildkite
├── pipeline.yml
└── shared-pipeline-vars
├── .eslintrc.cjs
├── .github
├── ISSUE_TEMPLATE
│ ├── bug.yml
│ └── feature_request.md
└── pull_request_template.md
├── .gitignore
├── .java-version
├── .nvmrc
├── .prettierignore
├── .prettierrc.cjs
├── .xcode-version
├── Makefile
├── Package.swift
├── README.md
├── __mocks__
└── @wordpress
│ ├── components
│ └── index.jsx
│ ├── hooks.js
│ └── i18n
│ └── index.js
├── android
├── Gutenberg
│ ├── .gitignore
│ ├── build.gradle.kts
│ ├── consumer-rules.pro
│ ├── proguard-rules.pro
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── org
│ │ │ └── wordpress
│ │ │ └── gutenberg
│ │ │ └── ExampleInstrumentedTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ │ └── org
│ │ │ └── wordpress
│ │ │ └── gutenberg
│ │ │ ├── EditorConfiguration.kt
│ │ │ ├── GutenbergJsException.kt
│ │ │ ├── GutenbergRequestInterceptor.kt
│ │ │ └── GutenbergView.kt
│ │ └── test
│ │ └── java
│ │ └── org
│ │ └── wordpress
│ │ └── gutenberg
│ │ ├── EditorConfigurationTest.kt
│ │ ├── ExampleUnitTest.kt
│ │ └── GutenbergViewTest.kt
├── app
│ ├── build.gradle.kts
│ ├── proguard-rules.pro
│ └── src
│ │ ├── androidTest
│ │ └── java
│ │ │ └── com
│ │ │ └── example
│ │ │ └── gutenbergkit
│ │ │ └── ExampleInstrumentedTest.kt
│ │ ├── main
│ │ ├── AndroidManifest.xml
│ │ ├── java
│ │ │ └── com
│ │ │ │ └── example
│ │ │ │ └── gutenbergkit
│ │ │ │ └── MainActivity.kt
│ │ └── res
│ │ │ ├── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ │ ├── drawable
│ │ │ └── ic_launcher_background.xml
│ │ │ ├── layout
│ │ │ └── activity_main.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ │ ├── values-night
│ │ │ └── themes.xml
│ │ │ ├── values
│ │ │ ├── colors.xml
│ │ │ ├── strings.xml
│ │ │ └── themes.xml
│ │ │ └── xml
│ │ │ ├── backup_rules.xml
│ │ │ ├── data_extraction_rules.xml
│ │ │ └── network_security_config.xml
│ │ └── test
│ │ └── java
│ │ └── com
│ │ └── example
│ │ └── gutenbergkit
│ │ └── ExampleUnitTest.kt
├── build.gradle.kts
├── gradle.properties
├── gradle
│ ├── libs.versions.toml
│ └── wrapper
│ │ ├── gradle-wrapper.jar
│ │ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
├── bin
└── prep-translations.js
├── docs
├── example-xcode-env-variable.png
├── gutenberg-kit-preview.png
└── test-cases.md
├── ios
├── Demo-iOS
│ ├── Gutenberg.xcodeproj
│ │ ├── project.pbxproj
│ │ ├── project.xcworkspace
│ │ │ ├── contents.xcworkspacedata
│ │ │ └── xcshareddata
│ │ │ │ └── IDEWorkspaceChecks.plist
│ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── Gutenberg.xcscheme
│ ├── PreviewContent
│ │ └── Preview Assets.xcassets
│ │ │ └── Contents.json
│ ├── Resources
│ │ └── Assets.xcassets
│ │ │ ├── AccentColor.colorset
│ │ │ └── Contents.json
│ │ │ ├── AppIcon.appiconset
│ │ │ └── Contents.json
│ │ │ └── Contents.json
│ └── Sources
│ │ ├── ContentView.swift
│ │ ├── EditorView.swift
│ │ └── GutenbergApp.swift
├── Sources
│ └── GutenbergKit
│ │ ├── Gutenberg
│ │ ├── assets
│ │ │ ├── ar-CyYCT0Yn.js
│ │ │ ├── bg-B2djTlFt.js
│ │ │ ├── bo-DOe3GxAO.js
│ │ │ ├── bridge-COXq4kB3.js
│ │ │ ├── ca--D3R8toT.js
│ │ │ ├── cs-DWmcEIfO.js
│ │ │ ├── cy-CGIlBSk6.js
│ │ │ ├── da-DwB1fLza.js
│ │ │ ├── de-H01QIQZg.js
│ │ │ ├── editor-BBJZD2BL.css
│ │ │ ├── editor-C-URa0vU.js
│ │ │ ├── editor-DWxDd151.js
│ │ │ ├── el-DhdK7rMw.js
│ │ │ ├── en-au-CPC6XjFj.js
│ │ │ ├── en-ca-VNTKwdAC.js
│ │ │ ├── en-gb-DURVrT_X.js
│ │ │ ├── en-nz-Dm2aq_Nd.js
│ │ │ ├── en-za-BYpQIJT9.js
│ │ │ ├── es-BJ3q4Wbp.js
│ │ │ ├── es-ar-DaruD1vk.js
│ │ │ ├── es-cl-BnB-IkOa.js
│ │ │ ├── es-cr-BY95D2Di.js
│ │ │ ├── fa-ByB6Bbvh.js
│ │ │ ├── fr-HxuvMa7s.js
│ │ │ ├── gl-McDu_gir.js
│ │ │ ├── he-4TZWuuaG.js
│ │ │ ├── hr-DOZGX9Ap.js
│ │ │ ├── hu-BCFI5Ug4.js
│ │ │ ├── id-BlpIZgTK.js
│ │ │ ├── index-Cz5GNwXH.js
│ │ │ ├── index-OGmBC7ij.css
│ │ │ ├── is-u59YF_6S.js
│ │ │ ├── it-OacWAa4D.js
│ │ │ ├── ja-eaKkqtgl.js
│ │ │ ├── ka-S_it2R8x.js
│ │ │ ├── ko-lhFU0drW.js
│ │ │ ├── localization-C-KsbNHa.js
│ │ │ ├── logger-B2MeKcsF.js
│ │ │ ├── nb-B4NppxVr.js
│ │ │ ├── nl-DjtAcROr.js
│ │ │ ├── nl-be-BLey3_eV.js
│ │ │ ├── pl-BcZoHphp.js
│ │ │ ├── pt-Pa9447RD.js
│ │ │ ├── pt-br-B3s-WxNp.js
│ │ │ ├── remote-3JsY0MSr.js
│ │ │ ├── remote-CQD0zuAs.css
│ │ │ ├── ro-D4eP2fbK.js
│ │ │ ├── ru-DgpwNl7b.js
│ │ │ ├── sk-C1b2igZL.js
│ │ │ ├── sq-CnDv6A4t.js
│ │ │ ├── sr-CtYHP_-W.js
│ │ │ ├── sv-Ffrez4Bf.js
│ │ │ ├── th-SkpHtVrn.js
│ │ │ ├── tr-By6oiZsW.js
│ │ │ ├── uk-TWibncG9.js
│ │ │ ├── ur-CSeJ9e2h.js
│ │ │ ├── vi-BO1ox2QA.js
│ │ │ ├── vite-helpers-C1_kvBzA.js
│ │ │ ├── zh-cn-B9xnqhUX.js
│ │ │ └── zh-tw-DNHfmYCf.js
│ │ ├── index.html
│ │ └── remote.html
│ │ └── Sources
│ │ ├── AnyDecodable.swift
│ │ ├── EditorBlock.swift
│ │ ├── EditorBlockPicker.swift
│ │ ├── EditorConfiguration.swift
│ │ ├── EditorJSMessage.swift
│ │ ├── EditorNetworking.swift
│ │ ├── EditorService.swift
│ │ ├── EditorTypes.swift
│ │ ├── EditorViewController.swift
│ │ ├── EditorViewControllerDelegate.swift
│ │ └── GBWebView.swift
└── Tests
│ └── GutenbergKitTests
│ ├── EditorConfigurationTests.swift
│ └── GutenbergKitTests.swift
├── package-lock.json
├── package.json
├── patches
├── @wordpress+block-editor+14.17.0.patch
├── @wordpress+components+29.8.0.patch
├── @wordpress+rich-text+7.22.0.patch
└── README.md
├── src
├── components
│ ├── default-block-appender
│ │ ├── index.jsx
│ │ └── style.scss
│ ├── editor-load-error
│ │ ├── index.jsx
│ │ └── style.scss
│ ├── editor-load-notice.jsx
│ ├── editor-load-notice.test.jsx
│ ├── editor-toolbar
│ │ ├── aria-helper.js
│ │ ├── index.jsx
│ │ ├── style.scss
│ │ └── use-modalize.js
│ ├── editor
│ │ ├── index.jsx
│ │ ├── style.scss
│ │ ├── test
│ │ │ └── use-media-upload.test.jsx
│ │ ├── use-editor-setup.js
│ │ ├── use-host-bridge.js
│ │ ├── use-host-exception-logging.js
│ │ ├── use-media-upload.js
│ │ └── use-sync-history-controls.js
│ ├── layout
│ │ ├── index.jsx
│ │ └── style.scss
│ ├── text-editor
│ │ ├── index.jsx
│ │ └── style.scss
│ └── visual-editor
│ │ ├── default-theme-styles.scss
│ │ ├── index.jsx
│ │ ├── style.scss
│ │ ├── use-editor-styles.js
│ │ ├── use-editor-visible.js
│ │ └── wp-common-styles.scss
├── index.html
├── index.jsx
├── index.scss
├── lock-unlock.js
├── remote.html
├── remote.jsx
└── utils
│ ├── api-fetch.js
│ ├── blocks.js
│ ├── bridge.js
│ ├── editor.jsx
│ ├── exception-parser.js
│ ├── localization.js
│ ├── logger.js
│ ├── post-type-entities.js
│ ├── remote-editor.js
│ └── stack-parsers.js
├── vite.config.js
├── vite.config.remote.js
├── vitest.config.js
└── vitest.setup.js
/.buildkite/pipeline.yml:
--------------------------------------------------------------------------------
1 | agents:
2 | queue: mac
3 |
4 | env:
5 | IMAGE_ID: $IMAGE_ID
6 |
7 | steps:
8 | - label: ':react: Build React App'
9 | command: make build
10 | plugins: &plugins
11 | - $CI_TOOLKIT_PLUGIN
12 | - $NVM_PLUGIN
13 |
14 | - label: ':eslint: Lint React App'
15 | command: make lint-js
16 | plugins: *plugins
17 |
18 | - label: ':javascript: Test JavaScript'
19 | command: make test-js
20 | plugins: *plugins
21 |
22 | - label: ':android: Publish Android Library'
23 | command: |
24 | make build
25 | echo "--- :android: Publishing Android Library"
26 | ./android/gradlew -p ./android :gutenberg:prepareToPublishToS3 $(prepare_to_publish_to_s3_params) :gutenberg:publish
27 | agents:
28 | queue: android
29 | plugins: *plugins
30 |
31 | - label: ':android: Test Android Library'
32 | command: make test-android
33 | agents:
34 | queue: android
35 | plugins: *plugins
36 |
37 | - label: ':swift: Test Swift Package'
38 | command: make test-swift-package
39 | plugins: *plugins
40 |
--------------------------------------------------------------------------------
/.buildkite/shared-pipeline-vars:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # This file is `source`'d before calling `buildkite-agent pipeline upload`, and can be used
4 | # to set up some variables that will be interpolated in the `.yml` pipeline before uploading it.
5 |
6 | # The ~> modifier is not currently used, but we check for it just in case
7 | XCODE_VERSION=$(sed 's/^~> *//' .xcode-version)
8 | CI_TOOLKIT_PLUGIN_VERSION="3.5.1"
9 | NVM_PLUGIN_VERSION='0.3.0'
10 |
11 | export IMAGE_ID="xcode-$XCODE_VERSION"
12 | export CI_TOOLKIT_PLUGIN="automattic/a8c-ci-toolkit#$CI_TOOLKIT_PLUGIN_VERSION"
13 | export NVM_PLUGIN="automattic/nvm#$NVM_PLUGIN_VERSION"
14 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: [ 'plugin:@wordpress/eslint-plugin/recommended' ],
4 | env: {
5 | browser: true,
6 | es2020: true,
7 | },
8 | ignorePatterns: [ 'android', 'dist', 'ios' ],
9 | parserOptions: {
10 | ecmaVersion: 'latest',
11 | sourceType: 'module',
12 | },
13 | plugins: [ 'react-refresh' ],
14 | rules: {
15 | 'react-refresh/only-export-components': [
16 | 'warn',
17 | { allowConstantExport: true },
18 | ],
19 | },
20 | };
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug.yml:
--------------------------------------------------------------------------------
1 | name: Bug report
2 | description: Report a bug with the WordPress block editor or Gutenberg plugin
3 | type: Bug
4 | labels: ['[Type] Bug']
5 | body:
6 | - type: markdown
7 | attributes:
8 | value: |
9 | Thanks for taking the time to fill out this bug report! If this is a security issue, please report it in HackerOne instead: https://hackerone.com/wordpress
10 | - type: textarea
11 | attributes:
12 | label: Description
13 | description: Please write a brief description of the bug, including what you expect to happen and what is currently happening.
14 | placeholder: |
15 | Feature '...' is not working properly. I expect '...' to happen, but '...' happens instead
16 | validations:
17 | required: true
18 |
19 | - type: textarea
20 | attributes:
21 | label: Step-by-step reproduction instructions
22 | description: Please write the steps needed to reproduce the bug.
23 | placeholder: |
24 | 1. Go to '...'
25 | 2. Click on '...'
26 | 3. Scroll down to '...'
27 | validations:
28 | required: true
29 |
30 | - type: textarea
31 | attributes:
32 | label: Screenshots, screen recording, code snippet
33 | description: |
34 | If possible, please upload a screenshot or screen recording which demonstrates the bug. You can use LIEcap to create a GIF screen recording: https://www.cockos.com/licecap/
35 | Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
36 | If this bug is to related to a developer API, please share a code snippet that demonstrates the issue. For small snippets paste it directly here, or you can use GitHub Gist to share multiple code files: https://gist.github.com
37 | Please ensure the shared code can be used by a developer to reproduce the issue—ideally it can be copied into a local development environment or executed in a browser console to help debug the issue
38 | validations:
39 | required: false
40 |
41 | - type: textarea
42 | attributes:
43 | label: Environment info
44 | description: |
45 | Please list what Gutenberg version you are using. If you aren't using Gutenberg, please note that it's not installed.
46 | placeholder: |
47 | - WordPress version, Gutenberg version, and active Theme you are using.
48 | - Browser(s) are you seeing the problem on.
49 | - Device you are using and operating system (e.g. "Desktop with Windows 10", "iPhone with iOS 14", etc.).
50 | validations:
51 | required: false
52 |
53 | - type: checkboxes
54 | id: existing
55 | attributes:
56 | label: Please confirm that you have searched existing issues in the repo.
57 | description: You can do this by searching https://github.com/WordPress/gutenberg/issues and making sure the bug is not related to another plugin.
58 | options:
59 | - label: 'Yes'
60 | required: true
61 |
62 | - type: checkboxes
63 | id: plugins
64 | attributes:
65 | label: Please confirm that you have tested with all plugins deactivated except Gutenberg.
66 | options:
67 | - label: 'Yes'
68 | required: true
69 |
70 | - type: checkboxes
71 | id: themes
72 | attributes:
73 | label: Please confirm which theme type you used for testing.
74 | options:
75 | - label: 'Block'
76 | - label: 'Classic'
77 | - label: 'Hybrid (e.g. classic with theme.json)'
78 | - label: 'Not sure'
79 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Propose an idea for a feature or an enhancement
4 | type: Feature
5 | labels: "[Type] Enhancement"
6 | ---
7 |
8 | ## What problem does this address?
9 |
14 |
15 | ## What is your proposed solution?
16 |
20 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
3 |
4 | ## What?
5 |
6 |
7 | ## Why?
8 |
9 |
10 | ## How?
11 |
12 |
13 | ## Testing Instructions
14 |
15 |
16 |
17 |
18 |
19 | ### Accessibility Testing Instructions
20 |
21 |
22 | ## Screenshots or screencast
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## System
2 | .DS_Store
3 |
4 | ## Build generated
5 | build/
6 | DerivedData
7 | .swiftpm/
8 | UserInterfaceState.xcuserstate
9 |
10 | ## Various settings
11 | *.pbxuser
12 | !default.pbxuser
13 | *.mode1v3
14 | !default.mode1v3
15 | *.mode2v3
16 | !default.mode2v3
17 | *.perspectivev3
18 | !default.perspectivev3
19 | xcuserdata
20 |
21 | ## Other
22 | *.xccheckout
23 | *.moved-aside
24 | *.xcuserstate
25 | *.xcscmblueprint
26 |
27 | ## Obj-C/Swift specific
28 | *.hmap
29 | *.ipa
30 |
31 | ## Playgrounds
32 | timeline.xctimeline
33 | playground.xcworkspace
34 |
35 |
36 | ## Swift Package Manager
37 | #
38 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
39 | # Packages/
40 |
41 | .build/
42 | .swiftpm
43 |
44 | # Logs
45 | logs
46 | *.log
47 | npm-debug.log*
48 | yarn-debug.log*
49 | yarn-error.log*
50 | lerna-debug.log*
51 | .pnpm-debug.log*
52 |
53 | # Diagnostic reports (https://nodejs.org/api/report.html)
54 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
55 |
56 | # Runtime data
57 | pids
58 | *.pid
59 | *.seed
60 | *.pid.lock
61 |
62 | # Directory for instrumented libs generated by jscoverage/JSCover
63 | lib-cov
64 |
65 | # Coverage directory used by tools like istanbul
66 | coverage
67 | *.lcov
68 |
69 | # nyc test coverage
70 | .nyc_output
71 |
72 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
73 | .grunt
74 |
75 | # Bower dependency directory (https://bower.io/)
76 | bower_components
77 |
78 | # node-waf configuration
79 | .lock-wscript
80 |
81 | # Dependency directories
82 | node_modules/
83 | jspm_packages/
84 |
85 | # Snowpack dependency directory (https://snowpack.dev/)
86 | web_modules/
87 |
88 | # TypeScript cache
89 | *.tsbuildinfo
90 |
91 | # Optional npm cache directory
92 | .npm
93 |
94 | # Optional eslint cache
95 | .eslintcache
96 |
97 | # Optional stylelint cache
98 | .stylelintcache
99 |
100 | # Microbundle cache
101 | .rpt2_cache/
102 | .rts2_cache_cjs/
103 | .rts2_cache_es/
104 | .rts2_cache_umd/
105 |
106 | # Optional REPL history
107 | .node_repl_history
108 |
109 | # Output of 'npm pack'
110 | *.tgz
111 |
112 | # Yarn Integrity file
113 | .yarn-integrity
114 |
115 | # dotenv environment variable files
116 | .env
117 | .env.development.local
118 | .env.test.local
119 | .env.production.local
120 | .env.local
121 |
122 | # parcel-bundler cache (https://parceljs.org/)
123 | .cache
124 | .parcel-cache
125 |
126 | # Next.js build output
127 | .next
128 | out
129 |
130 | # Nuxt.js build / generate output
131 | .nuxt
132 | dist
133 |
134 | # Gatsby files
135 | .cache/
136 | # Comment in the public line in if your project uses Gatsby and not Next.js
137 | # https://nextjs.org/blog/next-9-1#public-directory-support
138 | # public
139 |
140 | # vuepress build output
141 | .vuepress/dist
142 |
143 | # vuepress v2.x temp and cache directory
144 | .temp
145 | .cache
146 |
147 | # Docusaurus cache and generated files
148 | .docusaurus
149 |
150 | # Serverless directories
151 | .serverless/
152 |
153 | # FuseBox cache
154 | .fusebox/
155 |
156 | # DynamoDB Local files
157 | .dynamodb/
158 |
159 | # TernJS port file
160 | .tern-port
161 |
162 | # Editor directories and files
163 | .vscode/*
164 | !.vscode/extensions.json
165 | .vscode-test
166 | .idea
167 | .DS_Store
168 | *.suo
169 | *.ntvs*
170 | *.njsproj
171 | *.sln
172 | *.sw?
173 |
174 | # yarn v2
175 | .yarn/cache
176 | .yarn/unplugged
177 | .yarn/build-state.yml
178 | .yarn/install-state.gz
179 | .pnp.*
180 |
181 | # Android App
182 | local.properties
183 | /android/build
184 | /android/.gradle
185 | /android/.idea
186 |
187 | ## Production Build Products
188 | /android/Gutenberg/src/main/assets/assets
189 | /android/Gutenberg/src/main/assets/index.html
190 | /android/Gutenberg/src/main/assets/remote.html
191 |
192 | # Disabled removing these files until this is published like Android in CI.
193 | # /ios/Sources/GutenbergKit/Gutenberg/assets
194 | # /ios/Sources/GutenbergKit/Gutenberg/index.html
195 | # /ios/Sources/GutenbergKit/Gutenberg/remote.html
196 |
197 | # Translation files
198 | src/translations/
199 |
--------------------------------------------------------------------------------
/.java-version:
--------------------------------------------------------------------------------
1 | 17.0
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | android
2 | ios
3 | package-lock.json
4 | .github/**/*.md
5 |
--------------------------------------------------------------------------------
/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = require( '@wordpress/prettier-config' );
2 |
--------------------------------------------------------------------------------
/.xcode-version:
--------------------------------------------------------------------------------
1 | 15.4
2 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | SIMULATOR_DESTINATION := OS=17.5,name=iPhone 15 Plus
2 |
3 | define XCODEBUILD_CMD
4 | @set -o pipefail && \
5 | xcodebuild $(1) \
6 | -scheme GutenbergKit \
7 | -sdk iphonesimulator \
8 | -destination '${SIMULATOR_DESTINATION}' \
9 | | xcbeautify
10 | endef
11 |
12 | npm-dependencies:
13 | @if [ "$(SKIP_DEPS)" != "true" ] && [ "$(SKIP_DEPS)" != "1" ]; then \
14 | echo "--- :npm: Installing NPM Dependencies"; \
15 | npm ci; \
16 | fi
17 |
18 | prep-translations:
19 | @if [ "$(SKIP_L10N)" != "true" ] && [ "$(SKIP_L10N)" != "1" ]; then \
20 | echo "--- :npm: Preparing Translations"; \
21 | npm run prep-translations -- --force; \
22 | fi
23 |
24 | build: npm-dependencies prep-translations
25 | echo "--- :node: Building Gutenberg"
26 |
27 | npm run build
28 |
29 | # Copy build products into place
30 | echo "--- :open_file_folder: Copying Build Products into place"
31 | rm -rf ./ios/Sources/GutenbergKit/Gutenberg/ ./android/Gutenberg/src/main/assets/
32 | cp -r ./dist/. ./ios/Sources/GutenbergKit/Gutenberg/
33 | cp -r ./dist/. ./android/Gutenberg/src/main/assets
34 |
35 | dev-server: npm-dependencies
36 | npm run dev
37 |
38 | dev-server-remote: npm-dependencies
39 | npm run dev:remote
40 |
41 | fmt-js: npm-dependencies
42 | npm run format
43 |
44 | lint-js: npm-dependencies
45 | npm run lint
46 |
47 | test-js: npm-dependencies
48 | npm run test -- run
49 |
50 | local-android-library: build
51 | echo "--- :android: Building Library"
52 | ./android/gradlew -p ./android :gutenberg:publishToMavenLocal -exclude-task prepareToPublishToS3
53 |
54 | test-android:
55 | echo "--- :android: Running Android Tests"
56 | ./android/gradlew -p ./android :gutenberg:test
57 |
58 | build-swift-package: build
59 | $(call XCODEBUILD_CMD, build)
60 |
61 | test-swift-package: build
62 | $(call XCODEBUILD_CMD, test)
63 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version: 5.9
2 | // The swift-tools-version declares the minimum version of Swift required to build this package.
3 |
4 | import PackageDescription
5 |
6 | let package = Package(
7 | name: "GutenbergKit",
8 | platforms: [.iOS(.v15), .macOS(.v14)],
9 | products: [
10 | .library(name: "GutenbergKit", targets: ["GutenbergKit"])
11 | ],
12 | targets: [
13 | .target(
14 | name: "GutenbergKit",
15 | dependencies: [],
16 | path: "ios/Sources/GutenbergKit",
17 | exclude: [],
18 | resources: [.copy("Gutenberg")]
19 | ),
20 | .testTarget(
21 | name: "GutenbergKitTests",
22 | dependencies: ["GutenbergKit"],
23 | path: "ios/Tests",
24 | exclude: []
25 | )
26 | ]
27 | )
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GutenbergKit
2 |
3 | An experimental Gutenberg block editor for native iOS and Android apps relying upon web technologies.
4 |
5 |
6 |
7 | ## Development
8 |
9 | ### Preqrequisites
10 |
11 | In order to build GutenbergKit, the following tools must be installed on your development machine:
12 |
13 | - [Node.js](https://nodejs.org/en/download/) - Required for building the web app; recommend using [Node Version Manager](https://github.com/nvm-sh/nvm).
14 | - [Xcode](https://developer.apple.com/xcode/) - Required if building iOS demo app.
15 | - [Android Studio](https://developer.android.com/studio) - Required if building Android demo app.
16 |
17 | ### Web App
18 |
19 | Install the GutenbergKit dependencies and start the development server by running the following command in your terminal:
20 |
21 | ```bash
22 | make dev-server
23 | ```
24 |
25 | Once finished, the web app can now be accessed in your browser by visiting the URL logged in your terminal. However, it is recommended to use a native host app for testing the changes made to the editor for a more realistic experience. A demo app is included in the GutenbergKit package, along with instructions on how to use it below.
26 |
27 | ### Demo App
28 |
29 | This demo app is useful for quickly testing changes made to the editor. By default, the demo app uses a production build of the web app bundled with the GutenbergKit package—i.e., the output of the project's `make build` command. During development, however, it is more useful to run the web app with a server and provide the server URL as an environment variable for the demo app, so that changes are displayed in the app immediately.
30 |
31 | #### iOS
32 |
33 | 1. Start the development server by running `make dev-server`.
34 | 1. Launch Xcode and open the `ios/Demo-iOS/Gutenberg.xcodeproj` project.
35 | 1. Select the `Gutenberg` target.
36 | 1. Navigate to _Product_ → _Scheme_ → _Edit Scheme_.
37 | 1. Add an environment variable named `GUTENBERG_EDITOR_URL` with the URL of the development server.
38 | 1. Run the app.
39 |
40 |
41 | Example Xcode environment variable
42 |
43 |
44 |
45 |
46 |
47 | #### Android
48 |
49 | 1. Start the development server by running `make dev-server`.
50 | 1. Launch Android Studio and open the `android` project.
51 | 1. Modify the `android/local.properties` file to include an environment variable named `GUTENBERG_EDITOR_URL` with the URL of the development server.
52 | 1. Run the app.
53 |
54 |
55 | Example Android local.properties
56 |
57 | ```
58 | GUTENBERG_EDITOR_URL=http://:5173/
59 | ```
60 |
61 |
62 |
63 | ## Testing
64 |
65 | To run the JavaScript tests, run the following command in your terminal:
66 |
67 | ```bash
68 | make test-js
69 | ```
70 |
71 | To run the Swift tests, run the following command in your terminal:
72 |
73 | ```bash
74 | make test-swift
75 | ```
76 |
77 | To run the Android tests, run the following command in your terminal:
78 |
79 | ```bash
80 | make test-android
81 | ```
82 |
83 | ## Production
84 |
85 | To build GutenbergKit for production run the following command in your terminal:
86 |
87 | ```bash
88 | make build
89 | ```
90 |
91 | Once finished, the Swift and Kotlin packages are ready to publish. Consuming iOS or Android host apps can then include the GutenbergKit package as a dependency.
92 |
--------------------------------------------------------------------------------
/__mocks__/@wordpress/components/index.jsx:
--------------------------------------------------------------------------------
1 | export const Notice = ( { children, actions } ) => (
2 |
3 | { children }
4 | { actions?.map( ( action, index ) => (
5 |
10 | { action.label }
11 |
12 | ) ) }
13 |
14 | );
15 |
--------------------------------------------------------------------------------
/__mocks__/@wordpress/hooks.js:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 |
3 | export const addFilter = vi.fn();
4 | export const removeFilter = vi.fn();
5 |
--------------------------------------------------------------------------------
/__mocks__/@wordpress/i18n/index.js:
--------------------------------------------------------------------------------
1 | import { vi } from 'vitest';
2 |
3 | export const __ = vi.fn( ( text ) => text );
4 |
--------------------------------------------------------------------------------
/android/Gutenberg/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/android/Gutenberg/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | id("com.automattic.android.publish-to-s3")
5 | id("kotlin-parcelize")
6 | }
7 |
8 | android {
9 | namespace = "org.wordpress.gutenberg"
10 | compileSdk = 34
11 |
12 | buildFeatures {
13 | buildConfig = true
14 | }
15 |
16 | defaultConfig {
17 | minSdk = 22
18 |
19 | buildConfigField(
20 | "String",
21 | "GUTENBERG_EDITOR_URL",
22 | "\"${rootProject.ext["gutenbergEditorUrl"] ?: ""}\""
23 | )
24 |
25 | buildConfigField(
26 | "String",
27 | "GUTENBERG_EDITOR_REMOTE_URL",
28 | "\"${rootProject.ext["gutenbergEditorRemoteUrl"] ?: ""}\""
29 | )
30 |
31 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 | consumerProguardFiles("consumer-rules.pro")
33 | }
34 |
35 | buildTypes {
36 | release {
37 | isMinifyEnabled = false
38 | proguardFiles(
39 | getDefaultProguardFile("proguard-android-optimize.txt"),
40 | "proguard-rules.pro"
41 | )
42 | }
43 | }
44 | compileOptions {
45 | sourceCompatibility = JavaVersion.VERSION_1_8
46 | targetCompatibility = JavaVersion.VERSION_1_8
47 | }
48 | kotlinOptions {
49 | jvmTarget = "1.8"
50 | }
51 | }
52 |
53 | dependencies {
54 |
55 | implementation(libs.androidx.core.ktx)
56 | implementation(libs.androidx.appcompat)
57 | implementation(libs.material)
58 | implementation(libs.androidx.webkit)
59 | implementation(libs.gson)
60 |
61 | testImplementation(libs.junit)
62 | testImplementation(libs.mockito.core)
63 | testImplementation(libs.mockito.kotlin)
64 | testImplementation(libs.robolectric)
65 | androidTestImplementation(libs.androidx.junit)
66 | androidTestImplementation(libs.androidx.espresso.core)
67 | }
68 |
69 | project.afterEvaluate {
70 | publishing {
71 | publications {
72 | create("maven") {
73 | from(components["release"])
74 |
75 | groupId = "org.wordpress.gutenbergkit"
76 | artifactId = "android"
77 | // version is set by 'publish-to-s3' plugin
78 | }
79 | }
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/android/Gutenberg/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/Gutenberg/consumer-rules.pro
--------------------------------------------------------------------------------
/android/Gutenberg/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/android/Gutenberg/src/androidTest/java/org/wordpress/gutenberg/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package org.wordpress.gutenberg
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("org.wordpress.gutenberg.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/android/Gutenberg/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergJsException.kt:
--------------------------------------------------------------------------------
1 | package org.wordpress.gutenberg
2 |
3 | import com.google.gson.Gson
4 | import com.google.gson.JsonObject
5 |
6 | data class JsExceptionStackTraceElement (
7 | val fileName: String?,
8 | val lineNumber: Int?,
9 | val colNumber: Int?,
10 | val function: String,
11 | )
12 | class GutenbergJsException (
13 | val type: String,
14 | val message: String,
15 | var stackTrace: List,
16 | val context: Map = emptyMap(),
17 | val tags: Map = emptyMap(),
18 | val isHandled: Boolean,
19 | val handledBy: String
20 | ) {
21 |
22 | companion object {
23 | @JvmStatic
24 | fun fromString(exceptionString: String): GutenbergJsException {
25 | val gson = Gson()
26 | val rawException = gson.fromJson(exceptionString, JsonObject::class.java)
27 |
28 | val type = rawException.get("type")?.asString ?: ""
29 | val message = rawException.get("message")?.asString ?: ""
30 |
31 | val stackTrace = rawException.getAsJsonArray("stacktrace")?.map { element ->
32 | val stackTraceElement = element.asJsonObject
33 | val stackTraceFunction = stackTraceElement.get("function")?.asString
34 | stackTraceFunction?.let {
35 | JsExceptionStackTraceElement(
36 | stackTraceElement.get("filename")?.asString,
37 | stackTraceElement.get("lineno")?.asInt,
38 | stackTraceElement.get("colno")?.asInt,
39 | stackTraceFunction
40 | )
41 | }
42 | }?.filterNotNull() ?: emptyList()
43 |
44 | val context = rawException.getAsJsonObject("context")?.entrySet()?.associate {
45 | it.key to it.value.asString
46 | } ?: emptyMap()
47 |
48 | val tags = rawException.getAsJsonObject("tags")?.entrySet()?.associate {
49 | it.key to it.value.asString
50 | } ?: emptyMap()
51 |
52 | val isHandled = rawException.get("isHandled")?.asBoolean ?: false
53 | val handledBy = rawException.get("handledBy")?.asString ?: ""
54 |
55 | return GutenbergJsException(
56 | type,
57 | message,
58 | stackTrace,
59 | context,
60 | tags,
61 | isHandled,
62 | handledBy
63 | )
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergRequestInterceptor.kt:
--------------------------------------------------------------------------------
1 | package org.wordpress.gutenberg
2 |
3 | import android.webkit.WebResourceRequest
4 | import android.webkit.WebResourceResponse
5 |
6 | public interface GutenbergRequestInterceptor {
7 | fun canIntercept(request: WebResourceRequest): Boolean
8 | fun handleRequest(request: WebResourceRequest): WebResourceResponse?
9 | }
10 |
11 | class DefaultGutenbergRequestInterceptor: GutenbergRequestInterceptor {
12 | override fun canIntercept(request: WebResourceRequest): Boolean {
13 | return false
14 | }
15 |
16 | override fun handleRequest(request: WebResourceRequest): WebResourceResponse? {
17 | return null
18 | }
19 | }
--------------------------------------------------------------------------------
/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorConfigurationTest.kt:
--------------------------------------------------------------------------------
1 | package org.wordpress.gutenberg
2 |
3 | import org.junit.Test
4 | import org.junit.Assert.*
5 | import org.junit.Before
6 |
7 | class EditorConfigurationTest {
8 | private lateinit var editorConfig: EditorConfiguration
9 |
10 | @Before
11 | fun setup() {
12 | editorConfig = EditorConfiguration.builder()
13 | .setTitle("Test Title")
14 | .setContent("Test Content")
15 | .setPostId(123)
16 | .setPostType("post")
17 | .setThemeStyles(true)
18 | .setPlugins(true)
19 | .setHideTitle(false)
20 | .setSiteURL("https://example.com")
21 | .setSiteApiRoot("https://example.com/wp-json")
22 | .setSiteApiNamespace(arrayOf("wp/v2"))
23 | .setNamespaceExcludedPaths(arrayOf("users"))
24 | .setAuthHeader("Bearer token")
25 | .setWebViewGlobals(listOf(
26 | WebViewGlobal("testString", WebViewGlobalValue.StringValue("test")),
27 | WebViewGlobal("testNumber", WebViewGlobalValue.NumberValue(42.0)),
28 | WebViewGlobal("testBoolean", WebViewGlobalValue.BooleanValue(true))
29 | ))
30 | .build()
31 | }
32 |
33 | @Test
34 | fun `test EditorConfiguration builder creates correct configuration`() {
35 | assertEquals("Test Title", editorConfig.title)
36 | assertEquals("Test Content", editorConfig.content)
37 | assertEquals(123, editorConfig.postId)
38 | assertEquals("post", editorConfig.postType)
39 | assertTrue(editorConfig.themeStyles)
40 | assertTrue(editorConfig.plugins)
41 | assertFalse(editorConfig.hideTitle)
42 | assertEquals("https://example.com", editorConfig.siteURL)
43 | assertEquals("https://example.com/wp-json", editorConfig.siteApiRoot)
44 | assertArrayEquals(arrayOf("wp/v2"), editorConfig.siteApiNamespace)
45 | assertArrayEquals(arrayOf("users"), editorConfig.namespaceExcludedPaths)
46 | assertEquals("Bearer token", editorConfig.authHeader)
47 | assertEquals(3, editorConfig.webViewGlobals.size)
48 | }
49 |
50 | @Test
51 | fun `test WebViewGlobal StringValue toJavaScript conversion`() {
52 | val stringValue = WebViewGlobalValue.StringValue("test\nvalue")
53 | assertEquals("\"test\\nvalue\"", stringValue.toJavaScript())
54 | }
55 |
56 | @Test
57 | fun `test WebViewGlobal NumberValue toJavaScript conversion`() {
58 | val numberValue = WebViewGlobalValue.NumberValue(42.0)
59 | assertEquals("42.0", numberValue.toJavaScript())
60 | }
61 |
62 | @Test
63 | fun `test WebViewGlobal BooleanValue toJavaScript conversion`() {
64 | val booleanValue = WebViewGlobalValue.BooleanValue(true)
65 | assertEquals("true", booleanValue.toJavaScript())
66 | }
67 |
68 | @Test
69 | fun `test WebViewGlobal ObjectValue toJavaScript conversion`() {
70 | val objectValue = WebViewGlobalValue.ObjectValue(mapOf(
71 | "key1" to WebViewGlobalValue.StringValue("value1"),
72 | "key2" to WebViewGlobalValue.NumberValue(42.0)
73 | ))
74 | assertEquals("{\"key1\": \"value1\",\"key2\": 42.0}", objectValue.toJavaScript())
75 | }
76 |
77 | @Test
78 | fun `test WebViewGlobal ArrayValue toJavaScript conversion`() {
79 | val arrayValue = WebViewGlobalValue.ArrayValue(listOf(
80 | WebViewGlobalValue.StringValue("value1"),
81 | WebViewGlobalValue.NumberValue(42.0)
82 | ))
83 | assertEquals("[\"value1\",42.0]", arrayValue.toJavaScript())
84 | }
85 |
86 | @Test
87 | fun `test WebViewGlobal NullValue toJavaScript conversion`() {
88 | val nullValue = WebViewGlobalValue.NullValue
89 | assertEquals("null", nullValue.toJavaScript())
90 | }
91 |
92 | @Test
93 | fun `test WebViewGlobal valid identifier`() {
94 | val validGlobal = WebViewGlobal("validName", WebViewGlobalValue.StringValue("test"))
95 | assertEquals("validName", validGlobal.name)
96 | }
97 |
98 | @Test(expected = IllegalArgumentException::class)
99 | fun `test WebViewGlobal invalid identifier throws exception`() {
100 | WebViewGlobal("123invalid", WebViewGlobalValue.StringValue("test"))
101 | }
102 |
103 | @Test
104 | fun `test EditorConfiguration equals and hashCode`() {
105 | val config1 = EditorConfiguration.builder()
106 | .setTitle("Test")
107 | .setContent("Content")
108 | .build()
109 |
110 | val config2 = EditorConfiguration.builder()
111 | .setTitle("Test")
112 | .setContent("Content")
113 | .build()
114 |
115 | assertEquals(config1, config2)
116 | assertEquals(config1.hashCode(), config2.hashCode())
117 | }
118 |
119 | @Test
120 | fun `test EditorConfiguration not equals`() {
121 | val config1 = EditorConfiguration.builder()
122 | .setTitle("Test1")
123 | .setContent("Content")
124 | .build()
125 |
126 | val config2 = EditorConfiguration.builder()
127 | .setTitle("Test2")
128 | .setContent("Content")
129 | .build()
130 |
131 | assertNotEquals(config1, config2)
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/android/Gutenberg/src/test/java/org/wordpress/gutenberg/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package org.wordpress.gutenberg
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt:
--------------------------------------------------------------------------------
1 | package org.wordpress.gutenberg
2 |
3 | import android.content.Intent
4 | import android.net.Uri
5 | import android.os.Looper
6 | import android.webkit.ValueCallback
7 | import android.webkit.WebChromeClient
8 | import android.webkit.WebView
9 | import org.junit.Before
10 | import org.junit.Test
11 | import org.junit.runner.RunWith
12 | import org.mockito.Mock
13 | import org.mockito.Mockito.`when`
14 | import org.mockito.MockitoAnnotations
15 | import org.robolectric.RobolectricTestRunner
16 | import org.robolectric.RuntimeEnvironment
17 | import org.robolectric.Shadows.shadowOf
18 | import org.robolectric.annotation.Config
19 | import org.junit.Assert.assertEquals
20 | import org.junit.Assert.assertTrue
21 | import java.util.concurrent.CountDownLatch
22 | import java.util.concurrent.TimeUnit
23 |
24 | @RunWith(RobolectricTestRunner::class)
25 | @Config(sdk = [28], manifest = Config.NONE)
26 | class GutenbergViewTest {
27 | @Mock
28 | private lateinit var mockWebView: WebView
29 |
30 | @Mock
31 | private lateinit var mockFilePathCallback: ValueCallback?>
32 |
33 | @Mock
34 | private lateinit var mockFileChooserParams: WebChromeClient.FileChooserParams
35 |
36 | private lateinit var gutenbergView: GutenbergView
37 |
38 | @Before
39 | fun setup() {
40 | MockitoAnnotations.openMocks(this)
41 | gutenbergView = GutenbergView(RuntimeEnvironment.getApplication())
42 | gutenbergView.initializeWebView()
43 | }
44 |
45 | @Test
46 | fun `onShowFileChooser sets up file chooser with single file selection`() {
47 | // Given
48 | val latch = CountDownLatch(1)
49 | var capturedIntent: Intent? = null
50 |
51 | gutenbergView.setOnFileChooserRequestedListener { intent, _ ->
52 | capturedIntent = intent
53 | latch.countDown()
54 | }
55 |
56 | // When
57 | gutenbergView.webChromeClient?.onShowFileChooser(
58 | mockWebView,
59 | mockFilePathCallback,
60 | mockFileChooserParams
61 | )
62 |
63 | // Process any pending runnables
64 | shadowOf(Looper.getMainLooper()).runToEndOfTasks()
65 |
66 | // Wait for the callback to be executed
67 | latch.await(1, TimeUnit.SECONDS)
68 |
69 | // Then
70 | assertTrue("Intent should not be null", capturedIntent != null)
71 | assertTrue("Intent should be a chooser", capturedIntent?.action == Intent.ACTION_CHOOSER)
72 |
73 | // Get the original intent from the chooser
74 | val originalIntent = capturedIntent?.getParcelableExtra(Intent.EXTRA_INTENT)
75 | assertTrue("Original intent should not be null", originalIntent != null)
76 | assertTrue("Original intent action should be ACTION_GET_CONTENT",
77 | originalIntent?.action == Intent.ACTION_GET_CONTENT)
78 | assertTrue("Original intent should have CATEGORY_OPENABLE",
79 | originalIntent?.hasCategory(Intent.CATEGORY_OPENABLE) == true)
80 | assertEquals("Pick image request code should be 1",
81 | 1, gutenbergView.pickImageRequestCode)
82 | }
83 |
84 | @Test
85 | fun `onShowFileChooser sets up file chooser with multiple file selection`() {
86 | // Given
87 | val latch = CountDownLatch(1)
88 | var capturedIntent: Intent? = null
89 |
90 | gutenbergView.setOnFileChooserRequestedListener { intent, _ ->
91 | capturedIntent = intent
92 | latch.countDown()
93 | }
94 |
95 | // When
96 | `when`(mockFileChooserParams.mode).thenReturn(WebChromeClient.FileChooserParams.MODE_OPEN_MULTIPLE)
97 | gutenbergView.webChromeClient?.onShowFileChooser(
98 | mockWebView,
99 | mockFilePathCallback,
100 | mockFileChooserParams
101 | )
102 |
103 | // Process any pending runnables
104 | shadowOf(Looper.getMainLooper()).runToEndOfTasks()
105 |
106 | // Wait for the callback to be executed
107 | latch.await(1, TimeUnit.SECONDS)
108 |
109 | // Then
110 | assertTrue("Intent should not be null", capturedIntent != null)
111 | assertTrue("Intent should be a chooser", capturedIntent?.action == Intent.ACTION_CHOOSER)
112 |
113 | // Get the original intent from the chooser
114 | val originalIntent = capturedIntent?.getParcelableExtra(Intent.EXTRA_INTENT)
115 | assertTrue("Original intent should not be null", originalIntent != null)
116 | assertTrue("Original intent should allow multiple selection",
117 | originalIntent?.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false) == true)
118 | }
119 |
120 | @Test
121 | fun `onShowFileChooser stores file path callback`() {
122 | // When
123 | gutenbergView.webChromeClient?.onShowFileChooser(
124 | mockWebView,
125 | mockFilePathCallback,
126 | mockFileChooserParams
127 | )
128 |
129 | // Then
130 | assertEquals("File path callback should be stored",
131 | mockFilePathCallback, gutenbergView.filePathCallback)
132 | }
133 |
134 | @Test
135 | fun `resetFilePathCallback clears the callback`() {
136 | // Given
137 | gutenbergView.webChromeClient?.onShowFileChooser(
138 | mockWebView,
139 | mockFilePathCallback,
140 | mockFileChooserParams
141 | )
142 |
143 | // When
144 | gutenbergView.resetFilePathCallback()
145 |
146 | // Then
147 | assertEquals("File path callback should be null after reset",
148 | null, gutenbergView.filePathCallback)
149 | }
150 | }
151 |
--------------------------------------------------------------------------------
/android/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | }
5 |
6 | android {
7 | namespace = "com.example.gutenbergkit"
8 | compileSdk = 34
9 |
10 | defaultConfig {
11 | applicationId = "com.example.gutenbergkit"
12 | minSdk = 22
13 | targetSdk = 34
14 | versionCode = 1
15 | versionName = "1.0"
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | }
19 |
20 | buildTypes {
21 | release {
22 | isMinifyEnabled = false
23 | proguardFiles(
24 | getDefaultProguardFile("proguard-android-optimize.txt"),
25 | "proguard-rules.pro"
26 | )
27 | }
28 | }
29 | compileOptions {
30 | sourceCompatibility = JavaVersion.VERSION_1_8
31 | targetCompatibility = JavaVersion.VERSION_1_8
32 | }
33 | kotlinOptions {
34 | jvmTarget = "1.8"
35 | }
36 | }
37 |
38 | dependencies {
39 |
40 | implementation(libs.androidx.core.ktx)
41 | implementation(libs.androidx.appcompat)
42 | implementation(libs.material)
43 | implementation(libs.androidx.activity)
44 | implementation(libs.androidx.constraintlayout)
45 | implementation(libs.androidx.webkit)
46 | implementation(project(":Gutenberg"))
47 | testImplementation(libs.junit)
48 | androidTestImplementation(libs.androidx.junit)
49 | androidTestImplementation(libs.androidx.espresso.core)
50 | }
--------------------------------------------------------------------------------
/android/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/android/app/src/androidTest/java/com/example/gutenbergkit/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package com.example.gutenbergkit
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("com.example.gutenbergkit", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
16 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/example/gutenbergkit/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.example.gutenbergkit
2 |
3 | import android.os.Bundle
4 | import android.util.Log
5 | import android.webkit.WebView
6 | import androidx.activity.enableEdgeToEdge
7 | import androidx.appcompat.app.AppCompatActivity
8 | import androidx.core.view.ViewCompat
9 | import androidx.core.view.WindowInsetsCompat
10 | import org.wordpress.gutenberg.GutenbergView
11 | import org.wordpress.gutenberg.EditorConfiguration
12 |
13 | class MainActivity : AppCompatActivity() {
14 |
15 | override fun onCreate(savedInstanceState: Bundle?) {
16 | super.onCreate(savedInstanceState)
17 | enableEdgeToEdge()
18 | setContentView(R.layout.activity_main)
19 | ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
20 | val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
21 | v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
22 | insets
23 | }
24 |
25 | WebView.setWebContentsDebuggingEnabled(true)
26 |
27 | val gbView = findViewById(R.id.gutenbergView)
28 |
29 | val config = EditorConfiguration.builder()
30 | .setTitle("")
31 | .setContent("")
32 | .setPostType("post")
33 | .setThemeStyles(false)
34 | .setPlugins(false)
35 | .setHideTitle(false)
36 | .setSiteURL("")
37 | .setSiteApiRoot("")
38 | .setSiteApiNamespace(arrayOf())
39 | .setNamespaceExcludedPaths(arrayOf())
40 | .setAuthHeader("")
41 | .setWebViewGlobals(emptyList())
42 | .build()
43 |
44 | gbView.start(config)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
--------------------------------------------------------------------------------
/android/app/src/main/res/layout/activity_main.xml:
--------------------------------------------------------------------------------
1 |
2 |
9 |
10 |
20 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/android/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FF000000
4 | #FFFFFFFF
5 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | GutenbergKit
3 |
--------------------------------------------------------------------------------
/android/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/android/app/src/main/res/xml/backup_rules.xml:
--------------------------------------------------------------------------------
1 |
8 |
9 |
13 |
--------------------------------------------------------------------------------
/android/app/src/main/res/xml/data_extraction_rules.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/android/app/src/main/res/xml/network_security_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | localhost
5 | 10.0.0.2
6 |
7 |
--------------------------------------------------------------------------------
/android/app/src/test/java/com/example/gutenbergkit/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package com.example.gutenbergkit
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/android/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | val localProperties = java.util.Properties()
3 | val localPropertiesFile = rootProject.file("local.properties")
4 | if (localPropertiesFile.exists()) {
5 | localPropertiesFile.inputStream().use { localProperties.load(it) }
6 | }
7 |
8 | ext {
9 | set("gutenbergEditorUrl", localProperties.getProperty("GUTENBERG_EDITOR_URL") ?: "")
10 | set("gutenbergEditorRemoteUrl", localProperties.getProperty("GUTENBERG_EDITOR_REMOTE_URL") ?: "")
11 | }
12 |
13 | plugins {
14 | alias(libs.plugins.android.application) apply false
15 | alias(libs.plugins.jetbrains.kotlin.android) apply false
16 | alias(libs.plugins.android.library) apply false
17 | }
18 |
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/android/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.7.3"
3 | kotlin = "1.9.0"
4 | coreKtx = "1.13.1"
5 | junit = "4.13.2"
6 | junitVersion = "1.2.1"
7 | espressoCore = "3.6.1"
8 | appcompat = "1.7.0"
9 | material = "1.12.0"
10 | activity = "1.9.0"
11 | constraintlayout = "2.1.4"
12 | webkit = "1.11.0"
13 | gson = "2.8.9"
14 | mockito = "4.1.0"
15 | robolectric = "4.14.1"
16 |
17 | [libraries]
18 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
19 | junit = { group = "junit", name = "junit", version.ref = "junit" }
20 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
21 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
22 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
23 | material = { group = "com.google.android.material", name = "material", version.ref = "material" }
24 | androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
25 | androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
26 | androidx-webkit = { group = "androidx.webkit", name = "webkit", version.ref = "webkit" }
27 | gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
28 | mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
29 | mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockito" }
30 | robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
31 |
32 | [plugins]
33 | android-application = { id = "com.android.application", version.ref = "agp" }
34 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
35 | android-library = { id = "com.android.library", version.ref = "agp" }
36 |
--------------------------------------------------------------------------------
/android/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/android/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/android/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionSha256Sum=296742a352f0b20ec14b143fb684965ad66086c7810b7b255dee216670716175
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-all.zip
5 | networkTimeout=10000
6 | validateDistributionUrl=true
7 | zipStoreBase=GRADLE_USER_HOME
8 | zipStorePath=wrapper/dists
9 |
--------------------------------------------------------------------------------
/android/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/android/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | maven {
4 | url = uri("https://a8c-libs.s3.amazonaws.com/android")
5 | content {
6 | includeGroup("com.automattic.android")
7 | includeGroup("com.automattic.android.publish-to-s3")
8 | }
9 | }
10 | google {
11 | content {
12 | includeGroupByRegex("com\\.android.*")
13 | includeGroupByRegex("com\\.google.*")
14 | includeGroupByRegex("androidx.*")
15 | }
16 | }
17 | mavenCentral()
18 | gradlePluginPortal()
19 | }
20 |
21 | plugins {
22 | id("com.automattic.android.publish-to-s3") version "0.10.0"
23 | }
24 | }
25 |
26 | dependencyResolutionManagement {
27 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
28 | repositories {
29 | google()
30 | mavenCentral()
31 | }
32 | }
33 |
34 | rootProject.name = "GutenbergKit"
35 | include(":app")
36 | include(":Gutenberg")
37 |
--------------------------------------------------------------------------------
/bin/prep-translations.js:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 | import fs from 'fs';
5 | import path from 'path';
6 | import fetch from 'node-fetch';
7 |
8 | /**
9 | * Internal dependencies
10 | */
11 | import { info, error, debug } from '../src/utils/logger.js';
12 |
13 | const TRANSLATIONS_DIR = path.join( process.cwd(), 'src/translations' );
14 | const SUPPORTED_LOCALES = [
15 | 'ar', // Arabic
16 | 'bg', // Bulgarian
17 | 'bo', // Tibetan
18 | 'ca', // Catalan
19 | 'cs', // Czech
20 | 'cy', // Welsh
21 | 'da', // Danish
22 | 'de', // German
23 | 'en-au', // English (Australia)
24 | 'en-ca', // English (Canada)
25 | 'en-gb', // English (UK)
26 | 'en-nz', // English (New Zealand)
27 | 'en-za', // English (South Africa)
28 | 'el', // Greek
29 | 'es', // Spanish
30 | 'es-ar', // Spanish (Argentina)
31 | 'es-cl', // Spanish (Chile)
32 | 'es-cr', // Spanish (Costa Rica)
33 | 'fa', // Persian
34 | 'fr', // French
35 | 'gl', // Galician
36 | 'he', // Hebrew
37 | 'hr', // Croatian
38 | 'hu', // Hungarian
39 | 'id', // Indonesian
40 | 'is', // Icelandic
41 | 'it', // Italian
42 | 'ja', // Japanese
43 | 'ka', // Georgian
44 | 'ko', // Korean
45 | 'nb', // Norwegian (Bokmål)
46 | 'nl', // Dutch
47 | 'nl-be', // Dutch (Belgium)
48 | 'pl', // Polish
49 | 'pt', // Portuguese
50 | 'pt-br', // Portuguese (Brazil)
51 | 'ro', // Romainian
52 | 'ru', // Russian
53 | 'sk', // Slovak
54 | 'sq', // Albanian
55 | 'sr', // Serbian
56 | 'sv', // Swedish
57 | 'th', // Thai
58 | 'tr', // Turkish
59 | 'uk', // Ukrainian
60 | 'ur', // Urdu
61 | 'vi', // Vietnamese
62 | 'zh-cn', // Chinese (China)
63 | 'zh-tw', // Chinese (Taiwan)
64 | ];
65 |
66 | /**
67 | * Prepare translations for all supported locales.
68 | *
69 | * @param {boolean} force Whether to force download even if cache exists.
70 | *
71 | * @return {Promise} A promise that resolves when translations are prepared.
72 | */
73 | async function prepareTranslations( force = false ) {
74 | if ( force ) {
75 | info( 'Ignoring cache, downloading translations...' );
76 | } else {
77 | info( 'Verifying translations...' );
78 | }
79 |
80 | for ( const locale of SUPPORTED_LOCALES ) {
81 | try {
82 | await downloadTranslations( locale, force );
83 | } catch ( err ) {
84 | error( `✗ Failed to download translations for ${ locale }:`, err );
85 | }
86 | }
87 |
88 | info( '✓ Translations ready!' );
89 | }
90 |
91 | /**
92 | * Downloads translations for a specific locale from translate.wordpress.org.
93 | *
94 | * @param {string} locale The locale to download translations for.
95 | * @param {boolean} force Whether to force download even if cache exists.
96 | *
97 | * @return {Promise} A promise that resolves when translations are downloaded.
98 | */
99 | async function downloadTranslations( locale, force = false ) {
100 | if ( ! force && hasValidTranslations( locale ) ) {
101 | debug( `Skipping download of cached translations for ${ locale }` );
102 | return;
103 | }
104 | debug( `Downloading translations for ${ locale }...` );
105 |
106 | const url = `https://translate.wordpress.org/projects/wp-plugins/gutenberg/dev/${ locale }/default/export-translations/?format=json`;
107 | const response = await fetch( url );
108 |
109 | if ( ! response.ok ) {
110 | throw new Error( `Failed to download translations for ${ locale }` );
111 | }
112 |
113 | const translations = await response.json();
114 | const outputPath = path.join( TRANSLATIONS_DIR, `${ locale }.json` );
115 |
116 | // Ensure the translations directory exists
117 | if ( ! fs.existsSync( TRANSLATIONS_DIR ) ) {
118 | fs.mkdirSync( TRANSLATIONS_DIR, { recursive: true } );
119 | }
120 |
121 | // Write translations to file
122 | fs.writeFileSync( outputPath, JSON.stringify( translations, null, 2 ) );
123 | debug( `✓ Downloaded translations for ${ locale }` );
124 | }
125 |
126 | /**
127 | * Checks if translations exist and are valid for a specific locale.
128 | *
129 | * @param {string} locale The locale to check.
130 | *
131 | * @return {boolean} Whether valid translations exist.
132 | */
133 | function hasValidTranslations( locale ) {
134 | const filePath = path.join( TRANSLATIONS_DIR, `${ locale }.json` );
135 | if ( ! fs.existsSync( filePath ) ) {
136 | return false;
137 | }
138 |
139 | try {
140 | const content = fs.readFileSync( filePath, 'utf8' );
141 | const translations = JSON.parse( content );
142 | return translations && typeof translations === 'object';
143 | } catch ( err ) {
144 | return false;
145 | }
146 | }
147 |
148 | /**
149 | * Main entry point for the script.
150 | * Parses command line arguments and downloads translations.
151 | */
152 | const forceDownload =
153 | process.argv.includes( '--force' ) || process.argv.includes( '-f' );
154 |
155 | prepareTranslations( forceDownload ).catch( ( err ) => {
156 | error( 'Failed to prepare translations:', err );
157 | process.exit( 1 );
158 | } );
159 |
--------------------------------------------------------------------------------
/docs/example-xcode-env-variable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/docs/example-xcode-env-variable.png
--------------------------------------------------------------------------------
/docs/gutenberg-kit-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wordpress-mobile/GutenbergKit/f26928ac3a15ad619dd4f6ebf02402e385edce6f/docs/gutenberg-kit-preview.png
--------------------------------------------------------------------------------
/docs/test-cases.md:
--------------------------------------------------------------------------------
1 | # Mobile Editor Tests
2 |
3 | ## Smoke Tests
4 |
5 | **Purpose:** Verify the editor's core functionality: writing/formatting text, uploading media, saving/publishing, and basic block manipulation.
6 |
7 | ### S.1. Write and format text
8 |
9 | - **Steps:**
10 | - Add a Paragraph, List, or Heading block.
11 | - Type some text.
12 | - Apply bold, italic, and strikethrough formatting using the toolbar.
13 | - **Expected Outcome:** Text is entered and formatting is applied as expected.
14 |
15 | ### S.2. Add a link to a paragraph
16 |
17 | - **Steps:**
18 | - Add a Paragraph, List, or Heading block.
19 | - Type some text.
20 | - Apply a link to the text.
21 | - **Expected Outcome:** Link is applied to the text as expected.
22 |
23 | ### S.3. Merge and split blocks
24 |
25 | - **Steps:**
26 | - Write a long paragraph or list of multiple items.
27 | - Place the cursor somwewhere in the middle and split the block into two blocks using Enter.
28 | - Merge them back by deleting content at the start of the second block.
29 | - **Expected Outcome:** Blocks split and merge as expected; content remains intact.
30 |
31 | ### S.4. Undo/Redo Actions
32 |
33 | - **Steps:**
34 | - Add, remove, and edit blocks and text.
35 | - Use Undo and Redo buttons.
36 | - **Expected Outcome:** Editor correctly undoes and redoes actions, restoring previous states.
37 |
38 | ### S.5. Upload an image
39 |
40 | - **Steps:**
41 | - Add an Image block.
42 | - Tap "Choose from device" and select an image.
43 | - **Expected Outcome:** Image uploads and displays in the block. An activity indicator is shown while the image is uploading.
44 |
45 | ### S.6. Upload an video
46 |
47 | - **Steps:**
48 | - Add a Video block.
49 | - Tap "Choose from device" and select a video.
50 | - **Expected Outcome:** Video uploads and displays in the block. An activity indicator is shown while the video is uploading.
51 |
52 | ### S.7. Reorder blocks
53 |
54 | - **Steps:**
55 | - Add several content blocks to a post.
56 | - Select a block.
57 | - Use the up/down arrows in the block toolbar to relocate the block.
58 | - **Expected Outcome:** The block ordering is updated as expected.
59 |
60 | ### S.8. Save and publish a post
61 |
62 | - **Steps:**
63 | - Create a new post with text and media.
64 | - Save as draft, then publish.
65 | - **Expected Outcome:** Post is saved and published successfully; content appears as expected.
66 |
67 | ## Functionality Tests
68 |
69 | **Purpose:** Validate deeper content and formatting features, advanced block settings, and robust editor behaviors.
70 |
71 | ### F.1. Text alignment options
72 |
73 | - **Steps:**
74 | - Add a Paragraph or Verse block.
75 | - Type text and use alignment options (left, center, right).
76 | - **Expected Outcome:** Selected alignment is applied to the block content.
77 |
78 | ### F.2. Add and preview embedded content
79 |
80 | - **Steps:**
81 | - Add a Shortcode or Embed block.
82 | - Insert a YouTube or Twitter link.
83 | - Preview the post.
84 | - **Expected Outcome:** Embedded content (e.g., YouTube video) displays correctly in preview.
85 |
86 | ### F.3. Buttons block: add, remove, and style buttons
87 |
88 | - **Steps:**
89 | - Add a Buttons block.
90 | - Add multiple buttons, remove one, and verify focus.
91 | - Apply alignment, background, and text color changes.
92 | - **Expected Outcome:** Buttons can be added/removed; styles and alignment update as expected.
93 |
94 | ### F.4. Color and gradient customization
95 |
96 | - **Steps:**
97 | - Add a block supporting color (e.g., Buttons, Cover).
98 | - Open color settings, switch between solid and gradient, pick custom colors, and apply.
99 | - **Expected Outcome:** Selected colors/gradients are applied; UI updates accordingly.
100 |
101 | ### F.5. Gallery block: image uploads and captions
102 |
103 | - **Steps:**
104 | - Add a Gallery block, upload multiple images.
105 | - Add captions to gallery and individual images, apply formatting.
106 | - **Expected Outcome:** An activity indicator is shown while the images are uploading. Captions and formatting display as expected.
107 |
108 | ### F.6. Pattern insertion
109 |
110 | - **Steps:**
111 | - Insert a pattern from the inserter.
112 | - **Expected Outcome:** Pattern content appears.
113 |
114 | ### F.7. Upload an audio file
115 |
116 | Known issue: [Audio block unable to upload expected file formats](https://github.com/wordpress-mobile/GutenbergKit/issues/123)
117 |
118 | - **Steps:**
119 | - Add an Audio block.
120 | - Tap "Choose from device" and select an audio file.
121 | - **Expected Outcome:** Audio uploads and displays in the block. An activity indicator is shown while the audio is uploading.
122 |
123 | ### F.8. Upload a file
124 |
125 | Known issue: [File block unable to upload expected file formats](https://github.com/wordpress-mobile/GutenbergKit/issues/124)
126 |
127 | - **Steps:**
128 | - Add a File block.
129 | - Tap "Choose from device" and select a file.
130 | - **Expected Outcome:** File uploads, filename and download button appear when upload completes.
131 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Gutenberg.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | IDEDidComputeMac32BitWarning
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Gutenberg.xcodeproj/xcshareddata/xcschemes/Gutenberg.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
58 |
59 |
63 |
64 |
65 |
66 |
72 |
74 |
80 |
81 |
82 |
83 |
85 |
86 |
89 |
90 |
91 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/PreviewContent/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Resources/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal",
5 | "platform" : "ios",
6 | "size" : "1024x1024"
7 | }
8 | ],
9 | "info" : {
10 | "author" : "xcode",
11 | "version" : 1
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Sources/ContentView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import GutenbergKit
3 |
4 | let editorURL: URL? = ProcessInfo.processInfo.environment["GUTENBERG_EDITOR_URL"].flatMap(URL.init)
5 |
6 | struct ContentView: View {
7 | var body: some View {
8 | NavigationView {
9 | EditorView(editorURL: editorURL)
10 | }
11 | }
12 | }
13 |
14 | #Preview {
15 | ContentView()
16 | }
17 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Sources/EditorView.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 | import GutenbergKit
3 |
4 | struct EditorView: View {
5 | var editorURL: URL?
6 |
7 | var body: some View {
8 | _EditorView(editorURL: editorURL)
9 | .toolbar {
10 | ToolbarItemGroup(placement: .topBarLeading) {
11 | Button(action: {}, label: {
12 | Image(systemName: "xmark")
13 | })
14 | }
15 | ToolbarItemGroup(placement: .topBarTrailing) {
16 | Button(action: {}, label: {
17 | Image(systemName: "arrow.uturn.backward")
18 | })
19 | Button(action: {}, label: {
20 | Image(systemName: "arrow.uturn.forward")
21 | }).disabled(true)
22 | }
23 |
24 | ToolbarItemGroup(placement: .topBarTrailing) {
25 | Button(action: {}, label: {
26 | Image(systemName: "safari")
27 | })
28 |
29 | moreMenu
30 | }
31 | }
32 | }
33 |
34 | private var moreMenu: some View {
35 | Menu {
36 | Section {
37 | Button(action: {}, label: {
38 | Label("Code Editor", systemImage: "curlybraces")
39 | })
40 | Button(action: {}, label: {
41 | Label("Outline", systemImage: "list.bullet.indent")
42 | })
43 | Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: {
44 | Label("Preview", systemImage: "safari")
45 | })
46 | }
47 | Section {
48 | Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: {
49 | Label("Revisions (42)", systemImage: "clock.arrow.circlepath")
50 | })
51 | Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: {
52 | Label("Post Settings", systemImage: "gearshape")
53 | })
54 |
55 | Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: {
56 | Label("Help", systemImage: "questionmark.circle")
57 | })
58 | }
59 | Section {
60 | Text("Blocks: 4, Words: 8, Characters: 15")
61 | } header: {
62 |
63 | }
64 | } label: {
65 | Image(systemName: "ellipsis")
66 | }
67 | .tint(Color.primary)
68 | }
69 | }
70 |
71 | private struct _EditorView: UIViewControllerRepresentable {
72 | var editorURL: URL?
73 |
74 | func makeUIViewController(context: Context) -> EditorViewController {
75 | let viewController = EditorViewController()
76 | viewController.editorURL = editorURL
77 | if #available(iOS 16.4, *) {
78 | viewController.webView.isInspectable = true
79 | }
80 | viewController.startEditorSetup()
81 | return viewController
82 | }
83 |
84 | func updateUIViewController(_ uiViewController: EditorViewController, context: Context) {
85 | // Do nothing
86 | }
87 | }
88 |
89 | #Preview {
90 | NavigationStack {
91 | EditorView()
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/ios/Demo-iOS/Sources/GutenbergApp.swift:
--------------------------------------------------------------------------------
1 | import SwiftUI
2 |
3 | @main
4 | struct GutenbergApp: App {
5 | var body: some Scene {
6 | WindowGroup {
7 | ContentView()
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/assets/bridge-COXq4kB3.js:
--------------------------------------------------------------------------------
1 | const f="0.2.0",r="?";function d(t,e,n,i){const o={filename:t,function:e===""?r:e,in_app:!0};return n!==void 0&&(o.lineno=n),i!==void 0&&(o.colno=i),o}const l=/^\s*at (\S+?)(?::(\d+))(?::(\d+))\s*$/i,u=/^\s*at (?:(.+?\)(?: \[.+\])?|.*?) ?\((?:address at )?)?(?:async )?((?:|[-a-z]+:|.*bundle|\/)?.*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i,m=/\((\S*)(?::(\d+))(?::(\d+))\)/,p=t=>{const e=l.exec(t);if(e){const[,i,o,s]=e;return d(i,r,+o,+s)}const n=u.exec(t);if(n){if(n[2]&&n[2].indexOf("eval")===0){const a=m.exec(n[2]);a&&(n[2]=a[1],n[3]=a[2],n[4]=a[3])}const o=n[1]||r,s=n[2];return d(s,o,n[3]?+n[3]:void 0,n[4]?+n[4]:void 0)}},b=/^\s*(.*?)(?:\((.*?)\))?(?:^|@)?((?:[-a-z]+)?:\/.*?|\[native code\]|[^@]*(?:bundle|\d+\.js)|\/[\w\-. /=]+)(?::(\d+))?(?::(\d+))?\s*$/i,k=/(\S+) line (\d+)(?: > eval line \d+)* > eval/i,y=t=>{const e=b.exec(t);if(e){if(e[3]&&e[3].indexOf(" > eval")>-1){const s=k.exec(e[3]);s&&(e[1]=e[1]||"eval",e[3]=s[1],e[4]=s[2],e[5]="")}const i=e[3],o=e[1]||r;return d(i,o,e[4]?+e[4]:void 0,e[5]?+e[5]:void 0)}},g=50,E=[p,y],h=t=>{const e=t.stacktrace||t.stack||"",n=[],i=e.split(`
2 | `);for(let s=0;s1024)&&!a.match(/\S*Error: /)){for(const w of E){const c=w(a);if(c){n.push(c);break}}if(n.length>=g)break}}const o=Array.from(n).reverse();return o.slice(0,g).map(s=>({...s,filename:s.filename||o[o.length-1].filename}))},x=t=>{const e=t?.message;return e?typeof e.error?.message=="string"?e.error.message:e:"No error message"},v=t=>{const e={type:t?.name,message:x(t)},n=h(t);return n.length&&(e.stacktrace=n),e.type===void 0&&e.message===""&&(e.message="Unknown error"),e},D=(t,{context:e,tags:n}={})=>({...v(t),context:{...e},tags:{...n,gutenberg_kit_version:f}});function S(){window.editorDelegate&&window.editorDelegate.onEditorLoaded(),window.webkit&&window.webkit.messageHandlers.editorDelegate.postMessage({message:"onEditorLoaded",body:{}})}function M(){window.editorDelegate&&window.editorDelegate.onEditorContentChanged(),window.webkit&&window.webkit.messageHandlers.editorDelegate.postMessage({message:"onEditorContentChanged"})}function K(t,e){window.editorDelegate&&window.editorDelegate.onEditorHistoryChanged(t,e),window.webkit&&window.webkit.messageHandlers.editorDelegate.postMessage({message:"onEditorHistoryChanged",body:{hasUndo:t,hasRedo:e}})}function N(t){window.editorDelegate&&window.editorDelegate.openMediaLibrary(JSON.stringify(t)),window.webkit&&window.webkit.messageHandlers.editorDelegate.postMessage({message:"openMediaLibrary",body:t})}function C(){if(window.GBKit)return window.GBKit;try{return JSON.parse(localStorage.getItem("GBKit"))||{}}catch{return{}}}function B(){const{post:t}=C();return t?{id:t.id,type:t.type||"post",status:t.status,title:{raw:decodeURIComponent(t.title)},content:{raw:decodeURIComponent(t.content)}}:{id:-1,type:"post",status:"auto-draft",title:{raw:""},content:{raw:""}}}function G(t,{context:e,tags:n,isHandled:i,handledBy:o}={context:{},tags:{},isHandled:!1,handledBy:"Unknown"}){const s={...D(t,{context:e,tags:n}),isHandled:i,handledBy:o};window.editorDelegate&&window.editorDelegate.onEditorExceptionLogged(JSON.stringify(s)),window.webkit&&window.webkit.messageHandlers.editorDelegate.postMessage({message:"onEditorExceptionLogged",body:s})}function H(t=3e3){return new Promise((e,n)=>{const i=Date.now(),o=()=>{if(window.GBKit){e(window.GBKit);return}if(Date.now()-i>=t){n(new Error("GBKit global not available after timeout"));return}setTimeout(o,100)};o()})}export{H as a,N as b,M as c,B as d,S as e,C as g,G as l,K as o};
3 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/assets/editor-BBJZD2BL.css:
--------------------------------------------------------------------------------
1 | .gutenberg-kit-visual-editor{box-sizing:border-box;flex-shrink:0;height:100%;max-height:100%;max-width:100%;min-width:300px;position:relative;width:100%}.gutenberg-kit-visual-editor .block-editor-block-canvas{display:flex}.gutenberg-kit-visual-editor .editor-styles-wrapper{padding-bottom:56px;outline:none;width:100%}.gutenberg-kit-visual-editor.has-root-padding .editor-styles-wrapper{padding-left:16px;padding-right:16px}.gutenberg-kit-visual-editor .block-editor-default-block-appender .block-editor-inserter__toggle.components-button.has-icon{display:none}.gutenberg-kit-visual-editor .wp-block-image>div:first-child{height:auto;max-width:100%}.gutenberg-kit-visual-editor .wp-block-missing{max-width:100%;overflow:hidden}.gutenberg-kit-visual-editor .wp-block-missing img{height:auto;max-width:100%}.gutenberg-kit-visual-editor__toolbar{align-items:center;bottom:0;left:0;overflow-x:auto;position:fixed;right:0;z-index:40}.gutenberg-kit-visual-editor .block-editor-inserter__main-area{width:100%}.gutenberg-kit-visual-editor .block-editor-block-popover{display:none}.gutenberg-kit-visual-editor :where(fieldset){border:0;padding:0;margin:0}.editor-error-boundary{margin-left:16px;margin-right:16px}.gutenberg-kit-editor-toolbar.is-unstyled{background-color:#fff;border-bottom:none;border-left:none;border-right:none;border-top:1px solid #c8c7cc;border-radius:0}.gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar{width:auto}.gutenberg-kit-editor-toolbar::-webkit-scrollbar{display:none}.gutenberg-kit-editor-toolbar .components-toolbar-group{border-right-color:#c8c7cc;min-height:46px;padding-left:0;padding-right:0}.gutenberg-kit-editor-toolbar.gutenberg-kit-editor-toolbar .components-button.has-icon.has-icon{min-width:46px;max-height:46px}.gutenberg-kit-editor-toolbar .block-editor-inserter__toggle svg{background:#000;border-radius:2px;color:#fff}.gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar{flex-shrink:0}.gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar.is-unstyled{box-shadow:none}.gutenberg-kit-editor-toolbar.components-accessible-toolbar .components-button:focus:before,.gutenberg-kit-editor-toolbar.components-toolbar .components-button:focus:before{display:none}.gutenberg-kit-editor-toolbar.components-accessible-toolbar .components-button.is-pressed:before,.gutenberg-kit-editor-toolbar.components-toolbar .components-button.is-pressed:before{height:34px;left:6px;right:6px}.gutenberg-kit-default-block-appender{background:none;border:none;height:80px;width:100%}.gutenberg-kit-editor{-webkit-tap-highlight-color:transparent;flex-grow:1}.gutenberg-kit-editor *{box-sizing:border-box}.gutenberg-kit-editor .components-button{font-size:17px}.gutenberg-kit-editor .components-dropdown-menu__menu-item,.gutenberg-kit-editor .components-dropdown-menu__menu .components-menu-item__button,.gutenberg-kit-editor .components-dropdown-menu__menu .components-menu-item__button.components-button,.gutenberg-kit-editor .components-autocomplete__result.components-button{min-height:42px}input,select,textarea,button{box-sizing:border-box;font-family:inherit;font-size:inherit;font-weight:inherit}.gutenberg-kit-editor .components-editor-notices__snackbar{bottom:58px;left:0;padding-right:8px;padding-left:8px;position:fixed;right:0}.gutenberg-kit-text-editor{padding:12px}.gutenberg-kit-text-editor .editor-post-title.is-raw-text textarea{font-family:Menlo,Consolas,monaco,monospace;line-height:1.333;min-height:70px;padding:16px}.gutenberg-kit-text-editor .editor-post-title.is-raw-text textarea::placeholder{color:#1e1e1e9e}.gutenberg-kit-text-editor textarea.editor-post-text-editor{border-radius:2px;box-sizing:border-box;font-size:13px!important;line-height:2}.gutenberg-kit-text-editor .editor-post-text-editor:focus-visible{border-color:var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));box-shadow:0 0 0 .5px var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));outline:2px solid transparent}.gutenberg-kit-layout__load-notice{bottom:62px;left:16px;position:fixed;right:16px}
2 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/assets/localization-C-KsbNHa.js:
--------------------------------------------------------------------------------
1 | import{a as _,_ as t}from"./vite-helpers-C1_kvBzA.js";import{g as i}from"./bridge-COXq4kB3.js";import{d as a,e as n}from"./logger-B2MeKcsF.js";const{setLocaleData:s}=window.wp.i18n;async function E(){const{locale:r="en"}=i();await m(r)}async function m(r){if(r!==e)try{a("Loading translations for",r);const{default:o}=await _(Object.assign({"../translations/ar.json":()=>t(()=>import("./ar-CyYCT0Yn.js"),[],import.meta.url),"../translations/bg.json":()=>t(()=>import("./bg-B2djTlFt.js"),[],import.meta.url),"../translations/bo.json":()=>t(()=>import("./bo-DOe3GxAO.js"),[],import.meta.url),"../translations/ca.json":()=>t(()=>import("./ca--D3R8toT.js"),[],import.meta.url),"../translations/cs.json":()=>t(()=>import("./cs-DWmcEIfO.js"),[],import.meta.url),"../translations/cy.json":()=>t(()=>import("./cy-CGIlBSk6.js"),[],import.meta.url),"../translations/da.json":()=>t(()=>import("./da-DwB1fLza.js"),[],import.meta.url),"../translations/de.json":()=>t(()=>import("./de-H01QIQZg.js"),[],import.meta.url),"../translations/el.json":()=>t(()=>import("./el-DhdK7rMw.js"),[],import.meta.url),"../translations/en-au.json":()=>t(()=>import("./en-au-CPC6XjFj.js"),[],import.meta.url),"../translations/en-ca.json":()=>t(()=>import("./en-ca-VNTKwdAC.js"),[],import.meta.url),"../translations/en-gb.json":()=>t(()=>import("./en-gb-DURVrT_X.js"),[],import.meta.url),"../translations/en-nz.json":()=>t(()=>import("./en-nz-Dm2aq_Nd.js"),[],import.meta.url),"../translations/en-za.json":()=>t(()=>import("./en-za-BYpQIJT9.js"),[],import.meta.url),"../translations/es-ar.json":()=>t(()=>import("./es-ar-DaruD1vk.js"),[],import.meta.url),"../translations/es-cl.json":()=>t(()=>import("./es-cl-BnB-IkOa.js"),[],import.meta.url),"../translations/es-cr.json":()=>t(()=>import("./es-cr-BY95D2Di.js"),[],import.meta.url),"../translations/es.json":()=>t(()=>import("./es-BJ3q4Wbp.js"),[],import.meta.url),"../translations/fa.json":()=>t(()=>import("./fa-ByB6Bbvh.js"),[],import.meta.url),"../translations/fr.json":()=>t(()=>import("./fr-HxuvMa7s.js"),[],import.meta.url),"../translations/gl.json":()=>t(()=>import("./gl-McDu_gir.js"),[],import.meta.url),"../translations/he.json":()=>t(()=>import("./he-4TZWuuaG.js"),[],import.meta.url),"../translations/hr.json":()=>t(()=>import("./hr-DOZGX9Ap.js"),[],import.meta.url),"../translations/hu.json":()=>t(()=>import("./hu-BCFI5Ug4.js"),[],import.meta.url),"../translations/id.json":()=>t(()=>import("./id-BlpIZgTK.js"),[],import.meta.url),"../translations/is.json":()=>t(()=>import("./is-u59YF_6S.js"),[],import.meta.url),"../translations/it.json":()=>t(()=>import("./it-OacWAa4D.js"),[],import.meta.url),"../translations/ja.json":()=>t(()=>import("./ja-eaKkqtgl.js"),[],import.meta.url),"../translations/ka.json":()=>t(()=>import("./ka-S_it2R8x.js"),[],import.meta.url),"../translations/ko.json":()=>t(()=>import("./ko-lhFU0drW.js"),[],import.meta.url),"../translations/nb.json":()=>t(()=>import("./nb-B4NppxVr.js"),[],import.meta.url),"../translations/nl-be.json":()=>t(()=>import("./nl-be-BLey3_eV.js"),[],import.meta.url),"../translations/nl.json":()=>t(()=>import("./nl-DjtAcROr.js"),[],import.meta.url),"../translations/pl.json":()=>t(()=>import("./pl-BcZoHphp.js"),[],import.meta.url),"../translations/pt-br.json":()=>t(()=>import("./pt-br-B3s-WxNp.js"),[],import.meta.url),"../translations/pt.json":()=>t(()=>import("./pt-Pa9447RD.js"),[],import.meta.url),"../translations/ro.json":()=>t(()=>import("./ro-D4eP2fbK.js"),[],import.meta.url),"../translations/ru.json":()=>t(()=>import("./ru-DgpwNl7b.js"),[],import.meta.url),"../translations/sk.json":()=>t(()=>import("./sk-C1b2igZL.js"),[],import.meta.url),"../translations/sq.json":()=>t(()=>import("./sq-CnDv6A4t.js"),[],import.meta.url),"../translations/sr.json":()=>t(()=>import("./sr-CtYHP_-W.js"),[],import.meta.url),"../translations/sv.json":()=>t(()=>import("./sv-Ffrez4Bf.js"),[],import.meta.url),"../translations/th.json":()=>t(()=>import("./th-SkpHtVrn.js"),[],import.meta.url),"../translations/tr.json":()=>t(()=>import("./tr-By6oiZsW.js"),[],import.meta.url),"../translations/uk.json":()=>t(()=>import("./uk-TWibncG9.js"),[],import.meta.url),"../translations/ur.json":()=>t(()=>import("./ur-CSeJ9e2h.js"),[],import.meta.url),"../translations/vi.json":()=>t(()=>import("./vi-BO1ox2QA.js"),[],import.meta.url),"../translations/zh-cn.json":()=>t(()=>import("./zh-cn-B9xnqhUX.js"),[],import.meta.url),"../translations/zh-tw.json":()=>t(()=>import("./zh-tw-DNHfmYCf.js"),[],import.meta.url)}),`../translations/${r}.json`,3);s(o)}catch(o){n("Error loading translations",o)}}const e="en";export{E as configureLocale};
2 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/assets/logger-B2MeKcsF.js:
--------------------------------------------------------------------------------
1 | var n={};const r={ERROR:0,INFO:2,DEBUG:3};let t=n.LOG_LEVEL||r.INFO;const s=e=>e<=t,L=(e,o)=>{s(r.ERROR)&&console.error(`[GBK] ${e}`,o||"")},c=(e,o)=>{s(r.DEBUG)&&console.debug(`[GBK] ${e}`,o||"")};export{c as d,L as e};
2 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/assets/remote-CQD0zuAs.css:
--------------------------------------------------------------------------------
1 | @charset "UTF-8";:root{--wp--style--global--content-size: 645px}:where(body.gutenberg-kit){font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;margin:0}.gutenberg-kit-root{display:flex;flex-direction:column;height:100vh}.gutenberg-kit .block-editor-inserter__menu .block-editor-tabbed-sidebar__close-button{display:none}.gutenberg-kit .components-popover__header-title{padding-left:20px}.gutenberg-kit .components-popover.is-expanded .components-popover__content{border-radius:0!important;padding:0 8px}.gutenberg-kit .block-settings-menu{position:absolute;inset:0;background-color:#fff;transform:none!important;position:fixed!important;overflow:scroll}.gutenberg-kit .block-settings-menu__header{display:flex;flex-direction:column;align-items:end}.gutenberg-kit .block-settings-menu__close{margin:8px 8px 0 0}.gutenberg-kit .block-settings-menu .components-popover__content{width:100%;min-height:100vh}.gutenberg-kit .block-inspector-siderbar{background:#f6f6fb;border-left:.5px solid #c8c7cc;width:320px}.gutenberg-kit .block-editor-block-list__block-side-inserter-popover{display:none}.gutenberg-kit .block-editor-tabbed-sidebar__tablist button{font-size:16px}.gutenberg-kit .block-editor-inserter__menu{overflow-y:auto!important}.gutenberg-kit .block-editor-inserter__popover .block-editor-inserter__menu{margin-bottom:auto!important;margin-top:auto!important}.gutenberg-kit .block-editor-inserter__menu .block-editor-tabbed-sidebar__tabpanel button{font-size:16px}.gutenberg-kit .block-editor-inserter__menu .block-editor-tabbed-sidebar__tabpanel .block-editor-inserter__patterns-category-panel-title{font-size:18px}.gutenberg-kit .block-editor-inserter__menu .block-editor-tabbed-sidebar__tabpanel .block-editor-block-patterns-list__item-title{font-size:16px}.gutenberg-kit .block-editor-inserter__popover .components-dropdown__content .components-popover__content{padding:0 8px!important}.gutenberg-kit .components-popover__header-title{font-size:17px;font-weight:600;text-align:center}.gutenberg-kit .block-editor-inserter__panel-title{font-size:15px;font-weight:600;margin-left:12px}.gutenberg-kit .components-draggable-drag-component-root{display:none!important}.gutenberg-kit .components-button.block-editor-block-types-list__item{-webkit-tap-highlight-color:transparent;pointer-events:auto}.gutenberg-kit .components-button.block-editor-block-types-list__item:not(:disabled):hover:after{background:transparent}.gutenberg-kit .components-button.block-editor-block-types-list__item:not(:disabled):hover .block-editor-block-types-list__item-title{color:inherit!important}.gutenberg-kit .components-button.block-editor-block-types-list__item:not(:disabled):hover svg{color:inherit!important}.gutenberg-kit .block-editor-block-types-list__list-item{pointer-events:auto}.gutenberg-kit .block-editor-block-types-list__item{pointer-events:none}.gutenberg-kit .block-editor-block-types-list__item-icon{scale:1.3}.gutenberg-kit .block-editor-block-types-list__item-title{font-size:17px}.gutenberg-kit .block-editor-block-card__description{font-size:15px!important;margin-top:-4px!important}.gutenberg-kit .block-editor-block-inspector h2{font-size:17px!important;font-weight:600!important}.gutenberg-kit .components-base-control__help{font-size:13px!important}.gutenberg-kit .components-menu-group__label{font-size:13px}.gutenberg-kit .block-editor-block-card__title{font-size:15px!important}.gutenberg-kit .components-toggle-control__label{font-size:17px}.gutenberg-kit .components-menu-item__item span{font-size:17px!important}.gutenberg-kit .components-base-control__label{font-size:13px!important;color:gray}.gutenberg-kit .components-placeholder__label{font-size:17px!important}.gutenberg-kit .blocks-table__placeholder-form{gap:16px!important}.gutenberg-kit .components-popover.block-editor-block-switcher__popover .components-popover__content{min-width:274px!important}
2 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/assets/vite-helpers-C1_kvBzA.js:
--------------------------------------------------------------------------------
1 | (function(){const r=document.createElement("link").relList;if(r&&r.supports&&r.supports("modulepreload"))return;for(const e of document.querySelectorAll('link[rel="modulepreload"]'))o(e);new MutationObserver(e=>{for(const t of e)if(t.type==="childList")for(const n of t.addedNodes)n.tagName==="LINK"&&n.rel==="modulepreload"&&o(n)}).observe(document,{childList:!0,subtree:!0});function l(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin==="use-credentials"?t.credentials="include":e.crossOrigin==="anonymous"?t.credentials="omit":t.credentials="same-origin",t}function o(e){if(e.ep)return;e.ep=!0;const t=l(e);fetch(e.href,t)}})();const y="modulepreload",v=function(a,r){return new URL(a,r).href},p={},P=function(r,l,o){let e=Promise.resolve();if(l&&l.length>0){const n=document.getElementsByTagName("link"),s=document.querySelector("meta[property=csp-nonce]"),m=s?.nonce||s?.getAttribute("nonce");e=Promise.allSettled(l.map(i=>{if(i=v(i,o),i in p)return;p[i]=!0;const u=i.endsWith(".css"),h=u?'[rel="stylesheet"]':"";if(!!o)for(let f=n.length-1;f>=0;f--){const d=n[f];if(d.href===i&&(!u||d.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${i}"]${h}`))return;const c=document.createElement("link");if(c.rel=u?"stylesheet":y,u||(c.as="script"),c.crossOrigin="",c.href=i,m&&c.setAttribute("nonce",m),document.head.appendChild(c),u)return new Promise((f,d)=>{c.addEventListener("load",f),c.addEventListener("error",()=>d(new Error(`Unable to preload CSS for ${i}`)))})}))}function t(n){const s=new Event("vite:preloadError",{cancelable:!0});if(s.payload=n,window.dispatchEvent(s),!s.defaultPrevented)throw n}return e.then(n=>{for(const s of n||[])s.status==="rejected"&&t(s.reason);return r().catch(t)})},b=(a,r,l)=>{const o=a[r];return o?typeof o=="function"?o():Promise.resolve(o):new Promise((e,t)=>{(typeof queueMicrotask=="function"?queueMicrotask:setTimeout)(t.bind(null,new Error("Unknown variable dynamic import: "+r+(r.split("/").length!==l?". Note that variables only represent file names one level deep.":""))))})};export{P as _,b as a};
2 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Gutenberg
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Gutenberg/remote.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Gutenberg
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/AnyDecodable.swift:
--------------------------------------------------------------------------------
1 | public enum AnyDecodable: Equatable, Codable {
2 | case string(String)
3 | case number(Double)
4 | case object([String: AnyDecodable])
5 | case array([AnyDecodable])
6 | case bool(Bool)
7 |
8 | var value: Any {
9 | switch self {
10 | case .string(let string): return string
11 | case .number(let double): return double
12 | case .object(let dictionary): return dictionary
13 | case .array(let array): return array
14 | case .bool(let bool): return bool
15 | }
16 | }
17 |
18 | public func encode(to encoder: Encoder) throws {
19 | var container = encoder.singleValueContainer()
20 | switch self {
21 | case let .array(array): try container.encode(array)
22 | case let .object(object): try container.encode(object)
23 | case let .string(string): try container.encode(string)
24 | case let .number(number): try container.encode(number)
25 | case let .bool(bool): try container.encode(bool)
26 | }
27 | }
28 |
29 | public init(from decoder: Decoder) throws {
30 | let container = try decoder.singleValueContainer()
31 | if let object = try? container.decode([String: AnyDecodable].self) {
32 | self = .object(object)
33 | } else if let array = try? container.decode([AnyDecodable].self) {
34 | self = .array(array)
35 | } else if let string = try? container.decode(String.self) {
36 | self = .string(string)
37 | } else if let bool = try? container.decode(Bool.self) {
38 | self = .bool(bool)
39 | } else if let number = try? container.decode(Double.self) {
40 | self = .number(number)
41 | } else {
42 | throw DecodingError.dataCorrupted(
43 | .init(codingPath: decoder.codingPath, debugDescription: "Invalid JSON value.")
44 | )
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/EditorBlock.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | // TODO: hide AnyDecodable
4 | final class EditorBlock: Decodable {
5 | /// The name of the block, e.g. `core/paragraph`.
6 | var name: String
7 | /// The attributes of the block.
8 | var attributes: [String: AnyDecodable]
9 | /// The nested blocks.
10 | var innerBlocks: [EditorBlock]
11 | }
12 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/EditorConfiguration.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct EditorConfiguration {
4 | /// The initial title to initialize the editor with.
5 | public var title = ""
6 | /// The initial content to initialize the editor with.
7 | public var content = ""
8 |
9 | public var postID: Int?
10 | public var postType: String?
11 | public var themeStyles = false
12 | public var plugins = false
13 | public var hideTitle = false
14 | public var siteURL = ""
15 | public var siteApiRoot = ""
16 | public var siteApiNamespace: [String] = []
17 | public var namespaceExcludedPaths: [String] = []
18 | public var authHeader = ""
19 | public var webViewGlobals: [WebViewGlobal] = []
20 | /// Raw block editor settings from the WordPress REST API
21 | public var editorSettings: [String: Any]?
22 | /// The locale to use for translations
23 | public var locale = "en"
24 |
25 | public init(title: String = "", content: String = "") {
26 | self.title = title
27 | self.content = content
28 | }
29 |
30 | public mutating func updateEditorSettings(_ settings: [String: Any]?) {
31 | self.editorSettings = settings
32 | }
33 |
34 | public static let `default` = EditorConfiguration()
35 | }
36 |
37 | public struct WebViewGlobal {
38 | let name: String
39 | let value: WebViewGlobalValue
40 |
41 | public init(name: String, value: WebViewGlobalValue) throws {
42 | // Validate name is a valid JavaScript identifier
43 | guard Self.isValidJavaScriptIdentifier(name) else {
44 | throw WebViewGlobalError.invalidIdentifier(name)
45 | }
46 | self.name = name
47 | self.value = value
48 | }
49 |
50 | private static func isValidJavaScriptIdentifier(_ name: String) -> Bool {
51 | // Add validation logic for JavaScript identifiers
52 | return name.range(of: "^[a-zA-Z_$][a-zA-Z0-9_$]*$", options: .regularExpression) != nil
53 | }
54 | }
55 |
56 | public enum WebViewGlobalError: Error {
57 | case invalidIdentifier(String)
58 | }
59 |
60 | public enum WebViewGlobalValue {
61 | case string(String)
62 | case number(Double)
63 | case boolean(Bool)
64 | case object([String: WebViewGlobalValue])
65 | case array([WebViewGlobalValue])
66 | case null
67 |
68 | func toJavaScript() -> String {
69 | switch self {
70 | case .string(let str):
71 | return "\"\(str.escaped)\""
72 | case .number(let num):
73 | return "\(num)"
74 | case .boolean(let bool):
75 | return "\(bool)"
76 | case .object(let dict):
77 | let pairs = dict.map { key, value in
78 | "\"\(key.escaped)\": \(value.toJavaScript())"
79 | }
80 | return "{\(pairs.joined(separator: ","))}"
81 | case .array(let array):
82 | return "[\(array.map { $0.toJavaScript() }.joined(separator: ","))]"
83 | case .null:
84 | return "null"
85 | }
86 | }
87 | }
88 |
89 | // String escaping extension
90 | private extension String {
91 | var escaped: String {
92 | return self.replacingOccurrences(of: "\"", with: "\\\"")
93 | .replacingOccurrences(of: "\n", with: "\\n")
94 | .replacingOccurrences(of: "\r", with: "\\r")
95 | .replacingOccurrences(of: "\t", with: "\\t")
96 | .replacingOccurrences(of: "\u{8}", with: "\\b")
97 | .replacingOccurrences(of: "\u{12}", with: "\\f")
98 | }
99 | }
100 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift:
--------------------------------------------------------------------------------
1 | import WebKit
2 |
3 | /// A type that represents JavaScript messages send from and to the web view.
4 | struct EditorJSMessage {
5 | let type: MessageType
6 | let body: Any?
7 |
8 | init?(message: WKScriptMessage) {
9 | guard let object = message.body as? [String: Any],
10 | let type = (object["message"] as? String).flatMap(MessageType.init) else {
11 | return nil
12 | }
13 | self.type = type
14 | self.body = object["body"]
15 | }
16 |
17 | func decode(_ type: T.Type) throws -> T {
18 | guard let body else {
19 | throw URLError(.unknown)
20 | }
21 | let data = try JSONSerialization.data(withJSONObject: body, options: [])
22 | return try JSONDecoder().decode(T.self, from: data)
23 | }
24 |
25 | enum MessageType: String {
26 | /// The editor was mounted (initial useEffect was called).
27 | case onEditorLoaded
28 | /// The editor content changed.
29 | case onEditorContentChanged
30 | /// The editor history (undo, redo) changed.
31 | case onEditorHistoryChanged
32 | /// The editor logged an exception.
33 | case onEditorExceptionLogged
34 | /// The user tapped the inserter button.
35 | case showBlockPicker
36 | /// User requested the Media Library.
37 | case openMediaLibrary
38 | }
39 |
40 | struct DidUpdateBlocksBody: Decodable {
41 | let isEmpty: Bool
42 | }
43 |
44 | struct DidUpdateEditorHistoryBody: Decodable {
45 | let hasUndo: Bool
46 | let hasRedo: Bool
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/EditorNetworking.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public protocol EditorNetworkingClient {
4 | /// Asks the delegate to perform the network request on behalf of the editor.
5 | func send(_ request: EditorNetworkRequest) async throws -> EditorNetworkResponse
6 | }
7 |
8 | /// An HTTP network request.
9 | public struct EditorNetworkRequest: Sendable {
10 | public var method: String
11 | public var url: URL
12 |
13 | // TODO: Add support for remainig fields
14 | // public var query: [(String, String?)]?
15 | // public var body: Data?
16 | // public var headers: [String: String]?
17 | // public var id: String?
18 | }
19 |
20 | /// A response with an associated value and metadata.
21 | public struct EditorNetworkResponse: Sendable {
22 | public let urlResponse: URLResponse
23 | public let data: Data?
24 |
25 | public init(urlResponse: URLResponse, data: Data? = nil) {
26 | self.urlResponse = urlResponse
27 | self.data = data
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/EditorService.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | /// A service that manages the editor backend.
4 | ///
5 | /// - note: The service can be instantiated and warmed-up before the editor
6 | /// is presented.
7 | @MainActor
8 | public final class EditorService {
9 | private let client: EditorNetworkingClient
10 |
11 | @Published private(set) var blockTypes: [EditorBlockType] = []
12 | @Published private(set) var rawBlockTypesResponseData: Data?
13 |
14 | private var refreshBlockTypesTask: Task<[EditorBlockType], Error>?
15 |
16 | public init(client: EditorNetworkingClient) {
17 | self.client = client
18 | }
19 |
20 | /// Prefetches the settings used by the editor.
21 | public func warmup() async {
22 | _ = try? await refreshBlockTypes()
23 | }
24 |
25 | func refreshBlockTypes() async throws -> [EditorBlockType] {
26 | if let task = refreshBlockTypesTask {
27 | return try await task.value
28 | }
29 | let task = Task {
30 | let request = EditorNetworkRequest(method: "GET", url: URL(string: "./wp-json/wp/v2/block-types")!)
31 | let response = try await self.client.send(request)
32 | try validate(response.urlResponse)
33 | self.blockTypes = try await decode(response.data ?? Data())
34 | self.rawBlockTypesResponseData = response.data ?? Data()
35 | return blockTypes
36 | }
37 | self.refreshBlockTypesTask = task
38 | return try await task.value
39 | }
40 | }
41 |
42 | // MARK: - Helpers
43 |
44 | private func decode(_ data: Data, using decoder: JSONDecoder = JSONDecoder()) async throws -> T {
45 | try await Task.detached {
46 | try decoder.decode(T.self, from: data)
47 | }.value
48 | }
49 |
50 | private func validate(_ response: URLResponse) throws {
51 | guard let response = response as? HTTPURLResponse else {
52 | throw EditorRequestError.invalidResponseType
53 | }
54 | guard (200..<300).contains(response.statusCode) else {
55 | throw EditorRequestError.unacceptableStatusCode(response.statusCode)
56 | }
57 | }
58 |
59 | enum EditorRequestError: Error {
60 | case invalidResponseType
61 | case unacceptableStatusCode(Int)
62 | }
63 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/EditorTypes.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | struct EditorBlockType: Decodable, Identifiable {
4 | var id: String { name }
5 |
6 | let name: String
7 | let title: String?
8 | let description: String?
9 | let category: String?
10 | let keywords: [String]?
11 | }
12 |
13 | public struct EditorTitleAndContent: Decodable {
14 | public let title: String
15 | public let content: String
16 | public let changed: Bool
17 | }
18 |
19 |
--------------------------------------------------------------------------------
/ios/Sources/GutenbergKit/Sources/GBWebView.swift:
--------------------------------------------------------------------------------
1 | import WebKit
2 |
3 | class GBWebView: WKWebView {
4 |
5 | /// Disables the default bottom bar that competes with the Gutenberg inserter
6 | ///
7 | override var inputAccessoryView: UIView? {
8 | nil
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/ios/Tests/GutenbergKitTests/EditorConfigurationTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import GutenbergKit
3 |
4 | final class EditorConfigurationTests: XCTestCase {
5 |
6 | // MARK: - WebViewGlobal Tests
7 |
8 | func testValidJavaScriptIdentifiers() {
9 | let validIdentifiers = [
10 | "myVar",
11 | "_privateVar",
12 | "$jQuery",
13 | "myVar123",
14 | "MY_CONSTANT",
15 | "a",
16 | "A"
17 | ]
18 |
19 | for identifier in validIdentifiers {
20 | XCTAssertNoThrow(try WebViewGlobal(name: identifier, value: .string("test")))
21 | }
22 | }
23 |
24 | func testInvalidJavaScriptIdentifiers() {
25 | let invalidIdentifiers = [
26 | "123invalid",
27 | "my-var",
28 | "my.var",
29 | "my var",
30 | "",
31 | "my@var",
32 | "my#var"
33 | ]
34 |
35 | for identifier in invalidIdentifiers {
36 | XCTAssertThrowsError(try WebViewGlobal(name: identifier, value: .string("test")))
37 | }
38 | }
39 |
40 | // MARK: - WebViewGlobalValue Tests
41 |
42 | func testStringValueConversion() {
43 | let testCases = [
44 | ("simple", "\"simple\""),
45 | ("with \"quotes\"", "\"with \\\"quotes\\\"\""),
46 | ("with\nnewline", "\"with\\nnewline\""),
47 | ("with\ttab", "\"with\\ttab\""),
48 | ("with\rreturn", "\"with\\rreturn\""),
49 | ("with\u{8}backspace", "\"with\\bbackspace\""),
50 | ("with\u{12}formfeed", "\"with\\fformfeed\"")
51 | ]
52 |
53 | for (input, expected) in testCases {
54 | let value = WebViewGlobalValue.string(input)
55 | XCTAssertEqual(value.toJavaScript(), expected)
56 | }
57 | }
58 |
59 | func testNumberValueConversion() {
60 | let testCases = [
61 | (42.0, "42.0"),
62 | (-3.14, "-3.14"),
63 | (0.0, "0.0"),
64 | (1.0, "1.0")
65 | ]
66 |
67 | for (input, expected) in testCases {
68 | let value = WebViewGlobalValue.number(input)
69 | XCTAssertEqual(value.toJavaScript(), expected)
70 | }
71 | }
72 |
73 | func testBooleanValueConversion() {
74 | XCTAssertEqual(WebViewGlobalValue.boolean(true).toJavaScript(), "true")
75 | XCTAssertEqual(WebViewGlobalValue.boolean(false).toJavaScript(), "false")
76 | }
77 |
78 | func testNullValueConversion() {
79 | XCTAssertEqual(WebViewGlobalValue.null.toJavaScript(), "null")
80 | }
81 |
82 | func testObjectValueConversion() {
83 | let object = WebViewGlobalValue.object([
84 | "name": .string("test"),
85 | "count": .number(42),
86 | "active": .boolean(true),
87 | "nested": .object([
88 | "value": .string("nested")
89 | ])
90 | ])
91 |
92 | let actual = object.toJavaScript()
93 | let expected = "{\"name\": \"test\",\"active\": true,\"count\": 42.0,\"nested\": {\"value\": \"nested\"}}"
94 |
95 | guard let actualData = actual.data(using: .utf8),
96 | let expectedData = expected.data(using: .utf8),
97 | let actualJSON = try? JSONSerialization.jsonObject(with: actualData) as? [String: Any],
98 | let expectedJSON = try? JSONSerialization.jsonObject(with: expectedData) as? [String: Any] else {
99 | XCTFail("Failed to parse JSON")
100 | return
101 | }
102 |
103 | XCTAssertEqual(actualJSON as NSDictionary, expectedJSON as NSDictionary)
104 | }
105 |
106 | func testArrayValueConversion() {
107 | let array = WebViewGlobalValue.array([
108 | .string("test"),
109 | .number(42),
110 | .boolean(true),
111 | .null
112 | ])
113 |
114 | let expected = "[\"test\",42.0,true,null]"
115 | XCTAssertEqual(array.toJavaScript(), expected)
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/ios/Tests/GutenbergKitTests/GutenbergKitTests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 | @testable import GutenbergKit
3 |
4 | final class GutenbergKitTests: XCTestCase {
5 | func testExample() throws {
6 | XCTAssert(true)
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gutenberg-kit",
3 | "private": true,
4 | "homepage": "https://github.com/wordpress-mobile/GutenbergKit",
5 | "version": "0.2.0",
6 | "type": "module",
7 | "scripts": {
8 | "clean": "npm run clean:dist && npm run clean:l10n",
9 | "clean:dist": "rm -rf dist",
10 | "clean:l10n": "rm -rf src/translations",
11 | "dev": "vite --host",
12 | "dev:remote": "vite --host --config vite.config.remote.js",
13 | "build": "vite --emptyOutDir build && vite build --emptyOutDir=false --config vite.config.remote.js",
14 | "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
15 | "format": "prettier --write .",
16 | "postinstall": "patch-package && npm run prep-translations",
17 | "prep-translations": "node bin/prep-translations.js",
18 | "preview": "vite preview --host",
19 | "test": "vitest"
20 | },
21 | "dependencies": {
22 | "@wordpress/api-fetch": "^7.10",
23 | "@wordpress/base-styles": "^5.22.0",
24 | "@wordpress/block-editor": "^14.17.0",
25 | "@wordpress/block-library": "^9.22.0",
26 | "@wordpress/blocks": "^14.7.0",
27 | "@wordpress/components": "^29.4.0",
28 | "@wordpress/core-data": "^7.22.0",
29 | "@wordpress/data": "^10.18.0",
30 | "@wordpress/edit-post": "^8.22.0",
31 | "@wordpress/editor": "^14.22.0",
32 | "@wordpress/element": "^6.10",
33 | "@wordpress/format-library": "^5.22.0",
34 | "@wordpress/hooks": "^4.18.0",
35 | "@wordpress/i18n": "^5.18.0",
36 | "@wordpress/icons": "^10.16.0",
37 | "@wordpress/preferences": "^4.16.0",
38 | "@wordpress/private-apis": "^1.10",
39 | "@wordpress/rich-text": "^7.16.0",
40 | "clsx": "^2.1.1",
41 | "vite-plugin-node-polyfills": "^0.23.0"
42 | },
43 | "devDependencies": {
44 | "@testing-library/dom": "^10.4.0",
45 | "@testing-library/jest-dom": "^6.6.3",
46 | "@testing-library/react": "^16.3.0",
47 | "@testing-library/user-event": "^14.6.1",
48 | "@types/react": "^19.1.2",
49 | "@types/react-dom": "^19.1.2",
50 | "@vitejs/plugin-react": "^4.4.0",
51 | "@wordpress/dependency-extraction-webpack-plugin": "^6.22.0",
52 | "@wordpress/eslint-plugin": "^22.8.0",
53 | "@wordpress/prettier-config": "^4.22.0",
54 | "eslint": "^8.57",
55 | "eslint-plugin-react-refresh": "^0.4.19",
56 | "jsdom": "^26.1.0",
57 | "magic-string": "^0.30.17",
58 | "node-fetch": "^3.3.2",
59 | "patch-package": "^8.0.0",
60 | "prettier": "npm:wp-prettier@^3.0.3",
61 | "sass-embedded": "^1.86.3",
62 | "vite": "^6.3.0",
63 | "vitest": "^3.1.3"
64 | },
65 | "overrides": {
66 | "gradient-parser": "^1.0.2"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/patches/@wordpress+block-editor+14.17.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@wordpress/block-editor/build-module/components/inserter/index.js b/node_modules/@wordpress/block-editor/build-module/components/inserter/index.js
2 | index 78b9b8f..9a8efb8 100644
3 | --- a/node_modules/@wordpress/block-editor/build-module/components/inserter/index.js
4 | +++ b/node_modules/@wordpress/block-editor/build-module/components/inserter/index.js
5 | @@ -187,10 +187,12 @@ class Inserter extends Component {
6 | 'is-quick': isQuick
7 | }),
8 | popoverProps: {
9 | + ...this.props.popoverProps,
10 | position,
11 | shift: true
12 | },
13 | onToggle: this.onToggle,
14 | + open: this.props.open,
15 | expandOnMobile: true,
16 | headerTitle: __('Add a block'),
17 | renderToggle: this.renderToggle,
18 | diff --git a/node_modules/@wordpress/block-editor/build-module/components/provider/index.js b/node_modules/@wordpress/block-editor/build-module/components/provider/index.js
19 | index e89fa49..0f14ae8 100644
20 | --- a/node_modules/@wordpress/block-editor/build-module/components/provider/index.js
21 | +++ b/node_modules/@wordpress/block-editor/build-module/components/provider/index.js
22 | @@ -104,7 +104,7 @@ export const ExperimentalBlockEditorProvider = withRegistryProvider(props => {
23 | export const BlockEditorProvider = props => {
24 | return /*#__PURE__*/_jsx(ExperimentalBlockEditorProvider, {
25 | ...props,
26 | - stripExperimentalSettings: true,
27 | + stripExperimentalSettings: false,
28 | children: props.children
29 | });
30 | };
31 |
--------------------------------------------------------------------------------
/patches/@wordpress+components+29.8.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@wordpress/components/build-module/form-file-upload/index.js b/node_modules/@wordpress/components/build-module/form-file-upload/index.js
2 | index 9f1440e..e256971 100644
3 | --- a/node_modules/@wordpress/components/build-module/form-file-upload/index.js
4 | +++ b/node_modules/@wordpress/components/build-module/form-file-upload/index.js
5 | @@ -55,12 +55,23 @@ export function FormFileUpload({
6 | ...props,
7 | children: children
8 | });
9 | + const isSafari = typeof window !== "undefined" &&
10 | + !!window.document?.createElement &&
11 | + /mac|iphone|ipad|ipod/i.test( window?.navigator?.platform ) &&
12 | + /apple/i.test(window?.navigator?.vendor);
13 | + let compatAccept = accept;
14 | // @todo: Temporary fix a bug that prevents Chromium browsers from selecting ".heic" files
15 | // from the file upload. See https://core.trac.wordpress.org/ticket/62268#comment:4.
16 | // This can be removed once the Chromium fix is in the stable channel.
17 | // Prevent Safari from adding "image/heic" and "image/heif" to the accept attribute.
18 | - const isSafari = globalThis.window?.navigator.userAgent.includes('Safari') && !globalThis.window?.navigator.userAgent.includes('Chrome') && !globalThis.window?.navigator.userAgent.includes('Chromium');
19 | - const compatAccept = !isSafari && !!accept?.includes('image/*') ? `${accept}, image/heic, image/heif` : accept;
20 | + if (!isSafari && !!accept?.includes('image/*')) {
21 | + compatAccept = `${accept}, image/heic, image/heif`;
22 | + }
23 | + // iOS Safari's `accept` attribute lacks wildcard `audio/*` support.
24 | + // https://stackoverflow.com/a/66859581
25 | + if (isSafari && !!accept?.includes('audio/*')) {
26 | + compatAccept = `${accept}, audio/mp3, audio/x-m4a, audio/x-m4b, audio/x-m4p, audio/x-wav, audio/webm`;
27 | + }
28 | return /*#__PURE__*/_jsxs("div", {
29 | className: "components-form-file-upload",
30 | children: [ui, /*#__PURE__*/_jsx("input", {
31 |
--------------------------------------------------------------------------------
/patches/@wordpress+rich-text+7.22.0.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/@wordpress/rich-text/build-module/component/event-listeners/prevent-focus-capture.js b/node_modules/@wordpress/rich-text/build-module/component/event-listeners/prevent-focus-capture.js
2 | index 3b885f5..c2ea3d0 100644
3 | --- a/node_modules/@wordpress/rich-text/build-module/component/event-listeners/prevent-focus-capture.js
4 | +++ b/node_modules/@wordpress/rich-text/build-module/component/event-listeners/prevent-focus-capture.js
5 | @@ -36,9 +36,11 @@ export function preventFocusCapture() {
6 | }
7 | defaultView.addEventListener('pointerdown', onPointerDown);
8 | defaultView.addEventListener('pointerup', onPointerUp);
9 | + defaultView.addEventListener('pointercancel', onPointerUp);
10 | return () => {
11 | defaultView.removeEventListener('pointerdown', onPointerDown);
12 | defaultView.removeEventListener('pointerup', onPointerUp);
13 | + defaultView.removeEventListener('pointercancel', onPointerUp);
14 | };
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/patches/README.md:
--------------------------------------------------------------------------------
1 | # Dependency patches
2 |
3 | Sometimes there are problems with dependencies that can be solved by patching them. Gutenberg uses [patch-package](https://www.npmjs.com/package/patch-package) to patch npm dependencies when they're installed.
4 |
5 | Existing patches should be described and justified here.
6 |
7 | ## Patches
8 |
9 | ### `@wordpress/block-editor`
10 |
11 | - Expose an `open` prop on the `Inserter` component, allowing toggling the inserter visibility via the quick inserter's "Browse all" button.
12 | - Disable `stripExperimentalSettings` in the `BlockEditorProvider` component so that the Patterns and Media inserter tabs function.
13 | - Allow setting popover props for the `Inserter` component, so we can improve the mobile screen reader experience by marking it as a modal dialog.
14 |
15 | ### `@wordpress/components`
16 |
17 | - Apply workaround to `FormFileUpload` to address iOS Safari's lack of support for a wildcard `audio/*` MIME type. Can be removed once [the issue](https://github.com/WordPress/gutenberg/issues/70119) is resolved in a future release.
18 |
19 | ### `@wordpress/rich-text`
20 |
21 | - Fix `preventFocusCapture` causing uneditable text blocks on touch devices when scrolling by swiping outside of the block canvas--e.g., along the edge of the screen.
22 |
--------------------------------------------------------------------------------
/src/components/default-block-appender/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { createBlock } from '@wordpress/blocks';
5 | import { useDispatch, useSelect } from '@wordpress/data';
6 | import { __ } from '@wordpress/i18n';
7 | import { store as blockEditorStore } from '@wordpress/block-editor';
8 |
9 | /**
10 | * Internal dependencies
11 | */
12 | import './style.scss';
13 |
14 | /**
15 | * Renders a hidden button that, when clicked, inserts a new paragraph block
16 | * at the end of the block editor.
17 | *
18 | * @return {JSX.Element} The rendered button element.
19 | */
20 | export default function DefaultBlockAppender() {
21 | const { insertBlock } = useDispatch( blockEditorStore );
22 | const { blockCount } = useSelect( ( select ) => {
23 | const { getBlockCount } = select( blockEditorStore );
24 | return {
25 | blockCount: getBlockCount(),
26 | };
27 | } );
28 |
29 | const onAddParagraphBlock = () => {
30 | const paragraphBlock = createBlock( 'core/paragraph' );
31 | insertBlock( paragraphBlock, blockCount );
32 | };
33 |
34 | return (
35 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/default-block-appender/style.scss:
--------------------------------------------------------------------------------
1 | .gutenberg-kit-default-block-appender {
2 | background: none;
3 | border: none;
4 | height: 80px;
5 | width: 100%;
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/editor-load-error/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { Notice } from '@wordpress/components';
5 | import { __ } from '@wordpress/i18n';
6 |
7 | /**
8 | * Internal dependencies
9 | */
10 | import './style.scss';
11 |
12 | /**
13 | * Displays an error notice when the editor fails to load.
14 | *
15 | * @param {Object} props Component props
16 | * @param {string} props.error Error message displayed in the notice
17 | *
18 | * @return {JSX.Element} Editor load error component
19 | */
20 | const EditorLoadError = ( { error } ) => {
21 | return (
22 |
23 |
28 | { __( 'Editor Load Error', 'gutenberg-kit' ) }
29 |
30 | { __(
31 | 'Sorry, loading the experimental editor failed. Please try reopening the editor.',
32 | 'gutenberg-kit'
33 | ) }
34 |
35 |
36 | { __(
37 | 'If the problem persists, please contact support or disable the experimental editor within the app settings.',
38 | 'gutenberg-kit'
39 | ) }
40 |
41 | { error && (
42 |
43 |
44 |
45 | { __(
46 | 'Tap to view error details',
47 | 'gutenberg-kit'
48 | ) }
49 |
50 |
51 |
52 |
53 | { error.message || error }
54 |
55 |
56 | ) }
57 |
58 |
59 | );
60 | };
61 |
62 | export default EditorLoadError;
63 |
--------------------------------------------------------------------------------
/src/components/editor-load-error/style.scss:
--------------------------------------------------------------------------------
1 | .gutenberg-kit-editor-load-error {
2 | padding: 20px;
3 | }
4 |
5 | .gutenberg-kit-editor-load-error__message {
6 | font-size: 14px;
7 | }
8 |
9 | .gutenberg-kit-editor-load-error__details {
10 | white-space: pre-wrap;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/editor-load-notice.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { Notice } from '@wordpress/components';
5 | import { __ } from '@wordpress/i18n';
6 | import { useState, useEffect } from '@wordpress/element';
7 |
8 | /**
9 | * Displays a notice with actions to retry or dismiss.
10 | *
11 | * @param {Object} props Component props.
12 | * @param {string} props.className Additional class names to apply.
13 | *
14 | * @return {?JSX.Element} The rendered component or null if no notice is present.
15 | */
16 | export default function EditorLoadNotice( { className } ) {
17 | const { notice, clearNotice } = useEditorLoadNotice();
18 |
19 | const actions = [
20 | {
21 | label: __( 'Retry', 'gutenberg-kit' ),
22 | onClick: () => ( window.location.href = 'remote.html' ),
23 | variant: 'primary',
24 | },
25 | {
26 | label: __( 'Dismiss', 'gutenberg-kit' ),
27 | onClick: clearNotice,
28 | variant: 'secondary',
29 | },
30 | ];
31 |
32 | if ( ! notice ) {
33 | return null;
34 | }
35 |
36 | return (
37 |
38 |
43 | { notice }
44 |
45 |
46 | );
47 | }
48 |
49 | /**
50 | * Conditionally and temporarily sets a notice message based on the URL.
51 | *
52 | * @return {{notice:string, clearNotice:()=>void}} The notice message and a function to clear it.
53 | */
54 | function useEditorLoadNotice() {
55 | const [ notice, setNotice ] = useState( null );
56 |
57 | useEffect( () => {
58 | const url = new URL( window.location.href );
59 | const error = url.searchParams.get( 'error' );
60 |
61 | let message = null;
62 | switch ( error ) {
63 | case REMOTE_EDITOR_LOAD_ERROR:
64 | message = __(
65 | "Oops! We couldn't load your site's editor and plugins. Don't worry, you can use the default editor for now.",
66 | 'gutenberg-kit'
67 | );
68 | break;
69 | case GBKIT_GLOBAL_UNAVAILABLE:
70 | message = __(
71 | "Oops! Configuration for your site editor was unavailable. Don't worry, you can use the default editor for now.",
72 | 'gutenberg-kit'
73 | );
74 | break;
75 | default:
76 | message = null;
77 | }
78 |
79 | setNotice( message );
80 | }, [] );
81 |
82 | useEffect( () => {
83 | if ( notice ) {
84 | const timeout = setTimeout( () => {
85 | setNotice( null );
86 | }, 20000 );
87 | return () => clearTimeout( timeout );
88 | }
89 | }, [ notice ] );
90 |
91 | return { notice, clearNotice: () => setNotice( null ) };
92 | }
93 |
94 | const REMOTE_EDITOR_LOAD_ERROR = 'remote_editor_load_error';
95 | const GBKIT_GLOBAL_UNAVAILABLE = 'gbkit_global_unavailable';
96 |
--------------------------------------------------------------------------------
/src/components/editor-load-notice.test.jsx:
--------------------------------------------------------------------------------
1 | // @vitest-environment jsdom
2 |
3 | /**
4 | * External dependencies
5 | */
6 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7 | import { render, screen, fireEvent, act } from '@testing-library/react';
8 |
9 | /**
10 | * Internal dependencies
11 | */
12 | import EditorLoadNotice from './editor-load-notice';
13 |
14 | vi.mock( '@wordpress/i18n' );
15 | vi.mock( '@wordpress/components' );
16 |
17 | describe( 'EditorLoadNotice', () => {
18 | beforeEach( () => {
19 | vi.useFakeTimers();
20 | vi.stubGlobal( 'location', {
21 | href: 'https://example.com',
22 | } );
23 | } );
24 |
25 | afterEach( () => {
26 | vi.useRealTimers();
27 | vi.unstubAllGlobals();
28 | } );
29 |
30 | it( 'renders nothing when no error is present', () => {
31 | render( );
32 | expect( screen.queryByTestId( 'mock-notice' ) ).not.toBeInTheDocument();
33 | } );
34 |
35 | it( 'renders remote editor load error notice', () => {
36 | vi.stubGlobal( 'location', {
37 | href: 'https://example.com?error=remote_editor_load_error',
38 | } );
39 |
40 | render( );
41 |
42 | expect( screen.getByTestId( 'mock-notice' ) ).toBeInTheDocument();
43 | expect(
44 | screen.getByText(
45 | "Oops! We couldn't load your site's editor and plugins. Don't worry, you can use the default editor for now."
46 | )
47 | ).toBeInTheDocument();
48 | } );
49 |
50 | it( 'renders global unavailable error notice', () => {
51 | vi.stubGlobal( 'location', {
52 | href: 'https://example.com?error=gbkit_global_unavailable',
53 | } );
54 |
55 | render( );
56 |
57 | expect( screen.getByTestId( 'mock-notice' ) ).toBeInTheDocument();
58 | expect(
59 | screen.getByText(
60 | "Oops! Configuration for your site editor was unavailable. Don't worry, you can use the default editor for now."
61 | )
62 | ).toBeInTheDocument();
63 | } );
64 |
65 | it( 'clears notice when clicking Dismiss button', () => {
66 | vi.stubGlobal( 'location', {
67 | href: 'https://example.com?error=remote_editor_load_error',
68 | } );
69 |
70 | render( );
71 |
72 | expect( screen.getByTestId( 'mock-notice' ) ).toBeInTheDocument();
73 |
74 | fireEvent.click( screen.getByText( 'Dismiss' ) );
75 |
76 | expect( screen.queryByTestId( 'mock-notice' ) ).not.toBeInTheDocument();
77 | } );
78 |
79 | it( 'redirects when clicking Retry button', () => {
80 | vi.stubGlobal( 'location', {
81 | href: 'https://example.com?error=remote_editor_load_error',
82 | } );
83 |
84 | render( );
85 |
86 | fireEvent.click( screen.getByText( 'Retry' ) );
87 |
88 | expect( window.location.href ).toBe( 'remote.html' );
89 | } );
90 |
91 | it( 'auto-dismisses notice after 20 seconds', () => {
92 | vi.stubGlobal( 'location', {
93 | href: 'https://example.com?error=remote_editor_load_error',
94 | } );
95 |
96 | render( );
97 |
98 | expect( screen.getByTestId( 'mock-notice' ) ).toBeInTheDocument();
99 |
100 | act( () => {
101 | vi.advanceTimersByTime( 20000 );
102 | } );
103 |
104 | expect( screen.queryByTestId( 'mock-notice' ) ).not.toBeInTheDocument();
105 | } );
106 |
107 | it( 'applies custom className to container', () => {
108 | vi.stubGlobal( 'location', {
109 | href: 'https://example.com?error=remote_editor_load_error',
110 | } );
111 |
112 | const { container } = render(
113 |
114 | );
115 |
116 | expect( container.firstChild ).toHaveClass( 'custom-class' );
117 | } );
118 | } );
119 |
--------------------------------------------------------------------------------
/src/components/editor-toolbar/aria-helper.js:
--------------------------------------------------------------------------------
1 | /**
2 | * This file was sourced from the Gutenberg project and converted from
3 | * TypeScript to JavaScript.
4 | *
5 | * @see https://github.com/WordPress/gutenberg/blob/3f0a805c568f92622faf4b71b24eeb5f39b5bca8/packages/components/src/modal/aria-helper.ts
6 | */
7 |
8 | const LIVE_REGION_ARIA_ROLES = new Set( [
9 | 'alert',
10 | 'status',
11 | 'log',
12 | 'marquee',
13 | 'timer',
14 | ] );
15 |
16 | const hiddenElementsByDepth = [];
17 |
18 | /**
19 | * Hides all elements in the body element from screen-readers except
20 | * the provided element and elements that should not be hidden from
21 | * screen-readers.
22 | *
23 | * The reason we do this is because `aria-modal="true"` currently is bugged
24 | * in Safari, and support is spotty in other browsers overall. In the future
25 | * we should consider removing these helper functions in favor of
26 | * `aria-modal="true"`.
27 | *
28 | * @param {Element} modalElement The element that should not be hidden.
29 | */
30 | export function modalize( modalElement ) {
31 | const elements = Array.from( document.body.children );
32 | const hiddenElements = [];
33 | hiddenElementsByDepth.push( hiddenElements );
34 | for ( const element of elements ) {
35 | if ( element === modalElement ) {
36 | continue;
37 | }
38 |
39 | if ( elementShouldBeHidden( element ) ) {
40 | element.setAttribute( 'aria-hidden', 'true' );
41 | hiddenElements.push( element );
42 | }
43 | }
44 | }
45 | /**
46 | * Determines if the passed element should not be hidden from screen readers.
47 | *
48 | * @param {Element} element The element that should be checked.
49 | *
50 | * @return {boolean} Whether the element should not be hidden from screen-readers.
51 | */
52 | export function elementShouldBeHidden( element ) {
53 | const role = element.getAttribute( 'role' );
54 | return ! (
55 | element.tagName === 'SCRIPT' ||
56 | element.hasAttribute( 'hidden' ) ||
57 | element.hasAttribute( 'aria-hidden' ) ||
58 | element.hasAttribute( 'aria-live' ) ||
59 | ( role && LIVE_REGION_ARIA_ROLES.has( role ) )
60 | );
61 | }
62 |
63 | /**
64 | * Accessibly reveals the elements hidden by the latest modal.
65 | */
66 | export function unmodalize() {
67 | const hiddenElements = hiddenElementsByDepth.pop();
68 | if ( ! hiddenElements ) {
69 | return;
70 | }
71 |
72 | for ( const element of hiddenElements ) {
73 | element.removeAttribute( 'aria-hidden' );
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/editor-toolbar/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useState } from '@wordpress/element';
5 | import {
6 | BlockInspector,
7 | BlockToolbar,
8 | Inserter,
9 | store as blockEditorStore,
10 | } from '@wordpress/block-editor';
11 | import { useSelect, useDispatch } from '@wordpress/data';
12 | import {
13 | Button,
14 | Popover,
15 | Toolbar,
16 | ToolbarGroup,
17 | ToolbarButton,
18 | } from '@wordpress/components';
19 | import { __ } from '@wordpress/i18n';
20 | import { close, cog } from '@wordpress/icons';
21 | import clsx from 'clsx';
22 | import { store as editorStore } from '@wordpress/editor';
23 |
24 | /**
25 | * Internal dependencies
26 | */
27 | import './style.scss';
28 | import { useModalize } from './use-modalize';
29 |
30 | /**
31 | * Renders the editor toolbar containing block-related actions.
32 | *
33 | * @param {Object} props Component props.
34 | * @param {string} props.className Component classes.
35 | * @return {JSX.Element} The rendered editor toolbar component.
36 | */
37 | const EditorToolbar = ( { className } ) => {
38 | const [ isBlockInspectorShown, setBlockInspectorShown ] = useState( false );
39 | const { isSelected } = useSelect( ( select ) => {
40 | const { getSelectedBlockClientId } = select( blockEditorStore );
41 | const selectedBlockClientId = getSelectedBlockClientId();
42 | return {
43 | isSelected: selectedBlockClientId !== null,
44 | };
45 | } );
46 | const { isInserterOpened } = useSelect( ( select ) => {
47 | return {
48 | isInserterOpened: select( editorStore ).isInserterOpened(),
49 | };
50 | }, [] );
51 | const { setIsInserterOpened } = useDispatch( editorStore );
52 |
53 | useModalize( isInserterOpened );
54 | useModalize( isBlockInspectorShown );
55 |
56 | function openSettings() {
57 | setBlockInspectorShown( true );
58 | }
59 |
60 | function onCloseSettings() {
61 | setBlockInspectorShown( false );
62 | }
63 |
64 | function onFocusOutside( event ) {
65 | // Do not close the menu if the focus is inside the menu--e.g., a button
66 | // opening an adjacent popover.
67 | if ( event.target.closest( '.block-settings-menu' ) ) {
68 | return;
69 | }
70 |
71 | setBlockInspectorShown( false );
72 | }
73 |
74 | const classes = clsx( 'gutenberg-kit-editor-toolbar', className );
75 |
76 | return (
77 | <>
78 |
83 |
84 |
92 |
93 |
94 | { isSelected && (
95 |
96 |
101 |
102 | ) }
103 |
104 |
105 |
106 |
107 | { isBlockInspectorShown && (
108 |
117 | <>
118 |
119 |
124 |
125 |
126 | >
127 |
128 | ) }
129 | >
130 | );
131 | };
132 |
133 | export default EditorToolbar;
134 |
--------------------------------------------------------------------------------
/src/components/editor-toolbar/style.scss:
--------------------------------------------------------------------------------
1 | @use "@wordpress/base-styles/colors" as wordpress;
2 |
3 | $border-color: #c8c7cc;
4 | $border-radius: 2px;
5 | $min-touch-target-size: 46px;
6 |
7 | .gutenberg-kit-editor-toolbar.is-unstyled {
8 | background-color: wordpress.$white;
9 | border-bottom: none;
10 | border-left: none;
11 | border-right: none;
12 | border-top: 1px solid $border-color;
13 | border-radius: 0;
14 | }
15 |
16 | // Disable overflow scrolling for the block toolbar, and rely upon scrolling the
17 | // parent editor toolbar to avoid nested scrolling views.
18 | .gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar {
19 | width: auto;
20 | }
21 |
22 | // Hide the scrollbar in the toolbar
23 | .gutenberg-kit-editor-toolbar::-webkit-scrollbar {
24 | display: none;
25 | }
26 |
27 | .gutenberg-kit-editor-toolbar .components-toolbar-group {
28 | border-right-color: $border-color;
29 | min-height: $min-touch-target-size;
30 | padding-left: 0;
31 | padding-right: 0;
32 | }
33 |
34 | .gutenberg-kit-editor-toolbar.gutenberg-kit-editor-toolbar
35 | .components-button.has-icon.has-icon {
36 | min-width: $min-touch-target-size;
37 | max-height: $min-touch-target-size;
38 | }
39 |
40 | .gutenberg-kit-editor-toolbar .block-editor-inserter__toggle svg {
41 | background: wordpress.$black;
42 | border-radius: $border-radius;
43 | color: wordpress.$white;
44 | }
45 |
46 | // Disable scrolling of the block toolbar, rely upon parent container scrolling
47 | .gutenberg-kit-editor-toolbar .block-editor-block-contextual-toolbar {
48 | flex-shrink: 0;
49 | }
50 |
51 | .gutenberg-kit-editor-toolbar
52 | .block-editor-block-contextual-toolbar.is-unstyled {
53 | box-shadow: none;
54 | }
55 |
56 | // Disable focus outline that is irrelevant for touch devices
57 | .gutenberg-kit-editor-toolbar.components-accessible-toolbar
58 | .components-button:focus::before,
59 | .gutenberg-kit-editor-toolbar.components-toolbar
60 | .components-button:focus::before {
61 | display: none;
62 | }
63 |
64 | // Synchronize dimensions of toggleable button pseudo elements to the overriden
65 | // width
66 | .gutenberg-kit-editor-toolbar.components-accessible-toolbar
67 | .components-button.is-pressed::before,
68 | .gutenberg-kit-editor-toolbar.components-toolbar
69 | .components-button.is-pressed::before {
70 | height: calc($min-touch-target-size - 12px);
71 | left: 6px;
72 | right: 6px;
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/editor-toolbar/use-modalize.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useEffect } from '@wordpress/element';
5 |
6 | /**
7 | * Internal dependencies
8 | */
9 | import * as ariaHelper from './aria-helper';
10 |
11 | /** @typedef {import('@wordpress/element').RefObject} RefObject */
12 |
13 | /**
14 | * Conditionally applies the `aria-hidden` attribute to all direct decendents of
15 | * the body element, except for the element provided.
16 | *
17 | * @param {boolean} isModalVisible A boolean indicating whether the modal is visible.
18 | * @param {RefObject} elementRef A reference to the DOM element to be modalized.
19 | */
20 | export function useModalize( isModalVisible, elementRef = defaultPopover() ) {
21 | useEffect( () => {
22 | if ( isModalVisible ) {
23 | ariaHelper.modalize( elementRef.current );
24 | } else {
25 | ariaHelper.unmodalize();
26 | }
27 | }, [ elementRef, isModalVisible ] );
28 | }
29 |
30 | const popoverFallbackContainerRef = { current: null };
31 |
32 | /**
33 | * Retrieves or initializes the fallback container for popovers.
34 | *
35 | * This function checks if the `popoverFallbackContainerRef` is already defined.
36 | * If not, it attempts to find an element with the class name
37 | * 'components-popover__fallback-container' in the document and assigns it to
38 | * `popoverFallbackContainerRef`. It then returns an object with the current
39 | * `popoverFallbackContainerRef`.
40 | *
41 | * @return {Object} An object containing the current `popoverFallbackContainerRef`.
42 | */
43 | function defaultPopover() {
44 | if ( popoverFallbackContainerRef.current ) {
45 | return popoverFallbackContainerRef;
46 | }
47 |
48 | popoverFallbackContainerRef.current = document.getElementById(
49 | 'popover-fallback-container'
50 | );
51 |
52 | return popoverFallbackContainerRef;
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/editor/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { store as coreStore } from '@wordpress/core-data';
5 | import { useSelect } from '@wordpress/data';
6 | import { store as editorStore, EditorProvider } from '@wordpress/editor';
7 | import { useRef } from '@wordpress/element';
8 |
9 | /**
10 | * Internal dependencies
11 | */
12 | import VisualEditor from '../visual-editor';
13 | import './style.scss';
14 | import { useSyncHistoryControls } from './use-sync-history-controls';
15 | import { useHostBridge } from './use-host-bridge';
16 | import { useHostExceptionLogging } from './use-host-exception-logging';
17 | import { useEditorSetup } from './use-editor-setup';
18 | import { useMediaUpload } from './use-media-upload';
19 | import TextEditor from '../text-editor';
20 |
21 | /**
22 | * @typedef {import('../utils/bridge').Post} Post
23 | */
24 |
25 | /**
26 | * Entry component rendering the editor and surrounding UI.
27 | *
28 | * @param {Object} props Component props.
29 | * @param {Post} props.post Post object containing post details.
30 | * @param {boolean} props.hideTitle Whether to hide the title input.
31 | * @param {import('@wordpress/element').Element} props.children The children to render in the editor.
32 | *
33 | * @return {JSX.Element} The rendered App component.
34 | */
35 | export default function Editor( { post, children, hideTitle } ) {
36 | const editorRef = useRef( null );
37 | useHostBridge( post, editorRef );
38 | useSyncHistoryControls();
39 | useHostExceptionLogging();
40 | useEditorSetup( post );
41 | useMediaUpload();
42 |
43 | const { isReady, mode, isRichEditingEnabled, currentPost } = useSelect(
44 | ( select ) => {
45 | const {
46 | __unstableIsEditorReady,
47 | getEditorSettings,
48 | getEditorMode,
49 | } = select( editorStore );
50 | const editorSettings = getEditorSettings();
51 | const { getEntityRecord } = select( coreStore );
52 | const _currentPost = getEntityRecord(
53 | 'postType',
54 | post.type,
55 | post.id
56 | );
57 |
58 | return {
59 | // TODO(Perf): The `__unstableIsEditorReady` selector is insufficient as
60 | // it does not account for post type loading, which is first referenced
61 | // within the post title component render. This results in the post title
62 | // popping in after the editor mounted. The web editor does not experience
63 | // this issue because the post type is loaded for "mode" selection before
64 | // the editor is mounted.
65 | isReady: __unstableIsEditorReady(),
66 | mode: getEditorMode(),
67 | isRichEditingEnabled: editorSettings.richEditingEnabled,
68 | currentPost: _currentPost,
69 | };
70 | },
71 | [ post.id, post.type ]
72 | );
73 |
74 | if ( ! isReady ) {
75 | return null;
76 | }
77 |
78 | return (
79 |
80 |
85 | { mode === 'visual' && isRichEditingEnabled && (
86 |
87 | ) }
88 |
89 | { ( mode === 'text' || ! isRichEditingEnabled ) && (
90 |
96 | ) }
97 |
98 | { children }
99 |
100 |
101 | );
102 | }
103 |
104 | const settings = {};
105 |
--------------------------------------------------------------------------------
/src/components/editor/style.scss:
--------------------------------------------------------------------------------
1 | $min-menu-item-touch-target-size: 42px;
2 |
3 | .gutenberg-kit-editor {
4 | -webkit-tap-highlight-color: transparent; // Rely upon UI components to communicate touch feedback
5 | flex-grow: 1;
6 | }
7 |
8 | // Simplify layout calculcations and mirror the `edit-post` package
9 | .gutenberg-kit-editor * {
10 | box-sizing: border-box;
11 | }
12 |
13 | .gutenberg-kit-editor .components-dropdown-menu__menu-item,
14 | .gutenberg-kit-editor
15 | .components-dropdown-menu__menu
16 | .components-menu-item__button,
17 | .gutenberg-kit-editor
18 | .components-dropdown-menu__menu
19 | .components-menu-item__button.components-button {
20 | min-height: $min-menu-item-touch-target-size;
21 | }
22 |
23 | .gutenberg-kit-editor .components-autocomplete__result.components-button {
24 | min-height: $min-menu-item-touch-target-size;
25 | }
26 |
27 | // Apply select styles from WordPress' default form styles for consistency.
28 | // Namely, avoid elements overflowing their parent containers--e.g., the
29 | // textarea used when toggling "Edit as HTML" mode for a single block.
30 | // https://github.com/WordPress/wordpress-develop/blob/9acdbb9d8db4eba90d623eefd5ad312cde140593/src/wp-admin/css/forms.css#L2-L10
31 | input,
32 | select,
33 | textarea,
34 | button {
35 | box-sizing: border-box;
36 | font-family: inherit;
37 | font-size: inherit;
38 | font-weight: inherit;
39 | }
40 |
41 | .gutenberg-kit-editor .components-editor-notices__snackbar {
42 | bottom: 58px;
43 | left: 0;
44 | padding-right: 8px;
45 | padding-left: 8px;
46 | position: fixed;
47 | right: 0;
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/editor/test/use-media-upload.test.jsx:
--------------------------------------------------------------------------------
1 | // @vitest-environment jsdom
2 |
3 | /**
4 | * WordPress dependencies
5 | */
6 | import { addFilter, removeFilter } from '@wordpress/hooks';
7 | import { render, renderHook } from '@testing-library/react';
8 |
9 | /**
10 | * Internal dependencies
11 | */
12 | import { useMediaUpload } from '../use-media-upload';
13 | import {
14 | describe,
15 | it,
16 | expect,
17 | vi,
18 | beforeAll,
19 | afterAll,
20 | afterEach,
21 | } from 'vitest';
22 | import { openMediaLibrary } from '../../../utils/bridge';
23 |
24 | vi.mock( '@wordpress/hooks' );
25 | vi.mock( '../../../utils/bridge', { spy: true } );
26 |
27 | describe( 'useMediaUpload', () => {
28 | beforeAll( () => {
29 | vi.stubGlobal( 'editor', {
30 | setMediaUploadAttachment: vi.fn(),
31 | } );
32 | } );
33 |
34 | afterEach( () => {
35 | vi.clearAllMocks();
36 | } );
37 |
38 | afterAll( () => {
39 | vi.restoreAllMocks();
40 | } );
41 |
42 | it( 'should add the MediaUpload filter when mounted', () => {
43 | renderHook( () => useMediaUpload() );
44 |
45 | expect( addFilter ).toHaveBeenCalledWith(
46 | 'editor.MediaUpload',
47 | 'GutenbergKit',
48 | expect.any( Function )
49 | );
50 | } );
51 |
52 | it( 'should remove the MediaUpload filter when unmounted', () => {
53 | const { unmount } = renderHook( () => useMediaUpload() );
54 |
55 | unmount();
56 |
57 | expect( removeFilter ).toHaveBeenCalledWith(
58 | 'editor.MediaUpload',
59 | 'GutenbergKit'
60 | );
61 | } );
62 |
63 | it( 'should define the setMediaUploadAttachment function', () => {
64 | let MediaUploadComponent;
65 | addFilter.mockImplementation( ( name, namespace, callback ) => {
66 | MediaUploadComponent = callback();
67 | } );
68 | const onSelect = vi.fn();
69 |
70 | renderHook( () => useMediaUpload() );
71 | render(
72 | open() }
74 | onSelect={ onSelect }
75 | multiple={ false }
76 | />
77 | );
78 | window.editor.setMediaUploadAttachment( [ 'test' ] );
79 |
80 | expect( onSelect ).toHaveBeenCalledWith( 'test' );
81 | } );
82 |
83 | it( 'should clear the setMediaUploadAttachment function when the component is unmounted', () => {
84 | let MediaUploadComponent;
85 | addFilter.mockImplementation( ( name, namespace, callback ) => {
86 | MediaUploadComponent = callback();
87 | } );
88 | const onSelect = vi.fn();
89 |
90 | renderHook( () => useMediaUpload() );
91 | const { unmount } = render(
92 | open() }
94 | onSelect={ onSelect }
95 | multiple={ false }
96 | />
97 | );
98 |
99 | window.editor.setMediaUploadAttachment( [ 'test' ] );
100 |
101 | expect( onSelect ).toHaveBeenCalledWith( 'test' );
102 |
103 | unmount();
104 |
105 | window.editor.setMediaUploadAttachment( [ 'test' ] );
106 |
107 | expect( onSelect ).toHaveBeenCalledTimes( 1 );
108 | } );
109 |
110 | it( 'should open the media library when the filter is called', () => {
111 | let MediaUploadComponent;
112 | addFilter.mockImplementation( ( name, namespace, callback ) => {
113 | MediaUploadComponent = callback();
114 | } );
115 |
116 | renderHook( () => useMediaUpload() );
117 | render(
118 | open() }
120 | onSelect={ vi.fn() }
121 | multiple={ false }
122 | />
123 | );
124 |
125 | expect( openMediaLibrary ).toHaveBeenCalled();
126 | } );
127 |
128 | it( 'should always provide a multiple argument to the openMediaLibrary callback', () => {
129 | let MediaUploadComponent;
130 | addFilter.mockImplementation( ( name, namespace, callback ) => {
131 | MediaUploadComponent = callback();
132 | } );
133 |
134 | renderHook( () => useMediaUpload() );
135 | render(
136 | open() }
138 | onSelect={ vi.fn() }
139 | />
140 | );
141 |
142 | expect( openMediaLibrary ).toHaveBeenCalledWith( { multiple: false } );
143 | } );
144 | } );
145 |
--------------------------------------------------------------------------------
/src/components/editor/use-editor-setup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useEffect } from '@wordpress/element';
5 | import { useDispatch } from '@wordpress/data';
6 | import { store as coreStore } from '@wordpress/core-data';
7 | import { store as editorStore } from '@wordpress/editor';
8 |
9 | /**
10 | * Internal dependencies
11 | */
12 | import { postTypeEntities } from '../../utils/post-type-entities';
13 |
14 | export function useEditorSetup( post ) {
15 | const { addEntities, receiveEntityRecords } = useDispatch( coreStore );
16 | const { setEditedPost, setupEditor } = useDispatch( editorStore );
17 |
18 | useEffect( () => {
19 | addEntities( postTypeEntities );
20 | receiveEntityRecords( 'postType', post.type, post );
21 |
22 | setupEditor( post, {} );
23 |
24 | // Temp, check why this isn't being called in the provider.
25 | setEditedPost( post.type, post.id );
26 |
27 | // eslint-disable-next-line react-hooks/exhaustive-deps
28 | }, [] );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/editor/use-host-bridge.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useEffect, useCallback, useRef } from '@wordpress/element';
5 | import { useDispatch, useSelect } from '@wordpress/data';
6 | import { store as coreStore } from '@wordpress/core-data';
7 | import { store as editorStore } from '@wordpress/editor';
8 | import { parse, serialize } from '@wordpress/blocks';
9 |
10 | window.editor = window.editor || {};
11 |
12 | export function useHostBridge( post, editorRef ) {
13 | const { editEntityRecord } = useDispatch( coreStore );
14 | const { undo, redo, switchEditorMode } = useDispatch( editorStore );
15 | const { getEditedPostAttribute, getEditedPostContent } =
16 | useSelect( editorStore );
17 |
18 | const editContent = useCallback(
19 | ( edits ) => {
20 | editEntityRecord( 'postType', post.type, post.id, edits );
21 | },
22 | [ editEntityRecord, post.id, post.type ]
23 | );
24 |
25 | const postTitleRef = useRef( post.title.raw );
26 | const postContentRef = useRef( null );
27 | if ( postContentRef.current === null ) {
28 | postContentRef.current = serialize( parse( post.content.raw || '' ) );
29 | }
30 |
31 | useEffect( () => {
32 | window.editor.setContent = ( content ) => {
33 | editContent( { content: decodeURIComponent( content ) } );
34 | };
35 |
36 | window.editor.setTitle = ( title ) => {
37 | editContent( { title: decodeURIComponent( title ) } );
38 | };
39 |
40 | window.editor.getContent = ( completeComposition = false ) => {
41 | if ( completeComposition ) {
42 | endComposition( editorRef.current );
43 | }
44 |
45 | return getEditedPostContent();
46 | };
47 |
48 | window.editor.getTitleAndContent = ( completeComposition = false ) => {
49 | if ( completeComposition ) {
50 | endComposition( editorRef.current );
51 | }
52 |
53 | const title = getEditedPostAttribute( 'title' );
54 | const content = getEditedPostContent();
55 | const changed =
56 | title !== postTitleRef.current ||
57 | content !== postContentRef.current;
58 |
59 | if ( changed ) {
60 | postTitleRef.current = title;
61 | postContentRef.current = content;
62 | }
63 |
64 | return { title, content, changed };
65 | };
66 |
67 | window.editor.undo = () => {
68 | // Do not return the `Promise` return value to avoid host errors.
69 | undo();
70 | };
71 |
72 | window.editor.redo = () => {
73 | // Do not return the `Promise` return value to avoid host errors.
74 | redo();
75 | };
76 |
77 | window.editor.switchEditorMode = ( mode ) => {
78 | // Do not return the `Promise` return value to avoid host errors.
79 | switchEditorMode( mode );
80 | };
81 |
82 | return () => {
83 | delete window.editor.setContent;
84 | delete window.editor.setTitle;
85 | delete window.editor.getContent;
86 | delete window.editor.getTitleAndContent;
87 | delete window.editor.undo;
88 | delete window.editor.redo;
89 | delete window.editor.switchEditorMode;
90 | };
91 | }, [
92 | editorRef,
93 | editContent,
94 | getEditedPostAttribute,
95 | getEditedPostContent,
96 | redo,
97 | switchEditorMode,
98 | undo,
99 | ] );
100 | }
101 |
102 | /**
103 | * Ends the current text composition on the active element, if it is a
104 | * `contenteditable` element. This is used to ensure that the latest composition
105 | * text is persisted to the DOM before reading its value in the host.
106 | *
107 | * @param {Element} element The element in which to end the composition.
108 | *
109 | * @return {void}
110 | */
111 | function endComposition( element ) {
112 | const activeElement = element?.ownerDocument.activeElement;
113 |
114 | if ( activeElement && activeElement.contentEditable === 'true' ) {
115 | const compositionEvent = new Event( 'compositionend' );
116 | activeElement.dispatchEvent( compositionEvent );
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/src/components/editor/use-host-exception-logging.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useEffect } from '@wordpress/element';
5 | import { addAction, removeAction } from '@wordpress/hooks';
6 |
7 | /**
8 | * Internal dependencies
9 | */
10 | import { logException } from '../../utils/bridge';
11 |
12 | export function useHostExceptionLogging() {
13 | useEffect( () => {
14 | addAction(
15 | 'editor.ErrorBoundary.errorLogged',
16 | 'GutenbergKit',
17 | ( error ) => {
18 | logException( error, {
19 | isHandled: true,
20 | handledBy: 'editor.ErrorBoundary.errorLogged',
21 | } );
22 | }
23 | );
24 |
25 | return () => {
26 | removeAction( 'editor.ErrorBoundary.errorLogged', 'GutenbergKit' );
27 | };
28 | }, [] );
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/editor/use-media-upload.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { addFilter, removeFilter } from '@wordpress/hooks';
5 | import { useCallback, useEffect } from '@wordpress/element';
6 |
7 | /**
8 | * Internal dependencies
9 | */
10 | import { openMediaLibrary } from '../../utils/bridge';
11 |
12 | /**
13 | * @typedef {Object} MediaUploadConfig
14 | * @property {Function} onSelect Callback function to handle the selected media.
15 | * @property {string[]} [allowedTypes] Comma-separated list of media types to allow.
16 | * @property {boolean} [multiple=false] Flag to indicate if multiple media items can be selected.
17 | * @property {number|number[]} [value] The context's currently selected media.
18 | */
19 |
20 | /**
21 | * Adds a filter for the MediaUpload component in the Gutenberg editor.
22 | *
23 | * @return {void}
24 | */
25 | export function useMediaUpload() {
26 | useEffect( () => {
27 | addFilter( 'editor.MediaUpload', 'GutenbergKit', () => MediaUpload );
28 |
29 | return () => {
30 | removeFilter( 'editor.MediaUpload', 'GutenbergKit' );
31 | };
32 | }, [] );
33 | }
34 |
35 | /**
36 | * Component exposing the native media library.
37 | *
38 | * @param {MediaUploadConfig} props Component props.
39 | *
40 | * @return {Element} The rendered component.
41 | */
42 | function MediaUpload( { render, ...config } ) {
43 | const { open } = useNativeMediaLibrary( config );
44 |
45 | return render( { open } );
46 | }
47 |
48 | /**
49 | * Establishes global bridge function to handle native Media Library interactions.
50 | *
51 | * @param {MediaUploadConfig} config Configuration object for the Media Library.
52 | *
53 | * @return {{open: ()=>void}} An object containing a function to open the Media Library.
54 | */
55 | function useNativeMediaLibrary( { onSelect, ...config } ) {
56 | const { allowedTypes, multiple = false, value } = config;
57 |
58 | useEffect( () => {
59 | window.editor.setMediaUploadAttachment = ( attachment ) => {
60 | onSelect( config.multiple ? attachment : attachment[ 0 ] );
61 | };
62 |
63 | return () => {
64 | window.editor.setMediaUploadAttachment = () => {};
65 | };
66 | }, [ onSelect, config.multiple ] );
67 |
68 | const open = useCallback(
69 | () =>
70 | openMediaLibrary( {
71 | allowedTypes,
72 | multiple,
73 | value,
74 | } ),
75 | [ allowedTypes, multiple, value ]
76 | );
77 |
78 | return { open };
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/editor/use-sync-history-controls.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useEffect } from '@wordpress/element';
5 | import { useSelect } from '@wordpress/data';
6 | import { store as editorStore } from '@wordpress/editor';
7 |
8 | /**
9 | * Internal dependencies
10 | */
11 | import { onEditorHistoryChanged } from '../../utils/bridge';
12 |
13 | /**
14 | * Synchronizes the host's undo and redo history controls with the editor.
15 | *
16 | * This hook uses the `useSelect` hook to access the editor store and determine
17 | * whether there are undo and redo actions available. It then triggers the
18 | * `onEditorHistoryChanged` function whenever the availability of undo or redo
19 | * actions changes.
20 | *
21 | * @return {void}
22 | */
23 | export function useSyncHistoryControls() {
24 | const { hasUndo, hasRedo } = useSelect( ( select ) => {
25 | const store = select( editorStore );
26 | return {
27 | hasUndo: store.hasEditorUndo(),
28 | hasRedo: store.hasEditorRedo(),
29 | };
30 | }, [] );
31 |
32 | useEffect( () => {
33 | onEditorHistoryChanged( hasUndo, hasRedo );
34 | }, [ hasUndo, hasRedo ] );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/layout/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import {
5 | EditorSnackbars,
6 | ErrorBoundary,
7 | AutosaveMonitor,
8 | } from '@wordpress/editor';
9 |
10 | /**
11 | * Internal dependencies
12 | */
13 | import Editor from '../editor';
14 | import { onEditorContentChanged } from '../../utils/bridge';
15 | import EditorLoadNotice from '../editor-load-notice';
16 | import './style.scss';
17 |
18 | /**
19 | * Top-level layout, including the Editor component wrapped in an ErrorBoundary.
20 | *
21 | * @param {Object} props The settings passed along to the Editor component.
22 | *
23 | * @return {JSX.Element} The rendered Layout component.
24 | */
25 | export default function Layout( props ) {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/components/layout/style.scss:
--------------------------------------------------------------------------------
1 | .gutenberg-kit-layout__load-notice {
2 | bottom: 62px;
3 | left: 16px;
4 | position: fixed;
5 | right: 16px;
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/text-editor/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { PostTitleRaw, PostTextEditor } from '@wordpress/editor';
5 |
6 | /**
7 | * Internal dependencies
8 | */
9 | import './style.scss';
10 |
11 | /**
12 | * TextEditor component renders a text editor with an optional title.
13 | *
14 | * @param {Object} props Component props.
15 | * @param {boolean} props.hideTitle Whether to hide the title input.
16 | *
17 | * @return {JSX.Element} The rendered text editor component.
18 | */
19 | export default function TextEditor( { hideTitle } ) {
20 | return (
21 |
22 | { ! hideTitle &&
}
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/text-editor/style.scss:
--------------------------------------------------------------------------------
1 | @use "@wordpress/base-styles/variables" as wordpress;
2 |
3 | .gutenberg-kit-text-editor {
4 | padding: 12px;
5 | }
6 |
7 | .gutenberg-kit-text-editor .editor-post-title.is-raw-text textarea {
8 | font-family: wordpress.$font-family-mono;
9 | line-height: 1.333;
10 | min-height: 70px;
11 | padding: 16px;
12 | }
13 |
14 | .gutenberg-kit-text-editor
15 | .editor-post-title.is-raw-text
16 | textarea::placeholder {
17 | // Override Gutenberg's "dark theme" styles creating illegible text
18 | color: rgba(30, 30, 30, 0.62);
19 | }
20 |
21 | .gutenberg-kit-text-editor textarea.editor-post-text-editor {
22 | border-radius: 2px;
23 | box-sizing: border-box;
24 | font-size: 13px !important; // Must mirror Gutenberg's `!important` usage
25 | line-height: 2;
26 | }
27 |
28 | .gutenberg-kit-text-editor .editor-post-text-editor:focus-visible {
29 | border-color: var(
30 | --wp-components-color-accent,
31 | var(--wp-admin-theme-color, #3858e9)
32 | );
33 | box-shadow: 0 0 0 calc(1.5px - 1px)
34 | var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
35 | outline: 2px solid transparent;
36 | }
37 |
--------------------------------------------------------------------------------
/src/components/visual-editor/default-theme-styles.scss:
--------------------------------------------------------------------------------
1 | /**
2 | * Default theme styles conditionally applied when the site's settings are
3 | * unavailable or when the editor's theme styles feature is disabled.
4 | */
5 |
6 | .gutenberg-kit-visual-editor__post-title-wrapper .wp-block-post-title {
7 | font-size: 20px; // Set reasonable default size for small screens
8 | }
9 |
10 | .block-editor-default-block-appender__content {
11 | opacity: 0.62; // Match empty post title/rich text placholder style
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/visual-editor/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 | import clsx from 'clsx';
5 |
6 | /**
7 | * WordPress dependencies
8 | */
9 | import { useRef } from '@wordpress/element';
10 | import {
11 | BlockList,
12 | privateApis as blockEditorPrivateApis,
13 | store as blockEditorStore,
14 | } from '@wordpress/block-editor';
15 | import { store as editorStore, PostTitle } from '@wordpress/editor';
16 | import { useSelect } from '@wordpress/data';
17 | import { store as editPostStore } from '@wordpress/edit-post';
18 | import '@wordpress/format-library';
19 |
20 | /**
21 | * Internal dependencies
22 | */
23 | import './style.scss';
24 | import EditorToolbar from '../editor-toolbar';
25 | import { useEditorStyles } from './use-editor-styles';
26 | import { unlock } from '../../lock-unlock';
27 | import DefaultBlockAppender from '../default-block-appender';
28 | import { useEditorVisible } from './use-editor-visible';
29 |
30 | const {
31 | ExperimentalBlockCanvas: BlockCanvas,
32 | LayoutStyle,
33 | useLayoutClasses,
34 | useLayoutStyles,
35 | } = unlock( blockEditorPrivateApis );
36 |
37 | // Add some styles for alignwide/alignfull Post Content and its children.
38 | const alignCSS = `.is-root-container.alignwide { max-width: var(--wp--style--global--wide-size); margin-left: auto; margin-right: auto;}
39 | .is-root-container.alignwide:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: var(--wp--style--global--wide-size);}
40 | .is-root-container.alignfull { max-width: none; margin-left: auto; margin-right: auto;}
41 | .is-root-container.alignfull:where(.is-layout-flow) > :not(.alignleft):not(.alignright) { max-width: none;}`;
42 |
43 | /**
44 | * Editor component for managing and editing post content.
45 | *
46 | * @param {Object} props Component props.
47 | * @param {boolean} props.hideTitle Whether to hide the title input.
48 | *
49 | * @return {JSX.Element} The rendered Editor component.
50 | */
51 | function VisualEditor( { hideTitle } ) {
52 | const editorPostTitleRef = useRef();
53 | const editorVisibleRef = useEditorVisible();
54 |
55 | const {
56 | renderingMode,
57 | hasThemeStyleSupport,
58 | themeHasDisabledLayoutStyles,
59 | themeSupportsLayout,
60 | hasRootPaddingAwareAlignments,
61 | } = useSelect( ( select ) => {
62 | const { getRenderingMode } = select( editorStore );
63 | const _renderingMode = getRenderingMode();
64 | const { getSettings } = unlock( select( blockEditorStore ) );
65 | const _settings = getSettings();
66 |
67 | return {
68 | renderingMode: _renderingMode,
69 | hasThemeStyleSupport:
70 | select( editPostStore ).isFeatureActive( 'themeStyles' ),
71 | themeSupportsLayout: _settings.supportsLayout,
72 | themeHasDisabledLayoutStyles: _settings.disableLayoutStyles,
73 | hasRootPaddingAwareAlignments:
74 | _settings.__experimentalFeatures?.useRootPaddingAwareAlignments,
75 | };
76 | }, [] );
77 |
78 | const styles = useEditorStyles();
79 |
80 | const editorClasses = clsx( 'gutenberg-kit-visual-editor', {
81 | 'has-root-padding':
82 | ! hasThemeStyleSupport || ! hasRootPaddingAwareAlignments,
83 | } );
84 |
85 | const titleClasses = clsx(
86 | 'gutenberg-kit-visual-editor__post-title-wrapper',
87 | 'editor-visual-editor__post-title-wrapper',
88 | {
89 | 'has-global-padding':
90 | hasThemeStyleSupport && hasRootPaddingAwareAlignments,
91 | }
92 | );
93 |
94 | // An opinionated default, as we currently cannot retrievew the post content
95 | // attributes from the REST API, as it does not include the current post
96 | // context.
97 | const postContentAttributes = {
98 | align: 'full',
99 | layout: { type: 'constrained' },
100 | };
101 | const { layout = {}, align = '' } = postContentAttributes;
102 | const postContentLayoutClasses = useLayoutClasses(
103 | postContentAttributes,
104 | 'core/post-content'
105 | );
106 | const postContentLayoutStyles = useLayoutStyles(
107 | postContentAttributes,
108 | 'core/post-content',
109 | '.block-editor-block-list__layout.is-root-container'
110 | );
111 | const blockListClasses = clsx(
112 | themeSupportsLayout && postContentLayoutClasses,
113 | align && `align${ align }`,
114 | {
115 | 'is-layout-flow': ! themeSupportsLayout,
116 | 'has-global-padding':
117 | hasThemeStyleSupport && hasRootPaddingAwareAlignments,
118 | }
119 | );
120 |
121 | return (
122 |
123 |
124 | { themeSupportsLayout &&
125 | ! themeHasDisabledLayoutStyles &&
126 | renderingMode === 'post-only' && (
127 | <>
128 |
132 |
136 | { align && }
137 | { postContentLayoutStyles && (
138 |
142 | ) }
143 | >
144 | ) }
145 | { ! hideTitle && (
146 |
149 | ) }
150 |
151 |
152 |
153 |
154 |
155 |
156 | );
157 | }
158 |
159 | export default VisualEditor;
160 |
--------------------------------------------------------------------------------
/src/components/visual-editor/style.scss:
--------------------------------------------------------------------------------
1 | .gutenberg-kit-visual-editor {
2 | box-sizing: border-box;
3 | flex-shrink: 0;
4 | height: 100%;
5 | max-height: 100%;
6 | max-width: 100%;
7 | min-width: 300px;
8 | position: relative;
9 | width: 100%;
10 | }
11 |
12 | // TODO: Replace this `display` style--which ensures children margins are
13 | // contained, rendering the background color behind them--with a proper fix in
14 | // core.
15 | //
16 | // See the following related changes:
17 | // - https://github.com/WordPress/gutenberg/pull/66706
18 | // - https://github.com/WordPress/gutenberg/pull/66390
19 | .gutenberg-kit-visual-editor .block-editor-block-canvas {
20 | display: flex;
21 | }
22 |
23 | .gutenberg-kit-visual-editor .editor-styles-wrapper {
24 | // Ensure the toolbar does not overlap the content
25 | padding-bottom: 56px;
26 | // Remove outline to mirror default user agent styles for body elements, which
27 | // is the element used for Gutenberg's iframed editor
28 | outline: none;
29 | // TODO: Ideally we resolve this in Gutenberg core. Ensures the styles wrapper
30 | // fills its parent container.
31 | //
32 | // See: https://github.com/WordPress/gutenberg/pull/66706
33 | width: 100%;
34 | }
35 |
36 | // Apply root level padding for themes that do not use the experimental
37 | // `useRootPaddingAwareAlignments` settings
38 | .gutenberg-kit-visual-editor.has-root-padding .editor-styles-wrapper {
39 | padding-left: 16px;
40 | padding-right: 16px;
41 | }
42 |
43 | // Prevent horizontal scroll in the block inserter popover. This only appears to
44 | // occur on touch devices, not cursor devices.
45 | // TODO: Fix this in core and remove the styles.
46 | .gutenberg-kit-visual-editor
47 | .block-editor-inserter__popover
48 | .components-popover__content {
49 | overflow-x: hidden !important; // `!important` required to override inline styles
50 | }
51 |
52 | // Hide the inline block appendar button, as its positioning and size is not
53 | // ideal for small screens
54 | .gutenberg-kit-visual-editor
55 | .block-editor-default-block-appender
56 | .block-editor-inserter__toggle.components-button.has-icon {
57 | display: none;
58 | }
59 |
60 | // Apply an admittedly problematic workaround for a bug in responsive images.
61 | // This should be removed once the bug is fixed in core.
62 | // https://github.com/WordPress/gutenberg/issues/38381#issuecomment-1099194060
63 | .gutenberg-kit-visual-editor .wp-block-image > div:first-child {
64 | height: auto;
65 | max-width: 100%;
66 | }
67 |
68 | // Ensure unsupported blocks do not overflow the block canvas
69 | .gutenberg-kit-visual-editor .wp-block-missing {
70 | max-width: 100%;
71 | overflow: hidden;
72 | }
73 |
74 | .gutenberg-kit-visual-editor .wp-block-missing img {
75 | height: auto;
76 | max-width: 100%;
77 | }
78 |
79 | .gutenberg-kit-visual-editor__toolbar {
80 | align-items: center;
81 | bottom: 0;
82 | left: 0;
83 | overflow-x: auto;
84 | position: fixed;
85 | right: 0;
86 | z-index: 40; // Ensure the toolbar is above the block canvas and its controls
87 | }
88 |
89 | // HACK: Force a full-width inserter, consider replacing with a better inserter
90 | // popover implementation in the future.
91 | .gutenberg-kit-visual-editor .block-editor-inserter__main-area {
92 | width: 100%;
93 | }
94 |
95 | // Hide the inserter popover displayed between blocks when hovering between
96 | // blocks or hovering over a block type in the inserter. Its presence is less
97 | // useful for touch devices and steals focus away from the inserter, which
98 | // results in needing to tap a block type twice to insert it.
99 | .gutenberg-kit-visual-editor .block-editor-block-popover {
100 | display: none;
101 | }
102 |
103 | // Manually copy styles required by Gutenberg that are provided by the WP Admin
104 | // environment in the form of the `load-styles.php` utility.
105 | // https://github.com/WordPress/wordpress-develop/blob/a82874058f58575dbba64ce09b6dcbd43ccf5fdc/src/wp-admin/css/common.css#L2617-L2621
106 | .gutenberg-kit-visual-editor :where(fieldset) {
107 | border: 0;
108 | padding: 0;
109 | margin: 0;
110 | }
111 |
112 | // TODO: Styles improving small-screen layout. Ideally we instead improve
113 | // Gutenberg itself or implement our own error boudnary--including matching copy
114 | // content/error functionality.
115 | .editor-error-boundary {
116 | margin-left: 16px;
117 | margin-right: 16px;
118 | }
119 |
--------------------------------------------------------------------------------
/src/components/visual-editor/use-editor-styles.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { store as editorStore } from '@wordpress/editor';
5 | import { useSelect } from '@wordpress/data';
6 | import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor';
7 | import { store as editPostStore } from '@wordpress/edit-post';
8 | import { useMemo } from '@wordpress/element';
9 |
10 | /**
11 | * Internal dependencies
12 | */
13 | import { unlock } from '../../lock-unlock';
14 | // The Vite query parameter breaks the linter's import resolution
15 | // eslint-disable-next-line import/no-unresolved
16 | import defaultThemeStyles from './default-theme-styles.scss?inline';
17 | // eslint-disable-next-line import/no-unresolved
18 | import commonStyles from './wp-common-styles.scss?inline';
19 |
20 | const { getLayoutStyles } = unlock( blockEditorPrivateApis );
21 |
22 | /**
23 | * Custom hook to retrieve and memoize editor styles.
24 | *
25 | * @todo This should be exported from Core so no reimplementation is needed.
26 | *
27 | * @return {any[]} An array of editor styles.
28 | */
29 | export function useEditorStyles() {
30 | const { hasThemeStyleSupport, editorSettings } = useSelect( ( select ) => {
31 | return {
32 | hasThemeStyleSupport:
33 | select( editPostStore ).isFeatureActive( 'themeStyles' ),
34 | editorSettings: select( editorStore ).getEditorSettings(),
35 | };
36 | }, [] );
37 |
38 | return useMemo( () => {
39 | const defaultEditorStyles = [
40 | ...( editorSettings?.defaultEditorStyles ?? [] ),
41 | ];
42 |
43 | if ( ! editorSettings.disableLayoutStyles && ! hasThemeStyleSupport ) {
44 | defaultEditorStyles.push( {
45 | css: getLayoutStyles( {
46 | style: {},
47 | selector: 'body',
48 | hasBlockGapSupport: false,
49 | hasFallbackGapSupport: true,
50 | fallbackGapValue: '0.5em',
51 | } ),
52 | } );
53 | }
54 |
55 | if ( ! hasThemeStyleSupport ) {
56 | defaultEditorStyles.push( {
57 | css: defaultThemeStyles,
58 | } );
59 | }
60 |
61 | const baseStyles = hasThemeStyleSupport
62 | ? editorSettings.styles ?? []
63 | : defaultEditorStyles;
64 |
65 | // `commonStyles` represent manually added notable styles that are missing.
66 | // The styles likely absent due to them being injected by the WP Admin
67 | // context.
68 | return [ { css: commonStyles }, ...baseStyles ];
69 | }, [
70 | editorSettings.defaultEditorStyles,
71 | editorSettings.disableLayoutStyles,
72 | editorSettings.styles,
73 | hasThemeStyleSupport,
74 | ] );
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/visual-editor/use-editor-visible.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { useEffect, useRef } from '@wordpress/element';
5 |
6 | /**
7 | * Internal dependencies
8 | */
9 | import { editorLoaded } from '../../utils/bridge';
10 |
11 | /**
12 | * Returns a ref for observing an element and triggering the editorLoaded event when it becomes visible.
13 | *
14 | * @return {Object} The ref.
15 | */
16 | export const useEditorVisible = () => {
17 | const ref = useRef( null );
18 |
19 | useEffect( () => {
20 | const observer = new IntersectionObserver( ( entries ) => {
21 | entries.forEach( ( entry ) => {
22 | if ( entry.isIntersecting ) {
23 | editorLoaded();
24 | }
25 | } );
26 | } );
27 |
28 | observer.observe( ref.current );
29 |
30 | return () => observer.disconnect();
31 | }, [ ref ] );
32 |
33 | return ref;
34 | };
35 |
--------------------------------------------------------------------------------
/src/components/visual-editor/wp-common-styles.scss:
--------------------------------------------------------------------------------
1 | // Manually added styles that are missing from the editor settings, likely
2 | // because the styles are injected by the WP Admin context, which is not present
3 | // in the REST API endpoint.
4 |
5 | // Constraints for the post title.
6 | .editor-visual-editor__post-title-wrapper
7 | > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
8 | max-width: var(--wp--style--global--content-size);
9 | margin-left: auto !important;
10 | margin-right: auto !important;
11 | }
12 |
13 | .editor-visual-editor__post-title-wrapper > .alignwide {
14 | max-width: 1340px;
15 | }
16 |
17 | .editor-visual-editor__post-title-wrapper > .alignfull {
18 | max-width: none;
19 | }
20 |
21 | // Remove margin from the first and last child of a layout flow container.
22 | :root :where(.is-layout-flow) > :first-child {
23 | margin-block-start: 0;
24 | }
25 |
26 | :root :where(.is-layout-flow) > :last-child {
27 | margin-block-end: 0;
28 | }
29 |
30 | :root :where(.is-layout-flow) > * {
31 | margin-block-start: 1.2rem;
32 | margin-block-end: 0;
33 | }
34 |
35 | :root :where(.is-layout-constrained) > :first-child {
36 | margin-block-start: 0;
37 | }
38 |
39 | :root :where(.is-layout-constrained) > :last-child {
40 | margin-block-end: 0;
41 | }
42 |
43 | :root :where(.is-layout-constrained) > * {
44 | margin-block-start: 1.2rem;
45 | margin-block-end: 0;
46 | }
47 |
--------------------------------------------------------------------------------
/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Gutenberg
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { createRoot, StrictMode } from '@wordpress/element';
5 | // Default styles that are needed for the editor.
6 | import '@wordpress/components/build-style/style.css';
7 | import '@wordpress/block-editor/build-style/style.css';
8 | // Default styles that are needed for the core blocks.
9 | import '@wordpress/block-library/build-style/style.css';
10 | import '@wordpress/block-library/build-style/editor.css';
11 | import '@wordpress/block-library/build-style/theme.css';
12 | import '@wordpress/format-library/build-style/style.css';
13 | import '@wordpress/block-editor/build-style/content.css';
14 | import '@wordpress/editor/build-style/style.css';
15 |
16 | /**
17 | * Internal dependencies
18 | */
19 | import { initializeApiFetch } from './utils/api-fetch';
20 | import { awaitGBKitGlobal, editorLoaded } from './utils/bridge';
21 | import { configureLocale } from './utils/localization';
22 | import './index.scss';
23 | import EditorLoadError from './components/editor-load-error';
24 | import { error } from './utils/logger';
25 |
26 | try {
27 | await awaitGBKitGlobal();
28 | initializeApiFetch();
29 | await configureLocale();
30 |
31 | // Ensure the correct translation strings are used by postponing the import
32 | // of `@wordpress` packages until after the locale is set.
33 | //
34 | // @TODO: A circular dependency prevents the use of async/await. Ideally, we
35 | // address the circular dependency using Rollup's `manualChunks`. However, a
36 | // very specific configuration is necessary to ensure that no circular
37 | // dependencies are created--includeing from injected Vite helpers.
38 | //
39 | // See:
40 | // - https://github.com/vitejs/vite/issues/18551
41 | // - https://github.com/vitejs/vite/issues/13952
42 | // - https://github.com/vitejs/vite/issues/5189#issuecomment-2175410148
43 | import( './utils/editor' )
44 | .then( ( { initializeEditor } ) => {
45 | initializeEditor();
46 | } )
47 | .catch( ( err ) => {
48 | handleError( err );
49 | } );
50 | } catch ( err ) {
51 | handleError( err );
52 | }
53 |
54 | function handleError( err ) {
55 | error( 'Error initializing editor', err );
56 | const root = document.getElementById( 'root' );
57 | createRoot( root ).render(
58 |
59 |
60 |
61 | );
62 | editorLoaded();
63 | }
64 |
--------------------------------------------------------------------------------
/src/lock-unlock.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
5 |
6 | export const { lock, unlock } =
7 | __dangerousOptInToUnstableAPIsOnlyForCoreModules(
8 | 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
9 | '@wordpress/editor'
10 | );
11 |
--------------------------------------------------------------------------------
/src/remote.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 | Gutenberg
10 |
11 |
12 |
13 |
14 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/remote.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import apiFetch from '@wordpress/api-fetch';
5 | // Default styles that are needed for the editor.
6 | import '@wordpress/components/build-style/style.css';
7 | import '@wordpress/block-editor/build-style/style.css';
8 | // Default styles that are needed for the core blocks.
9 | import '@wordpress/block-library/build-style/style.css';
10 | import '@wordpress/block-library/build-style/editor.css';
11 | import '@wordpress/block-library/build-style/theme.css';
12 | import '@wordpress/format-library/build-style/style.css';
13 | import '@wordpress/block-editor/build-style/content.css';
14 | import '@wordpress/editor/build-style/style.css';
15 |
16 | /**
17 | * Internal dependencies
18 | */
19 | import { awaitGBKitGlobal } from './utils/bridge';
20 | import { initializeApiFetch } from './utils/api-fetch';
21 | import { loadEditorAssets } from './utils/remote-editor';
22 | import { error } from './utils/logger';
23 | import './index.scss';
24 |
25 | window.wp = window.wp || {};
26 | window.wp.apiFetch = apiFetch;
27 |
28 | const I18N_PACKAGES = [ 'i18n', 'hooks' ];
29 |
30 | try {
31 | await awaitGBKitGlobal();
32 | initializeApiFetch();
33 |
34 | // Ensure the i18n packages are loaded, then set the locale before importing
35 | // the rest of the packages.
36 | await loadEditorAssets( { allowedPackages: I18N_PACKAGES } );
37 | const { configureLocale } = await import( './utils/localization' );
38 | await configureLocale();
39 |
40 | // Ensure the correct translation strings are used by postponing the import
41 | // of the remaining `@wordpress` packages until after the locale is set.
42 | const { allowedBlockTypes } = await loadEditorAssets( {
43 | disallowedPackages: I18N_PACKAGES,
44 | } );
45 | const { initializeEditor } = await import( './utils/editor' );
46 | initializeEditor( { allowedBlockTypes } );
47 | } catch ( err ) {
48 | error( 'Error initializing editor', err );
49 | // Fallback to the local editor and display a notice. Because the remote
50 | // editor loading failed, it is more practical to rely upon the local
51 | // editor's scripts and styles for displaying the notice.
52 | window.location.href = 'index.html?error=gbkit_global_unavailable';
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/blocks.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { getBlockTypes, unregisterBlockType } from '@wordpress/blocks';
5 | import { debug } from './logger';
6 |
7 | /**
8 | * Unregister blocks that are disallowed.
9 | *
10 | * @param {Array} allowedBlockTypes The list of allowed block types.
11 | */
12 | export function unregisterDisallowedBlocks( allowedBlockTypes ) {
13 | if ( ! allowedBlockTypes ) {
14 | return;
15 | }
16 |
17 | const unregisteredBlocks = [];
18 | getBlockTypes().forEach( ( block ) => {
19 | if ( ! allowedBlockTypes.includes( block.name ) ) {
20 | unregisterBlockType( block.name );
21 | unregisteredBlocks.push( block.name );
22 | }
23 | } );
24 |
25 | debug( 'Blocks unregistered:', unregisteredBlocks );
26 | }
27 |
--------------------------------------------------------------------------------
/src/utils/editor.jsx:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import defaultEditorStyles from '@wordpress/block-editor/build-style/default-editor-styles.css?inline';
5 |
6 | /**
7 | * Internal dependencies
8 | */
9 | import Layout from '../components/layout';
10 | import { createRoot, StrictMode } from '@wordpress/element';
11 | import { dispatch } from '@wordpress/data';
12 | import { store as editorStore } from '@wordpress/editor';
13 | import { store as preferencesStore } from '@wordpress/preferences';
14 | import { registerCoreBlocks } from '@wordpress/block-library';
15 | import { unregisterDisallowedBlocks } from './blocks';
16 | import { getGBKit, getPost } from './bridge';
17 |
18 | /**
19 | * Configure editor settings and styles, and render the editor.
20 | *
21 | * Dependency injection is used for various `@wordpress` package functions so
22 | * that this utility can be used in both the local and remote editor, which
23 | * rely upon ES modules and global variables, respectively.
24 | *
25 | * @param {Object} [options]
26 | * @param {Array} [options.allowedBlockTypes]
27 | */
28 | export function initializeEditor( { allowedBlockTypes } = {} ) {
29 | const { themeStyles, hideTitle, editorSettings } = getGBKit();
30 |
31 | const settings = editorSettings || {
32 | defaultEditorStyles: [ { css: defaultEditorStyles } ],
33 | };
34 | dispatch( editorStore ).updateEditorSettings( settings );
35 |
36 | const preferenceDispatch = dispatch( preferencesStore );
37 | preferenceDispatch.setDefaults( 'core', {
38 | fixedToolbar: true,
39 | } );
40 | preferenceDispatch.setDefaults( 'core/edit-post', {
41 | themeStyles,
42 | } );
43 |
44 | registerCoreBlocks();
45 | unregisterDisallowedBlocks( allowedBlockTypes );
46 | const post = getPost();
47 |
48 | createRoot( document.getElementById( 'root' ) ).render(
49 |
50 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/utils/exception-parser.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Internal dependencies
3 | */
4 | import { version as gbkVersion } from '../../package.json';
5 | import { chromeStackParser, geckoStackParser } from './stack-parsers';
6 |
7 | // The stack trace is limited to prevent crash logging service fail processing the exception.
8 | const STACKTRACE_LIMIT = 50;
9 |
10 | const stackParsers = [ chromeStackParser, geckoStackParser ];
11 |
12 | // Based on function `createStackParser` and `parseStackFrames` of Sentry JavaScript SDK:
13 | // - https://github.com/getsentry/sentry-javascript/blob/de681dcf7d6dac69da9374bbdbe2e2f7e00f0fdc/packages/utils/src/stacktrace.ts#L16-L59
14 | // - https://github.com/getsentry/sentry-javascript/blob/de681dcf7d6dac69da9374bbdbe2e2f7e00f0fdc/packages/browser/src/eventbuilder.ts#L100-L118
15 | // And function `stripSentryFramesAndReverse` of Sentry React Native SDK:
16 | // https://github.com/getsentry/sentry-javascript/blob/de681dcf7d6dac69da9374bbdbe2e2f7e00f0fdc/packages/utils/src/stacktrace.ts#L80-L117
17 | const parseStacktrace = ( exception ) => {
18 | const plainStacktrace = exception.stacktrace || exception.stack || '';
19 | const frames = [];
20 | const lines = plainStacktrace.split( '\n' );
21 |
22 | for ( let i = 0; i < lines.length; i++ ) {
23 | const line = lines[ i ];
24 | // Ignore lines over 1kb as they are unlikely to be valid frames.
25 | if ( line.length > 1024 ) {
26 | continue;
27 | }
28 |
29 | // Skip error message lines.
30 | if ( line.match( /\S*Error: / ) ) {
31 | continue;
32 | }
33 |
34 | for ( const parser of stackParsers ) {
35 | const entry = parser( line );
36 |
37 | if ( entry ) {
38 | frames.push( entry );
39 | break;
40 | }
41 | }
42 |
43 | if ( frames.length >= STACKTRACE_LIMIT ) {
44 | break;
45 | }
46 | }
47 |
48 | const reverseFrames = Array.from( frames ).reverse();
49 |
50 | return reverseFrames.slice( 0, STACKTRACE_LIMIT ).map( ( entry ) => ( {
51 | ...entry,
52 | filename:
53 | entry.filename ||
54 | reverseFrames[ reverseFrames.length - 1 ].filename,
55 | } ) );
56 | };
57 |
58 | // Based on function `extractMessage` of Sentry JavaScript SDK:
59 | // https://github.com/getsentry/sentry-javascript/blob/de681dcf7d6dac69da9374bbdbe2e2f7e00f0fdc/packages/browser/src/eventbuilder.ts#L142-L151
60 | const extractMessage = ( exception ) => {
61 | const message = exception?.message;
62 | if ( ! message ) {
63 | return 'No error message';
64 | }
65 | if ( typeof message.error?.message === 'string' ) {
66 | return message.error.message;
67 | }
68 | return message;
69 | };
70 |
71 | // Based on function `exceptionFromError` of Sentry JavaScript SDK:
72 | // https://github.com/getsentry/sentry-javascript/blob/de681dcf7d6dac69da9374bbdbe2e2f7e00f0fdc/packages/browser/src/eventbuilder.ts#L31-L49
73 | const parseException = ( originalException ) => {
74 | const exception = {
75 | type: originalException?.name,
76 | message: extractMessage( originalException ),
77 | };
78 |
79 | const stacktrace = parseStacktrace( originalException );
80 | if ( stacktrace.length ) {
81 | exception.stacktrace = stacktrace;
82 | }
83 |
84 | if ( exception.type === undefined && exception.message === '' ) {
85 | exception.message = 'Unknown error';
86 | }
87 |
88 | return exception;
89 | };
90 |
91 | export default ( exception, { context, tags } = {} ) => {
92 | return {
93 | ...parseException( exception ),
94 | context: {
95 | ...context,
96 | },
97 | tags: {
98 | ...tags,
99 | gutenberg_kit_version: gbkVersion,
100 | },
101 | };
102 | };
103 |
--------------------------------------------------------------------------------
/src/utils/localization.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { setLocaleData } from '@wordpress/i18n';
5 |
6 | /**
7 | * Internal dependencies
8 | */
9 | import { getGBKit } from './bridge';
10 | import { error, debug } from './logger';
11 | /**
12 | * Initializes i18n support for the editor.
13 | *
14 | * @return {Promise} A promise that resolves when i18n is initialized.
15 | */
16 | export async function configureLocale() {
17 | const { locale = 'en' } = getGBKit();
18 | await loadTranslations( locale );
19 | }
20 |
21 | /**
22 | * Loads translations for the specified locale from the downloaded files.
23 | *
24 | * @param {string} locale The locale to load translations for.
25 | * @return {Promise} A promise that resolves when translations are loaded.
26 | */
27 | async function loadTranslations( locale ) {
28 | if ( locale === DEFAULT_LOCALE ) {
29 | return;
30 | }
31 |
32 | try {
33 | debug( 'Loading translations for', locale );
34 | const { default: translations } = await import(
35 | `../translations/${ locale }.json`
36 | );
37 | setLocaleData( translations );
38 | } catch ( err ) {
39 | // Continue with default locale
40 | error( 'Error loading translations', err );
41 | }
42 | }
43 |
44 | const DEFAULT_LOCALE = 'en';
45 |
--------------------------------------------------------------------------------
/src/utils/logger.js:
--------------------------------------------------------------------------------
1 | // Log levels in order of verbosity
2 | const LOG_LEVELS = {
3 | ERROR: 0,
4 | WARN: 1,
5 | INFO: 2,
6 | DEBUG: 3,
7 | };
8 |
9 | // Default log level
10 | let currentLogLevel = process.env.LOG_LEVEL || LOG_LEVELS.INFO;
11 |
12 | /**
13 | * Set the current log level
14 | * @param {string} level - The log level to set (ERROR, WARN, INFO, DEBUG)
15 | */
16 | const setLogLevel = ( level ) => {
17 | if ( LOG_LEVELS[ level ] !== undefined ) {
18 | currentLogLevel = LOG_LEVELS[ level ];
19 | } else {
20 | // eslint-disable-next-line no-console
21 | console.warn(
22 | `Invalid log level: ${ level }. Using default level INFO.`
23 | );
24 | }
25 | };
26 |
27 | /**
28 | * Check if a message should be logged based on the current log level
29 | * @param {number} level - The level of the message to check
30 | *
31 | * @return {boolean} - Whether the message should be logged
32 | */
33 | const shouldLog = ( level ) => {
34 | return level <= currentLogLevel;
35 | };
36 |
37 | /**
38 | * Log an error message
39 | * @param {string} message - The message to log
40 | * @param {*} [data] - Optional data to log
41 | */
42 | const error = ( message, data ) => {
43 | if ( shouldLog( LOG_LEVELS.ERROR ) ) {
44 | // eslint-disable-next-line no-console
45 | console.error( `[GBK] ${ message }`, data || '' );
46 | }
47 | };
48 |
49 | /**
50 | * Log a warning message
51 | * @param {string} message - The message to log
52 | * @param {*} [data] - Optional data to log
53 | */
54 | const warn = ( message, data ) => {
55 | if ( shouldLog( LOG_LEVELS.WARN ) ) {
56 | // eslint-disable-next-line no-console
57 | console.warn( `[GBK] ${ message }`, data || '' );
58 | }
59 | };
60 |
61 | /**
62 | * Log an info message
63 | * @param {string} message - The message to log
64 | * @param {*} [data] - Optional data to log
65 | */
66 | const info = ( message, data ) => {
67 | if ( shouldLog( LOG_LEVELS.INFO ) ) {
68 | // eslint-disable-next-line no-console
69 | console.info( `[GBK] ${ message }`, data || '' );
70 | }
71 | };
72 |
73 | /**
74 | * Log a debug message
75 | * @param {string} message - The message to log
76 | * @param {*} [data] - Optional data to log
77 | */
78 | const debug = ( message, data ) => {
79 | if ( shouldLog( LOG_LEVELS.DEBUG ) ) {
80 | // eslint-disable-next-line no-console
81 | console.debug( `[GBK] ${ message }`, data || '' );
82 | }
83 | };
84 |
85 | export { setLogLevel, error, warn, info, debug, LOG_LEVELS };
86 |
--------------------------------------------------------------------------------
/src/utils/post-type-entities.js:
--------------------------------------------------------------------------------
1 | export const postTypeEntities = [
2 | { name: 'post', baseURL: '/wp/v2/posts' },
3 | { name: 'page', baseURL: '/wp/v2/pages' },
4 | { name: 'attachment', baseURL: '/wp/v2/media' },
5 | { name: 'wp_block', baseURL: '/wp/v2/blocks' },
6 | ].map( ( postTypeEntity ) => ( {
7 | kind: 'postType',
8 | ...postTypeEntity,
9 | transientEdits: {
10 | blocks: true,
11 | selection: true,
12 | },
13 | mergedEdits: {
14 | meta: true,
15 | },
16 | rawAttributes: [ 'title', 'excerpt', 'content' ],
17 | } ) );
18 |
--------------------------------------------------------------------------------
/src/utils/stack-parsers.js:
--------------------------------------------------------------------------------
1 | // Copyright (c) 2012 Functional Software, Inc. dba Sentry
2 | //
3 | // Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | // this software and associated documentation files (the "Software"), to deal in
5 | // the Software without restriction, including without limitation the rights to
6 | // use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7 | // of the Software, and to permit persons to whom the Software is furnished to do
8 | // so, subject to the following conditions:
9 | //
10 | // The above copyright notice and this permission notice shall be included in all
11 | // copies or substantial portions of the Software.
12 | //
13 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | // SOFTWARE.
20 |
21 | const UNKNOWN_FUNCTION = '?';
22 |
23 | function createFrame( filename, func, lineno, colno ) {
24 | const frame = {
25 | filename,
26 | function: func === '' ? UNKNOWN_FUNCTION : func,
27 | in_app: true, // All browser frames are considered in_app
28 | };
29 |
30 | if ( lineno !== undefined ) {
31 | frame.lineno = lineno;
32 | }
33 |
34 | if ( colno !== undefined ) {
35 | frame.colno = colno;
36 | }
37 |
38 | return frame;
39 | }
40 |
41 | // This regex matches frames that have no function name (ie. are at the top level of a module).
42 | // For example "at http://localhost:5000//script.js:1:126"
43 | // Frames _with_ function names usually look as follows: "at commitLayoutEffects (react-dom.development.js:23426:1)"
44 | const chromeRegexNoFnName = /^\s*at (\S+?)(?::(\d+))(?::(\d+))\s*$/i;
45 |
46 | // This regex matches all the frames that have a function name.
47 | const chromeRegex =
48 | /^\s*at (?:(.+?\)(?: \[.+\])?|.*?) ?\((?:address at )?)?(?:async )?((?:|[-a-z]+:|.*bundle|\/)?.*?)(?::(\d+))?(?::(\d+))?\)?\s*$/i;
49 |
50 | const chromeEvalRegex = /\((\S*)(?::(\d+))(?::(\d+))\)/;
51 |
52 | // Chromium based browsers: Chrome, Brave, new Opera, new Edge
53 | // We cannot call this variable `chrome` because it can conflict with global `chrome` variable in certain environments
54 | // See: https://github.com/getsentry/sentry-javascript/issues/6880
55 | export const chromeStackParser = ( line ) => {
56 | // If the stack line has no function name, we need to parse it differently
57 | const noFnParts = chromeRegexNoFnName.exec( line );
58 |
59 | if ( noFnParts ) {
60 | const [ , filename, _line, col ] = noFnParts;
61 | return createFrame( filename, UNKNOWN_FUNCTION, +_line, +col );
62 | }
63 |
64 | const parts = chromeRegex.exec( line );
65 |
66 | if ( parts ) {
67 | const isEval = parts[ 2 ] && parts[ 2 ].indexOf( 'eval' ) === 0; // start of line
68 |
69 | if ( isEval ) {
70 | const subMatch = chromeEvalRegex.exec( parts[ 2 ] );
71 |
72 | if ( subMatch ) {
73 | // throw out eval line/column and use top-most line/column number
74 | parts[ 2 ] = subMatch[ 1 ]; // url
75 | parts[ 3 ] = subMatch[ 2 ]; // line
76 | parts[ 4 ] = subMatch[ 3 ]; // column
77 | }
78 | }
79 |
80 | const func = parts[ 1 ] || UNKNOWN_FUNCTION;
81 | const filename = parts[ 2 ];
82 |
83 | return createFrame(
84 | filename,
85 | func,
86 | parts[ 3 ] ? +parts[ 3 ] : undefined,
87 | parts[ 4 ] ? +parts[ 4 ] : undefined
88 | );
89 | }
90 | };
91 |
92 | // gecko regex: `(?:bundle|\d+\.js)`: `bundle` is for react native, `\d+\.js` also but specifically for ram bundles because it
93 | // generates filenames without a prefix like `file://` the filenames in the stacktrace are just 42.js
94 | // We need this specific case for now because we want no other regex to match.
95 | const geckoREgex =
96 | /^\s*(.*?)(?:\((.*?)\))?(?:^|@)?((?:[-a-z]+)?:\/.*?|\[native code\]|[^@]*(?:bundle|\d+\.js)|\/[\w\-. /=]+)(?::(\d+))?(?::(\d+))?\s*$/i;
97 | const geckoEvalRegex = /(\S+) line (\d+)(?: > eval line \d+)* > eval/i;
98 |
99 | export const geckoStackParser = ( line ) => {
100 | const parts = geckoREgex.exec( line );
101 |
102 | if ( parts ) {
103 | const isEval = parts[ 3 ] && parts[ 3 ].indexOf( ' > eval' ) > -1;
104 | if ( isEval ) {
105 | const subMatch = geckoEvalRegex.exec( parts[ 3 ] );
106 |
107 | if ( subMatch ) {
108 | // throw out eval line/column and use top-most line number
109 | parts[ 1 ] = parts[ 1 ] || 'eval';
110 | parts[ 3 ] = subMatch[ 1 ];
111 | parts[ 4 ] = subMatch[ 2 ];
112 | parts[ 5 ] = ''; // no column when eval
113 | }
114 | }
115 |
116 | const filename = parts[ 3 ];
117 | const func = parts[ 1 ] || UNKNOWN_FUNCTION;
118 |
119 | return createFrame(
120 | filename,
121 | func,
122 | parts[ 4 ] ? +parts[ 4 ] : undefined,
123 | parts[ 5 ] ? +parts[ 5 ] : undefined
124 | );
125 | }
126 | };
127 |
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 | import { defineConfig } from 'vite';
5 | import react from '@vitejs/plugin-react';
6 | import { nodePolyfills } from 'vite-plugin-node-polyfills';
7 |
8 | export default defineConfig( {
9 | base: '',
10 | build: {
11 | outDir: '../dist',
12 | target: 'esnext',
13 | },
14 | plugins: [ nodePolyfills(), react() ],
15 | root: 'src',
16 | css: {
17 | preprocessorOptions: {
18 | scss: {
19 | quietDeps: true,
20 | },
21 | },
22 | },
23 | } );
24 |
--------------------------------------------------------------------------------
/vite.config.remote.js:
--------------------------------------------------------------------------------
1 | /**
2 | * WordPress dependencies
3 | */
4 | import { defaultRequestToExternal } from '@wordpress/dependency-extraction-webpack-plugin/lib/util';
5 | import { nodePolyfills } from 'vite-plugin-node-polyfills';
6 |
7 | /**
8 | * External dependencies
9 | */
10 | import { resolve } from 'path';
11 | import { defineConfig } from 'vite';
12 | import react from '@vitejs/plugin-react';
13 | import MagicString from 'magic-string';
14 |
15 | export default defineConfig( {
16 | base: '',
17 | build: {
18 | outDir: '../dist',
19 | rollupOptions: {
20 | input: resolve( __dirname, 'src/remote.html' ),
21 | external: externalize,
22 | output: {
23 | // Manual chunks are necessary to prevent circular dependencies, some of
24 | // which originate from Vite's injected helpers.
25 | //
26 | // See:
27 | // - https://github.com/vitejs/vite/issues/18551
28 | // - https://github.com/vitejs/vite/issues/13952
29 | // - https://github.com/vitejs/vite/issues/5189#issuecomment-2175410148
30 | manualChunks( id ) {
31 | const VITE_COMMON_MODULES = [
32 | 'vite/preload-helper',
33 | 'vite/modulepreload-polyfill',
34 | 'vite/dynamic-import-helper',
35 | 'commonjsHelpers',
36 | 'commonjs-dynamic-modules',
37 | '__vite-browser-external',
38 | ];
39 | if (
40 | VITE_COMMON_MODULES.some( ( m ) => id.includes( m ) )
41 | ) {
42 | return 'vite-helpers';
43 | }
44 |
45 | if ( id.includes( 'src/utils/bridge.js' ) ) {
46 | return 'bridge';
47 | }
48 |
49 | if ( id.includes( 'src/utils/logger.js' ) ) {
50 | return 'logger';
51 | }
52 | },
53 | },
54 | },
55 | target: 'esnext',
56 | },
57 | plugins: [ nodePolyfills(), react(), wordPressExternals() ],
58 | root: 'src',
59 | css: {
60 | preprocessorOptions: {
61 | scss: {
62 | quietDeps: true,
63 | },
64 | },
65 | },
66 | } );
67 |
68 | function externalize( id ) {
69 | const externalDefinition = defaultRequestToExternal( id );
70 | return (
71 | !! externalDefinition &&
72 | ! id.match( /\.css(?:\?inline)?$/ ) &&
73 | ! [ 'apiFetch', 'i18n', 'url', 'hooks' ].includes(
74 | externalDefinition[ externalDefinition.length - 1 ]
75 | )
76 | );
77 | }
78 |
79 | /**
80 | * Transform code by replacing WordPress imports with global definitions.
81 | *
82 | * @return {Object} The transformed code and map.
83 | */
84 | function wordPressExternals() {
85 | return {
86 | name: 'wordpress-externals-plugin',
87 | transform( code, id ) {
88 | const magicString = new MagicString( code );
89 | let hasReplacements = false;
90 |
91 | // Match WordPress and React JSX runtime import statements
92 | const regex =
93 | /import\s*(?:{([^}]+)}\s*from)?\s*['"](@wordpress\/([^'"]+)|react\/jsx-runtime)['"];/g;
94 | let match;
95 |
96 | while ( ( match = regex.exec( code ) ) !== null ) {
97 | const [ fullMatch, imports, module ] = match;
98 | const externalDefinition = defaultRequestToExternal( module );
99 |
100 | if (
101 | ! externalDefinition ||
102 | /@wordpress\/(api-fetch|i18n|url)/.test( id ) ||
103 | /@wordpress\/(api-fetch|i18n|url)/.test( module )
104 | ) {
105 | continue; // Exclude the module from externalization
106 | }
107 |
108 | hasReplacements = true;
109 |
110 | if ( ! imports ) {
111 | // Remove the side effect import entirely
112 | magicString.remove(
113 | match.index,
114 | match.index + fullMatch.length
115 | );
116 | continue;
117 | }
118 |
119 | const importList = imports.split( ',' ).map( ( i ) => {
120 | const parts = i.trim().split( /\s+as\s+/ );
121 | if ( parts.length === 2 ) {
122 | // Convert import "as" syntax to destructuring assignment
123 | return `${ parts[ 0 ] }: ${ parts[ 1 ] }`;
124 | }
125 | return i.trim();
126 | } );
127 |
128 | const definitionArray = Array.isArray( externalDefinition )
129 | ? externalDefinition
130 | : [ externalDefinition ];
131 |
132 | const replacement = `const { ${ importList.join(
133 | ', '
134 | ) } } = window.${ definitionArray.join( '.' ) };`;
135 | magicString.overwrite(
136 | match.index,
137 | match.index + fullMatch.length,
138 | replacement
139 | );
140 | }
141 |
142 | if ( ! hasReplacements ) {
143 | return null;
144 | }
145 |
146 | return {
147 | code: magicString.toString(),
148 | map: magicString.generateMap( { hires: true } ),
149 | };
150 | },
151 | };
152 | }
153 |
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 | import { defineConfig } from 'vite';
5 | import react from '@vitejs/plugin-react';
6 |
7 | export default defineConfig( {
8 | plugins: [ react() ],
9 | test: {
10 | setupFiles: [ './vitest.setup.js' ],
11 | css: false,
12 | },
13 | } );
14 |
--------------------------------------------------------------------------------
/vitest.setup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * External dependencies
3 | */
4 | import '@testing-library/jest-dom/vitest';
5 | import { afterEach } from 'vitest';
6 | import { cleanup } from '@testing-library/react';
7 |
8 | // Mirror Jest's auto-cleanup behavior
9 | // https://testing-library.com/docs/react-testing-library/setup#auto-cleanup-in-vitest
10 | afterEach( cleanup );
11 |
--------------------------------------------------------------------------------