├── .editorconfig ├── .eslintrc ├── .github └── workflows │ ├── be-linting.yml │ ├── wp-linting.yml │ └── wp-tests.yml ├── .gitignore ├── .prettierrc.cjs ├── .stylelintrc ├── .wp-env.json ├── CONTRIBUTING.md ├── EXTEND.md ├── LICENSE ├── README.md ├── babel.config.cjs ├── composer.json ├── composer.lock ├── jest.config.cjs ├── jest.setup.js ├── package-lock.json ├── package.json ├── phpcs.xml ├── phpunit.xml.dist ├── schema ├── build.mjs ├── meta │ └── schema.json └── subjects │ ├── blog-post.json │ └── page.json ├── screenshot.png ├── src ├── api │ ├── ApiClient.ts │ ├── ApiTypes.ts │ ├── Blueprints.ts │ ├── Settings.ts │ ├── SubjectsApi.ts │ └── Users.ts ├── bus │ ├── Bus.ts │ ├── Command.ts │ └── Event.ts ├── extension │ ├── background.ts │ ├── content.ts │ ├── icons │ │ ├── icon-128.png │ │ └── icon-32.png │ ├── manifest-chrome.json │ └── manifest-firefox.json ├── model │ ├── Blueprint.ts │ ├── Schema.ts │ ├── SiteSettings.ts │ ├── Subject.ts │ ├── User.ts │ └── field │ │ ├── DateField.ts │ │ ├── Field.ts │ │ ├── HtmlField.ts │ │ ├── LinkField.ts │ │ └── TextField.ts ├── mu-plugin-disable-rest-api-auth │ └── disable-rest-api-auth.php ├── parser │ ├── field.ts │ ├── init.ts │ ├── navigation.ts │ └── util.ts ├── plugin │ ├── class-engine.php │ ├── class-handlers-registry.php │ ├── class-observers-registry.php │ ├── class-ops.php │ ├── class-post-type-ui.php │ ├── class-schema.php │ ├── class-storage.php │ ├── class-subject.php │ ├── class-subjects-controller.php │ ├── class-transformer.php │ ├── enum-subject-type.php │ ├── plugin.php │ └── utils.php ├── storage │ ├── config.ts │ └── session.ts └── ui │ ├── App.tsx │ ├── Home.tsx │ ├── app.css │ ├── app.html │ ├── blueprints │ ├── EditBlueprint.tsx │ └── NewBlueprint.tsx │ ├── components │ ├── Breadcrumbs.tsx │ ├── ContentEventHandler.tsx │ ├── FieldsEditor │ │ ├── FieldsEditor.tsx │ │ └── SingleFieldEditor.tsx │ └── Toolbar.tsx │ ├── hooks │ ├── useBlueprint.ts │ └── useSubject.ts │ ├── import │ ├── ImportWithBlueprint.tsx │ └── pages │ │ ├── Done.tsx │ │ ├── ImportPage.tsx │ │ ├── SelectNavigation.tsx │ │ ├── SelectPagesFromNavigation.tsx │ │ ├── StartPageImport.tsx │ │ ├── Toolbar.tsx │ │ ├── useNavigationHtml.ts │ │ └── useSelectedPages.ts │ ├── main.ts │ ├── preview │ ├── PlaceholderPreview.tsx │ ├── Playground.tsx │ ├── PlaygroundHttpProxy.ts │ ├── Preview.tsx │ └── PreviewTabBar.tsx │ └── session │ ├── NewSession.tsx │ ├── SessionProvider.ts │ └── ViewSession.tsx ├── tests ├── bin │ └── install-wp-tests.sh └── plugin │ ├── base-test.php │ ├── bootstrap.php │ ├── test-blogpost-controller.php │ ├── test-liberate-controller.php │ ├── test-page-controller.php │ ├── test-storage.php │ └── test-utils.php ├── tsconfig.json ├── web-ext-config.mjs └── webpack.config.cjs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = tab 6 | end_of_line = lf 7 | insert_final_newline = true 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "plugin:@wordpress/eslint-plugin/recommended" 4 | ], 5 | "plugins": [ 6 | "jest" 7 | ], 8 | "env": { 9 | "jest/globals": true 10 | }, 11 | "settings": { 12 | "import/resolver": { 13 | "typescript": { 14 | "alwaysTryTypes": true 15 | } 16 | } 17 | }, 18 | "rules": { 19 | // Turn on errors for missing imports. 20 | "import/no-unresolved": "error", 21 | "import/named": "off", 22 | "no-useless-constructor": "off", 23 | "no-console": [ 24 | "off" 25 | ] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/be-linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting (Browser extension) 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, synchronize ] 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | 15 | - name: Install dependencies 16 | run: npm install 17 | 18 | - name: Lint js 19 | run: npm run lint:js 20 | 21 | - name: Lint styles 22 | run: npm run lint:style 23 | -------------------------------------------------------------------------------- /.github/workflows/wp-linting.yml: -------------------------------------------------------------------------------- 1 | name: Linting (WP plugin) 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, synchronize ] 6 | 7 | jobs: 8 | phpcs: 9 | name: phpcs 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v4 15 | 16 | - name: Install PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '8.3' 20 | coverage: 'none' 21 | tools: composer, cs2pr 22 | 23 | - name: Install dependencies 24 | uses: ramsey/composer-install@v2 25 | with: 26 | composer-options: "--no-progress --no-ansi --no-interaction" 27 | 28 | - name: Run PHPCS 29 | run: src/plugin/vendor/bin/phpcs --standard=./phpcs.xml -q --report=checkstyle | cs2pr --notices-as-warnings 30 | -------------------------------------------------------------------------------- /.github/workflows/wp-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests (WP plugin) 2 | 3 | on: 4 | pull_request: 5 | types: [ opened, synchronize ] 6 | 7 | jobs: 8 | phpunit: 9 | name: PHPUnit ${{ matrix.php }} 10 | runs-on: ubuntu-latest 11 | 12 | services: 13 | mysql: 14 | image: mysql:5.7 15 | ports: 16 | - 3306/tcp 17 | env: 18 | MYSQL_ROOT_PASSWORD: password 19 | # Set health checks to wait until mysql has started 20 | options: >- 21 | --health-cmd "mysqladmin ping" 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 3 25 | 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | php: [ '8.3' ] 30 | 31 | steps: 32 | - name: Check out Git repository 33 | uses: actions/checkout@v4 34 | 35 | - name: Setup npm 36 | uses: actions/setup-node@v4 37 | 38 | - name: Setup PHP 39 | uses: shivammathur/setup-php@v2 40 | with: 41 | php-version: ${{ matrix.php }} 42 | coverage: 'none' 43 | tools: composer 44 | 45 | - name: Install dependencies 46 | uses: ramsey/composer-install@v2 47 | with: 48 | composer-options: "--no-progress --no-ansi --no-interaction" 49 | 50 | - name: Build schema.json 51 | run: npm install && npm run build:schema 52 | 53 | - name: Install WordPress test setup 54 | run: bash tests/bin/install-wp-tests.sh wordpress_test root password 127.0.0.1:${{ job.services.mysql.ports[3306] }} latest 55 | 56 | - name: Setup problem matchers for PHPUnit 57 | run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" 58 | 59 | - name: Run tests 60 | env: 61 | WP_TESTS_DIR: /tmp/wordpress-tests-lib/ 62 | PHPUNIT_UNDER_GITHUB_ACTIONS: true 63 | run: src/plugin/vendor/bin/phpunit 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | test-results/ 3 | build/ 4 | vendor/ 5 | schema/schema.json 6 | 7 | .phpunit.result.cache 8 | src/plugin/schema.json 9 | -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | // Import the default config file and expose it in the project root. 2 | // Useful for editor integrations. 3 | module.exports = require( '@wordpress/prettier-config' ); 4 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@wordpress/stylelint-config" 3 | } 4 | -------------------------------------------------------------------------------- /.wp-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "core": "WordPress/WordPress#6.6.2", 3 | "phpVersion": "8.3", 4 | "env": { 5 | "tests": { 6 | "phpVersion": "8.3" 7 | } 8 | }, 9 | "plugins": [ "./src/plugin" ], 10 | "mappings": { 11 | "wp-content/phpunit.xml.dist": "./phpunit.xml.dist", 12 | "wp-content/tests": "./tests", 13 | "wp-content/mu-plugins/mu-plugin-disable-rest-api.php": "./src/mu-plugin-disable-rest-api-auth/disable-rest-api-auth.php" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contribute 2 | 3 | This repo provides a development environment that facilitates: 4 | 5 | - Developing the browser extension, using the [`web-ext`](https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext/) tool. 6 | - Developing the WordPress plugin that is used under WordPress Playground, using [`wp-env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/). 7 | 8 | ## Development environment - Browser extension 9 | 10 | First install required dependencies: 11 | 12 | ```shell 13 | npm install 14 | ``` 15 | 16 | Then build the extension: 17 | 18 | ```shell 19 | npm run build:firefox 20 | # or 21 | npm run build:chrome 22 | ``` 23 | 24 | You can then use the `start` script to start a browser instance separate from your main instance that has the extension automatically installed: 25 | 26 | ```shell 27 | npm run start:firefox 28 | # or 29 | npm run start:chrome 30 | ``` 31 | 32 | The extension will also automatically reload whenever you modify source files. 33 | 34 | > Please note that at the moment not all `web-ext` features work on chrome, so firefox is the recommended browser for developing this project, since it provides the best developer experience. One example of a `web-ext` feature that doesn't currently work on chrome is to have the developer tools and extension console automatically open when the extension loads. 35 | 36 | ## Development environment - WordPress plugin 37 | 38 | First install required dependencies: 39 | 40 | ```shell 41 | composer install 42 | ``` 43 | 44 | The development environment requires [wp-env](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/), you can install it with: 45 | 46 | ```shell 47 | npm install -g @wordpress/env 48 | ``` 49 | 50 | Start the development environment: 51 | ```shell 52 | composer run dev:start 53 | ``` 54 | 55 | You will need docker engine running for this command to work, since `wp-env` uses container that runs on docker engine. 56 | This command starts the WordPress environment and sets up the permalink structure. 57 | 58 | To stop the development environment: 59 | ```shell 60 | composer run dev:stop 61 | ``` 62 | 63 | Additionally, there is also support for `xdebug`, `phpcs` and `phpcbf`: 64 | 65 | For debugging with Xdebug: 66 | ```shell 67 | composer run dev:debug 68 | ``` 69 | 70 | To run linting on the codebase: 71 | ```shell 72 | composer run lint 73 | ``` 74 | 75 | To automatically fix linting issues: 76 | ```shell 77 | composer run lint:fix 78 | ``` 79 | 80 | ## Building for production 81 | You can build both the firefox and chrome versions of the extension with the following command. The resulting files will be under the `build/firefox` and `build/chrome` directories, respectively. 82 | 83 | ```shell 84 | npm run build 85 | ``` 86 | 87 | > We would soon have the build & release pipeline for publishing the plugin to WP.org repo. 88 | 89 | ## Running tests 90 | 91 | You can run tests with: 92 | 93 | **For browser extension:** 94 | 95 | ```shell 96 | npm run test 97 | ``` 98 | 99 | **For WordPress plugin:** 100 | 101 | ```shell 102 | composer run dev:test 103 | ``` 104 | This command runs the tests in the WordPress environment using PHPUnit. 105 | -------------------------------------------------------------------------------- /EXTEND.md: -------------------------------------------------------------------------------- 1 | # Receive Liberated Data 2 | 3 | ## Definitions 4 | 5 | Each identified piece of content is referred to as 6 | a [subject](src/plugin/class-subject.php), and hence has 7 | a [subject type](src/plugin/enum-subject-type.php). 8 | 9 | The act of extracting data is referred to as `liberation` and the act of using the extracted raw data to convert into a 10 | usable form is referred to as `transformation` throughout the documentation and code. 11 | 12 | While liberating data, we always store the raw data that we are handling in hopes of running a better transformation in 13 | the future or any third-party plugin to transform the data upon installation of their plugin. 14 | 15 | ## Integration 16 | 17 | If your plugin is available during data liberation, it can register handlers for the desired subject types and run its 18 | own transformations. 19 | 20 | You can use `data_liberated_{$subject_type}` action hook to run your transformation. 21 | In your handler, you get access to the raw data through an instance of the Subject class. 22 | 23 | The [Subject class](src/plugin/class-subject.php) provides a clean API to access raw data and existing transformed 24 | output: 25 | 26 | ### Code example: 27 | 28 | For example, to transform "product" (subject type) data into your custom product post type, you would do this: 29 | 30 | ```php 31 | add_action( 'data_liberated_product', 'myplugin_unique_slug_product_handler' ); 32 | 33 | function myplugin_unique_slug_product_handler( $subject ) { 34 | // process raw data 35 | $title = $subject->title; 36 | $date = $subject->date; 37 | $content = $subject->content; 38 | 39 | // access the entire HTML source of page or its URL 40 | // $subject->source_html 41 | // $subject->source_url 42 | 43 | // Create a product in your custom post type 44 | $my_product_id = wp_insert_post( array( 45 | 'post_type' => 'my_product_type', 46 | 'post_title' => $title, 47 | 'post_date' => $date, 48 | 'post_content' => $content, 49 | 'post_status' => 'publish', 50 | ) ); 51 | 52 | return $my_product_id; // would be used for preview 53 | } 54 | ``` 55 | 56 | ## Best Practices 57 | 58 | 1. Always use the Subject class's public API to access liberated data. Don't rely on internal implementation details. 59 | 60 | ## Storage Architecture 61 | 62 | Try WordPress stores all liberated data in a custom post type called `liberated_data`, exposed via a constant: 63 | `\DotOrg\TryWordPress\Engine::STORAGE_POST_TYPE`. 64 | 65 | We maintain references between the source data and transformed output using two post meta keys: 66 | 67 | - `_data_liberation_source`: Points to the source content (exposed via 68 | `\DotOrg\TryWordPress\Transformer::META_KEY_LIBERATED_SOURCE`) 69 | - `_data_liberation_output`: Points to the transformed output (exposed via 70 | `\DotOrg\TryWordPress\Transformer::META_KEY_LIBERATED_OUTPUT`) 71 | 72 | This two-way reference allows both Try WordPress and your plugin to track relationships between original content and 73 | transformed posts. 74 | 75 | ## Need Help? 76 | 77 | Open an issue on our [GitHub repository](https://github.com/WordPress/try-wordpress) if you: 78 | 79 | - Need additional integration points 80 | - Have a use case not currently covered 81 | - Want to discuss your integration approach 82 | 83 | We actively work with plugin authors to ensure Try WordPress meets real-world needs. 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Try WordPress 2 | _Try WordPress_ allows you to **import your existing website to WordPress** in an intuitive way. 3 | 4 | It's a browser extension that comes with a local WordPress site, and makes it easy for you to import content from your existing site. At the end, you can export the local site to its permanent location, at a WordPress host of your choosing. 5 | 6 | ![screenshot.png](screenshot.png) 7 | 8 | ## Installing 9 | > This project is in _alpha_ state, not all features are fully implemented and it is likely not very useful yet. 10 | 11 | Currently, the extension is not published to the browser extension stores, so you will not be able to install it directly from your browser. Instead, the easiest way to install the extension is by following the [development environment setup instructions](CONTRIBUTING.md). 12 | 13 | ## How to contribute 14 | There are multiple ways you can contribute to this project: 15 | 16 | - Submit improvements or fixes to the Try WordPress browser extension itself: see [`CONTRIBUTING.md`](CONTRIBUTING.md) 17 | - Create or improve site definitions so that other users can more easily import their site: Coming soon. 18 | - Create or improve transformation plugins so that content can better fit in the WordPress plugin ecosystem: Coming soon. 19 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ '@babel/preset-env' ], 3 | }; 4 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wordpress/try-wordpress", 3 | "description": "", 4 | "license": "", 5 | "require-dev": { 6 | "phpunit/phpunit": "^9.6.21", 7 | "yoast/phpunit-polyfills": "^2.0.2", 8 | "wp-coding-standards/wpcs": "^3.1.0" 9 | }, 10 | "scripts": { 11 | "lint": "phpcs --standard=phpcs.xml -s", 12 | "lint:fix": "phpcbf --standard=phpcs.xml", 13 | "lint:autofix": [ 14 | "@lint", 15 | "@lint:fix" 16 | ], 17 | "load:preset": [ 18 | "wp-env run cli wp rewrite structure '/%postname%/'", 19 | "wp-env run cli wp rewrite flush" 20 | ], 21 | "dev:start": [ 22 | "wp-env start", 23 | "@load:preset" 24 | ], 25 | "dev:debug": "wp-env start --xdebug", 26 | "dev:stop": "wp-env stop", 27 | "dev:destroy": "yes | wp-env destroy", 28 | "dev:reset": [ 29 | "wp-env clean all", 30 | "@dev:start" 31 | ], 32 | "dev:db": "wp-env run cli wp db export - > backup.sql", 33 | "dev:db:backup": "wp-env run cli wp db export - > backup-$(date +%Y%m%d-%H%M%S).sql", 34 | "dev:test": "wp-env run tests-cli --env-cwd=wp-content/ plugins/plugin/vendor/bin/phpunit --testdox", 35 | "dev:test:api": "wp-env run tests-cli --env-cwd=wp-content/ plugins/plugin/vendor/bin/phpunit --testdox --filter 'Controller_Test'", 36 | "dev:test:failing": "wp-env run tests-cli --env-cwd=wp-content/ plugins/plugin/vendor/bin/phpunit --testdox --group failing" 37 | }, 38 | "config": { 39 | "vendor-dir": "src/plugin/vendor", 40 | "allow-plugins": { 41 | "dealerdirect/phpcodesniffer-composer-installer": true 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.jsx?$': 'babel-jest', // Transform JavaScript files using babel-jest 4 | }, 5 | setupFiles: [ './jest.setup.js' ], 6 | testEnvironment: 'jsdom', 7 | testPathIgnorePatterns: [ '/tests/e2e/*' ], 8 | }; 9 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | 3 | // We need this when running jest test in the context of the browser. 4 | import { TextDecoder, TextEncoder } from 'util'; 5 | global.TextEncoder = TextEncoder; 6 | global.TextDecoder = TextDecoder; 7 | 8 | global.chrome = { 9 | runtime: { 10 | onMessage: { 11 | addListener: jest.fn(), // eslint-disable-line no-undef 12 | }, 13 | }, 14 | }; 15 | 16 | global.HTMLElement = class {}; 17 | 18 | // Patch JSDOM with window.matchMedia 19 | Object.defineProperty( window, 'matchMedia', { 20 | writable: true, 21 | value: jest.fn().mockImplementation( ( query ) => ( { 22 | matches: false, 23 | media: query, 24 | onchange: null, 25 | addListener: jest.fn(), // Deprecated 26 | removeListener: jest.fn(), // Deprecated 27 | addEventListener: jest.fn(), 28 | removeEventListener: jest.fn(), 29 | dispatchEvent: jest.fn(), 30 | } ) ), 31 | } ); 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "build": "echo 'Please choose a target: build:firefox or build:chrome, or build:production:firefox or build:production:chrome'", 5 | "build:production": "echo 'Please choose a target: build:production:firefox or build:production:chrome'", 6 | "build:production:firefox": "rm -rf build/production/firefox && webpack --env mode=production --env target=firefox", 7 | "build:production:chrome": "rm -rf build/production/chrome && webpack --env mode=production --env target=chrome", 8 | "build:firefox": "rm -rf build/firefox && webpack --env target=firefox", 9 | "build:chrome": "rm -rf build/chrome && webpack --env target=chrome", 10 | "build:schema": "./schema/build.mjs", 11 | "preview:production": "echo 'Please choose a target: preview:production:firefox or preview:production:chrome'", 12 | "preview:production:firefox": "npm run build:production:firefox && web-ext -s build/production/firefox run --target firefox-desktop", 13 | "preview:production:chrome": "npm run build:production:chrome && web-ext -s build/production/chrome run --target chromium --arg='--disable-search-engine-choice-screen'", 14 | "watch": "echo 'Please choose a target: watch:firefox or watch:chrome'", 15 | "watch:firefox": "webpack --watch --env target=firefox", 16 | "watch:chrome": "webpack --watch --env target=chrome", 17 | "start": "echo 'Please choose a target: start:firefox or start:chrome'", 18 | "start:firefox": "npm run build:firefox && concurrently \"npm:watch:firefox\" \"npm:web-ext:run:firefox\"", 19 | "start:chrome": "npm run build:chrome && concurrently \"npm:watch:chrome\" \"npm:web-ext:run:chrome\"", 20 | "start:noreload": "npm run web-ext:run:firefox -- --no-reload", 21 | "web-ext:run": "echo 'Please choose a target: web-ext:run:firefox or web-ext:run:chrome'", 22 | "web-ext:run:firefox": "web-ext -s build/firefox run --target firefox-desktop", 23 | "web-ext:run:chrome": "web-ext -s build/chrome run --target chromium --arg='--disable-search-engine-choice-screen'", 24 | "lint": "npm run lint:js && npm run lint:style", 25 | "lint:js": "wp-scripts lint-js", 26 | "lint:style": "wp-scripts lint-style", 27 | "format": "wp-scripts format", 28 | "web-ext": "web-ext", 29 | "wp-scripts": "wp-scripts", 30 | "prettier": "prettier" 31 | }, 32 | "devDependencies": { 33 | "@babel/preset-env": "^7.26.0", 34 | "@types/chrome": "^0.0.281", 35 | "@types/firefox-webext-browser": "^120.0.4", 36 | "@types/react": "^18.3.12", 37 | "@types/react-dom": "^18.3.1", 38 | "@types/wordpress__block-library": "^2.6.3", 39 | "@types/wordpress__blocks": "^12.5.14", 40 | "@wordpress/scripts": "^30.4.0", 41 | "ajv": "^8.17.1", 42 | "concurrently": "^9.1.0", 43 | "copy-webpack-plugin": "^12.0.2", 44 | "css-loader": "^7.1.2", 45 | "eslint-import-resolver-typescript": "^3.6.3", 46 | "filemanager-webpack-plugin": "^8.0.0", 47 | "mini-css-extract-plugin": "^2.9.2", 48 | "style-loader": "^4.0.0", 49 | "ts-loader": "^9.5.1", 50 | "tsconfig-paths-webpack-plugin": "^4.1.0", 51 | "typescript": "^5.6.3", 52 | "web-ext": "^8.3.0", 53 | "wp-types": "^4.67.0", 54 | "webpack": "^5.96.1" 55 | }, 56 | "dependencies": { 57 | "@wordpress/block-library": "^9.12.0", 58 | "@wordpress/blocks": "^13.10.0", 59 | "@wp-playground/client": "^1.0.13", 60 | "react": "^18.3.1", 61 | "react-dom": "^18.3.1", 62 | "react-router-dom": "^6.28.0", 63 | "webextension-polyfill": "^0.12.0" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Coding standards 4 | 5 | 6 | 7 | 8 | ./src/plugin/ 9 | ./tests/plugin/ 10 | 11 | */vendor/* 12 | schemas/* 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | tests/plugin 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /schema/build.mjs: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Validates each schema (blog-post.json, page.json, etc) against the meta schema (meta/schema.json). 4 | // Concatenates each schema into a single schema.json file, in the same directory as this script. 5 | // Exits with non-zero code when validation fails or on error. 6 | 7 | // eslint-disable-next-line import/no-extraneous-dependencies 8 | import { Ajv } from 'ajv'; 9 | import { fileURLToPath } from 'url'; 10 | import * as path from 'node:path'; 11 | import { copyFileSync, readFileSync } from 'node:fs'; 12 | import * as fs from 'node:fs'; 13 | 14 | const cwd = path.dirname( fileURLToPath( import.meta.url ) ); 15 | const schemaDir = path.join( cwd, 'subjects' ); 16 | const outputSchemaPath = path.join( cwd, 'schema.json' ); 17 | 18 | const metaSchema = JSON.parse( 19 | readFileSync( path.join( cwd, 'meta', 'schema.json' ) ).toString() 20 | ); 21 | 22 | const schemas = fs 23 | .readdirSync( schemaDir ) 24 | .filter( ( file ) => file.endsWith( '.json' ) ) 25 | .map( ( file ) => 26 | JSON.parse( readFileSync( path.join( schemaDir, file ) ).toString() ) 27 | ); 28 | 29 | const build = new Ajv( { 30 | allErrors: true, 31 | verbose: true, 32 | } ).compile( metaSchema ); 33 | 34 | const slugs = new Set(); 35 | const errors = []; 36 | for ( const schema of schemas ) { 37 | if ( slugs.has( schema.slug ) ) { 38 | console.error( 39 | `A schema with slug "${ schema.slug }" already exists.` 40 | ); 41 | process.exit( 1 ); 42 | } 43 | slugs.add( schema.slug ); 44 | if ( ! build( schema ) ) { 45 | errors.push( ...build.errors ); 46 | } 47 | } 48 | 49 | if ( errors.length > 0 ) { 50 | console.error( errors ); 51 | console.error( 'Schema validation failed' ); 52 | process.exit( 1 ); 53 | } 54 | 55 | console.log( 'Schema validation complete' ); 56 | 57 | const outputSchema = {}; 58 | for ( const schema of schemas ) { 59 | outputSchema[ schema.slug ] = schema; 60 | } 61 | 62 | fs.writeFileSync( 63 | outputSchemaPath, 64 | JSON.stringify( outputSchema, null, '\t' ) 65 | ); 66 | 67 | copyFileSync( 68 | outputSchemaPath, 69 | path.join( cwd, '..', 'src', 'plugin', 'schema.json' ) 70 | ); 71 | 72 | console.log( 'Schema file generated successfully:', outputSchemaPath ); 73 | -------------------------------------------------------------------------------- /schema/meta/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": { 3 | "field": { 4 | "type": "object", 5 | "properties": { 6 | "type": { 7 | "description": "The type of field", 8 | "type": "string", 9 | "enum": [ 10 | "date", 11 | "text", 12 | "html" 13 | ] 14 | }, 15 | "description": { 16 | "description": "The description of the field", 17 | "type": "string" 18 | } 19 | }, 20 | "required": [ 21 | "type" 22 | ], 23 | "additionalProperties": false 24 | } 25 | }, 26 | "type": "object", 27 | "properties": { 28 | "title": { 29 | "description": "The subject's title.", 30 | "type": "string" 31 | }, 32 | "slug": { 33 | "description": "The subject's unique slug. Must be a sequence of lowercase letters, potentially with dashes.", 34 | "type": "string", 35 | "pattern": "^[a-z]+(?:-+[a-z]+)*$", 36 | "maxLength": 16 37 | }, 38 | "fields": { 39 | "description": "The subject's fields.", 40 | "type": "object", 41 | "additionalProperties": { 42 | "$ref": "#/definitions/field" 43 | } 44 | } 45 | }, 46 | "required": [ 47 | "title", 48 | "slug", 49 | "fields" 50 | ], 51 | "additionalProperties": false 52 | } 53 | -------------------------------------------------------------------------------- /schema/subjects/blog-post.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Blog Post", 3 | "slug": "blog-post", 4 | "fields": { 5 | "title": { 6 | "description": "The title of the blog post", 7 | "type": "text" 8 | }, 9 | "date": { 10 | "description": "The date the blog post was authored", 11 | "type": "date" 12 | }, 13 | "content": { 14 | "description": "The body of the blog post", 15 | "type": "html" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /schema/subjects/page.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Page", 3 | "slug": "page", 4 | "fields": { 5 | "title": { 6 | "description": "The title of the page", 7 | "type": "text" 8 | }, 9 | "content": { 10 | "description": "The body of the page", 11 | "type": "html" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WordPress/try-wordpress/f175c9055657878eb91b3b50071bd48fd6a5df94/screenshot.png -------------------------------------------------------------------------------- /src/api/ApiClient.ts: -------------------------------------------------------------------------------- 1 | import { PlaygroundClient } from '@wp-playground/client'; 2 | import { SettingsApi } from '@/api/Settings'; 3 | import { UsersApi } from '@/api/Users'; 4 | import { BlueprintsApi } from '@/api/Blueprints'; 5 | import { SubjectsApi } from '@/api/SubjectsApi'; 6 | import { PlaygroundHttpProxy } from '@/ui/preview/PlaygroundHttpProxy'; 7 | 8 | export class ApiClient { 9 | private readonly _client: PlaygroundHttpProxy; 10 | private readonly _siteUrl: string; 11 | private readonly _subjects: SubjectsApi; 12 | private readonly _settings: SettingsApi; 13 | private readonly _users: UsersApi; 14 | private readonly _blueprints: BlueprintsApi; 15 | 16 | constructor( playgroundClient: PlaygroundClient, siteUrl: string ) { 17 | this._client = new PlaygroundHttpProxy( playgroundClient ); 18 | this._siteUrl = siteUrl; 19 | this._blueprints = new BlueprintsApi( this ); 20 | this._subjects = new SubjectsApi( this ); 21 | this._settings = new SettingsApi( this ); 22 | this._users = new UsersApi( this ); 23 | } 24 | 25 | get siteUrl(): string { 26 | return this._siteUrl; 27 | } 28 | 29 | get blueprints(): BlueprintsApi { 30 | return this._blueprints; 31 | } 32 | 33 | get subjects(): SubjectsApi { 34 | return this._subjects; 35 | } 36 | 37 | get settings(): SettingsApi { 38 | return this._settings; 39 | } 40 | 41 | get users(): UsersApi { 42 | return this._users; 43 | } 44 | 45 | async get( 46 | route: string, 47 | params?: Record< string, string > 48 | ): Promise< object | null > { 49 | let url = `/index.php?rest_route=/try-wp/v1${ route }`; 50 | for ( const name in params ) { 51 | const encoded = encodeURIComponent( params[ name ] ); 52 | url += `&${ name }=${ encoded }`; 53 | } 54 | const response = await this._client.request( { 55 | url, 56 | method: 'GET', 57 | } ); 58 | if ( response.httpStatusCode === 404 ) { 59 | return null; 60 | } 61 | if ( response.httpStatusCode < 200 || response.httpStatusCode >= 300 ) { 62 | throw Error( response.json.message ); 63 | } 64 | return response.json; 65 | } 66 | 67 | async post( route: string, body: object ): Promise< object > { 68 | const url = `/index.php?rest_route=/try-wp/v1${ route }`; 69 | const response = await this._client.request( { 70 | url, 71 | method: 'POST', 72 | headers: { 73 | 'Content-Type': 'application/json', 74 | }, 75 | body: JSON.stringify( body ), 76 | } ); 77 | 78 | if ( response.httpStatusCode < 200 || response.httpStatusCode >= 300 ) { 79 | throw Error( response.json.message ); 80 | } 81 | return response.json; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/api/ApiTypes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable camelcase */ 2 | import { WP_REST_API_Settings, WP_REST_API_User } from 'wp-types'; 3 | 4 | export type ApiPost = { 5 | id: number; 6 | transformedId: number; 7 | previewUrl: string; 8 | sourceUrl: string; 9 | [ key: string ]: any; // For the raw/parsed fields 10 | }; 11 | 12 | export type ApiUser = WP_REST_API_User; 13 | 14 | export type ApiSettings = WP_REST_API_Settings; 15 | /* eslint-enable camelcase */ 16 | -------------------------------------------------------------------------------- /src/api/Blueprints.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from '@/api/ApiClient'; 2 | import { Blueprint } from '@/model/Blueprint'; 3 | import { SubjectType } from '@/model/Subject'; 4 | 5 | export class BlueprintsApi { 6 | // eslint-disable-next-line no-useless-constructor 7 | constructor( private readonly client: ApiClient ) {} 8 | 9 | async create( blueprint: Blueprint ): Promise< Blueprint > { 10 | blueprint.id = Date.now().toString( 16 ); 11 | 12 | const values: Record< string, Blueprint > = {}; 13 | values[ key( blueprint.id ) ] = blueprint; 14 | await browser.storage.local.set( values ); 15 | 16 | // We also maintain an array of blueprintIds to serve as "index" for when we need to list blueprints. 17 | let blueprintIds: string[]; 18 | const blueprintIdsValues = 19 | await browser.storage.local.get( 'blueprints' ); 20 | if ( ! blueprintIdsValues || ! blueprintIdsValues.blueprints ) { 21 | blueprintIds = []; 22 | } else { 23 | blueprintIds = blueprintIdsValues.blueprints; 24 | } 25 | blueprintIds.push( blueprint.id ); 26 | await browser.storage.local.set( { blueprints: blueprintIds } ); 27 | 28 | return blueprint; 29 | } 30 | 31 | async update( blueprint: Blueprint ): Promise< Blueprint > { 32 | const values: Record< string, Blueprint > = {}; 33 | values[ key( blueprint.id ) ] = blueprint; 34 | await browser.storage.local.set( values ); 35 | return blueprint; 36 | } 37 | 38 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 39 | async findById( id: string ): Promise< Blueprint | null > { 40 | const values = await browser.storage.local.get( key( id ) ); 41 | if ( ! values || ! values[ key( id ) ] ) { 42 | return null; 43 | } 44 | return values[ key( id ) ] as Blueprint; 45 | } 46 | 47 | async findBySubjectType( 48 | subjectType: SubjectType 49 | ): Promise< Blueprint[] > { 50 | let blueprintIds = []; 51 | const values = await browser.storage.local.get( 'blueprints' ); 52 | if ( values && values.blueprints ) { 53 | blueprintIds = values.blueprints; 54 | } 55 | 56 | const blueprints = []; 57 | for ( const blueprintId of blueprintIds ) { 58 | // eslint-disable-next-line react/no-is-mounted 59 | const blueprint = await this.findById( blueprintId ); 60 | if ( blueprint && blueprint.type === subjectType ) { 61 | blueprints.push( blueprint ); 62 | } 63 | } 64 | 65 | return blueprints; 66 | } 67 | } 68 | 69 | function key( blueprintId: string ): string { 70 | return `blueprint-${ blueprintId }`; 71 | } 72 | -------------------------------------------------------------------------------- /src/api/Settings.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from '@/api/ApiClient'; 2 | import { SiteSettings } from '@/model/SiteSettings'; 3 | import { ApiSettings } from '@/api/ApiTypes'; 4 | 5 | interface UpdateBody { 6 | title?: string; 7 | } 8 | 9 | export class SettingsApi { 10 | // eslint-disable-next-line no-useless-constructor 11 | constructor( private readonly client: ApiClient ) {} 12 | 13 | async update( body: UpdateBody ): Promise< SiteSettings > { 14 | const actualBody: any = {}; 15 | if ( body.title ) { 16 | actualBody.title = body.title; 17 | } 18 | if ( Object.keys( actualBody ).length === 0 ) { 19 | throw Error( 'attempting to update zero fields' ); 20 | } 21 | const response = ( await this.client.post( 22 | `/settings`, 23 | actualBody 24 | ) ) as ApiSettings; 25 | return makeSiteSettingsFromApiResponse( response ); 26 | } 27 | } 28 | 29 | function makeSiteSettingsFromApiResponse( 30 | response: ApiSettings 31 | ): SiteSettings { 32 | return { 33 | title: response.title, 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/api/SubjectsApi.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from '@/api/ApiClient'; 2 | import { Subject, SubjectType } from '@/model/Subject'; 3 | import { ApiPost } from '@/api/ApiTypes'; 4 | import { getSchema } from '@/model/Schema'; 5 | import { Field, FieldType } from '@/model/field/Field'; 6 | import { newDateField } from '@/model/field/DateField'; 7 | import { newTextField } from '@/model/field/TextField'; 8 | import { newHtmlField } from '@/model/field/HtmlField'; 9 | 10 | export class SubjectsApi { 11 | constructor( private readonly client: ApiClient ) {} 12 | 13 | async create( type: SubjectType, sourceUrl: string ): Promise< Subject > { 14 | const path = getEndpoint( type ); 15 | const response = ( await this.client.post( path, { 16 | sourceUrl, 17 | } ) ) as ApiPost; 18 | return fromApiResponse( type, response ); 19 | } 20 | 21 | async update( subject: Subject ): Promise< Subject > { 22 | const { id, type } = subject; 23 | const path = `${ getEndpoint( type ) }/${ id }`; 24 | const response = ( await this.client.post( 25 | path, 26 | toApiUpdateRequest( subject ) 27 | ) ) as ApiPost; 28 | return fromApiResponse( type, response ); 29 | } 30 | 31 | async findById( type: SubjectType, id: number ): Promise< Subject | null > { 32 | const path = `${ getEndpoint( type ) }/${ id }`; 33 | const post = ( await this.client.get( path ) ) as ApiPost; 34 | return post ? fromApiResponse( type, post ) : null; 35 | } 36 | 37 | async findBySourceUrl( 38 | type: SubjectType, 39 | sourceUrl: string 40 | ): Promise< Subject | null > { 41 | const path = `${ getEndpoint( type ) }&sourceurl=${ encodeURIComponent( 42 | sourceUrl 43 | ) }`; 44 | const post = ( await this.client.get( path ) ) as ApiPost; 45 | return post ? fromApiResponse( type, post ) : null; 46 | } 47 | } 48 | 49 | function getEndpoint( type: SubjectType ): string { 50 | const schema = getSchema( type ); 51 | return `/subjects/${ schema.slug }`; 52 | } 53 | 54 | function fromApiResponse( type: SubjectType, response: ApiPost ): Subject { 55 | const schema = getSchema( type ); 56 | 57 | const fields = Object.entries( schema.fields ).reduce< 58 | Record< string, Field > 59 | >( ( acc, [ fieldName, schemaField ] ) => { 60 | // Create the raw/parsed key names from the field name 61 | const rawKey = `raw${ fieldName 62 | .charAt( 0 ) 63 | .toUpperCase() }${ fieldName.slice( 1 ) }`; 64 | const parsedKey = `parsed${ fieldName 65 | .charAt( 0 ) 66 | .toUpperCase() }${ fieldName.slice( 1 ) }`; 67 | 68 | // Get values from response 69 | const rawValue = response[ rawKey ]; 70 | const parsedValue = response[ parsedKey ]; 71 | 72 | // Create field based on schema-defined type 73 | switch ( schemaField.type ) { 74 | case FieldType.Date: 75 | acc[ fieldName ] = newDateField( rawValue, parsedValue ); 76 | break; 77 | case FieldType.Text: 78 | acc[ fieldName ] = newTextField( rawValue, parsedValue ); 79 | break; 80 | case FieldType.Html: 81 | acc[ fieldName ] = newHtmlField( rawValue, parsedValue ); 82 | break; 83 | } 84 | 85 | return acc; 86 | }, {} ); 87 | 88 | return { 89 | id: response.id, 90 | type, 91 | sourceUrl: response.sourceUrl, 92 | transformedId: response.transformedId, 93 | previewUrl: response.previewUrl, 94 | fields, 95 | }; 96 | } 97 | 98 | type UpdateBody = Omit< ApiPost, 'sourceUrl' | 'transformedId' | 'previewUrl' >; 99 | 100 | function toApiUpdateRequest( subject: Subject ): UpdateBody { 101 | let request: UpdateBody = { 102 | id: subject.id, 103 | }; 104 | 105 | Object.entries( subject.fields ).forEach( ( [ fieldName, field ] ) => { 106 | const capitalizedFieldName = 107 | fieldName.charAt( 0 ).toUpperCase() + fieldName.slice( 1 ); 108 | 109 | let parsedValue = field.parsedValue; 110 | // Handle special cases 111 | 112 | if ( 113 | field.type === FieldType.Date && 114 | field.parsedValue instanceof Date 115 | ) { 116 | parsedValue = field.parsedValue.toISOString(); 117 | } 118 | 119 | request = { 120 | ...request, 121 | [ `raw${ capitalizedFieldName }` ]: field.rawValue, 122 | [ `parsed${ capitalizedFieldName }` ]: parsedValue, 123 | }; 124 | } ); 125 | 126 | return request; 127 | } 128 | -------------------------------------------------------------------------------- /src/api/Users.ts: -------------------------------------------------------------------------------- 1 | import { ApiClient } from '@/api/ApiClient'; 2 | import { User } from '@/model/User'; 3 | import { ApiUser } from '@/api/ApiTypes'; 4 | 5 | interface CreateBody { 6 | username: string; 7 | email: string; 8 | password: string; 9 | role?: string; // default roles: administrator, editor, author, subscriber (default) 10 | firstName?: string; 11 | lastName?: string; 12 | } 13 | 14 | export class UsersApi { 15 | // eslint-disable-next-line no-useless-constructor 16 | constructor( private readonly client: ApiClient ) {} 17 | 18 | async create( body: CreateBody ): Promise< User > { 19 | const actualBody: any = { 20 | username: body.username, 21 | email: body.email, 22 | password: body.password, 23 | }; 24 | if ( body.role ) { 25 | actualBody.roles = [ body.role ]; 26 | } 27 | if ( body.firstName ) { 28 | actualBody.first_name = body.firstName; 29 | } 30 | if ( body.lastName ) { 31 | actualBody.last_name = body.lastName; 32 | } 33 | const response = ( await this.client.post( 34 | `/users`, 35 | actualBody 36 | ) ) as ApiUser; 37 | return makeUserFromApiResponse( response ); 38 | } 39 | } 40 | 41 | function makeUserFromApiResponse( response: ApiUser ): User { 42 | return { 43 | username: response.username ?? '', 44 | email: response.email ?? '', 45 | role: response.roles ? response.roles[ 0 ] : '', 46 | firstName: response.first_name ?? '', 47 | lastName: response.last_name ?? '', 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/bus/Bus.ts: -------------------------------------------------------------------------------- 1 | import MessageSender = browser.runtime.MessageSender; 2 | import { EventType, EventWithNamespace, EventWithResponder } from '@/bus/Event'; 3 | import { CommandType } from '@/bus/Command'; 4 | 5 | export const Namespace = 'TRY_WORDPRESS'; 6 | export type EventSender = MessageSender; 7 | 8 | export type Listener = ( 9 | event: EventWithNamespace, 10 | sender: EventSender, 11 | sendResponse: ( response: any ) => void 12 | ) => void; 13 | 14 | export function startListening( 15 | type: EventType | CommandType, 16 | callback: ( event: EventWithResponder ) => void 17 | ): Listener { 18 | const internalListener = ( 19 | event: EventWithNamespace, 20 | sender: EventSender, 21 | sendResponse: ( response?: any ) => void 22 | ) => { 23 | if ( event.namespace === Namespace && event.type === type ) { 24 | callback( { event, sendResponse } ); 25 | } 26 | }; 27 | browser.runtime.onMessage.addListener( internalListener ); 28 | return internalListener; 29 | } 30 | 31 | export function stopListening( listener: Listener ) { 32 | if ( browser.runtime.onMessage.hasListener( listener ) ) { 33 | browser.runtime.onMessage.removeListener( listener ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/bus/Command.ts: -------------------------------------------------------------------------------- 1 | import { Namespace } from '@/bus/Bus'; 2 | 3 | export enum CommandTypes { 4 | GetCurrentPageInfo = 'GetCurrentPageInfo', 5 | NavigateTo = 'NavigateTo', 6 | SwitchToDefaultMode = 'SwitchToDefaultMode', 7 | SwitchToGenericSelectionMode = 'SwitchToGenericSelectionMode', 8 | SwitchToNavigationSelectionMode = 'SwitchToNavigationSelectionMode', 9 | } 10 | 11 | export interface CurrentPageInfo { 12 | url: string; 13 | title: string; 14 | } 15 | 16 | export type CommandType = `${ CommandTypes }`; 17 | 18 | export interface Command { 19 | type: CommandType; 20 | payload: object; 21 | } 22 | 23 | export async function sendCommandToContent( command: Command ): Promise< any > { 24 | const currentTabId = await getCurrentTabId(); 25 | if ( ! currentTabId ) { 26 | throw Error( 'current tab not found' ); 27 | } 28 | return browser.tabs.sendMessage( currentTabId, { 29 | namespace: Namespace, 30 | type: command.type, 31 | payload: command.payload, 32 | } ); 33 | } 34 | 35 | async function getCurrentTabId(): Promise< number | undefined > { 36 | const tabs = await browser.tabs.query( { 37 | currentWindow: true, 38 | active: true, 39 | } ); 40 | if ( tabs.length !== 1 ) { 41 | return; 42 | } 43 | return tabs[ 0 ]?.id; 44 | } 45 | -------------------------------------------------------------------------------- /src/bus/Event.ts: -------------------------------------------------------------------------------- 1 | import { Namespace } from '@/bus/Bus'; 2 | 3 | export enum EventTypes { 4 | OnElementClick = 'OnElementClick', 5 | } 6 | 7 | export type EventType = `${ EventTypes }`; 8 | 9 | export interface Event { 10 | type: EventType; 11 | payload: object; 12 | } 13 | 14 | export interface EventWithResponder { 15 | event: Event; 16 | sendResponse: ( response?: any ) => void; 17 | } 18 | 19 | export type EventWithNamespace = Event & { 20 | namespace: string; 21 | }; 22 | 23 | export async function sendEventToApp( event: Event ): Promise< any > { 24 | return browser.runtime.sendMessage( { 25 | namespace: Namespace, 26 | type: event.type, 27 | payload: event.payload, 28 | } ); 29 | } 30 | -------------------------------------------------------------------------------- /src/extension/background.ts: -------------------------------------------------------------------------------- 1 | // Open the sidebar when clicking on the extension icon. 2 | if ( typeof chrome.sidePanel !== 'undefined' ) { 3 | // Chrome. 4 | chrome.sidePanel 5 | .setPanelBehavior( { openPanelOnActionClick: true } ) 6 | .catch( ( error ) => console.error( error ) ); 7 | } else if ( typeof browser.sidebarAction !== 'undefined' ) { 8 | // Firefox. 9 | browser.action.onClicked.addListener( () => { 10 | browser.sidebarAction.toggle(); 11 | } ); 12 | } else { 13 | console.error( 'unsupported browser' ); 14 | } 15 | -------------------------------------------------------------------------------- /src/extension/content.ts: -------------------------------------------------------------------------------- 1 | import { startListening } from '@/bus/Bus'; 2 | import { CommandTypes } from '@/bus/Command'; 3 | import { EventTypes, sendEventToApp } from '@/bus/Event'; 4 | 5 | enum Modes { 6 | // Default mode, nothing is happening. 7 | Default = 0, 8 | // Generic element selection mode. 9 | GenericSelection, 10 | // Selection mode specific to navigation. 11 | NavigationSelection, 12 | } 13 | 14 | let currentMode = Modes.Default; 15 | 16 | startListening( CommandTypes.GetCurrentPageInfo, ( event ) => { 17 | event.sendResponse( { 18 | url: document.documentURI, 19 | title: document.title, 20 | } ); 21 | } ); 22 | 23 | startListening( CommandTypes.NavigateTo, ( event ) => { 24 | const url = ( event.event.payload as any ).url; 25 | if ( document.location.href !== url ) { 26 | document.location.href = url; 27 | } 28 | } ); 29 | 30 | startListening( CommandTypes.SwitchToNavigationSelectionMode, () => { 31 | currentMode = Modes.NavigationSelection; 32 | enableHighlighting(); 33 | } ); 34 | 35 | startListening( CommandTypes.SwitchToGenericSelectionMode, () => { 36 | currentMode = Modes.GenericSelection; 37 | enableHighlighting(); 38 | } ); 39 | 40 | startListening( CommandTypes.SwitchToDefaultMode, () => { 41 | currentMode = Modes.Default; 42 | disableHighlighting(); 43 | } ); 44 | 45 | function onClick( event: MouseEvent ) { 46 | event.preventDefault(); 47 | const element = event.target as HTMLElement; 48 | if ( ! element ) { 49 | return; 50 | } 51 | 52 | let content = ''; 53 | switch ( currentMode ) { 54 | case Modes.GenericSelection: 55 | const clone = element.cloneNode( true ) as HTMLElement; 56 | clone.style.outline = ''; 57 | content = clone.outerHTML.trim(); 58 | break; 59 | case Modes.NavigationSelection: 60 | // The user should have clicked on one of the navigation entries. 61 | // Look for the parent ul or ol. 62 | let navigationContainer; 63 | let currentElement: HTMLElement | null = element; 64 | while ( currentElement ) { 65 | if ( currentElement.tagName.toLowerCase() === 'li' ) { 66 | navigationContainer = currentElement.parentElement; 67 | break; 68 | } 69 | currentElement = currentElement.parentElement; 70 | } 71 | content = navigationContainer ? navigationContainer.innerHTML : ''; 72 | break; 73 | default: 74 | throw Error( `unknown mode ${ currentMode }` ); 75 | } 76 | 77 | content = content.replaceAll( ' style=""', '' ); 78 | void sendEventToApp( { 79 | type: EventTypes.OnElementClick, 80 | payload: { content }, 81 | } ); 82 | } 83 | 84 | let highlightedElement: HTMLElement | null = null; 85 | 86 | function onMouseOver( event: MouseEvent ) { 87 | const element = event.target as HTMLElement | null; 88 | if ( ! element ) { 89 | return; 90 | } 91 | highlightedElement = element; 92 | highlightedElement.style.outline = '1px solid blue'; 93 | } 94 | 95 | function onMouseOut( event: MouseEvent ) { 96 | const element = event.target as HTMLElement | null; 97 | if ( ! element ) { 98 | return; 99 | } 100 | removeStyle(); 101 | highlightedElement = null; 102 | } 103 | 104 | const cursorStyleId = 'hover-highlighter-style'; 105 | 106 | function enableHighlighting() { 107 | document.body.addEventListener( 'mouseover', onMouseOver ); 108 | document.body.addEventListener( 'mouseout', onMouseOut ); 109 | document.body.addEventListener( 'click', onClick ); 110 | 111 | let style = document.getElementById( cursorStyleId ); 112 | if ( style ) { 113 | // The highlighting cursor is already enabled. 114 | return; 115 | } 116 | style = document.createElement( 'style' ); 117 | style.id = cursorStyleId; 118 | style.textContent = '* { cursor: crosshair !important; }'; 119 | document.head.append( style ); 120 | } 121 | 122 | function disableHighlighting() { 123 | document.body.removeEventListener( 'mouseover', onMouseOver ); 124 | document.body.removeEventListener( 'mouseout', onMouseOut ); 125 | document.body.removeEventListener( 'click', onClick ); 126 | 127 | const style = document.getElementById( cursorStyleId ); 128 | if ( style ) { 129 | style.remove(); 130 | } 131 | 132 | removeStyle(); 133 | } 134 | 135 | function removeStyle() { 136 | if ( ! highlightedElement ) { 137 | return; 138 | } 139 | highlightedElement.style.outline = ''; 140 | } 141 | 142 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 143 | function getElementXPath( element: Element ): string { 144 | if ( element === document.body ) { 145 | return '/html/body'; 146 | } 147 | 148 | let xpath = ''; 149 | let currentElement: Element | null = element; 150 | 151 | while ( currentElement !== null ) { 152 | let sibling = currentElement.previousSibling; 153 | let count = 1; 154 | 155 | while ( sibling !== null ) { 156 | if ( 157 | sibling.nodeType === Node.ELEMENT_NODE && 158 | sibling.nodeName === currentElement.nodeName 159 | ) { 160 | count++; 161 | } 162 | sibling = sibling.previousSibling; 163 | } 164 | 165 | const tagName = currentElement.nodeName.toLowerCase(); 166 | const index = count > 1 ? `[${ count }]` : ''; 167 | 168 | xpath = `/${ tagName }${ index }${ xpath }`; 169 | 170 | currentElement = currentElement.parentElement; 171 | } 172 | 173 | return xpath; 174 | } 175 | -------------------------------------------------------------------------------- /src/extension/icons/icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WordPress/try-wordpress/f175c9055657878eb91b3b50071bd48fd6a5df94/src/extension/icons/icon-128.png -------------------------------------------------------------------------------- /src/extension/icons/icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WordPress/try-wordpress/f175c9055657878eb91b3b50071bd48fd6a5df94/src/extension/icons/icon-32.png -------------------------------------------------------------------------------- /src/extension/manifest-chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Try WordPress", 4 | "description": "An extension that liberates your data into WordPress", 5 | "version": "0.0.1", 6 | "permissions": [ 7 | "sidePanel", 8 | "storage", 9 | "tabs" 10 | ], 11 | "host_permissions": [ "*://*/*" ], 12 | "icons": { 13 | "32": "icons/icon-32.png", 14 | "128": "icons/icon-128.png" 15 | }, 16 | "action": { 17 | "default_icon": { 18 | "32": "icons/icon-32.png", 19 | "128": "icons/icon-128.png" 20 | }, 21 | "default_title": "Try WordPress" 22 | }, 23 | "content_scripts": [ 24 | { 25 | "js": [ "content.js" ], 26 | "matches": [ "" ] 27 | } 28 | ], 29 | "background": { 30 | "service_worker": "background.js" 31 | }, 32 | "side_panel": { 33 | "default_title": "Try WordPress", 34 | "default_icon": { 35 | "32": "icons/icon-32.png", 36 | "128": "icons/icon-128" 37 | }, 38 | "default_path": "app.html" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/extension/manifest-firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Try WordPress", 4 | "description": "An extension that liberates your data into WordPress", 5 | "version": "0.0.1", 6 | "permissions": [ 7 | "storage", 8 | "tabs" 9 | ], 10 | "host_permissions": [ "*://*/*" ], 11 | "icons": { 12 | "32": "icons/icon-32.png", 13 | "128": "icons/icon-128.png" 14 | }, 15 | "action": { 16 | "default_icon": { 17 | "32": "icons/icon-32.png", 18 | "128": "icons/icon-128.png" 19 | }, 20 | "default_title": "Try WordPress" 21 | }, 22 | "content_scripts": [ 23 | { 24 | "js": [ "content.js" ], 25 | "matches": [ "" ] 26 | } 27 | ], 28 | "background": { 29 | "scripts": [ "background.js" ] 30 | }, 31 | "sidebar_action": { 32 | "default_title": "Try WordPress", 33 | "default_icon": { 34 | "32": "icons/icon-32.png", 35 | "128": "icons/icon-128" 36 | }, 37 | "default_panel": "app.html", 38 | "open_at_install": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/model/Blueprint.ts: -------------------------------------------------------------------------------- 1 | import { SubjectType } from '@/model/Subject'; 2 | 3 | export interface Blueprint { 4 | type: SubjectType; 5 | id: string; // TODO: Probably need to make this a number when we start storing Blueprints on the backend. 6 | sourceUrl: string; 7 | valid: boolean; 8 | selectors: Record< string, string >; 9 | } 10 | 11 | export function newBlueprint( 12 | type: SubjectType, 13 | sourceUrl: string 14 | ): Blueprint { 15 | return { 16 | id: '', 17 | type, 18 | sourceUrl, 19 | valid: false, 20 | selectors: {}, 21 | }; 22 | } 23 | 24 | export function validateBlueprint( blueprint: Blueprint ): boolean { 25 | let isValid = true; 26 | for ( const selector of Object.values( blueprint.selectors ) ) { 27 | if ( selector === '' ) { 28 | isValid = false; 29 | break; 30 | } 31 | } 32 | return isValid; 33 | } 34 | -------------------------------------------------------------------------------- /src/model/Schema.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unresolved 2 | import SchemasJson from '@schema/schema.json'; 3 | import { SubjectType } from '@/model/Subject'; 4 | import { FieldType } from '@/model/field/Field'; 5 | 6 | interface Schemas { 7 | [ key: SubjectType ]: Schema; 8 | } 9 | 10 | interface SchemaField { 11 | description: string; 12 | type: FieldType; 13 | } 14 | 15 | interface Schema { 16 | title: string; 17 | slug: SubjectType; 18 | fields: Record< string, SchemaField >; 19 | } 20 | 21 | const schemas: Schemas = SchemasJson as Schemas; 22 | 23 | export function getSchemas(): Schemas { 24 | return schemas; 25 | } 26 | 27 | export function getSchema( subjectType: SubjectType ): Schema { 28 | if ( ! schemas.hasOwnProperty( subjectType ) ) { 29 | throw new Error( `Unknown subjectType: ${ subjectType }` ); 30 | } 31 | return schemas[ subjectType ]; 32 | } 33 | -------------------------------------------------------------------------------- /src/model/SiteSettings.ts: -------------------------------------------------------------------------------- 1 | export interface SiteSettings { 2 | title: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/model/Subject.ts: -------------------------------------------------------------------------------- 1 | import { Field } from '@/model/field/Field'; 2 | 3 | // Some subject types have a custom import UI, where the user manually imports each subject of that type. 4 | // These types are listed here so that we can centralize their slugs, and navigate to places in the code where they are referenced. 5 | export enum ManualSubjectTypes { 6 | Page = 'page', 7 | } 8 | 9 | export type SubjectType = string; 10 | 11 | export interface Subject { 12 | type: SubjectType; 13 | id: number; 14 | transformedId: number; 15 | sourceUrl: string; 16 | previewUrl: string; 17 | fields: Record< string, Field >; 18 | } 19 | 20 | export function validateFields( subject: Subject ): boolean { 21 | let isValid = true; 22 | Object.keys( subject.fields ).forEach( ( key ) => { 23 | const f = subject.fields[ key ]; 24 | if ( f.rawValue === '' || f.parsedValue === '' ) { 25 | isValid = false; 26 | } 27 | } ); 28 | return isValid; 29 | } 30 | -------------------------------------------------------------------------------- /src/model/User.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | username: string; 3 | email: string; 4 | role: string; 5 | firstName: string; 6 | lastName: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/model/field/DateField.ts: -------------------------------------------------------------------------------- 1 | import { Field, FieldType } from '@/model/field/Field'; 2 | 3 | export interface DateField extends Field { 4 | type: FieldType.Date; 5 | rawValue: string; 6 | parsedValue: Date; 7 | } 8 | 9 | export function newDateField( 10 | raw: string = '', 11 | parsed: string = '' 12 | ): DateField { 13 | const date = parsed === '' ? new Date() : new Date( parsed ); 14 | return { 15 | type: FieldType.Date, 16 | rawValue: raw, 17 | parsedValue: date, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/model/field/Field.ts: -------------------------------------------------------------------------------- 1 | export enum FieldType { 2 | Date = 'date', 3 | Text = 'text', 4 | Html = 'html', 5 | Link = 'link', 6 | } 7 | 8 | export interface Field { 9 | type: FieldType; 10 | rawValue: string; 11 | parsedValue: any; 12 | } 13 | -------------------------------------------------------------------------------- /src/model/field/HtmlField.ts: -------------------------------------------------------------------------------- 1 | import { Field, FieldType } from '@/model/field/Field'; 2 | 3 | export interface HtmlField extends Field { 4 | type: FieldType.Html; 5 | parsedValue: string; 6 | } 7 | 8 | export function newHtmlField( 9 | raw: string = '', 10 | parsed: string = '' 11 | ): HtmlField { 12 | return { 13 | type: FieldType.Html, 14 | rawValue: raw, 15 | parsedValue: parsed, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/model/field/LinkField.ts: -------------------------------------------------------------------------------- 1 | import { Field, FieldType } from '@/model/field/Field'; 2 | 3 | export interface LinkField extends Field { 4 | type: FieldType.Link; 5 | parsedValue: { 6 | title: string; 7 | url: string; 8 | }; 9 | } 10 | 11 | export function newLinkField( 12 | raw: string = '', 13 | title: string, 14 | url: string 15 | ): LinkField { 16 | return { 17 | type: FieldType.Link, 18 | rawValue: raw, 19 | parsedValue: { title, url }, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/model/field/TextField.ts: -------------------------------------------------------------------------------- 1 | import { Field, FieldType } from '@/model/field/Field'; 2 | 3 | export interface TextField extends Field { 4 | type: FieldType.Text; 5 | parsedValue: string; 6 | } 7 | 8 | export function newTextField( 9 | raw: string = '', 10 | parsed: string = '' 11 | ): TextField { 12 | return { 13 | type: FieldType.Text, 14 | rawValue: raw, 15 | parsedValue: parsed, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/mu-plugin-disable-rest-api-auth/disable-rest-api-auth.php: -------------------------------------------------------------------------------- 1 | or
    containing an anchor element per li element into link fields. 5 | export function parseNavigationHtml( html: string ): LinkField[] { 6 | if ( html === '' ) { 7 | return []; 8 | } 9 | 10 | const container = document.createElement( 'ul' ); 11 | container.innerHTML = html.trim(); 12 | const liElements = container.querySelectorAll( 'li' ).values().toArray(); 13 | 14 | const anchors: HTMLAnchorElement[] = liElements 15 | .map( ( element ) => { 16 | const anchorElement = findDeepestChild( element.innerHTML ); 17 | if ( 18 | anchorElement && 19 | anchorElement.tagName.toLowerCase() === 'a' 20 | ) { 21 | return anchorElement as HTMLAnchorElement; 22 | } 23 | return undefined; 24 | } ) 25 | .filter( ( link ) => !! link ); 26 | 27 | return anchors.map( ( anchor ) => { 28 | return newLinkField( anchor.outerHTML, anchor.text, anchor.href ); 29 | } ); 30 | } 31 | -------------------------------------------------------------------------------- /src/parser/util.ts: -------------------------------------------------------------------------------- 1 | import { pasteHandler, serialize } from '@wordpress/blocks'; 2 | 3 | export function htmlToBlocks( html: string ): string { 4 | const blocks = pasteHandler( { 5 | mode: 'BLOCKS', 6 | HTML: html, 7 | } ); 8 | return serialize( blocks ); 9 | } 10 | 11 | export function findDeepestChild( html: string ): Element | undefined { 12 | const container = document.createElement( 'div' ); 13 | container.innerHTML = html.trim(); 14 | 15 | let deepestChild = container as Element; 16 | while ( deepestChild.firstElementChild ) { 17 | if ( ! deepestChild.firstElementChild ) { 18 | break; 19 | } else { 20 | deepestChild = deepestChild.firstElementChild; 21 | } 22 | } 23 | 24 | if ( deepestChild.innerHTML === html ) { 25 | // There are no children. 26 | return undefined; 27 | } 28 | return deepestChild; 29 | } 30 | -------------------------------------------------------------------------------- /src/plugin/class-engine.php: -------------------------------------------------------------------------------- 1 | value; 50 | if ( isset( $wp_filter[ $hook_name ] ) ) { 51 | foreach ( $wp_filter[ $hook_name ]->callbacks as $callbacks ) { 52 | foreach ( $callbacks as $callback ) { 53 | Ops::handle( 54 | $type, 55 | array( 56 | 'slug' => 'wp_action_' . wp_generate_uuid4(), 57 | 'description' => 'Handler registered via WordPress action', 58 | ), 59 | $callback['function'] 60 | ); 61 | } 62 | } 63 | } 64 | } 65 | }, 66 | PHP_INT_MAX 67 | ); 68 | } )(); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/plugin/class-handlers-registry.php: -------------------------------------------------------------------------------- 1 | value ] ) ) { 31 | self::$handlers[ $type->value ] = array(); 32 | } 33 | 34 | self::$handlers[ $type->value ][ $identifier['slug'] ] = array( 35 | 'slug' => $identifier['slug'], 36 | 'description' => $identifier['description'], 37 | 'handler' => $handler, 38 | ); 39 | } 40 | 41 | /** 42 | * Check if handlers exist for a type 43 | * 44 | * @param SubjectType $type The subject type to check for. 45 | * @return bool True if handlers exist 46 | */ 47 | public static function has( SubjectType $type ): bool { 48 | return isset( self::$handlers[ $type->value ] ) && ! empty( self::$handlers[ $type->value ] ); 49 | } 50 | 51 | /** 52 | * Check if there is a "compete" i.e., multiple handlers for a type 53 | * 54 | * @param SubjectType $type The subject type to check for. 55 | * @return bool True if handlers exist 56 | */ 57 | public static function is_compete( SubjectType $type ): bool { 58 | return isset( self::$handlers[ $type->value ] ) && count( self::$handlers[ $type->value ] ) > 1; 59 | } 60 | 61 | /** 62 | * Execute all handlers for a type 63 | * 64 | * @param SubjectType $type The subject type to handle. 65 | * @param Subject $subject Data to pass to handlers. 66 | * @return void 67 | * @throws Exception If no handler has been registered or user choice hasn't been set when multiples are registered. 68 | */ 69 | public static function handle( SubjectType $type, Subject $subject ): void { 70 | if ( ! self::has( $type ) ) { 71 | throw new Exception( sprintf( 'no handler registered for type: %s', esc_html( $type->value ) ) ); 72 | } 73 | 74 | if ( self::is_compete( $type ) ) { 75 | $choice = self::get_user_choice( $type ); 76 | if ( ! empty( $choice ) ) { 77 | $chosen = self::$handlers[ $type->value ][ $choice ]; 78 | } else { 79 | throw new Exception( 'handle() invoked without user choice on compete' ); 80 | } 81 | } else { 82 | $chosen = current( self::$handlers[ $type->value ] ); 83 | } 84 | 85 | $transformed_post_id = $chosen['handler']( $subject ); 86 | 87 | if ( $transformed_post_id ) { 88 | update_post_meta( $transformed_post_id, Transformer::META_KEY_LIBERATED_SOURCE, $subject->id() ); 89 | update_post_meta( $subject->id(), Transformer::META_KEY_LIBERATED_OUTPUT, $transformed_post_id ); 90 | } 91 | } 92 | 93 | /** 94 | * Set user choice for what transformer to run for a subject type when multiples are registered 95 | * 96 | * @param SubjectType $type The subject type for which choice is to be saved. 97 | * @param string $transformer_slug Identifying slug of the chosen transformer. 98 | * @return void 99 | */ 100 | public static function set_user_choice( SubjectType $type, string $transformer_slug ): void { 101 | update_user_meta( get_current_user_id(), self::$user_choice_meta_key_prefix . $type->value, $transformer_slug ); 102 | } 103 | 104 | /** 105 | * Retrieves the user choice for what transformer to run for a subject type when multiples are registered 106 | * 107 | * @param SubjectType $type The subject type for which choice is to be retrieved. 108 | * @return string $transformer_slug Identifying slug of the chosen transformer. 109 | */ 110 | public static function get_user_choice( SubjectType $type ): string { 111 | return get_user_meta( get_current_user_id(), self::$user_choice_meta_key_prefix . $type->value, true ); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/plugin/class-observers-registry.php: -------------------------------------------------------------------------------- 1 | value ] ) ) { 30 | self::$observers[ $type->value ] = array(); 31 | } 32 | 33 | self::$observers[ $type->value ][ $identifier['slug'] ] = array( 34 | 'slug' => $identifier['slug'], 35 | 'description' => $identifier['description'], 36 | 'observer' => $observer, 37 | ); 38 | } 39 | 40 | /** 41 | * Check if observers exist for a type 42 | * 43 | * @param SubjectType $type The subject type to check for. 44 | * @return bool True if observers exist 45 | */ 46 | public static function has( SubjectType $type ): bool { 47 | return isset( self::$observers[ $type->value ] ) && ! empty( self::$observers[ $type->value ] ); 48 | } 49 | 50 | /** 51 | * Execute all observers for a type 52 | * 53 | * @param SubjectType $type The subject type to handle. 54 | * @param Subject $subject Data to pass to observers. 55 | * @return void 56 | */ 57 | public static function observe( SubjectType $type, Subject $subject ): void { 58 | if ( ! self::has( $type ) ) { 59 | return; 60 | } 61 | foreach ( self::$observers[ $type->value ] as $registered_observer ) { 62 | $registered_observer['observer']( $subject ); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/plugin/class-ops.php: -------------------------------------------------------------------------------- 1 | static::$post_type, 49 | 'post_status' => 'publish', 50 | 'posts_per_page' => -1, 51 | // @phpcs:ignore 52 | 'meta_query' => array( 53 | 'key' => 'subject_type', 54 | 'value' => $subject_type->value, 55 | 'compare' => '=', 56 | ), 57 | ); 58 | $posts = get_posts( $args ); 59 | 60 | return array_map( 61 | fn( WP_Post $post ) => Subject::from_post( $post->ID ), 62 | $posts 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/plugin/class-post-type-ui.php: -------------------------------------------------------------------------------- 1 | post_type = $custom_post_type; 10 | 11 | $this->remove_add_new_option( $this->post_type ); 12 | 13 | // Strip editor to be barebones. 14 | add_filter( 15 | 'wp_editor_settings', 16 | function ( $settings, $editor_id ) { 17 | if ( 'content' === $editor_id && get_current_screen()->post_type === $this->post_type ) { 18 | $settings['tinymce'] = false; 19 | $settings['quicktags'] = false; 20 | $settings['media_buttons'] = false; 21 | } 22 | 23 | return $settings; 24 | }, 25 | 10, 26 | 2 27 | ); 28 | 29 | // CPT screen-specific filters 30 | add_action( 31 | 'admin_head', 32 | function () { 33 | global $pagenow; 34 | 35 | $cpt_screen = false; 36 | if ( 'post-new.php' === $pagenow ) { // New post screen 37 | // @phpcs:ignore WordPress.Security.NonceVerification.Recommended 38 | if ( isset( $_GET['post_type'] ) && $_GET['post_type'] === $this->post_type ) { 39 | $cpt_screen = true; 40 | } 41 | } 42 | 43 | if ( 'post.php' === $pagenow ) { // Edit post screen 44 | // @phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotValidated 45 | $post_type = get_post_type( absint( $_GET['post'] ) ); 46 | if ( $post_type === $this->post_type ) { 47 | $cpt_screen = true; 48 | } 49 | } 50 | 51 | // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedIf 52 | if ( $cpt_screen ) { 53 | // CPT screen-specific filters go here. 54 | } 55 | } 56 | ); 57 | 58 | // Disable Block editor. 59 | add_filter( 60 | 'use_block_editor_for_post_type', 61 | function ( $use_block_editor, $post_type ) { 62 | if ( $post_type === $this->post_type ) { 63 | return false; 64 | } 65 | 66 | return $use_block_editor; 67 | }, 68 | 10, 69 | 2 70 | ); 71 | 72 | // Remove meta boxes. 73 | add_action( 74 | 'add_meta_boxes', 75 | function () { 76 | // Remove default meta boxes 77 | remove_meta_box( 'submitdiv', $this->post_type, 'side' ); 78 | remove_meta_box( 'slugdiv', $this->post_type, 'normal' ); 79 | /** 80 | * We would need to remove more metaboxes as their support is added to CPTs. 81 | * Leaving code here for reference. 82 | */ 83 | // phpcs:ignore Squiz.PHP.CommentedOutCode.Found 84 | // remove_meta_box( 'postexcerpt', $post_type, 'normal' ); 85 | // remove_meta_box( 'authordiv', $post_type, 'normal' ); 86 | // remove_meta_box( 'categorydiv', $post_type, 'side' ); 87 | // remove_meta_box( 'tagsdiv-post_tag', $post_type, 'side' ); 88 | // remove_meta_box( 'postimagediv', $post_type, 'side' ); 89 | // remove_meta_box( 'revisionsdiv', $post_type, 'normal' ); 90 | // remove_meta_box( 'commentstatusdiv', $post_type, 'normal' ); 91 | // remove_meta_box( 'commentsdiv', $post_type, 'normal' ); 92 | // remove_meta_box( 'trackbacksdiv', $post_type, 'normal' ); 93 | 94 | add_meta_box( 95 | 'transformedpost', 96 | 'Transformed To', 97 | function () { 98 | global $post; 99 | 100 | $post_id = $post->ID; 101 | $transformed_post_id = get_post_meta( $post_id, Transformer::META_KEY_LIBERATED_OUTPUT, true ); 102 | 103 | if ( $transformed_post_id ) { 104 | echo '
    PostID: ' . esc_html( $transformed_post_id ) . '
    '; 105 | $preview_link = get_permalink( $transformed_post_id ); 106 | $edit_link = get_edit_post_link( $transformed_post_id ); 107 | ?> 108 |

    109 | Preview Post | 110 | Edit Post 111 |

    112 | This post hasn't been transformed yet.

    "; 115 | } 116 | }, 117 | $this->post_type, 118 | 'side', 119 | 'default' 120 | ); 121 | }, 122 | 999 123 | ); 124 | } 125 | 126 | public function remove_add_new_option( $post_type ): void { 127 | // Remove "Add New" from sidebar menu. 128 | add_action( 129 | 'admin_menu', 130 | function () use ( $post_type ) { 131 | $menu_slug = 'edit.php?post_type=' . $post_type; 132 | $submenu_slug = 'post-new.php?post_type=' . $post_type; 133 | remove_submenu_page( $menu_slug, $submenu_slug ); 134 | } 135 | ); 136 | 137 | // Remove "Add New" from admin bar menu. 138 | add_action( 139 | 'admin_bar_menu', 140 | function ( $wp_admin_bar ) use ( $post_type ) { 141 | $wp_admin_bar->remove_node( 'new-' . $post_type ); 142 | }, 143 | 999 144 | ); 145 | 146 | // Redirect if you go to "Add New" page directly. 147 | add_action( 148 | 'admin_init', 149 | function () use ( $post_type ) { 150 | global $pagenow; 151 | // @phpcs:ignore WordPress.Security.NonceVerification.Recommended 152 | if ( 'post-new.php' === $pagenow && isset( $_GET['post_type'] ) && $_GET['post_type'] === $post_type ) { 153 | wp_safe_redirect( admin_url( 'edit.php?post_type=' . $post_type ) ); 154 | exit; 155 | } 156 | } 157 | ); 158 | 159 | // Hide "Add New" button next to title on the listing page. 160 | add_action( 161 | 'admin_head', 162 | function () use ( $post_type ) { 163 | $post_type_key = $post_type; 164 | global $pagenow, $post_type; 165 | 166 | if ( 'edit.php' === $pagenow && $post_type === $post_type_key ) { 167 | echo ''; 168 | } 169 | } 170 | ); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /src/plugin/class-schema.php: -------------------------------------------------------------------------------- 1 | true ) ); 12 | 13 | if ( json_last_error() !== JSON_ERROR_NONE ) { 14 | wp_die( esc_html( 'Failed to parse schema.json - ' . json_last_error_msg() ) ); 15 | } 16 | } 17 | } 18 | 19 | public static function get( $subject_type = null ): ?array { 20 | self::load_schema(); 21 | if ( $subject_type ) { 22 | return self::$schema[ $subject_type ] ?? null; 23 | } 24 | return self::$schema; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/plugin/class-storage.php: -------------------------------------------------------------------------------- 1 | post_type = $post_type; 13 | $this->post_type_name = ucwords( str_replace( '_', ' ', $post_type ) ); 14 | 15 | add_action( 'init', array( $this, 'register_post_types' ) ); 16 | } 17 | 18 | private function get_singular_name(): string { 19 | return $this->post_type_name; 20 | } 21 | 22 | private function get_plural_name(): string { 23 | return $this->post_type_name; 24 | } 25 | 26 | public function register_post_types(): void { 27 | $name = $this->get_singular_name(); 28 | $name_plural = $this->get_plural_name(); 29 | 30 | $args = array( 31 | 'public' => false, 32 | 'exclude_from_search' => true, 33 | 'publicly_queryable' => false, 34 | 'show_in_rest' => true, 35 | 'show_ui' => true, 36 | 'show_in_menu' => WP_DEBUG, 37 | 'menu_icon' => 'dashicons-database', 38 | 'supports' => $this->custom_post_types_supports, 39 | 'labels' => $this->get_post_type_registration_labels( $name, $name_plural ), 40 | 'rest_base' => $this->post_type, 41 | ); 42 | 43 | register_post_type( $this->post_type, $args ); 44 | } 45 | 46 | public function get_post_type_registration_labels( string $name, string $name_plural ): array { 47 | return array( 48 | 'name' => $name_plural, 49 | 'singular_name' => $name, 50 | 'menu_name' => $name_plural, 51 | 'name_admin_bar' => $name, 52 | 53 | // translators: %s: Name of the custom post type in singular form 54 | 'archives' => sprintf( __( '%s Archives', 'try_wordpress' ), ucwords( $name ) ), 55 | 56 | // translators: %s: Name of the custom post type in singular form 57 | 'attributes' => sprintf( __( '%s Attributes', 'try_wordpress' ), ucwords( $name ) ), 58 | 59 | // translators: %s: Name of the custom post type in singular form 60 | 'parent_item_colon' => sprintf( __( 'Parent %s:', 'try_wordpress' ), ucwords( $name ) ), 61 | 62 | // translators: %s: Name of the custom post type in plural form 63 | 'all_items' => sprintf( __( 'All %s', 'try_wordpress' ), ucwords( $name_plural ) ), 64 | 65 | // translators: %s: Name of the custom post type in singular form 66 | 'add_new_item' => sprintf( __( 'Add New %s', 'try_wordpress' ), ucwords( $name ) ), 67 | 'add_new' => __( 'Add New', 'try_wordpress' ), 68 | 69 | // translators: %s: Name of the custom post type in singular form 70 | 'new_item' => sprintf( __( 'New %s', 'try_wordpress' ), ucwords( $name ) ), 71 | 72 | // translators: %s: Name of the custom post type in singular form 73 | 'edit_item' => sprintf( __( 'Edit %s', 'try_wordpress' ), ucwords( $name ) ), 74 | 75 | // translators: %s: Name of the custom post type in singular form 76 | 'update_item' => sprintf( __( 'Update %s', 'try_wordpress' ), ucwords( $name ) ), 77 | 78 | // translators: %s: Name of the custom post type in singular form 79 | 'view_item' => sprintf( __( 'View %s', 'try_wordpress' ), ucwords( $name ) ), 80 | 81 | // translators: %s: Name of the custom post type in plural form 82 | 'view_items' => sprintf( __( 'View %s', 'try_wordpress' ), ucwords( $name_plural ) ), 83 | 84 | // translators: %s: Name of the custom post type in singular form 85 | 'search_items' => sprintf( __( 'Search %s', 'try_wordpress' ), ucwords( $name ) ), 86 | 87 | 'not_found' => __( 'Not found', 'try_wordpress' ), 88 | 'not_found_in_trash' => __( 'Not found in Trash', 'try_wordpress' ), 89 | 90 | // translators: %s: Name of the custom post type in singular form 91 | 'featured_image' => sprintf( __( '%s Image', 'try_wordpress' ), ucwords( $name ) ), 92 | 93 | // translators: %s: Name of the custom post type in lowercase 94 | 'set_featured_image' => sprintf( __( 'Set %s image', 'try_wordpress' ), strtolower( $name ) ), 95 | 96 | // translators: %s: Name of the custom post type in lowercase 97 | 'remove_featured_image' => sprintf( __( 'Remove %s image', 'try_wordpress' ), strtolower( $name ) ), 98 | 99 | // translators: %s: Name of the custom post type in lowercase 100 | 'use_featured_image' => sprintf( __( 'Use as %s image', 'try_wordpress' ), strtolower( $name ) ), 101 | 102 | // translators: %s: Name of the custom post type in lowercase 103 | 'insert_into_item' => sprintf( __( 'Insert into %s', 'try_wordpress' ), strtolower( $name ) ), 104 | 105 | // translators: %s: Name of the custom post type in lowercase 106 | 'uploaded_to_this_item' => sprintf( __( 'Uploaded to this %s', 'try_wordpress' ), strtolower( $name ) ), 107 | 108 | // translators: %s: Name of the custom post type in plural form 109 | 'items_list' => sprintf( __( '%s list', 'try_wordpress' ), ucwords( $name_plural ) ), 110 | 111 | // translators: %s: Name of the custom post type in plural form 112 | 'items_list_navigation' => sprintf( __( '%s list navigation', 'try_wordpress' ), ucwords( $name_plural ) ), 113 | 114 | // translators: %s: Name of the custom post type in lowercase plural form 115 | 'filter_items_list' => sprintf( __( 'Filter %s list', 'try_wordpress' ), strtolower( $name_plural ) ), 116 | ); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/plugin/class-subject.php: -------------------------------------------------------------------------------- 1 | id = $post->ID; 48 | $this->author_id = $post->post_author; 49 | $this->source_html = $post->post_content_filtered; 50 | $this->source_url = $post->guid; 51 | 52 | $this->type = get_post_meta( $post->ID, 'subject_type', true ); 53 | $this->title = get_post_meta( $post->ID, 'raw_title', true ); 54 | $this->date = get_post_meta( $post->ID, 'raw_date', true ); 55 | $this->content = get_post_meta( $post->ID, 'raw_content', true ); 56 | } 57 | 58 | public function id(): int { 59 | return $this->id; 60 | } 61 | 62 | public function author_id(): int { 63 | return $this->author_id; 64 | } 65 | 66 | public function transformed_post_id(): int { 67 | return absint( get_post_meta( $this->id, Transformer::META_KEY_LIBERATED_OUTPUT, true ) ); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/plugin/class-transformer.php: -------------------------------------------------------------------------------- 1 | 'try_wordpress', 14 | 'description' => 'Try WordPress handling blog-post natively', 15 | ), 16 | array( 17 | $this, 18 | 'handler', 19 | ) 20 | ); 21 | 22 | Ops::handle( 23 | SubjectType::PAGE, 24 | array( 25 | 'slug' => 'try_wordpress', 26 | 'description' => 'Try WordPress handling page natively', 27 | ), 28 | array( 29 | $this, 30 | 'handler', 31 | ) 32 | ); 33 | } 34 | 35 | public function get_post_type( Subject $subject ): string { 36 | return match ( $subject->type ) { 37 | 'page' => 'page', 38 | default => 'post', 39 | }; 40 | } 41 | 42 | public function handler( Subject $subject ) { 43 | // Since parsed versions come from paste_handler in frontend, look for them in postmeta, instead of subject instance 44 | $title = get_post_meta( $subject->id(), 'parsed_title', true ); 45 | if ( empty( $title ) ) { 46 | $title = '[Title]'; 47 | } 48 | $body = get_post_meta( $subject->id(), 'parsed_content', true ); 49 | if ( empty( $body ) ) { 50 | $body = '[Body]'; 51 | } 52 | 53 | $args = array( 54 | 'post_author' => $subject->author_id(), 55 | 'post_date' => get_post_meta( $subject->id(), 'parsed_date', true ), 56 | 'post_content' => $body, 57 | 'post_title' => $title, 58 | 'post_status' => 'publish', 59 | 'post_type' => $this->get_post_type( $subject ), 60 | ); 61 | 62 | // have we already transformed this subject before? 63 | $transformed_post_id = get_post_meta( $subject->id(), self::META_KEY_LIBERATED_OUTPUT, true ); 64 | if ( ! empty( $transformed_post_id ) ) { 65 | $args['ID'] = $transformed_post_id; 66 | } 67 | 68 | add_filter( 'wp_insert_post_empty_content', '__return_false' ); 69 | $inserted_post_id = wp_insert_post( $args ); 70 | remove_filter( 'wp_insert_post_empty_content', '__return_false' ); 71 | 72 | // @TODO: handle attachments, terms etc in future 73 | // Note: Do not need anything from postmeta. 74 | // We should potentially use another plugin here for this purpose and call its API to do it for us. 75 | 76 | if ( 0 === $inserted_post_id ) { 77 | return null; 78 | } 79 | 80 | return $inserted_post_id; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/plugin/enum-subject-type.php: -------------------------------------------------------------------------------- 1 | value ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/plugin/plugin.php: -------------------------------------------------------------------------------- 1 | 'string', 19 | default => $type, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/storage/config.ts: -------------------------------------------------------------------------------- 1 | export interface Config { 2 | currentPath: string; 3 | } 4 | 5 | export async function setConfig( value: Config ): Promise< void > { 6 | const config = await getConfig(); 7 | let key: keyof Config; 8 | for ( key in value ) { 9 | config[ key ] = value[ key ]; 10 | } 11 | return browser.storage.local.set( { config } ); 12 | } 13 | 14 | export async function getConfig(): Promise< Config > { 15 | const values = await browser.storage.local.get( 'config' ); 16 | if ( ! values || ! values.config ) { 17 | return { currentPath: '/' }; 18 | } 19 | return values.config as Config; 20 | } 21 | -------------------------------------------------------------------------------- /src/storage/session.ts: -------------------------------------------------------------------------------- 1 | export interface Session { 2 | id: string; 3 | url: string; 4 | title: string; 5 | } 6 | 7 | export async function createSession( data: { 8 | url: string; 9 | title: string; 10 | } ): Promise< Session > { 11 | const { url, title } = data; 12 | const session: Session = { 13 | id: Date.now().toString( 16 ), 14 | url, 15 | title, 16 | }; 17 | const values: Record< string, Session > = {}; 18 | values[ key( session.id ) ] = session; 19 | await browser.storage.local.set( values ); 20 | 21 | // We also maintain an array of sessionIds to serve as "index" for when we need to list sessions. 22 | let sessionIds: string[]; 23 | const sessionIdsValues = await browser.storage.local.get( 'sessions' ); 24 | if ( ! sessionIdsValues || ! sessionIdsValues.sessions ) { 25 | sessionIds = []; 26 | } else { 27 | sessionIds = sessionIdsValues.sessions; 28 | } 29 | sessionIds.push( session.id ); 30 | await browser.storage.local.set( { sessions: sessionIds } ); 31 | 32 | return session; 33 | } 34 | 35 | export async function getSession( id: string ): Promise< Session | null > { 36 | const values = await browser.storage.local.get( key( id ) ); 37 | if ( ! values || ! values[ key( id ) ] ) { 38 | return null; 39 | } 40 | return values[ key( id ) ] as Session; 41 | } 42 | 43 | export async function listSessions(): Promise< Session[] > { 44 | let sessionIds = []; 45 | const values = await browser.storage.local.get( 'sessions' ); 46 | if ( values && values.sessions ) { 47 | sessionIds = values.sessions; 48 | } 49 | 50 | const sessions = []; 51 | for ( const sessionId of sessionIds ) { 52 | const session = await getSession( sessionId ); 53 | if ( session ) { 54 | sessions.push( session ); 55 | } 56 | } 57 | 58 | return sessions; 59 | } 60 | 61 | function key( sessionId: string ): string { 62 | return `session-${ sessionId }`; 63 | } 64 | -------------------------------------------------------------------------------- /src/ui/App.tsx: -------------------------------------------------------------------------------- 1 | import { Preview } from '@/ui/preview/Preview'; 2 | import { 3 | createHashRouter, 4 | createRoutesFromElements, 5 | LoaderFunction, 6 | Navigate, 7 | Outlet, 8 | Route, 9 | RouterProvider, 10 | useLocation, 11 | useNavigate, 12 | useRouteLoaderData, 13 | } from 'react-router-dom'; 14 | import { StrictMode, useEffect, useState } from 'react'; 15 | import { NewSession } from '@/ui/session/NewSession'; 16 | import { ViewSession } from '@/ui/session/ViewSession'; 17 | import { Home } from '@/ui/Home'; 18 | import { getConfig, setConfig } from '@/storage/config'; 19 | import { getSession, listSessions, Session } from '@/storage/session'; 20 | import { PlaceholderPreview } from '@/ui/preview/PlaceholderPreview'; 21 | import { SessionContext, SessionProvider } from '@/ui/session/SessionProvider'; 22 | import { ApiClient } from '@/api/ApiClient'; 23 | import { PlaygroundClient } from '@wp-playground/client'; 24 | import { Breadcrumbs } from '@/ui/components/Breadcrumbs'; 25 | import { NewBlueprint } from '@/ui/blueprints/NewBlueprint'; 26 | import { EditBlueprint } from '@/ui/blueprints/EditBlueprint'; 27 | import { SubjectType } from '@/model/Subject'; 28 | import { ImportWithBlueprint } from '@/ui/import/ImportWithBlueprint'; 29 | import { StartPageImport } from '@/ui/import/pages/StartPageImport'; 30 | import { SelectNavigation } from '@/ui/import/pages/SelectNavigation'; 31 | import { SelectPagesFromNavigation } from '@/ui/import/pages/SelectPagesFromNavigation'; 32 | import { ImportPage } from '@/ui/import/pages/ImportPage'; 33 | import { Done } from '@/ui/import/pages/Done'; 34 | 35 | export const Screens = { 36 | home: () => '/start/home', 37 | newSession: () => '/start/new-session', 38 | viewSession: ( sessionId: string ) => `/session/${ sessionId }`, 39 | blueprints: { 40 | new: ( sessionId: string, subjectType: SubjectType ) => 41 | `/session/${ sessionId }/blueprints/new/${ subjectType }`, 42 | edit: ( sessionId: string, postId: string ) => 43 | `/session/${ sessionId }/blueprints/${ postId }`, 44 | }, 45 | importWithBlueprint: ( sessionId: string, blueprintId: string ) => 46 | `/session/${ sessionId }/import-with-blueprint/${ blueprintId }`, 47 | importPagesStart: ( sessionId: string ) => 48 | `/session/${ sessionId }/import-pages/start`, 49 | importPagesSelectNavigation: ( sessionId: string ) => 50 | `/session/${ sessionId }/import-pages/select-navigation`, 51 | importPagesSelectPages: ( sessionId: string ) => 52 | `/session/${ sessionId }/import-pages/select-pages-from-navigation`, 53 | importPagesImportPage: ( sessionId: string, page: number ) => 54 | `/session/${ sessionId }/import-pages/import-page/${ page }`, 55 | importPagesDone: ( sessionId: string ) => 56 | `/session/${ sessionId }/import-pages/done`, 57 | }; 58 | 59 | const homeLoader: LoaderFunction = async () => { 60 | return await listSessions(); 61 | }; 62 | 63 | const sessionLoader: LoaderFunction = async ( { params } ) => { 64 | const sessionId = params.sessionId; 65 | if ( ! sessionId ) { 66 | throw new Response( 'sessionId param is required', { status: 404 } ); 67 | } 68 | const session = await getSession( sessionId ); 69 | if ( ! session ) { 70 | throw new Response( `Session with id ${ sessionId } was not found`, { 71 | status: 404, 72 | } ); 73 | } 74 | return session; 75 | }; 76 | 77 | function Routes( props: { initialScreen: string } ) { 78 | const { initialScreen } = props; 79 | return ( 80 | }> 81 | } 84 | /> 85 | 86 | } loader={ homeLoader } /> 87 | } /> 88 | 89 | 94 | } /> 95 | 96 | } 99 | /> 100 | } /> 101 | 102 | } 105 | /> 106 | 107 | } /> 108 | } 111 | /> 112 | } 115 | /> 116 | } 119 | /> 120 | } /> 121 | 122 | 123 | 124 | ); 125 | } 126 | 127 | function App() { 128 | const navigate = useNavigate(); 129 | const location = useLocation(); 130 | useEffect( () => { 131 | setConfig( { currentPath: location.pathname } ).catch( console.error ); 132 | }, [ location ] ); 133 | 134 | const session = useRouteLoaderData( 'session' ) as Session; 135 | const [ playgroundClient, setPlaygroundClient ] = 136 | useState< PlaygroundClient >(); 137 | const [ apiClient, setApiClient ] = useState< ApiClient >(); 138 | const sectionContext: SessionContext = { 139 | session, 140 | apiClient, 141 | playgroundClient, 142 | }; 143 | 144 | // Debugging tools. 145 | useEffect( () => { 146 | if ( ! ( window as any ).trywp ) { 147 | ( window as any ).trywp = { 148 | navigateTo: ( url: string ) => navigate( url ), 149 | }; 150 | } 151 | if ( apiClient ) { 152 | ( window as any ).trywp.apiClient = apiClient; 153 | ( window as any ).trywp.playgroundClient = playgroundClient; 154 | } 155 | }, [ apiClient, playgroundClient, navigate ] ); 156 | 157 | const preview = ! session ? ( 158 | 159 | ) : ( 160 | { 162 | // Because client is "function-y", we need to wrap it in a function so that React doesn't call it. 163 | // See: https://react.dev/reference/react/useState#im-trying-to-set-state-to-a-function-but-it-gets-called-instead. 164 | setPlaygroundClient( () => client ); 165 | setApiClient( 166 | new ApiClient( client, await client.absoluteUrl ) 167 | ); 168 | } } 169 | /> 170 | ); 171 | 172 | return ( 173 | 174 |
    175 | 176 |
    177 | 178 |
    179 |
    180 |
    { preview }
    181 |
    182 | ); 183 | } 184 | 185 | export async function createApp() { 186 | const config = await getConfig(); 187 | let initialScreen = config.currentPath; 188 | if ( ! initialScreen || initialScreen === '/' ) { 189 | initialScreen = Screens.home(); 190 | } 191 | 192 | const router = createHashRouter( 193 | createRoutesFromElements( Routes( { initialScreen } ) ) 194 | ); 195 | 196 | return ( 197 | 198 | 199 | 200 | ); 201 | } 202 | -------------------------------------------------------------------------------- /src/ui/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useLoaderData, useNavigate } from 'react-router-dom'; 2 | import { Screens } from '@/ui/App'; 3 | import { Session } from '@/storage/session'; 4 | import { Button } from '@wordpress/components'; 5 | 6 | export function Home() { 7 | const navigate = useNavigate(); 8 | const sessions = useLoaderData() as Session[]; 9 | return ( 10 | <> 11 |

    Try WordPress

    12 |

    13 | Migrate from any site to WordPress. 14 |

    15 |

    Import using this tool, and preview the result immediately.

    16 | 17 | 24 | 25 | ); 26 | } 27 | 28 | function SessionPicker( props: { sessions: Session[] } ) { 29 | const { sessions } = props; 30 | const navigate = useNavigate(); 31 | 32 | if ( sessions.length === 0 ) { 33 | return; 34 | } 35 | 36 | return ( 37 | <> 38 |

    Continue a previous session:

    39 |
      40 | { sessions.map( ( session ) => { 41 | return ( 42 |
    • 43 | 54 |
    • 55 | ); 56 | } ) } 57 |
    58 |

    Or:

    59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/ui/app.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Try WordPress extension styles 3 | */ 4 | 5 | /* Variables */ 6 | :root { 7 | --color-base: #fff; 8 | --color-text: #1e1e1e; 9 | --color-contrast: #3857e9; 10 | --color-border: rgba(0, 0, 0, 0.075); 11 | --color-gray-700: #757575; 12 | --color-gray-600: #949494; 13 | --color-gray-100: #f9f9f9; 14 | 15 | --spacing-sm: 0.5rem; 16 | --spacing-md: 1rem; 17 | --spacing-lg: 1.25rem; 18 | --spacing-vl: 2rem; 19 | 20 | --radius-sm: 2px; 21 | 22 | --font-size-xs: 13px; 23 | --font-size-sm: 16px; 24 | --font-size-md: 20px; 25 | --font-size-lg: 24px; 26 | 27 | --col-width: 320px; /* By default the extension opens at 216px wide, setting it to 200px shows the preview peeking in, but is pretty small. */ 28 | } 29 | 30 | * { 31 | box-sizing: border-box; 32 | } 33 | 34 | a { 35 | color: var(--color-contrast); 36 | } 37 | 38 | html, 39 | body { 40 | margin: 0; 41 | padding: 0; 42 | } 43 | 44 | html, 45 | body, 46 | #app { 47 | height: 100%; 48 | } 49 | 50 | body { 51 | font: var(--font-size-xs)/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 52 | text-wrap: pretty; 53 | color: var(--color-text); 54 | } 55 | 56 | pre { 57 | margin: 0; 58 | } 59 | 60 | textarea, 61 | input, 62 | button { 63 | font: inherit; 64 | line-height: inherit; 65 | border-radius: var(--radius-sm); 66 | padding: var(--spacing-sm) var(--spacing-md); 67 | word-break: break-word; 68 | } 69 | 70 | ul, 71 | ol { 72 | list-style: none; 73 | margin: 0; 74 | padding: 0; 75 | } 76 | 77 | p, 78 | li { 79 | margin-top: var(--spacing-sm); 80 | margin-bottom: var(--spacing-md); 81 | } 82 | 83 | button { 84 | background: var(--color-contrast); 85 | color: var(--color-base); 86 | border: solid 1px var(--color-contrast); 87 | margin: 1px; 88 | cursor: pointer; 89 | } 90 | 91 | button.outline { 92 | background: var(--color-base); 93 | color: var(--color-contrast); 94 | border: solid 1px var(--color-contrast); 95 | } 96 | 97 | button.minimal { 98 | background: var(--color-base); 99 | color: var(--color-contrast); 100 | border: none; 101 | } 102 | 103 | .button-block { 104 | width: 100%; 105 | display: flex; 106 | justify-content: center; 107 | } 108 | 109 | .button-inline { 110 | background: var(--color-base); 111 | color: var(--color-contrast); 112 | border: none; 113 | padding: 0; 114 | } 115 | 116 | h1 { 117 | font-size: var(--font-size-sm); 118 | } 119 | 120 | h2, 121 | h3, 122 | h4, 123 | h5, 124 | h6 { 125 | font-size: var(--font-size-md); 126 | } 127 | 128 | h1, 129 | h2, 130 | h3, 131 | h4, 132 | h5, 133 | h6 { 134 | margin-top: var(--spacing-sm); 135 | margin-bottom: var(--spacing-md); 136 | } 137 | 138 | .section { 139 | margin-bottom: var(--spacing-vl); 140 | } 141 | 142 | /* Utilities */ 143 | .hidden { 144 | display: none !important; 145 | } 146 | 147 | /* Two-column layout */ 148 | #app { 149 | display: flex; 150 | } 151 | 152 | /* First column */ 153 | #app > div:first-child { 154 | border-right: 1px solid var(--color-border); 155 | min-width: var(--col-width); 156 | width: var(--col-width); 157 | max-width: var(--col-width); 158 | } 159 | 160 | /* Second column */ 161 | #app > div:last-child { 162 | flex: 1; 163 | min-width: var(--col-width); 164 | } 165 | 166 | /* Breadcrumbs */ 167 | .breadcrumbs { 168 | padding: var(--spacing-sm) var(--spacing-md); 169 | border-bottom: 1px solid var(--color-border); 170 | color: var(--color-gray-600); 171 | } 172 | 173 | .breadcrumbs a { 174 | color: var(--color-contrast); 175 | text-decoration: none; 176 | } 177 | 178 | .breadcrumbs ul { 179 | display: flex; 180 | flex-wrap: wrap; 181 | } 182 | 183 | .breadcrumbs li { 184 | margin-top: 0; 185 | margin-bottom: 0; 186 | } 187 | 188 | .breadcrumbs li:not(:last-child)::after { 189 | display: inline-block; 190 | margin: 0 0.25rem; 191 | content: "→"; 192 | } 193 | 194 | 195 | /* App Main */ 196 | .app-main { 197 | padding: var(--spacing-md) var(--spacing-md); 198 | max-height: 100%; 199 | overflow: auto; 200 | } 201 | 202 | /* Preview */ 203 | .preview { 204 | width: 100%; 205 | height: 100%; 206 | display: flex; 207 | flex-direction: column; 208 | align-items: stretch; 209 | justify-content: center; 210 | color: var(--color-gray-700); 211 | } 212 | 213 | .preview .preview-tab-panel { 214 | width: 100%; 215 | height: 100%; 216 | } 217 | 218 | .preview iframe { 219 | width: 100%; 220 | height: 100%; 221 | flex-grow: 1; 222 | border: none; 223 | margin: 0; 224 | padding: 0; 225 | } 226 | 227 | /* Preview tabs. */ 228 | .preview-tabs { 229 | display: flex; 230 | flex-direction: row; 231 | flex-wrap: wrap; 232 | width: 100%; 233 | border-bottom: 1px solid var(--color-border); 234 | background: var(--color-base); 235 | justify-content: center; 236 | } 237 | 238 | .preview-tabs-tab { 239 | display: flex; 240 | flex-direction: column; 241 | flex: 0 1 auto; 242 | color: var(--color-text); 243 | } 244 | 245 | .preview-tabs-tab:hover { 246 | color: var(--color-contrast); 247 | } 248 | 249 | .preview-tabs-tab.selected { 250 | cursor: default; 251 | box-shadow: inset 0 1.5px 0 0 var(--color-contrast); 252 | } 253 | 254 | .preview-tabs-tab > * { 255 | cursor: pointer; 256 | } 257 | 258 | .preview-tabs-tab label { 259 | padding: var(--spacing-sm) var(--spacing-lg); 260 | } 261 | 262 | .preview-tabs-tab input { 263 | display: none; 264 | } 265 | 266 | /* Posts */ 267 | .toolbar { 268 | display: flex; 269 | margin-bottom: var(--spacing-lg); 270 | gap: var(--spacing-md); 271 | } 272 | 273 | /* Section pickers */ 274 | fieldset { 275 | border-radius: var(--radius-sm); 276 | border: 1px solid var(--color-border); 277 | margin-bottom: var(--spacing-lg); 278 | } 279 | 280 | fieldset .field-description { 281 | margin-bottom: var(--spacing-md); 282 | } 283 | 284 | legend { 285 | text-transform: capitalize; 286 | } 287 | 288 | /* Parsed strings */ 289 | .string { 290 | background: var(--color-gray-100); 291 | border-radius: var(--radius-sm); 292 | word-break: break-word; 293 | } 294 | -------------------------------------------------------------------------------- /src/ui/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Try WordPress 7 | 8 | 9 | 10 |
    11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ui/blueprints/EditBlueprint.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from 'react-router-dom'; 2 | import { ReactElement, useEffect } from 'react'; 3 | import { useSessionContext } from '@/ui/session/SessionProvider'; 4 | import { Toolbar } from '@/ui/components/Toolbar'; 5 | import { validateFields } from '@/model/Subject'; 6 | import { Screens } from '@/ui/App'; 7 | import { useBlueprint } from '@/ui/hooks/useBlueprint'; 8 | import { useSubject } from '@/ui/hooks/useSubject'; 9 | import { Field } from '@/model/field/Field'; 10 | import { CommandTypes, sendCommandToContent } from '@/bus/Command'; 11 | import { Button } from '@wordpress/components'; 12 | import { validateBlueprint } from '@/model/Blueprint'; 13 | import { parseField } from '@/parser/field'; 14 | import { FieldsEditor } from '@/ui/components/FieldsEditor/FieldsEditor'; 15 | 16 | export function EditBlueprint() { 17 | const params = useParams(); 18 | const blueprintId = params.blueprintId!; 19 | const [ blueprint, setBlueprint ] = useBlueprint( blueprintId ); 20 | const [ subject, setSubject ] = useSubject( 21 | blueprint?.type, 22 | blueprint?.sourceUrl 23 | ); 24 | const { session, apiClient, playgroundClient } = useSessionContext(); 25 | const navigate = useNavigate(); 26 | 27 | // Make the source site navigate to the blueprint's source URL. 28 | useEffect( () => { 29 | if ( blueprint ) { 30 | void sendCommandToContent( { 31 | type: CommandTypes.NavigateTo, 32 | payload: { url: blueprint.sourceUrl }, 33 | } ); 34 | } 35 | }, [ blueprint ] ); 36 | 37 | // Make playground navigate to the transformed post of the page. 38 | useEffect( () => { 39 | if ( subject && !! playgroundClient ) { 40 | void playgroundClient.goTo( subject.previewUrl ); 41 | } 42 | }, [ subject, playgroundClient ] ); 43 | 44 | // Handle field change events. 45 | async function onFieldChanged( 46 | name: string, 47 | field: Field, 48 | selector: string 49 | ) { 50 | if ( ! blueprint || ! subject ) { 51 | return; 52 | } 53 | 54 | blueprint.selectors[ name ] = selector; 55 | blueprint.valid = validateBlueprint( blueprint ); 56 | subject.fields[ name ] = parseField( field ); 57 | 58 | const bp = await apiClient!.blueprints.update( blueprint ); 59 | setBlueprint( bp ); 60 | 61 | const p = await apiClient!.subjects.update( subject ); 62 | setSubject( p ); 63 | } 64 | 65 | let isValid = false; 66 | let editor: ReactElement | undefined; 67 | 68 | if ( blueprint && subject ) { 69 | editor = ( 70 | 75 | ); 76 | isValid = validateFields( subject ); 77 | } 78 | 79 | if ( isValid ) { 80 | isValid = blueprint!.valid; 81 | } 82 | 83 | return ( 84 | <> 85 | { ! editor ? ( 86 | 'Loading...' 87 | ) : ( 88 | <> 89 | 90 | 109 | 110 | { editor } 111 | 112 | ) } 113 | 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/ui/blueprints/NewBlueprint.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useSessionContext } from '@/ui/session/SessionProvider'; 3 | import { useNavigate, useParams } from 'react-router-dom'; 4 | import { Screens } from '@/ui/App'; 5 | import { Toolbar } from '@/ui/components/Toolbar'; 6 | import { SubjectType } from '@/model/Subject'; 7 | import { newBlueprint } from '@/model/Blueprint'; 8 | import { 9 | CommandTypes, 10 | CurrentPageInfo, 11 | sendCommandToContent, 12 | } from '@/bus/Command'; 13 | import { Button } from '@wordpress/components'; 14 | import { getSchema } from '@/model/Schema'; 15 | 16 | export function NewBlueprint() { 17 | const params = useParams(); 18 | const subjectType = params.subjectType as SubjectType; 19 | const navigate = useNavigate(); 20 | const [ isLoading, setIsLoading ] = useState( true ); 21 | const { session, apiClient } = useSessionContext(); 22 | 23 | // Check if there is already a blueprint for the subjectType and if so, 24 | // redirect to that blueprint's edit screen if the blueprint is not valid yet, 25 | // or redirect to the import screen if the blueprint is already valid. 26 | useEffect( () => { 27 | if ( ! apiClient ) { 28 | return; 29 | } 30 | 31 | async function maybeRedirect() { 32 | const blueprints = 33 | await apiClient!.blueprints.findBySubjectType( subjectType ); 34 | const blueprint = blueprints.length > 0 ? blueprints[ 0 ] : null; 35 | if ( blueprint && blueprint.valid ) { 36 | navigate( 37 | Screens.importWithBlueprint( session.id, blueprint.id ) 38 | ); 39 | return; 40 | } else if ( blueprint ) { 41 | navigate( Screens.blueprints.edit( session.id, blueprint.id ) ); 42 | return; 43 | } 44 | setIsLoading( false ); 45 | } 46 | 47 | maybeRedirect().catch( console.error ); 48 | }, [ session.id, apiClient, subjectType, navigate ] ); 49 | 50 | const schema = getSchema( subjectType ); 51 | const navigateMessage = <>Navigate to a { schema.title }; 52 | 53 | const element = ( 54 | <> 55 | 56 | 74 | 75 |
    { navigateMessage }
    76 | 77 | ); 78 | 79 | return <>{ isLoading ? 'Loading...' : element }; 80 | } 81 | -------------------------------------------------------------------------------- /src/ui/components/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLocation } from 'react-router-dom'; 2 | import { Screens } from '@/ui/App'; 3 | import { useSessionContext } from '@/ui/session/SessionProvider'; 4 | 5 | export function Breadcrumbs( props: { className: string } ) { 6 | const { className } = props; 7 | const { session } = useSessionContext(); 8 | const location = useLocation(); 9 | 10 | const showSession = location.pathname.startsWith( '/session' ); 11 | 12 | return ( 13 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/components/ContentEventHandler.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | import { stopListening, startListening, Listener } from '@/bus/Bus'; 3 | import { EventType, EventWithResponder } from '@/bus/Event'; 4 | 5 | // Listen to events coming from the content script. 6 | export function ContentEventHandler( props: { 7 | eventType: EventType; 8 | onEvent: ( event: EventWithResponder ) => void; 9 | } ) { 10 | const { eventType, onEvent } = props; 11 | const listenerRef = useRef< Listener >(); 12 | const callback = useCallback( onEvent, [ onEvent ] ); 13 | 14 | // Start listening for events. 15 | useEffect( () => { 16 | if ( listenerRef.current ) { 17 | stopListening( listenerRef.current ); 18 | } 19 | listenerRef.current = startListening( eventType, callback ); 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | }, [ callback ] ); 22 | 23 | // Stop listening on unmount. 24 | useEffect( () => { 25 | return () => { 26 | if ( listenerRef.current !== undefined ) { 27 | stopListening( listenerRef.current ); 28 | } 29 | }; 30 | // eslint-disable-next-line react-hooks/exhaustive-deps 31 | }, [] ); 32 | 33 | return <>; 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/components/FieldsEditor/FieldsEditor.tsx: -------------------------------------------------------------------------------- 1 | import { Field } from '@/model/field/Field'; 2 | import { ReactElement, useEffect, useState } from 'react'; 3 | import { CommandTypes, sendCommandToContent } from '@/bus/Command'; 4 | import { SingleFieldEditor } from '@/ui/components/FieldsEditor/SingleFieldEditor'; 5 | import { EventTypes } from '@/bus/Event'; 6 | import { ContentEventHandler } from '@/ui/components/ContentEventHandler'; 7 | import { Subject } from '@/model/Subject'; 8 | import { getSchema } from '@/model/Schema'; 9 | 10 | // Displays a list of fields that can be "edited" by selecting the content of each field, 11 | // which is done by clicking on elements in the source site. 12 | export function FieldsEditor( props: { 13 | subject: Subject; 14 | selectors?: Record< string, string >; 15 | onFieldChanged: ( name: string, field: Field, selector: string ) => void; 16 | } ) { 17 | const { subject, selectors, onFieldChanged } = props; 18 | const schema = getSchema( subject.type ); 19 | const [ fieldWaitingForSelection, setFieldWaitingForSelection ] = useState< 20 | false | { field: Field; name: string } 21 | >( false ); 22 | 23 | // Enable or disable highlighting according to whether a field is waiting for selection. 24 | useEffect( () => { 25 | const type = 26 | fieldWaitingForSelection === false 27 | ? CommandTypes.SwitchToDefaultMode 28 | : CommandTypes.SwitchToGenericSelectionMode; 29 | void sendCommandToContent( { type, payload: {} } ); 30 | }, [ fieldWaitingForSelection ] ); 31 | 32 | // Render each field. 33 | const elements: ReactElement[] = []; 34 | for ( const name in subject.fields ) { 35 | const field = subject.fields[ name ]; 36 | const schemaField = schema.fields[ name ]; 37 | 38 | const isWaitingForSelection = 39 | !! fieldWaitingForSelection && 40 | fieldWaitingForSelection.name === name; 41 | 42 | elements.push( 43 | { 51 | if ( f === false ) { 52 | setFieldWaitingForSelection( false ); 53 | } else { 54 | setFieldWaitingForSelection( { field: f, name } ); 55 | } 56 | } } 57 | onClear={ async () => { 58 | field.rawValue = ''; 59 | field.parsedValue = ''; 60 | onFieldChanged( name, field, '' ); 61 | } } 62 | /> 63 | ); 64 | } 65 | 66 | return ( 67 | <> 68 | { /* 69 | Handle a click on an element in the content script, 70 | according to which field is currently waiting for selection. 71 | */ } 72 | { 75 | if ( fieldWaitingForSelection === false ) { 76 | console.warn( 77 | 'Received an OnElementClick event but no field is waiting for selection' 78 | ); 79 | return; 80 | } 81 | const selector = ' '; 82 | fieldWaitingForSelection.field.rawValue = ( 83 | event.event.payload as any 84 | ).content; 85 | onFieldChanged( 86 | fieldWaitingForSelection.name, 87 | fieldWaitingForSelection.field, 88 | selector 89 | ); 90 | setFieldWaitingForSelection( false ); 91 | } } 92 | /> 93 | { elements } 94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/ui/components/FieldsEditor/SingleFieldEditor.tsx: -------------------------------------------------------------------------------- 1 | import { Field } from '@/model/field/Field'; 2 | import { Button, ButtonGroup } from '@wordpress/components'; 3 | 4 | export function SingleFieldEditor( props: { 5 | field: Field; 6 | label: string; 7 | description: string; 8 | selector: string; 9 | waitingForSelection: boolean; 10 | onWaitingForSelection: ( field: Field | false ) => void; 11 | onClear: () => void; 12 | } ) { 13 | const { 14 | field, 15 | label, 16 | description, 17 | selector, 18 | waitingForSelection, 19 | onWaitingForSelection, 20 | onClear, 21 | } = props; 22 | 23 | return ( 24 |
    25 | { label } 26 |
    { description }
    27 |
    28 | 29 | 36 | { ! waitingForSelection ? null : ( 37 | 43 | ) } 44 | 45 |
    46 |
    47 |

    48 | Selector ( 49 | 59 | ): 60 |

    61 | { selector } 62 |
    63 |
    64 | Original: 65 |
    66 |
    { field.rawValue }
    67 |
    68 |
    69 | Parsed: 70 |
    71 |
    { field.parsedValue.toString() }
    72 |
    73 |
    74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/ui/components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | 3 | export function Toolbar( props: PropsWithChildren ) { 4 | return
    { props.children }
    ; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/hooks/useBlueprint.ts: -------------------------------------------------------------------------------- 1 | import { Blueprint } from '@/model/Blueprint'; 2 | import { useSessionContext } from '@/ui/session/SessionProvider'; 3 | import { useEffect, useState } from 'react'; 4 | 5 | export function useBlueprint( 6 | blueprintId: string 7 | ): [ Blueprint | undefined, ( blueprint: Blueprint ) => void ] { 8 | const [ blueprint, setBlueprint ] = useState< Blueprint >(); 9 | const { apiClient } = useSessionContext(); 10 | 11 | useEffect( () => { 12 | if ( apiClient ) { 13 | apiClient.blueprints 14 | .findById( blueprintId ) 15 | .then( ( bp ) => { 16 | if ( ! bp ) { 17 | throw Error( 18 | `blueprint with id ${ blueprintId } not found` 19 | ); 20 | } 21 | setBlueprint( bp ); 22 | } ) 23 | .catch( console.error ); 24 | } 25 | }, [ blueprintId, apiClient ] ); 26 | 27 | return [ blueprint, setBlueprint ]; 28 | } 29 | -------------------------------------------------------------------------------- /src/ui/hooks/useSubject.ts: -------------------------------------------------------------------------------- 1 | import { Subject, SubjectType } from '@/model/Subject'; 2 | import { useEffect, useState } from 'react'; 3 | import { useSessionContext } from '@/ui/session/SessionProvider'; 4 | 5 | // Create or load a Subject by its source URL. 6 | // If a Subject already exists for the source URL, we use that Subject, 7 | // otherwise we create a new one. 8 | export function useSubject( 9 | type: SubjectType | undefined, 10 | sourceUrl: string | undefined 11 | ): [ Subject | undefined, ( subject: Subject ) => void ] { 12 | const [ subject, setSubject ] = useState< Subject >(); 13 | const { apiClient } = useSessionContext(); 14 | 15 | useEffect( () => { 16 | async function loadSubject() { 17 | if ( ! type || ! sourceUrl || ! apiClient ) { 18 | return; 19 | } 20 | let subj = await apiClient!.subjects.findBySourceUrl( 21 | type, 22 | sourceUrl 23 | ); 24 | if ( ! subj ) { 25 | subj = await apiClient!.subjects.create( type, sourceUrl ); 26 | } 27 | setSubject( subj ); 28 | } 29 | loadSubject().catch( console.error ); 30 | }, [ type, sourceUrl, apiClient ] ); 31 | 32 | return [ subject, setSubject ]; 33 | } 34 | -------------------------------------------------------------------------------- /src/ui/import/ImportWithBlueprint.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigate, useParams } from 'react-router-dom'; 2 | import { useBlueprint } from '@/ui/hooks/useBlueprint'; 3 | import { Toolbar } from '@/ui/components/Toolbar'; 4 | import { ReactElement, useEffect } from 'react'; 5 | import { Screens } from '@/ui/App'; 6 | import { useSessionContext } from '@/ui/session/SessionProvider'; 7 | import { Button } from '@wordpress/components'; 8 | import { getSchema } from '@/model/Schema'; 9 | 10 | export function ImportWithBlueprint() { 11 | const params = useParams(); 12 | const blueprintId = params.blueprintId!; 13 | const [ blueprint ] = useBlueprint( blueprintId ); 14 | const { session } = useSessionContext(); 15 | const navigate = useNavigate(); 16 | 17 | // Navigate to the blueprint's edit screen if the blueprint is not valid. 18 | useEffect( () => { 19 | if ( blueprint && ! blueprint.valid ) { 20 | navigate( Screens.blueprints.edit( session.id, blueprint.id ) ); 21 | } 22 | }, [ session.id, blueprint, navigate ] ); 23 | 24 | const fields: ReactElement[] = []; 25 | if ( blueprint ) { 26 | for ( const [ name, selector ] of Object.entries( 27 | blueprint.selectors 28 | ) ) { 29 | fields.push( 30 |
  1. 31 | { name }: { selector } 32 |
  2. 33 | ); 34 | } 35 | } 36 | 37 | const schema = blueprint ? getSchema( blueprint.type ) : undefined; 38 | return ( 39 | <> 40 | { ! blueprint ? ( 41 | 'Loading...' 42 | ) : ( 43 | <> 44 | 45 | 59 | 68 | 69 | We'll now import { schema!.title }s using the following 70 | selectors: 71 |
    72 |
    73 |
      { fields }
    74 | 75 | ) } 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/ui/import/pages/Done.tsx: -------------------------------------------------------------------------------- 1 | export function Done() { 2 | return <>Pages have been imported.; 3 | } 4 | -------------------------------------------------------------------------------- /src/ui/import/pages/ImportPage.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useSessionContext } from '@/ui/session/SessionProvider'; 3 | import { useNavigate, useParams } from 'react-router-dom'; 4 | import { useSelectedPages } from '@/ui/import/pages/useSelectedPages'; 5 | import { useSubject } from '@/ui/hooks/useSubject'; 6 | import { ManualSubjectTypes, SubjectType } from '@/model/Subject'; 7 | import { Field } from '@/model/field/Field'; 8 | import { FieldsEditor } from '@/ui/components/FieldsEditor/FieldsEditor'; 9 | import { CommandTypes, sendCommandToContent } from '@/bus/Command'; 10 | import { Toolbar } from '@/ui/import/pages/Toolbar'; 11 | import { Screens } from '@/ui/App'; 12 | import { parseField } from '@/parser/field'; 13 | 14 | const subjectType = ManualSubjectTypes.Page as unknown as SubjectType; 15 | 16 | // Import a specific page. 17 | // The urls of pages to import come from local storage. 18 | export function ImportPage() { 19 | const params = useParams(); 20 | const pageIndex = parseInt( params.page! ?? 0, 10 ); 21 | const { session, playgroundClient, apiClient } = useSessionContext(); 22 | const navigate = useNavigate(); 23 | const [ selectedPages ] = useSelectedPages(); 24 | const [ sourceUrl, setSourceUrl ] = useState< string >(); 25 | const [ subject, setPage ] = useSubject( subjectType, sourceUrl ); 26 | 27 | // Find the url of the page to import. 28 | useEffect( () => { 29 | if ( ! selectedPages ) { 30 | return; 31 | } 32 | if ( 33 | selectedPages.length === 0 || 34 | ! selectedPages[ pageIndex ] || 35 | selectedPages[ pageIndex ] === '' 36 | ) { 37 | throw Error( `page with index ${ pageIndex } not found` ); 38 | } 39 | setSourceUrl( selectedPages[ pageIndex ] ); 40 | }, [ session.id, pageIndex, navigate, selectedPages ] ); 41 | 42 | // Make the source site navigate to the source URL. 43 | useEffect( () => { 44 | if ( sourceUrl ) { 45 | void sendCommandToContent( { 46 | type: CommandTypes.NavigateTo, 47 | payload: { url: sourceUrl }, 48 | } ); 49 | } 50 | }, [ sourceUrl ] ); 51 | 52 | // Make playground navigate to the transformed post of the page. 53 | useEffect( () => { 54 | if ( subject && !! playgroundClient ) { 55 | void playgroundClient.goTo( subject.previewUrl ); 56 | } 57 | }, [ subject, playgroundClient ] ); 58 | 59 | if ( ! subject ) { 60 | return 'Loading...'; 61 | } 62 | 63 | const backUrl = 64 | pageIndex === 0 65 | ? Screens.importPagesSelectPages( session.id ) 66 | : Screens.importPagesImportPage( session.id, pageIndex - 1 ); 67 | const continueUrl = 68 | pageIndex === selectedPages!.length - 1 69 | ? Screens.importPagesDone( session.id ) 70 | : Screens.importPagesImportPage( session.id, pageIndex + 1 ); 71 | 72 | return ( 73 | <> 74 | 79 |

    80 | Importing page { pageIndex + 1 } of { selectedPages!.length } 81 |

    82 | { 85 | subject.fields[ name ] = parseField( field ); 86 | const s = await apiClient!.subjects.update( subject ); 87 | setPage( s ); 88 | } } 89 | /> 90 | 91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /src/ui/import/pages/SelectNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { useNavigationHtml } from '@/ui/import/pages/useNavigationHtml'; 2 | import { useSelectedPages } from '@/ui/import/pages/useSelectedPages'; 3 | import { EventTypes } from '@/bus/Event'; 4 | import { CommandTypes, sendCommandToContent } from '@/bus/Command'; 5 | import { Screens } from '@/ui/App'; 6 | import { useSessionContext } from '@/ui/session/SessionProvider'; 7 | import { useNavigate } from 'react-router-dom'; 8 | import { useEffect } from 'react'; 9 | import { Toolbar } from '@/ui/import/pages/Toolbar'; 10 | import { ContentEventHandler } from '@/ui/components/ContentEventHandler'; 11 | 12 | // Ask the user where the navigation is and store its html in local storage. 13 | // Once we have the navigation html, proceed to next step. 14 | export function SelectNavigation() { 15 | const { session } = useSessionContext(); 16 | const navigate = useNavigate(); 17 | const [ , setNavigationHtml ] = useNavigationHtml(); 18 | const [ , setSelectedPages ] = useSelectedPages(); 19 | 20 | // Enable highlighting in source site. 21 | useEffect( () => { 22 | void sendCommandToContent( { 23 | type: CommandTypes.SwitchToNavigationSelectionMode, 24 | payload: {}, 25 | } ); 26 | }, [] ); 27 | 28 | // Disable highlighting on unmount. 29 | useEffect( () => { 30 | return () => { 31 | void sendCommandToContent( { 32 | type: CommandTypes.SwitchToDefaultMode, 33 | payload: {}, 34 | } ); 35 | }; 36 | }, [] ); 37 | 38 | return ( 39 | <> 40 | 41 |

    Click on one of the entries of the navigation menu.

    42 |

    43 | If the menu is not shown on screen, click the Back button and 44 | then open the menu. 45 |

    46 | { 49 | void sendCommandToContent( { 50 | type: CommandTypes.SwitchToDefaultMode, 51 | payload: {}, 52 | } ); 53 | setNavigationHtml( ( event.event.payload as any ).content ); 54 | setSelectedPages( undefined ); 55 | navigate( Screens.importPagesSelectPages( session.id ) ); 56 | } } 57 | /> 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/ui/import/pages/SelectPagesFromNavigation.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useMemo } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Screens } from '@/ui/App'; 4 | import { LinkField } from '@/model/field/LinkField'; 5 | import { parseNavigationHtml } from '@/parser/navigation'; 6 | import { useSessionContext } from '@/ui/session/SessionProvider'; 7 | import { useSelectedPages } from '@/ui/import/pages/useSelectedPages'; 8 | import { useNavigationHtml } from '@/ui/import/pages/useNavigationHtml'; 9 | import { Toolbar } from '@/ui/import/pages/Toolbar'; 10 | 11 | // Parse the navigation html into a list of links. 12 | // Display page selector for user to select pages to import. 13 | // Selected pages are stored in local storage. 14 | export function SelectPagesFromNavigation() { 15 | const { session } = useSessionContext(); 16 | const [ navigationHtml ] = useNavigationHtml(); 17 | const [ selected, setSelected ] = useSelectedPages(); 18 | const navigate = useNavigate(); 19 | 20 | // If the navigation html is empty, redirect back to the previous step. 21 | useEffect( () => { 22 | if ( navigationHtml === '' ) { 23 | navigate( Screens.importPagesSelectNavigation( session.id ) ); 24 | } 25 | }, [ session.id, navigationHtml, navigate ] ); 26 | 27 | // Parse the navigation html into a list of links. 28 | const links = useMemo< LinkField[] >( () => { 29 | return parseNavigationHtml( navigationHtml ?? '' ); 30 | }, [ navigationHtml ] ); 31 | 32 | const elements: ReactNode[] = []; 33 | links.forEach( ( link ) => { 34 | const url = link.parsedValue.url; 35 | const title = link.parsedValue.title; 36 | elements.push( 37 |
  3. 38 | u === url ) } 41 | onChange={ () => { 42 | if ( ! selected ) { 43 | return; 44 | } 45 | const isChecked = selected.some( ( u ) => u === url ); 46 | if ( isChecked ) { 47 | // It was previously selected, now it becomes not selected. 48 | // So we keep other ones. 49 | setSelected( 50 | selected.filter( ( u ) => u !== url ) 51 | ); 52 | } else { 53 | setSelected( selected.concat( url ) ); 54 | } 55 | } } 56 | /> 57 |

    Title: { title }

    58 |

    URL: { url }

    59 |
  4. 60 | ); 61 | } ); 62 | 63 | return ( 64 | <> 65 | 0 } 68 | continueUrl={ Screens.importPagesImportPage( session.id, 0 ) } 69 | /> 70 |

    Select the pages you want to import.

    71 |

    72 | Do not select pages that should be automatically generated, like 73 | your blog posts index page, as those will automatically be 74 | created. 75 |

    76 |
      { elements }
    77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/ui/import/pages/StartPageImport.tsx: -------------------------------------------------------------------------------- 1 | import { Toolbar } from '@/ui/import/pages/Toolbar'; 2 | import { Screens } from '@/ui/App'; 3 | import { useSessionContext } from '@/ui/session/SessionProvider'; 4 | 5 | export function StartPageImport() { 6 | const { session } = useSessionContext(); 7 | return ( 8 | <> 9 | 16 |

    Navigate to a page that shows the navigation menu.

    17 |

    18 | Make sure the menu is shown on screen before clicking Continue. 19 |

    20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/import/pages/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Toolbar as BaseToolbar } from '@/ui/components/Toolbar'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { Button } from '@wordpress/components'; 4 | 5 | export function Toolbar( props: { 6 | canContinue?: boolean; 7 | continueUrl?: string; 8 | backUrl?: string; 9 | } ) { 10 | const { canContinue, continueUrl, backUrl } = props; 11 | const navigate = useNavigate(); 12 | 13 | return ( 14 | 15 | { ! backUrl ? undefined : ( 16 | 22 | ) } 23 | { ! continueUrl ? undefined : ( 24 | 31 | ) } 32 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/ui/import/pages/useNavigationHtml.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useSessionContext } from '@/ui/session/SessionProvider'; 3 | 4 | // Store and get navigation html from local storage. 5 | export function useNavigationHtml(): [ 6 | string | undefined, 7 | ( html: string ) => void, 8 | ] { 9 | const { session } = useSessionContext(); 10 | const [ html, setHtml ] = useState< string >(); 11 | 12 | // Load from local storage. 13 | useEffect( () => { 14 | getNavigationHtml( session.id ).then( setHtml ).catch( console.error ); 15 | }, [ session.id ] ); 16 | 17 | // Save to local storage. 18 | const setNavigationHtml = useCallback( 19 | ( value: string ) => { 20 | setHtml( value ); 21 | saveNavigationHtml( session.id, value ).catch( console.error ); 22 | }, 23 | [ session.id ] 24 | ); 25 | 26 | return [ html, setNavigationHtml ]; 27 | } 28 | 29 | async function saveNavigationHtml( 30 | sessionId: string, 31 | html: string 32 | ): Promise< void > { 33 | const values: Record< string, string > = {}; 34 | values[ key( sessionId ) ] = html; 35 | return browser.storage.local.set( values ); 36 | } 37 | 38 | async function getNavigationHtml( sessionId: string ): Promise< string > { 39 | const values = await browser.storage.local.get( key( sessionId ) ); 40 | if ( ! values || ! values[ key( sessionId ) ] ) { 41 | return ''; 42 | } 43 | return values[ key( sessionId ) ] as string; 44 | } 45 | 46 | function key( sessionId: string ): string { 47 | return `navigation-${ sessionId }`; 48 | } 49 | -------------------------------------------------------------------------------- /src/ui/import/pages/useSelectedPages.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from 'react'; 2 | import { useSessionContext } from '@/ui/session/SessionProvider'; 3 | 4 | // Store and get selected pages from local storage. 5 | export function useSelectedPages(): [ 6 | string[] | undefined, 7 | ( urls: string[] | undefined ) => void, 8 | ] { 9 | const { session } = useSessionContext(); 10 | const [ urls, setUrls ] = useState< string[] >(); 11 | 12 | // Load from local storage. 13 | useEffect( () => { 14 | getSelectedPages( session.id ).then( setUrls ).catch( console.error ); 15 | }, [ session.id ] ); 16 | 17 | // Save to local storage. 18 | const setSelectedPages = useCallback( 19 | ( values: string[] | undefined ) => { 20 | setUrls( values ); 21 | saveSelectedPages( session.id, values ?? [] ).catch( 22 | console.error 23 | ); 24 | }, 25 | [ session.id ] 26 | ); 27 | 28 | return [ urls, setSelectedPages ]; 29 | } 30 | 31 | async function saveSelectedPages( 32 | sessionId: string, 33 | urls: string[] 34 | ): Promise< void > { 35 | const values: Record< string, string[] > = {}; 36 | values[ key( sessionId ) ] = urls; 37 | return browser.storage.local.set( values ); 38 | } 39 | 40 | async function getSelectedPages( sessionId: string ): Promise< string[] > { 41 | const values = await browser.storage.local.get( key( sessionId ) ); 42 | if ( ! values || ! values[ key( sessionId ) ] ) { 43 | return []; 44 | } 45 | return values[ key( sessionId ) ] as string[]; 46 | } 47 | 48 | function key( sessionId: string ): string { 49 | return `selected-pages-${ sessionId }`; 50 | } 51 | -------------------------------------------------------------------------------- /src/ui/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from '@/ui/App'; 2 | import { Container, createRoot } from 'react-dom/client'; 3 | import { initParser } from '@/parser/init'; 4 | import '@wordpress/components/build-style/style.css'; 5 | import './app.css'; 6 | 7 | initParser(); 8 | 9 | const root = createRoot( document.getElementById( 'app' ) as Container ); 10 | root.render( await createApp() ); 11 | -------------------------------------------------------------------------------- /src/ui/preview/PlaceholderPreview.tsx: -------------------------------------------------------------------------------- 1 | export function PlaceholderPreview() { 2 | return ( 3 |
    13 | Icon 22 |
    23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/ui/preview/Playground.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { 3 | MountDescriptor, 4 | PlaygroundClient, 5 | StartPlaygroundOptions, 6 | startPlaygroundWeb, 7 | StepDefinition, 8 | } from '@wp-playground/client'; 9 | 10 | const playgroundIframeId = 'playground'; 11 | 12 | export function Playground( props: { 13 | slug: string; 14 | className?: string; 15 | blogName: string; 16 | onReady: ( client: PlaygroundClient ) => void; 17 | } ) { 18 | const { slug, className, blogName, onReady } = props; 19 | const initializationRef = useRef( false ); 20 | 21 | useEffect( () => { 22 | const iframe = document.getElementById( playgroundIframeId ); 23 | if ( ! ( iframe instanceof HTMLIFrameElement ) ) { 24 | throw Error( 'Playground container element must be an iframe' ); 25 | } 26 | if ( iframe.src !== '' || initializationRef.current ) { 27 | // Playground is already started or initialization has been attempted. 28 | return; 29 | } 30 | 31 | initializationRef.current = true; 32 | 33 | initPlayground( iframe, slug, blogName ) 34 | .then( async ( client: PlaygroundClient ) => { 35 | const url = await client.absoluteUrl; 36 | console.log( 'Playground communication established', url ); 37 | onReady( client ); 38 | } ) 39 | .catch( ( error ) => { 40 | throw error; 41 | } ); 42 | }, [ slug, blogName, onReady ] ); 43 | 44 | return ( 45 |