├── .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 | 
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 | navigate( Screens.newSession() ) }
21 | >
22 | Start importing
23 |
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 |
47 | navigate(
48 | Screens.viewSession( session.id )
49 | )
50 | }
51 | >
52 | { session.title } ({ session.url })
53 |
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 | {
95 | void sendCommandToContent( {
96 | type: CommandTypes.SwitchToDefaultMode,
97 | payload: {},
98 | } );
99 | navigate(
100 | Screens.importWithBlueprint(
101 | session.id,
102 | blueprint!.id
103 | )
104 | );
105 | } }
106 | >
107 | Continue
108 |
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 | {
60 | const currentPage = ( await sendCommandToContent( {
61 | type: CommandTypes.GetCurrentPageInfo,
62 | payload: {},
63 | } ) ) as CurrentPageInfo;
64 | const blueprint = await apiClient!.blueprints.create(
65 | newBlueprint( subjectType, currentPage.url )
66 | );
67 | navigate(
68 | Screens.blueprints.edit( session.id, blueprint.id )
69 | );
70 | } }
71 | >
72 | Continue
73 |
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 |
14 |
15 |
16 | Home
17 |
18 | { ! showSession ? null : (
19 |
20 |
21 | { session.title }
22 |
23 |
24 | ) }
25 |
26 |
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 | onWaitingForSelection( field ) }
33 | >
34 | Select
35 |
36 | { ! waitingForSelection ? null : (
37 | onWaitingForSelection( false ) }
40 | >
41 | Cancel
42 |
43 | ) }
44 |
45 |
46 |
47 |
48 | Selector (
49 |
57 | Clear
58 |
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 |
31 | { name }: { selector }
32 |
33 | );
34 | }
35 | }
36 |
37 | const schema = blueprint ? getSchema( blueprint.type ) : undefined;
38 | return (
39 | <>
40 | { ! blueprint ? (
41 | 'Loading...'
42 | ) : (
43 | <>
44 |
45 | {
49 | navigate(
50 | Screens.blueprints.edit(
51 | session.id,
52 | blueprint.id
53 | )
54 | );
55 | } }
56 | >
57 | Edit blueprint
58 |
59 | {
63 | console.log( 'TODO' );
64 | } }
65 | >
66 | Continue
67 |
68 |
69 | We'll now import { schema!.title }s using the following
70 | selectors:
71 |
72 |
73 |
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 |
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 |
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 |
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 | navigate( backUrl ) }
19 | >
20 | Back
21 |
22 | ) }
23 | { ! continueUrl ? undefined : (
24 | navigate( continueUrl ) }
28 | >
29 | Continue
30 |
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 |
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 |
50 | );
51 | }
52 |
53 | async function initPlayground(
54 | iframe: HTMLIFrameElement,
55 | slug: string,
56 | blogName: string
57 | ): Promise< PlaygroundClient > {
58 | const opfsEnabled = process.env.OPFS_ENABLED === 'true';
59 |
60 | // TODO: We should pass the initialSyncDirection property.
61 | // @ts-ignore
62 | const mountDescriptor: MountDescriptor = {
63 | device: {
64 | type: 'opfs',
65 | path: '/try-wp-sites/' + slug,
66 | },
67 | mountpoint: '/wordpress',
68 | };
69 |
70 | const isWPInstalled = await isWordPressInstalled( slug );
71 | console.info(
72 | 'opfsEnabled:',
73 | opfsEnabled,
74 | 'isWordPressInstalled:',
75 | isWPInstalled
76 | );
77 |
78 | const options: StartPlaygroundOptions = {
79 | iframe,
80 | remoteUrl: `https://pg.ashfame.com/remote.html`,
81 | mounts: opfsEnabled ? [ mountDescriptor ] : undefined,
82 | shouldInstallWordPress: opfsEnabled ? ! isWPInstalled : undefined,
83 | blueprint: {
84 | login: true,
85 | steps: steps(),
86 | siteOptions: {
87 | blogname: blogName,
88 | },
89 | },
90 | };
91 |
92 | const client: PlaygroundClient = await startPlaygroundWeb( options );
93 | await client.isReady();
94 |
95 | if ( ! isWPInstalled ) {
96 | await setWordPressAsInstalled( slug );
97 | }
98 |
99 | return client;
100 | }
101 |
102 | async function isWordPressInstalled( slug: string ) {
103 | const localStorageKey = `${ slug }-isWordPressInstalled`;
104 | let isInstalled = false;
105 |
106 | try {
107 | const result = await browser.storage.local.get( localStorageKey );
108 | if ( result[ localStorageKey ] === 'true' ) {
109 | isInstalled = true;
110 | }
111 | return isInstalled;
112 | } catch ( error ) {
113 | console.log( `Error: ${ error }` );
114 | return false; // In case of error, assume WordPress is not installed
115 | }
116 | }
117 |
118 | async function setWordPressAsInstalled( slug: string ) {
119 | const localStorageKey = `${ slug }-isWordPressInstalled`;
120 | await browser.storage.local.set( { [ localStorageKey ]: 'true' } );
121 | }
122 |
123 | function steps(): StepDefinition[] {
124 | return [
125 | {
126 | step: 'defineWpConfigConsts',
127 | consts: {
128 | WP_ENVIRONMENT_TYPE: 'local',
129 | },
130 | },
131 | {
132 | step: 'login',
133 | username: 'admin',
134 | password: 'password',
135 | },
136 | {
137 | step: 'updateUserMeta',
138 | userId: 1,
139 | meta: {
140 | admin_color: 'modern',
141 | },
142 | },
143 | {
144 | step: 'runPHP',
145 | code: deleteDefaultContent(),
146 | },
147 | {
148 | step: 'runPHP',
149 | code: createHomePage(),
150 | },
151 | {
152 | step: 'unzip',
153 | zipFile: {
154 | resource: 'url',
155 | url: 'plugin.zip',
156 | },
157 | extractToPath: '/wordpress/wp-content/plugins/try-wordpress',
158 | },
159 | {
160 | step: 'activatePlugin',
161 | pluginName: 'Try WordPress',
162 | pluginPath: '/wordpress/wp-content/plugins/try-wordpress',
163 | },
164 | {
165 | step: 'mkdir',
166 | path: '/wordpress/wp-content/mu-plugins',
167 | },
168 | {
169 | step: 'writeFile',
170 | path: '/wordpress/wp-content/mu-plugins/authenticate-rest-request.php',
171 | data: authenticateRestRequest(),
172 | },
173 | ];
174 | }
175 |
176 | function authenticateRestRequest(): string {
177 | return `term_id;
204 | }
205 | $post_id = wp_insert_post(array(
206 | 'post_title' => 'Home',
207 | 'post_name' => 'home',
208 | 'post_type' => 'wp_template',
209 | 'post_status' => 'publish',
210 | 'tax_input' => array(
211 | 'wp_theme' => array($term_id)
212 | ),
213 | 'post_content' => '
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
228 |
229 |
230 |
231 |
232 | ',
233 | ));
234 | wp_set_object_terms($post_id, $term_id, 'wp_theme');
235 | `;
236 | }
237 |
--------------------------------------------------------------------------------
/src/ui/preview/PlaygroundHttpProxy.ts:
--------------------------------------------------------------------------------
1 | import {
2 | PHPRequest,
3 | PHPResponse,
4 | PlaygroundClient,
5 | } from '@wp-playground/client';
6 |
7 | export class PlaygroundHttpProxy {
8 | constructor( private readonly client: PlaygroundClient ) {}
9 |
10 | async request( request: PHPRequest ): Promise< PHPResponse > {
11 | const response = await this.client.request( request );
12 |
13 | if ( response.httpStatusCode < 200 || response.httpStatusCode >= 300 ) {
14 | logFailedRequest( { request, response } );
15 | } else if ( process.env.LOG_REQUESTS === 'true' ) {
16 | logRequest( { request, response } );
17 | }
18 |
19 | return response;
20 | }
21 | }
22 |
23 | function logRequest( args: { request: PHPRequest; response: PHPResponse } ) {
24 | const { request, response } = args;
25 | const url = request.url;
26 | console.log( {
27 | type: 'API Request/Response',
28 | request: {
29 | url,
30 | body:
31 | typeof request.body === 'string'
32 | ? ( () => {
33 | try {
34 | return JSON.parse( request.body );
35 | } catch {
36 | return request.body;
37 | }
38 | } )()
39 | : request.body,
40 | },
41 | response: { status: response.httpStatusCode, body: response.json },
42 | } );
43 | }
44 |
45 | function logFailedRequest( args: {
46 | request: PHPRequest;
47 | response: PHPResponse;
48 | } ) {
49 | const { request, response } = args;
50 | const url = request.url;
51 | const params = request.body;
52 | const message = `Request to ${ url } failed [${ response.httpStatusCode }]`;
53 | if ( params ) {
54 | console.error( message, params, response.json, response );
55 | } else {
56 | console.error( message, response.json, response );
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/src/ui/preview/Preview.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import { PreviewTabBar } from '@/ui/preview/PreviewTabBar';
3 | import { Playground } from '@/ui/preview/Playground';
4 | import { useSessionContext } from '@/ui/session/SessionProvider';
5 | import { PlaygroundClient } from '@wp-playground/client';
6 |
7 | const tabFront = 0;
8 | const tabAdmin = 1;
9 | const defaultTab = tabFront;
10 |
11 | export function Preview( props: {
12 | onReady: ( playgroundClient: PlaygroundClient ) => void;
13 | } ) {
14 | const { onReady } = props;
15 | const [ currentTab, setCurrentTab ] = useState< number >( defaultTab );
16 | const { session, apiClient } = useSessionContext();
17 |
18 | const previewAdminUrl =
19 | apiClient?.siteUrl && apiClient.siteUrl?.length > 0
20 | ? `${ apiClient.siteUrl }/wp-admin/`
21 | : '';
22 |
23 | const isPlaygroundLoading = previewAdminUrl === '';
24 |
25 | const tabBar = (
26 | setCurrentTab( tab ) }
32 | />
33 | );
34 |
35 | const previewFront = (
36 |
41 | );
42 |
43 | const previewAdmin = (
44 |
45 | );
46 |
47 | const showTabBar = ! isPlaygroundLoading;
48 | const showPreviewFront = currentTab === tabFront;
49 | const showPreviewAdmin = currentTab === tabAdmin;
50 |
51 | return (
52 | <>
53 | { tabBar }
54 |
61 | { previewFront }
62 |
63 |
70 | { previewAdmin }
71 |
72 | >
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/src/ui/preview/PreviewTabBar.tsx:
--------------------------------------------------------------------------------
1 | export function PreviewTabBar( props: {
2 | entries: string[];
3 | value: number;
4 | className: string;
5 | tabClassName: string;
6 | hidden?: boolean;
7 | onChange: ( newValue: number ) => void;
8 | } ) {
9 | const { entries, value, className, tabClassName, onChange } = props;
10 |
11 | const tabs = entries.map( ( label, index ) => {
12 | const key = label.toLowerCase().replace( ' ', '-' );
13 | const classes = [ tabClassName ];
14 | if ( value === index ) {
15 | classes.push( 'selected' );
16 | }
17 | return (
18 |
19 | onChange( index ) }
24 | checked={ value === index }
25 | readOnly
26 | />
27 | { label }
28 |
29 | );
30 | } );
31 |
32 | return { tabs }
;
33 | }
34 |
--------------------------------------------------------------------------------
/src/ui/session/NewSession.tsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from 'react-router-dom';
2 | import { Screens } from '@/ui/App';
3 | import { createSession } from '@/storage/session';
4 | import {
5 | CommandTypes,
6 | CurrentPageInfo,
7 | sendCommandToContent,
8 | } from '@/bus/Command';
9 | import { Button } from '@wordpress/components';
10 |
11 | export function NewSession() {
12 | const navigate = useNavigate();
13 | const handleContinue = async () => {
14 | try {
15 | const info = ( await sendCommandToContent( {
16 | type: CommandTypes.GetCurrentPageInfo,
17 | payload: {},
18 | } ) ) as CurrentPageInfo;
19 | const session = await createSession( {
20 | url: info.url,
21 | title: info.title ?? new URL( info.url ).hostname,
22 | } );
23 | navigate( Screens.viewSession( session.id ) );
24 | } catch ( error ) {
25 | console.error( 'Failed to create session', error );
26 | return (
27 |
28 | Failed to create session: { ( error as Error ).message }
29 |
30 | );
31 | }
32 | };
33 |
34 | return (
35 | <>
36 |
37 | Start by navigating to the main page of your site, then click
38 | Continue.
39 |
40 |
45 | Continue
46 |
47 | >
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/src/ui/session/SessionProvider.ts:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 | import { Session } from '@/storage/session';
3 | import { ApiClient } from '@/api/ApiClient';
4 | import { PlaygroundClient } from '@wp-playground/client';
5 |
6 | export interface SessionContext {
7 | session: Session;
8 | apiClient?: ApiClient;
9 | playgroundClient?: PlaygroundClient;
10 | }
11 |
12 | const sessionContext = createContext< SessionContext >( {
13 | session: {
14 | id: '',
15 | url: '',
16 | title: '',
17 | },
18 | } );
19 |
20 | export const SessionProvider = sessionContext.Provider;
21 |
22 | export function useSessionContext() {
23 | return useContext( sessionContext );
24 | }
25 |
--------------------------------------------------------------------------------
/src/ui/session/ViewSession.tsx:
--------------------------------------------------------------------------------
1 | import { useSessionContext } from '@/ui/session/SessionProvider';
2 | import { useNavigate } from 'react-router-dom';
3 | import { Screens } from '@/ui/App';
4 | import { ManualSubjectTypes } from '@/model/Subject';
5 | import { Button } from '@wordpress/components';
6 | import { getSchemas } from '@/model/Schema';
7 |
8 | const schemas = getSchemas();
9 |
10 | export function ViewSession() {
11 | const { session } = useSessionContext();
12 | const navigate = useNavigate();
13 |
14 | const importButtons: { text: string; url: string }[] = Object.keys(
15 | schemas
16 | ).map( ( subjectType ) => {
17 | // Pages get a specific button.
18 | if ( subjectType === ManualSubjectTypes.Page ) {
19 | return {
20 | text: `Import Pages`,
21 | url: Screens.importPagesStart( session.id ),
22 | };
23 | }
24 | const schema = schemas[ subjectType ];
25 | return {
26 | text: `Import ${ schema.title }s`,
27 | url: Screens.blueprints.new( session.id, schema.slug ),
28 | };
29 | } );
30 |
31 | return (
32 | <>
33 |
34 | { session.title } ({ session.url })
35 |
36 |
37 | { importButtons.map( ( { text, url } ) => (
38 |
39 | navigate( url ) }
43 | >
44 | { text }
45 |
46 |
47 | ) ) }
48 |
49 | >
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/tests/bin/install-wp-tests.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | if [ $# -lt 3 ]; then
4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]"
5 | exit 1
6 | fi
7 |
8 | DB_NAME=$1
9 | DB_USER=$2
10 | DB_PASS=$3
11 | DB_HOST=${4-localhost}
12 | WP_VERSION=${5-latest}
13 | SKIP_DB_CREATE=${6-false}
14 |
15 | TMPDIR=${TMPDIR-/tmp}
16 | TMPDIR=$(echo $TMPDIR | sed -e "s/\/$//")
17 | WP_TESTS_DIR=${WP_TESTS_DIR-$TMPDIR/wordpress-tests-lib}
18 | WP_CORE_DIR=${WP_CORE_DIR-$TMPDIR/wordpress}
19 |
20 | download() {
21 | if [ `which curl` ]; then
22 | curl -s "$1" > "$2";
23 | elif [ `which wget` ]; then
24 | wget -nv -O "$2" "$1"
25 | fi
26 | }
27 |
28 | if [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+\-(beta|RC)[0-9]+$ ]]; then
29 | WP_BRANCH=${WP_VERSION%\-*}
30 | WP_TESTS_TAG="branches/$WP_BRANCH"
31 |
32 | elif [[ $WP_VERSION =~ ^[0-9]+\.[0-9]+$ ]]; then
33 | WP_TESTS_TAG="branches/$WP_VERSION"
34 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
35 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
36 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
37 | WP_TESTS_TAG="tags/${WP_VERSION%??}"
38 | else
39 | WP_TESTS_TAG="tags/$WP_VERSION"
40 | fi
41 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
42 | WP_TESTS_TAG="trunk"
43 | else
44 | # http serves a single offer, whereas https serves multiple. we only want one
45 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json
46 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json
47 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//')
48 | if [[ -z "$LATEST_VERSION" ]]; then
49 | echo "Latest WordPress version could not be found"
50 | exit 1
51 | fi
52 | WP_TESTS_TAG="tags/$LATEST_VERSION"
53 | fi
54 | set -ex
55 |
56 | install_wp() {
57 |
58 | if [ -d $WP_CORE_DIR ]; then
59 | return;
60 | fi
61 |
62 | mkdir -p $WP_CORE_DIR
63 |
64 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then
65 | mkdir -p $TMPDIR/wordpress-trunk
66 | rm -rf $TMPDIR/wordpress-trunk/*
67 | svn export --quiet https://core.svn.wordpress.org/trunk $TMPDIR/wordpress-trunk/wordpress
68 | mv $TMPDIR/wordpress-trunk/wordpress/* $WP_CORE_DIR
69 | else
70 | if [ $WP_VERSION == 'latest' ]; then
71 | local ARCHIVE_NAME='latest'
72 | elif [[ $WP_VERSION =~ [0-9]+\.[0-9]+ ]]; then
73 | # https serves multiple offers, whereas http serves single.
74 | download https://api.wordpress.org/core/version-check/1.7/ $TMPDIR/wp-latest.json
75 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+\.[0] ]]; then
76 | # version x.x.0 means the first release of the major version, so strip off the .0 and download version x.x
77 | LATEST_VERSION=${WP_VERSION%??}
78 | else
79 | # otherwise, scan the releases and get the most up to date minor version of the major release
80 | local VERSION_ESCAPED=`echo $WP_VERSION | sed 's/\./\\\\./g'`
81 | LATEST_VERSION=$(grep -o '"version":"'$VERSION_ESCAPED'[^"]*' $TMPDIR/wp-latest.json | sed 's/"version":"//' | head -1)
82 | fi
83 | if [[ -z "$LATEST_VERSION" ]]; then
84 | local ARCHIVE_NAME="wordpress-$WP_VERSION"
85 | else
86 | local ARCHIVE_NAME="wordpress-$LATEST_VERSION"
87 | fi
88 | else
89 | local ARCHIVE_NAME="wordpress-$WP_VERSION"
90 | fi
91 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz $TMPDIR/wordpress.tar.gz
92 | tar --strip-components=1 -zxmf $TMPDIR/wordpress.tar.gz -C $WP_CORE_DIR
93 | fi
94 |
95 | download https://raw.githubusercontent.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php
96 | }
97 |
98 | install_test_suite() {
99 | # portable in-place argument for both GNU sed and Mac OSX sed
100 | if [[ $(uname -s) == 'Darwin' ]]; then
101 | local ioption='-i.bak'
102 | else
103 | local ioption='-i'
104 | fi
105 |
106 | # set up testing suite if it doesn't yet exist
107 | if [ ! -d $WP_TESTS_DIR ]; then
108 | # set up testing suite
109 | mkdir -p $WP_TESTS_DIR
110 | rm -rf $WP_TESTS_DIR/{includes,data}
111 | svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes
112 | svn export --quiet --ignore-externals https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data
113 | fi
114 |
115 | if [ ! -f wp-tests-config.php ]; then
116 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php
117 | # remove all forward slashes in the end
118 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::")
119 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
120 | sed $ioption "s:__DIR__ . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php
121 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php
122 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php
123 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php
124 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php
125 | fi
126 |
127 | }
128 |
129 | recreate_db() {
130 | shopt -s nocasematch
131 | if [[ $1 =~ ^(y|yes)$ ]]
132 | then
133 | mysqladmin drop $DB_NAME -f --user="$DB_USER" --password="$DB_PASS"$EXTRA
134 | create_db
135 | echo "Recreated the database ($DB_NAME)."
136 | else
137 | echo "Leaving the existing database ($DB_NAME) in place."
138 | fi
139 | shopt -u nocasematch
140 | }
141 |
142 | create_db() {
143 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA
144 | }
145 |
146 | install_db() {
147 |
148 | if [ ${SKIP_DB_CREATE} = "true" ]; then
149 | return 0
150 | fi
151 |
152 | # parse DB_HOST for port or socket references
153 | local PARTS=(${DB_HOST//\:/ })
154 | local DB_HOSTNAME=${PARTS[0]};
155 | local DB_SOCK_OR_PORT=${PARTS[1]};
156 | local EXTRA=""
157 |
158 | if ! [ -z $DB_HOSTNAME ] ; then
159 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then
160 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp"
161 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then
162 | EXTRA=" --socket=$DB_SOCK_OR_PORT"
163 | elif ! [ -z $DB_HOSTNAME ] ; then
164 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp"
165 | fi
166 | fi
167 |
168 | # create database
169 | if [ $(mysql --user="$DB_USER" --password="$DB_PASS"$EXTRA --execute='show databases;' | grep ^$DB_NAME$) ]
170 | then
171 | echo "Reinstalling will delete the existing test database ($DB_NAME)"
172 | read -p 'Are you sure you want to proceed? [y/N]: ' DELETE_EXISTING_DB
173 | recreate_db $DELETE_EXISTING_DB
174 | else
175 | create_db
176 | fi
177 | }
178 |
179 | install_wp
180 | install_test_suite
181 | install_db
182 |
--------------------------------------------------------------------------------
/tests/plugin/base-test.php:
--------------------------------------------------------------------------------
1 | This is the test title';
16 | private string $parsed_title = 'This is the test title';
17 | private string $raw_date = '25 Oct 2024 18:39:20 ';
18 | private string $parsed_date = '2024-10-25T18:39:20.000Z';
19 | private string $raw_content = 'This is the test content.
';
20 | private string $parsed_content = 'This is the test content.
';
21 |
22 | protected function setUp(): void {
23 | parent::setUp();
24 |
25 | $this->endpoint = '/' . $this->namespace . '/subjects/' . $this->subject_type;
26 | $this->source_html = ' ' . $this->raw_content . '';
27 |
28 | $this->blogpost_controller = new Subjects_Controller( $this->storage_post_type );
29 | }
30 |
31 | public function testRegisterRoutes(): void {
32 | do_action( 'rest_api_init' ); // so that register_route() executes.
33 |
34 | $routes = rest_get_server()->get_routes( $this->namespace );
35 | $this->assertArrayHasKey( $this->endpoint, $routes );
36 | $this->assertArrayHasKey( $this->endpoint . '/(?P\d+)', $routes );
37 | $this->assertArrayHasKey( $this->endpoint . '/schema', $routes );
38 | }
39 |
40 | public function testSchemaEndpoint() {
41 | $api_endpoint = $this->endpoint . '/schema';
42 |
43 | $request = new WP_REST_Request( 'GET', $api_endpoint );
44 | $response = rest_do_request( $request );
45 |
46 | $schema = $this->blogpost_controller->get_public_subject_schema( 'blog-post' );
47 | $this->assertEquals(
48 | $this->remove_arg_options_from_schema( $schema ),
49 | $response->get_data()
50 | );
51 | }
52 |
53 | private function remove_arg_options_from_schema( &$schema ) {
54 | foreach ( $schema['properties'] as &$property ) {
55 | unset( $property['arg_options'] );
56 | }
57 | return $schema;
58 | }
59 |
60 | public function testCreateItemEmptyBody() {
61 | $request = new WP_REST_Request( 'POST', $this->endpoint );
62 | $request->set_header( 'Content-Type', 'application/json' );
63 | $response = rest_do_request( $request );
64 |
65 | $this->assertEquals( 400, $response->get_status() );
66 | }
67 |
68 | public function testCreateItemMinimalBody() {
69 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
70 |
71 | $request = new WP_REST_Request( 'POST', $this->endpoint );
72 | $request->set_header( 'Content-Type', 'application/json' );
73 | $request->set_body(
74 | wp_json_encode(
75 | array(
76 | 'sourceUrl' => $source_url,
77 | )
78 | )
79 | );
80 | $response = rest_do_request( $request );
81 |
82 | $this->assertEquals( 200, $response->get_status() );
83 |
84 | // read from db
85 | $post = get_post( $response->get_data()['id'] );
86 | $this->assertEquals( $source_url, $post->guid );
87 | }
88 |
89 | public function testCreateItemFullBody() {
90 | global $wpdb;
91 |
92 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
93 | $author_id = 23;
94 |
95 | // phpcs:ignore
96 | $biggest_post_id = $wpdb->get_var(
97 | $wpdb->prepare(
98 | "SELECT ID FROM $wpdb->posts WHERE post_type = %s ORDER BY ID DESC LIMIT 1",
99 | $this->storage_post_type
100 | )
101 | );
102 |
103 | $request = new WP_REST_Request( 'POST', $this->endpoint );
104 | $request->set_header( 'Content-Type', 'application/json' );
105 | $request->set_body(
106 | wp_json_encode(
107 | array(
108 | 'sourceUrl' => $source_url,
109 | 'sourceHtml' => $this->source_html,
110 | 'rawTitle' => $this->raw_title,
111 | 'parsedTitle' => $this->parsed_title,
112 | 'rawContent' => $this->raw_content,
113 | 'parsedContent' => $this->parsed_content,
114 | 'rawDate' => $this->raw_date,
115 | 'parsedDate' => $this->parsed_date,
116 | 'authorId' => $author_id,
117 | )
118 | )
119 | );
120 | $response = rest_do_request( $request );
121 |
122 | $this->assertEquals( 200, $response->get_status() );
123 | $response_data = $response->get_data();
124 |
125 | $this->assertGreaterThan( $biggest_post_id, $response_data['id'] );
126 | $this->assertEquals( $author_id, $response_data['authorId'] );
127 | $this->assertEquals( $this->raw_title, $response_data['rawTitle'] );
128 | $this->assertEquals( $this->parsed_title, $response_data['parsedTitle'] );
129 | $this->assertEquals( $this->raw_content, $response_data['rawContent'] );
130 | $this->assertEquals( $this->parsed_content, $response_data['parsedContent'] );
131 | $this->assertEquals( $this->raw_date, $response_data['rawDate'] );
132 | $this->assertEquals( $this->parsed_date, $response_data['parsedDate'] );
133 | $this->assertEquals( $source_url, $response_data['sourceUrl'] );
134 | $this->assertEquals( $this->source_html, $response_data['sourceHtml'] );
135 |
136 | $this->assertNotEmpty( $response_data['transformedId'] );
137 |
138 | // read from db
139 | $post = get_post( $response_data['id'] );
140 | $this->assertEquals( $source_url, $post->guid );
141 | $this->assertEquals( $this->parsed_title, get_post_meta( $response_data['id'], 'parsed_title', true ) );
142 | $this->assertEquals( $this->parsed_content, get_post_meta( $response_data['id'], 'parsed_content', true ) );
143 | $this->assertEquals( $author_id, $post->post_author );
144 | $this->assertEquals( $this->parsed_date, get_post_meta( $response_data['id'], 'parsed_date', true ) );
145 |
146 | // assert types
147 | $this->assertIsInt( $response_data['id'] );
148 | $this->assertIsInt( $response_data['authorId'] );
149 | $this->assertIsInt( $response_data['transformedId'] );
150 | }
151 |
152 | public function testCreateItemMissingSourceUrl() {
153 | $author_id = 23;
154 |
155 | $request = new WP_REST_Request( 'POST', $this->endpoint );
156 | $request->set_header( 'Content-Type', 'application/json' );
157 | $request->set_body(
158 | wp_json_encode(
159 | array(
160 | 'rawTitle' => $this->raw_title,
161 | 'parsedTitle' => $this->parsed_title,
162 | 'rawContent' => $this->raw_content,
163 | 'parsedContent' => $this->parsed_content,
164 | 'rawDate' => $this->raw_date,
165 | 'parsedDate' => $this->parsed_date,
166 | 'authorId' => $author_id,
167 | )
168 | )
169 | );
170 | $response = rest_do_request( $request );
171 |
172 | $this->assertEquals( 400, $response->get_status() );
173 | }
174 |
175 | public function testUpdateItem() {
176 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
177 |
178 | // First create a post to update
179 | $request = new WP_REST_Request( 'POST', $this->endpoint );
180 | $request->set_header( 'Content-Type', 'application/json' );
181 | $request->set_body(
182 | wp_json_encode(
183 | array(
184 | 'sourceUrl' => $source_url,
185 | 'parsedTitle' => 'Original Title',
186 | 'parsedContent' => 'Original Content',
187 | )
188 | )
189 | );
190 | $response = rest_do_request( $request );
191 | $post_id = $response->get_data()['id'];
192 |
193 | // Now update the post
194 | $update_endpoint = $this->endpoint . '/' . $post_id;
195 | $new_title = 'Updated Title';
196 | $new_content = 'Updated Content';
197 |
198 | $request = new WP_REST_Request( 'PUT', $update_endpoint );
199 | $request->set_header( 'Content-Type', 'application/json' );
200 | $request->set_body(
201 | wp_json_encode(
202 | array(
203 | 'parsedTitle' => $new_title,
204 | 'parsedContent' => $new_content,
205 | 'sourceUrl' => $source_url,
206 | )
207 | )
208 | );
209 | $response = rest_do_request( $request );
210 | $this->assertEquals( 200, $response->get_status() );
211 | $response_data = $response->get_data();
212 |
213 | // Verify response data
214 | $this->assertEquals( $post_id, $response_data['id'] );
215 | $this->assertEquals( $new_title, $response_data['parsedTitle'] );
216 | $this->assertEquals( $new_content, $response_data['parsedContent'] );
217 | $this->assertEquals( $source_url, $response_data['sourceUrl'] );
218 |
219 | // Verify database update
220 | $post = get_post( $post_id );
221 | $this->assertEquals( $new_title, get_post_meta( $post_id, 'parsed_title', true ) );
222 | $this->assertEquals( $new_content, get_post_meta( $post_id, 'parsed_content', true ) );
223 | $this->assertEquals( $source_url, $post->guid );
224 | }
225 |
226 | public function testDeleteItem() {
227 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
228 |
229 | // First create a post to delete
230 | $request = new WP_REST_Request( 'POST', $this->endpoint );
231 | $request->set_header( 'Content-Type', 'application/json' );
232 | $request->set_body(
233 | wp_json_encode(
234 | array(
235 | 'sourceUrl' => $source_url,
236 | )
237 | )
238 | );
239 | $response = rest_do_request( $request );
240 | $post_id = $response->get_data()['id'];
241 |
242 | // Now delete the post
243 | $delete_endpoint = $this->endpoint . '/' . $post_id;
244 | $request = new WP_REST_Request( 'DELETE', $delete_endpoint );
245 | $response = rest_do_request( $request );
246 |
247 | $this->assertEquals( 204, $response->get_status() );
248 | }
249 |
250 | public function testDeleteNonexistentItem() {
251 | $delete_endpoint = $this->endpoint . '/' . PHP_INT_MAX;
252 | $request = new WP_REST_Request( 'DELETE', $delete_endpoint );
253 | $response = rest_do_request( $request );
254 |
255 | $this->assertEquals( 404, $response->get_status() );
256 | }
257 |
258 | public function testFindBySourceUrlNoArgs() {
259 | $request = new WP_REST_Request( 'GET', $this->endpoint );
260 | $request->set_header( 'Content-Type', 'application/json' );
261 |
262 | $response = rest_do_request( $request );
263 |
264 | $this->assertEquals( 400, $response->get_status() );
265 | }
266 |
267 | public function testFindBySourceUrlNoUrl() {
268 | $request = new WP_REST_Request( 'GET', $this->endpoint );
269 | $request->set_header( 'Content-Type', 'application/json' );
270 | $request->set_query_params(
271 | array(
272 | 'sourceurl' => '',
273 | )
274 | );
275 |
276 | $response = rest_do_request( $request );
277 |
278 | $this->assertEquals( 400, $response->get_status() );
279 | }
280 |
281 | public function testFindBySourceUrlValidUrl() {
282 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
283 |
284 | // First create a post to lookup
285 | $request = new WP_REST_Request( 'POST', $this->endpoint );
286 | $request->set_header( 'Content-Type', 'application/json' );
287 | $request->set_body(
288 | wp_json_encode(
289 | array(
290 | 'sourceUrl' => $source_url,
291 | )
292 | )
293 | );
294 | $response = rest_do_request( $request );
295 | $post_id = $response->get_data()['id'];
296 |
297 | $request = new WP_REST_Request( 'GET', $this->endpoint );
298 | $request->set_header( 'Content-Type', 'application/json' );
299 | $request->set_query_params(
300 | array(
301 | 'sourceurl' => $source_url,
302 | )
303 | );
304 |
305 | $response = rest_do_request( $request );
306 |
307 | $this->assertEquals( 200, $response->get_status() );
308 | $this->assertEquals( $post_id, $response->get_data()['id'] );
309 | }
310 |
311 | public function testFindBySourceUrlInvalidUrl() {
312 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
313 | $request = new WP_REST_Request( 'GET', $this->endpoint );
314 | $request->set_header( 'Content-Type', 'application/json' );
315 | $request->set_query_params(
316 | array(
317 | 'sourceurl' => $source_url,
318 | )
319 | );
320 |
321 | $response = rest_do_request( $request );
322 |
323 | $this->assertEquals( 404, $response->get_status() );
324 | }
325 |
326 | public function testGuidCache(): void {
327 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
328 | $cache_group = 'try_wp';
329 | $cache_key = 'try_wp_cache_guid_' . md5( $source_url );
330 |
331 | // First create a post
332 | $request = new WP_REST_Request( 'POST', $this->endpoint );
333 | $request->set_header( 'Content-Type', 'application/json' );
334 | $request->set_body(
335 | wp_json_encode(
336 | array(
337 | 'sourceUrl' => $source_url,
338 | )
339 | )
340 | );
341 | $response = rest_do_request( $request );
342 | $post_id = $response->get_data()['id'];
343 |
344 | // do a look-up, so that it gets cached
345 | $request = new WP_REST_Request( 'GET', $this->endpoint );
346 | $request->set_header( 'Content-Type', 'application/json' );
347 | $request->set_query_params(
348 | array(
349 | 'sourceurl' => $source_url,
350 | )
351 | );
352 |
353 | rest_do_request( $request );
354 |
355 | // Verify cache was set
356 | $this->assertEquals( $post_id, wp_cache_get( $cache_key, $cache_group ) );
357 |
358 | // Delete the post
359 | wp_delete_post( $post_id );
360 |
361 | // Verify the cache was cleared
362 | $this->assertFalse( wp_cache_get( $cache_key, $cache_group ) );
363 | }
364 | }
365 |
--------------------------------------------------------------------------------
/tests/plugin/test-liberate-controller.php:
--------------------------------------------------------------------------------
1 | This is the test title';
15 | private string $parsed_title = 'This is the test title';
16 | private string $raw_date = '25 Oct 2024 18:39:20 ';
17 | private string $parsed_date = '2024-10-25T18:39:20.000Z';
18 | private string $raw_content = 'This is the test content.
';
19 | private string $parsed_content = 'This is the test content.
';
20 |
21 | private string $inserted_post_id;
22 | private int $transformed_post_id;
23 |
24 | protected function setUp(): void {
25 | parent::setUp();
26 |
27 | $this->liberate_controller = new Subjects_Controller( $this->storage_post_type );
28 |
29 | $this->source_html = ' ' . $this->raw_content . '';
30 |
31 | // we instantiate Promoter class so that the sample post we insert also has its transformed post saved in the database
32 | new Transformer( $this->storage_post_type );
33 |
34 | $this->inserted_post_id = wp_insert_post(
35 | array(
36 | 'post_author' => 23,
37 | 'post_date' => $this->parsed_date,
38 | 'post_date_gmt' => $this->parsed_date,
39 | 'post_content' => $this->parsed_content,
40 | 'post_title' => $this->parsed_title,
41 | 'post_excerpt' => 'This is the test excerpt',
42 | 'post_status' => 'draft',
43 | 'comment_status' => 'closed',
44 | 'ping_status' => 'closed',
45 | 'post_password' => '',
46 | 'post_name' => '',
47 | 'to_ping' => '',
48 | 'pinged' => $this->parsed_date,
49 | 'post_modified' => $this->parsed_date,
50 | 'post_modified_gmt' => $this->parsed_date,
51 | 'post_content_filtered' => $this->raw_content,
52 | 'post_parent' => 0,
53 | 'guid' => 'https://example.org/default',
54 | 'menu_order' => 0,
55 | 'post_type' => $this->storage_post_type,
56 | 'comment_count' => 0,
57 | )
58 | );
59 | update_post_meta( $this->inserted_post_id, 'raw_date', $this->raw_date );
60 | update_post_meta( $this->inserted_post_id, 'raw_title', $this->raw_title );
61 | update_post_meta( $this->inserted_post_id, 'raw_content', $this->raw_content );
62 | update_post_meta( $this->inserted_post_id, 'parsed_date', $this->parsed_date );
63 | update_post_meta( $this->inserted_post_id, 'parsed_title', $this->parsed_title );
64 | update_post_meta( $this->inserted_post_id, 'parsed_content', $this->parsed_content );
65 |
66 | $this->transformed_post_id = absint( get_post_meta( $this->inserted_post_id, '_dl_transformed', true ) );
67 | }
68 |
69 | protected function tearDown(): void {
70 | wp_delete_post( $this->inserted_post_id, true );
71 | wp_delete_post( $this->transformed_post_id, true );
72 | }
73 |
74 | public function testGetStoragePostType() {
75 | $this->assertEquals( $this->storage_post_type, $this->liberate_controller->get_storage_post_type() );
76 | }
77 |
78 | public function testValidRequestForInsert() {
79 | $source_url = 'https://example.org/default'; // non-unique, already inserted in setUp()
80 |
81 | $request = new WP_REST_Request( 'POST', $this->endpoint );
82 | $request->set_header( 'Content-Type', 'application/json' );
83 | $request->set_body(
84 | wp_json_encode(
85 | array(
86 | 'sourceUrl' => $source_url,
87 | )
88 | )
89 | );
90 | $response = rest_do_request( $request );
91 |
92 | $result = $this->liberate_controller->valid_request_for_insert( $request );
93 |
94 | $this->assertInstanceOf( 'WP_Error', $result );
95 | $this->assertEquals( 'rest_source_url_not_unique', $result->get_error_code() );
96 | $this->assertEquals( 409, $response->get_status() );
97 | }
98 |
99 | public function testValidRequestForUpdateRule1() {
100 | // invalid id, rule 1 violation
101 | $api_endpoint = $this->endpoint . '/' . PHP_INT_MAX;
102 | $request = new WP_REST_Request( 'POST', $api_endpoint );
103 | $request->set_body(
104 | wp_json_encode(
105 | array(
106 | 'title' => 'Some title',
107 | )
108 | )
109 | );
110 | rest_do_request( $request );
111 |
112 | $result = $this->liberate_controller->valid_request_for_update( $request );
113 | $this->assertInstanceOf( 'WP_Error', $result );
114 | $this->assertEquals( 'rest_post_invalid_id', $result->get_error_code() );
115 | }
116 |
117 | public function testValidRequestForUpdateRule2() {
118 | // attempting to update sourceUrl/guid, rule 2 violation
119 | $api_endpoint = $this->endpoint . '/' . $this->inserted_post_id;
120 | $request = new WP_REST_Request( 'PUT', $api_endpoint );
121 | $request->set_body(
122 | wp_json_encode(
123 | array(
124 | 'title' => 'Updated title',
125 | 'sourceUrl' => 'https://example.org/different',
126 | )
127 | )
128 | );
129 | rest_do_request( $request );
130 |
131 | $result = $this->liberate_controller->valid_request_for_update( $request );
132 | $this->assertInstanceOf( 'WP_Error', $result );
133 | $this->assertEquals( 'rest_source_url_immutable', $result->get_error_code() );
134 | }
135 |
136 | public function testValidRequestForUpdateSuccess() {
137 | // valid id and same sourceUrl specified
138 | $api_endpoint = $this->endpoint . '/' . $this->inserted_post_id;
139 | $request = new WP_REST_Request( 'POST', $api_endpoint );
140 | $request->set_query_params( array( 'id' => $this->inserted_post_id ) );
141 | $request->set_body(
142 | wp_json_encode(
143 | array(
144 | 'title' => 'Some title',
145 | 'sourceUrl' => 'https://example.org/default',
146 | )
147 | )
148 | );
149 | rest_do_request( $request );
150 |
151 | $this->assertTrue( $this->liberate_controller->valid_request_for_update( $request ) );
152 | }
153 |
154 | public function testPrepareItemForResponseWithoutId() {
155 | $result = $this->liberate_controller->prepare_item_for_response(
156 | array(), // missing ID
157 | new WP_REST_Request()
158 | );
159 |
160 | $this->assertInstanceOf( 'WP_Error', $result );
161 | }
162 |
163 | public function testPrepareItemForResponse() {
164 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
165 | // Note: Not all fields are currently used, so we only look up for fields that we do use
166 | // When we start using a new field, this test would fail and require an update :)
167 | $post_array = array(
168 | 'ID' => $this->inserted_post_id,
169 | 'post_author' => 23,
170 | 'post_date' => $this->parsed_date,
171 | 'post_date_gmt' => $this->parsed_date,
172 | 'post_content' => $this->parsed_content,
173 | 'post_title' => 'This is the test title',
174 | 'post_excerpt' => 'This is the test excerpt',
175 | 'post_status' => 'publish',
176 | 'comment_status' => 'closed',
177 | 'ping_status' => 'closed',
178 | 'post_password' => '',
179 | 'post_name' => '',
180 | 'to_ping' => '',
181 | 'pinged' => $this->parsed_date,
182 | 'post_modified' => $this->parsed_date,
183 | 'post_modified_gmt' => $this->parsed_date,
184 | 'post_content_filtered' => $this->source_html,
185 | 'post_parent' => 0,
186 | 'guid' => $source_url,
187 | 'menu_order' => 0,
188 | 'post_type' => $this->liberate_controller->get_storage_post_type(),
189 | 'comment_count' => 0,
190 | );
191 |
192 | $response = $this->liberate_controller->prepare_item_for_response(
193 | $post_array,
194 | new WP_REST_Request( 'GET', $this->endpoint )
195 | );
196 |
197 | $this->assertEquals(
198 | array(
199 | 'id' => $this->inserted_post_id,
200 | 'authorId' => 23,
201 | 'sourceUrl' => $source_url,
202 | 'sourceHtml' => $this->source_html,
203 | 'rawTitle' => $this->raw_title,
204 | 'parsedTitle' => $this->parsed_title,
205 | 'rawDate' => $this->raw_date,
206 | 'parsedDate' => $this->parsed_date,
207 | 'rawContent' => $this->raw_content,
208 | 'parsedContent' => $this->parsed_content,
209 | 'transformedId' => $this->transformed_post_id,
210 | 'previewUrl' => get_permalink( $this->transformed_post_id ),
211 | ),
212 | $response->get_data()
213 | );
214 | }
215 |
216 | public function testPrepareItemForDatabase() {
217 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
218 |
219 | // prepare the request object
220 | $request = new WP_REST_Request( 'POST', $this->endpoint . '/777' );
221 | $request->set_body(
222 | wp_json_encode(
223 | array(
224 | 'id' => 777,
225 | 'authorId' => 23,
226 | 'sourceUrl' => $source_url,
227 | 'sourceHtml' => $this->source_html,
228 | 'rawTitle' => $this->raw_title,
229 | 'parsedTitle' => $this->parsed_title,
230 | 'rawDate' => $this->raw_date,
231 | 'parsedDate' => $this->parsed_date,
232 | 'rawContent' => $this->raw_content,
233 | 'parsedContent' => $this->parsed_content,
234 | )
235 | )
236 | );
237 | rest_do_request( $request );
238 |
239 | // call the testing func
240 | $prepared_post = $this->liberate_controller->prepare_item_for_database( $request );
241 |
242 | $this->assertEquals( 777, $prepared_post['ID'] );
243 | $this->assertEquals( 23, $prepared_post['post_author'] );
244 | $this->assertEquals( $source_url, $prepared_post['guid'] );
245 | $this->assertEquals(
246 | $this->liberate_controller->get_storage_post_type(),
247 | $prepared_post['post_type']
248 | );
249 | $this->assertEquals(
250 | $this->parsed_title,
251 | $prepared_post['meta']['parsed_title']
252 | );
253 | $this->assertEquals(
254 | $this->raw_title,
255 | $prepared_post['meta']['raw_title']
256 | );
257 | $this->assertEquals(
258 | $this->parsed_content,
259 | $prepared_post['meta']['parsed_content']
260 | );
261 | $this->assertEquals(
262 | $this->source_html,
263 | $prepared_post['post_content_filtered']
264 | );
265 | $this->assertEquals( $this->parsed_date, $prepared_post['meta']['parsed_date'] );
266 | $this->assertEquals( $this->raw_date, $prepared_post['meta']['raw_date'] );
267 | $this->assertEquals( $this->raw_content, $prepared_post['meta']['raw_content'] );
268 | }
269 |
270 | public function testGetPostIdByGuidWithExistingPost() {
271 | $guid = 'https://example.org/default'; // This GUID was set in setUp()
272 |
273 | $post_id = $this->liberate_controller->get_post_id_by_guid( $guid );
274 | $this->assertEquals( $this->inserted_post_id, $post_id );
275 |
276 | // Test that it's cached
277 | $cache_key = 'try_wp_cache_guid_' . md5( $guid );
278 | $cached_id = wp_cache_get( $cache_key, 'try_wp' );
279 | $this->assertEquals( $post_id, $cached_id );
280 | }
281 |
282 | public function testGetPostIdByGuidWithNonExistentPost() {
283 | $guid = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
284 |
285 | $post_id = $this->liberate_controller->get_post_id_by_guid( $guid );
286 | $this->assertNull( $post_id );
287 |
288 | // Verify no cache was set
289 | $cache_key = 'try_wp_cache_guid_' . md5( $guid );
290 | $cached_id = wp_cache_get( $cache_key, 'try_wp' );
291 | $this->assertFalse( $cached_id );
292 | }
293 |
294 | public function testCacheIsInvalidatedWhenPostIsDeleted() {
295 | $guid = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
296 |
297 | // Create a new post
298 | $new_post_id = wp_insert_post(
299 | array(
300 | 'post_author' => 23,
301 | 'post_date' => $this->parsed_date,
302 | 'post_date_gmt' => $this->parsed_date,
303 | 'post_content' => $this->parsed_content,
304 | 'post_title' => $this->parsed_title,
305 | 'post_excerpt' => 'This is the test excerpt',
306 | 'post_status' => 'draft',
307 | 'comment_status' => 'closed',
308 | 'ping_status' => 'closed',
309 | 'post_password' => '',
310 | 'post_name' => '',
311 | 'to_ping' => '',
312 | 'pinged' => $this->parsed_date,
313 | 'post_modified' => $this->parsed_date,
314 | 'post_modified_gmt' => $this->parsed_date,
315 | 'post_content_filtered' => $this->raw_content,
316 | 'post_parent' => 0,
317 | 'guid' => $guid,
318 | 'menu_order' => 0,
319 | 'post_type' => $this->storage_post_type,
320 | 'comment_count' => 0,
321 | )
322 | );
323 |
324 | // First access to cache the post ID
325 | $post_id = $this->liberate_controller->get_post_id_by_guid( $guid );
326 | $this->assertEquals( $new_post_id, $post_id );
327 |
328 | // Verify it's in cache
329 | $cache_key = 'try_wp_cache_guid_' . md5( $guid );
330 | $cached_id = wp_cache_get( $cache_key, 'try_wp' );
331 | $this->assertEquals( $post_id, $cached_id );
332 |
333 | // Delete the post - this should trigger our delete_post hook
334 | wp_delete_post( $new_post_id, true );
335 |
336 | // Verify cache was cleared by the hook
337 | $cached_id = wp_cache_get( $cache_key, 'try_wp' );
338 | $this->assertFalse( $cached_id );
339 |
340 | // Later lookups should return null
341 | $post_id = $this->liberate_controller->get_post_id_by_guid( $guid );
342 | $this->assertNull( $post_id );
343 | }
344 | }
345 |
--------------------------------------------------------------------------------
/tests/plugin/test-page-controller.php:
--------------------------------------------------------------------------------
1 | This is the test title';
16 | private string $parsed_title = 'This is the test title';
17 | private string $raw_content = 'This is the test content.
';
18 | private string $parsed_content = 'This is the test content.
';
19 |
20 | protected function setUp(): void {
21 | parent::setUp();
22 |
23 | $this->endpoint = '/' . $this->namespace . '/subjects/' . $this->subject_type;
24 | $this->source_html = ' ' . $this->raw_content . '';
25 |
26 | $this->page_controller = new Subjects_Controller( $this->storage_post_type );
27 | }
28 |
29 | public function testRegisterRoutes(): void {
30 | do_action( 'rest_api_init' ); // so that register_route() executes.
31 |
32 | $routes = rest_get_server()->get_routes( $this->namespace );
33 | $this->assertArrayHasKey( $this->endpoint, $routes );
34 | $this->assertArrayHasKey( $this->endpoint . '/(?P\d+)', $routes );
35 | $this->assertArrayHasKey( $this->endpoint . '/schema', $routes );
36 | }
37 |
38 | public function testSchemaEndpoint() {
39 | $api_endpoint = $this->endpoint . '/schema';
40 |
41 | $request = new WP_REST_Request( 'GET', $api_endpoint );
42 | $response = rest_do_request( $request );
43 |
44 | $schema = $this->page_controller->get_public_subject_schema( 'page' );
45 | $this->assertEquals(
46 | $this->remove_arg_options_from_schema( $schema ),
47 | $response->get_data()
48 | );
49 | }
50 |
51 | private function remove_arg_options_from_schema( &$schema ) {
52 | foreach ( $schema['properties'] as &$property ) {
53 | unset( $property['arg_options'] );
54 | }
55 | return $schema;
56 | }
57 |
58 | public function testCreateItemEmptyBody() {
59 | $request = new WP_REST_Request( 'POST', $this->endpoint );
60 | $request->set_header( 'Content-Type', 'application/json' );
61 | $response = rest_do_request( $request );
62 |
63 | $this->assertEquals( 400, $response->get_status() );
64 | }
65 |
66 | public function testCreateItemMinimalBody() {
67 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
68 |
69 | $request = new WP_REST_Request( 'POST', $this->endpoint );
70 | $request->set_header( 'Content-Type', 'application/json' );
71 | $request->set_body(
72 | wp_json_encode(
73 | array(
74 | 'sourceUrl' => $source_url,
75 | )
76 | )
77 | );
78 | $response = rest_do_request( $request );
79 |
80 | $this->assertEquals( 200, $response->get_status() );
81 |
82 | // read from db
83 | $post = get_post( $response->get_data()['id'] );
84 | $this->assertEquals( $source_url, $post->guid );
85 | }
86 |
87 | public function testCreateItemFullBody() {
88 | global $wpdb;
89 |
90 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
91 | $author_id = 23;
92 |
93 | // phpcs:ignore
94 | $biggest_post_id = $wpdb->get_var(
95 | $wpdb->prepare(
96 | "SELECT ID FROM $wpdb->posts WHERE post_type = %s ORDER BY ID DESC LIMIT 1",
97 | $this->storage_post_type
98 | )
99 | );
100 |
101 | $request = new WP_REST_Request( 'POST', $this->endpoint );
102 | $request->set_header( 'Content-Type', 'application/json' );
103 | $request->set_body(
104 | wp_json_encode(
105 | array(
106 | 'sourceUrl' => $source_url,
107 | 'sourceHtml' => $this->source_html,
108 | 'rawTitle' => $this->raw_title,
109 | 'parsedTitle' => $this->parsed_title,
110 | 'rawContent' => $this->raw_content,
111 | 'parsedContent' => $this->parsed_content,
112 | 'authorId' => $author_id,
113 | )
114 | )
115 | );
116 | $response = rest_do_request( $request );
117 |
118 | $this->assertEquals( 200, $response->get_status() );
119 | $response_data = $response->get_data();
120 |
121 | $this->assertGreaterThan( $biggest_post_id, $response_data['id'] );
122 | $this->assertEquals( $author_id, $response_data['authorId'] );
123 | $this->assertEquals( $this->raw_title, $response_data['rawTitle'] );
124 | $this->assertEquals( $this->parsed_title, $response_data['parsedTitle'] );
125 | $this->assertEquals( $this->raw_content, $response_data['rawContent'] );
126 | $this->assertEquals( $this->parsed_content, $response_data['parsedContent'] );
127 | $this->assertEquals( $source_url, $response_data['sourceUrl'] );
128 | $this->assertEquals( $this->source_html, $response_data['sourceHtml'] );
129 |
130 | $this->assertNotEmpty( $response_data['transformedId'] );
131 |
132 | // read from db
133 | $post = get_post( $response_data['id'] );
134 | $this->assertEquals( $source_url, $post->guid );
135 | $this->assertEquals( $this->parsed_title, get_post_meta( $response_data['id'], 'parsed_title', true ) );
136 | $this->assertEquals( $this->parsed_content, get_post_meta( $response_data['id'], 'parsed_content', true ) );
137 | $this->assertEquals( $author_id, $post->post_author );
138 | }
139 |
140 | public function testCreateItemMissingSourceUrl() {
141 | $author_id = 23;
142 |
143 | $request = new WP_REST_Request( 'POST', $this->endpoint );
144 | $request->set_header( 'Content-Type', 'application/json' );
145 | $request->set_body(
146 | wp_json_encode(
147 | array(
148 | 'rawTitle' => $this->raw_title,
149 | 'parsedTitle' => $this->parsed_title,
150 | 'rawContent' => $this->raw_content,
151 | 'parsedContent' => $this->parsed_content,
152 | 'authorId' => $author_id,
153 | )
154 | )
155 | );
156 | $response = rest_do_request( $request );
157 |
158 | $this->assertEquals( 400, $response->get_status() );
159 | }
160 |
161 | public function testUpdateItem() {
162 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
163 |
164 | // First create a post to update
165 | $request = new WP_REST_Request( 'POST', $this->endpoint );
166 | $request->set_header( 'Content-Type', 'application/json' );
167 | $request->set_body(
168 | wp_json_encode(
169 | array(
170 | 'sourceUrl' => $source_url,
171 | 'parsedTitle' => 'Original Title',
172 | 'parsedContent' => 'Original Content',
173 | )
174 | )
175 | );
176 | $response = rest_do_request( $request );
177 | $post_id = $response->get_data()['id'];
178 |
179 | // Now update the post
180 | $update_endpoint = $this->endpoint . '/' . $post_id;
181 | $new_title = 'Updated Title';
182 | $new_content = 'Updated Content';
183 |
184 | $request = new WP_REST_Request( 'PUT', $update_endpoint );
185 | $request->set_header( 'Content-Type', 'application/json' );
186 | $request->set_body(
187 | wp_json_encode(
188 | array(
189 | 'parsedTitle' => $new_title,
190 | 'parsedContent' => $new_content,
191 | 'sourceUrl' => $source_url,
192 | )
193 | )
194 | );
195 | $response = rest_do_request( $request );
196 | $this->assertEquals( 200, $response->get_status() );
197 | $response_data = $response->get_data();
198 |
199 | // Verify response data
200 | $this->assertEquals( $post_id, $response_data['id'] );
201 | $this->assertEquals( $new_title, $response_data['parsedTitle'] );
202 | $this->assertEquals( $new_content, $response_data['parsedContent'] );
203 | $this->assertEquals( $source_url, $response_data['sourceUrl'] );
204 |
205 | // Verify database update
206 | $post = get_post( $post_id );
207 | $this->assertEquals( $new_title, get_post_meta( $post_id, 'parsed_title', true ) );
208 | $this->assertEquals( $new_content, get_post_meta( $post_id, 'parsed_content', true ) );
209 | $this->assertEquals( $source_url, $post->guid );
210 | }
211 |
212 | public function testDeleteItem() {
213 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
214 |
215 | // First create a post to delete
216 | $api_endpoint = $this->endpoint;
217 | $request = new WP_REST_Request( 'POST', $api_endpoint );
218 | $request->set_header( 'Content-Type', 'application/json' );
219 | $request->set_body(
220 | wp_json_encode(
221 | array(
222 | 'sourceUrl' => $source_url,
223 | )
224 | )
225 | );
226 | $response = rest_do_request( $request );
227 | $post_id = $response->get_data()['id'];
228 |
229 | // Now delete the post
230 | $delete_endpoint = $this->endpoint . '/' . $post_id;
231 | $request = new WP_REST_Request( 'DELETE', $delete_endpoint );
232 | $response = rest_do_request( $request );
233 |
234 | $this->assertEquals( 204, $response->get_status() );
235 | }
236 |
237 | public function testDeleteNonexistentItem() {
238 | $delete_endpoint = $this->endpoint . '/' . PHP_INT_MAX;
239 | $request = new WP_REST_Request( 'DELETE', $delete_endpoint );
240 | $response = rest_do_request( $request );
241 |
242 | $this->assertEquals( 404, $response->get_status() );
243 | }
244 |
245 | public function testFindBySourceUrlNoArgs() {
246 | $request = new WP_REST_Request( 'GET', $this->endpoint );
247 | $request->set_header( 'Content-Type', 'application/json' );
248 |
249 | $response = rest_do_request( $request );
250 |
251 | $this->assertEquals( 400, $response->get_status() );
252 | }
253 |
254 | public function testFindBySourceUrlNoUrl() {
255 | $request = new WP_REST_Request( 'GET', $this->endpoint );
256 | $request->set_header( 'Content-Type', 'application/json' );
257 | $request->set_query_params(
258 | array(
259 | 'sourceurl' => '',
260 | )
261 | );
262 |
263 | $response = rest_do_request( $request );
264 |
265 | $this->assertEquals( 400, $response->get_status() );
266 | }
267 |
268 | public function testFindBySourceUrlValidUrl() {
269 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
270 |
271 | // First create a post to lookup
272 | $request = new WP_REST_Request( 'POST', $this->endpoint );
273 | $request->set_header( 'Content-Type', 'application/json' );
274 | $request->set_body(
275 | wp_json_encode(
276 | array(
277 | 'sourceUrl' => $source_url,
278 | )
279 | )
280 | );
281 | $response = rest_do_request( $request );
282 | $post_id = $response->get_data()['id'];
283 |
284 | $request = new WP_REST_Request( 'GET', $this->endpoint );
285 | $request->set_header( 'Content-Type', 'application/json' );
286 | $request->set_query_params(
287 | array(
288 | 'sourceurl' => $source_url,
289 | )
290 | );
291 |
292 | $response = rest_do_request( $request );
293 |
294 | $this->assertEquals( 200, $response->get_status() );
295 | $this->assertEquals( $post_id, $response->get_data()['id'] );
296 | }
297 |
298 | public function testFindBySourceUrlInvalidUrl() {
299 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
300 |
301 | $request = new WP_REST_Request( 'GET', $this->endpoint );
302 | $request->set_header( 'Content-Type', 'application/json' );
303 | $request->set_query_params(
304 | array(
305 | 'sourceurl' => $source_url,
306 | )
307 | );
308 |
309 | $response = rest_do_request( $request );
310 |
311 | $this->assertEquals( 404, $response->get_status() );
312 | }
313 |
314 | public function testGuidCache(): void {
315 | $source_url = 'https://example.org/' . __CLASS__ . '/' . __FUNCTION__;
316 |
317 | $cache_group = 'try_wp';
318 | $cache_key = 'try_wp_cache_guid_' . md5( $source_url );
319 |
320 | // First create a post
321 | $request = new WP_REST_Request( 'POST', $this->endpoint );
322 | $request->set_header( 'Content-Type', 'application/json' );
323 | $request->set_body(
324 | wp_json_encode(
325 | array(
326 | 'sourceUrl' => $source_url,
327 | )
328 | )
329 | );
330 | $response = rest_do_request( $request );
331 | $post_id = $response->get_data()['id'];
332 |
333 | // do a look-up, so that it gets cached
334 | $request = new WP_REST_Request( 'GET', $this->endpoint );
335 | $request->set_header( 'Content-Type', 'application/json' );
336 | $request->set_query_params(
337 | array(
338 | 'sourceurl' => $source_url,
339 | )
340 | );
341 |
342 | rest_do_request( $request );
343 |
344 | // Verify cache was set
345 | $this->assertEquals( $post_id, wp_cache_get( $cache_key, $cache_group ) );
346 |
347 | // Delete the post
348 | wp_delete_post( $post_id );
349 |
350 | // Verify the cache was cleared
351 | $this->assertFalse( wp_cache_get( $cache_key, $cache_group ) );
352 | }
353 | }
354 |
--------------------------------------------------------------------------------
/tests/plugin/test-storage.php:
--------------------------------------------------------------------------------
1 | storage = new Storage( 'lib_x' );
12 | }
13 |
14 | public function testRegisterPostTypes(): void {
15 | do_action( 'init' );
16 | $this->assertTrue( post_type_exists( 'lib_x' ), 'Custom post type meant for storage not registered' );
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/tests/plugin/test-utils.php:
--------------------------------------------------------------------------------
1 | assertEquals( $expected, convert_schema_type_to_rest_api_type( $input ) );
15 | }
16 |
17 | /**
18 | * Data provider for test_convert_schema_type_to_rest_api_type.
19 | *
20 | * @return array[] Test cases with input and expected output.
21 | */
22 | public function data_convert_schema_type_to_rest_api_type(): array {
23 | return array(
24 | 'html type converts to string' => array(
25 | 'html',
26 | 'string',
27 | ),
28 | 'text type converts to string' => array(
29 | 'text',
30 | 'string',
31 | ),
32 | 'date type converts to string' => array(
33 | 'date',
34 | 'string',
35 | ),
36 | 'number type remains unchanged' => array(
37 | 'number',
38 | 'number',
39 | ),
40 | 'boolean type remains unchanged' => array(
41 | 'boolean',
42 | 'boolean',
43 | ),
44 | 'integer type remains unchanged' => array(
45 | 'integer',
46 | 'integer',
47 | ),
48 | );
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "useDefineForClassFields": true,
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ESNext"
9 | ],
10 | "allowJs": false,
11 | "skipLibCheck": true,
12 | "esModuleInterop": false,
13 | "allowSyntheticDefaultImports": true,
14 | "strict": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "react-jsx",
21 | "paths": {
22 | "@schema/*": ["./schema/*"],
23 | "@/*": ["./src/*"]
24 | }
25 | },
26 | "include": [
27 | "src",
28 | "schema/schema.json"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/web-ext-config.mjs:
--------------------------------------------------------------------------------
1 | // Note that not all settings apply to chrome, for example browserConsole and devtools do nothing on chrome.
2 | // See https://github.com/mozilla/web-ext/issues/2580.
3 |
4 | export default {
5 | // verbose: true,
6 | run: {
7 | startUrl: [ 'https://alex.kirk.at' ],
8 | browserConsole: true,
9 | devtools: true,
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/webpack.config.cjs:
--------------------------------------------------------------------------------
1 | const path = require( 'node:path' );
2 | const { execSync } = require( 'child_process' );
3 | const CopyPlugin = require( 'copy-webpack-plugin' );
4 | const { TsconfigPathsPlugin } = require( 'tsconfig-paths-webpack-plugin' );
5 | const FileManagerPlugin = require( 'filemanager-webpack-plugin' );
6 | const webpack = require( 'webpack' );
7 | const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
8 |
9 | const SCHEMA_SRC = './schema/schema.json';
10 | const SCHEMA_OUTPUT_NAME = 'schema.json';
11 | const SCHEMA_SRC_PATH = path.resolve( __dirname, SCHEMA_SRC );
12 | const SCHEMA_PLUGIN_PATH = path.resolve(
13 | __dirname,
14 | 'src/plugin/',
15 | SCHEMA_OUTPUT_NAME
16 | );
17 |
18 | module.exports = function ( env ) {
19 | let targets = [ 'firefox', 'chrome' ];
20 | const mode = env.mode || 'development';
21 |
22 | // Validate environment.
23 | if ( mode === 'production' && ! env.target ) {
24 | throw new Error(
25 | 'Production builds require a target. Use --env target=firefox or --env target=chrome'
26 | );
27 | }
28 |
29 | // Set target(s).
30 | if ( env.target ) {
31 | targets = [ env.target ];
32 | }
33 |
34 | // Build schema/schema.json.
35 | execSync( './schema/build.mjs', { stdio: 'inherit' } );
36 |
37 | let modules = [];
38 | for ( const target of targets ) {
39 | modules = modules.concat( extensionModules( mode, target ) );
40 | }
41 |
42 | return modules;
43 | };
44 |
45 | // Build the extension.
46 | function extensionModules( mode, target ) {
47 | let outputDir = path.resolve( __dirname, 'build' );
48 | if ( mode === 'production' ) {
49 | outputDir = path.resolve( outputDir, 'production' );
50 | }
51 | const targetPath = path.resolve( outputDir, target );
52 |
53 | const devtool = mode === 'production' ? false : 'cheap-module-source-map';
54 | const resolve = {
55 | extensions: [ '.ts', '.tsx', '.js' ],
56 | plugins: [ new TsconfigPathsPlugin() ],
57 | };
58 | const module = {
59 | rules: [
60 | {
61 | test: /\.tsx?$/,
62 | use: 'ts-loader',
63 | },
64 | {
65 | // If you enable `experiments.css` or `experiments.futureDefaults`, please uncomment line below
66 | // type: "javascript/auto",
67 | test: /\.(sa|sc|c)ss$/i,
68 | use: [
69 | mode === 'production'
70 | ? MiniCssExtractPlugin.loader
71 | : 'style-loader',
72 | 'css-loader',
73 | 'postcss-loader',
74 | 'sass-loader',
75 | ],
76 | },
77 | ],
78 | };
79 |
80 | const watchOptions = {
81 | ignored: [ SCHEMA_SRC_PATH, SCHEMA_PLUGIN_PATH ],
82 | };
83 |
84 | const webExtensionPolyfillPlugin = new webpack.ProvidePlugin( {
85 | browser: 'webextension-polyfill',
86 | } );
87 |
88 | const envPlugin = new webpack.DefinePlugin( {
89 | 'process.env.OPFS_ENABLED': JSON.stringify(
90 | mode === 'production' ? 'true' : 'false'
91 | ),
92 | 'process.env.LOG_REQUESTS': JSON.stringify(
93 | mode === 'development' ? 'true' : 'false'
94 | ),
95 | } );
96 |
97 | return [
98 | // Extension background script.
99 | {
100 | mode,
101 | devtool,
102 | resolve,
103 | module,
104 | entry: [ 'webextension-polyfill', './src/extension/background.ts' ],
105 | output: {
106 | path: targetPath,
107 | filename: path.join( 'background.js' ),
108 | },
109 | plugins: [
110 | new CopyPlugin( {
111 | patterns: [
112 | {
113 | from: `./src/extension/manifest-${ target }.json`,
114 | to: path.join( targetPath, 'manifest.json' ),
115 | },
116 | {
117 | from: './src/extension/icons',
118 | to: path.join( targetPath, 'icons' ),
119 | },
120 | ],
121 | } ),
122 | webExtensionPolyfillPlugin,
123 | envPlugin,
124 | ],
125 | watchOptions,
126 | },
127 | // Extension content script.
128 | {
129 | mode,
130 | devtool,
131 | resolve,
132 | module,
133 | entry: [ 'webextension-polyfill', './src/extension/content.ts' ],
134 | output: {
135 | path: targetPath,
136 | filename: path.join( 'content.js' ),
137 | },
138 | plugins: [ webExtensionPolyfillPlugin, envPlugin ],
139 | watchOptions,
140 | },
141 | // The app.
142 | {
143 | mode,
144 | devtool,
145 | resolve,
146 | module,
147 | entry: [ 'webextension-polyfill', './src/ui/main.ts' ],
148 | output: {
149 | path: targetPath,
150 | filename: path.join( 'app.js' ),
151 | },
152 | plugins: [
153 | new EmitSubjectsSchemaPlugin(),
154 | new CopyPlugin( {
155 | patterns: [
156 | {
157 | from: './src/ui/app.html',
158 | to: path.join( targetPath, 'app.html' ),
159 | },
160 | {
161 | from: '**/*',
162 | context: 'src/plugin/',
163 | globOptions: {
164 | ignore: [ '**/plugin/vendor/**/*' ],
165 | },
166 | to: path.join( targetPath, 'plugin' ),
167 | },
168 | {
169 | from: SCHEMA_SRC,
170 | to: path.join(
171 | targetPath,
172 | 'plugin',
173 | SCHEMA_OUTPUT_NAME
174 | ),
175 | },
176 | ],
177 | } ),
178 | // Create plugin.zip.
179 | new FileManagerPlugin( {
180 | events: {
181 | onEnd: {
182 | archive: [
183 | {
184 | source: path.join( targetPath, 'plugin' ),
185 | destination: path.join(
186 | targetPath,
187 | 'plugin.zip'
188 | ),
189 | },
190 | ],
191 | },
192 | },
193 | } ),
194 | webExtensionPolyfillPlugin,
195 | envPlugin,
196 | ].concat(
197 | mode === 'production' ? [ new MiniCssExtractPlugin() ] : []
198 | ),
199 | watchOptions,
200 | },
201 | ];
202 | }
203 |
204 | class EmitSubjectsSchemaPlugin {
205 | apply( compiler ) {
206 | compiler.hooks.compilation.tap(
207 | 'EmitSubjectsSchemaPlugin',
208 | ( compilation ) => {
209 | compilation.hooks.processAssets.tapAsync(
210 | {
211 | name: 'EmitSubjectsSchemaPlugin',
212 | stage: webpack.Compilation
213 | .PROCESS_ASSETS_STAGE_ADDITIONAL,
214 | },
215 | async ( assets, callback ) => {
216 | execSync( './schema/build.mjs', { stdio: 'inherit' } );
217 | callback();
218 | }
219 | );
220 | }
221 | );
222 | }
223 | }
224 |
--------------------------------------------------------------------------------