From 90a1466d5e675ffaca75b45a1cac75bcac222e09 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Sat, 18 Jul 2026 22:48:41 +0300 Subject: [PATCH 1/9] feat: adopt Lexical 0.48 with compatibility gates Move the complete Lexical graph in lockstep while preserving the package's React, Markdown, and browser contracts. Add real-browser, packed-consumer, and cross-version gates so later feature and architecture changes remain attributable and reversible. --- .github/workflows/ci.yml | 48 ++ .gitignore | 3 + CONTRIBUTING.md | 52 ++ package-lock.json | 745 ++++++++++++------ package.json | 31 +- playwright.config.ts | 47 ++ scripts/assert-lexical-versions.mjs | 71 ++ scripts/package-consumer-utils.mjs | 223 ++++++ scripts/verify-cross-version-markdown.mjs | 96 +++ scripts/verify-package-consumer.mjs | 60 ++ src/examples/lexical-compatibility.tsx | 4 + src/mdastUtilHtmlComment.ts | 7 +- src/plugins/codeblock/CodeBlockNode.tsx | 6 +- src/plugins/link-dialog/LinkDialog.tsx | 3 +- src/plugins/link-dialog/index.ts | 20 +- src/plugins/markdown-shortcut/index.tsx | 1 + .../LexicalThematicBreakVisitor.ts | 5 +- .../MdastThematicBreakVisitor.ts | 1 + src/plugins/thematic-break/index.ts | 1 + src/test/compatibility.test.tsx | 58 ++ src/test/core.test.tsx | 41 +- .../fixtures/LexicalCompatibilityHarness.tsx | 203 +++++ src/test/fixtures/lexicalCompatibility.035.md | 41 + .../fixtures/lexicalCompatibility.input.md | 41 + src/test/fixtures/lexicalCompatibility.ts | 13 + tests/browser/lexical-compatibility.spec.ts | 370 +++++++++ tests/package-consumer/react-18/package.json | 22 + tests/package-consumer/react-19/package.json | 22 + tests/package-consumer/shared/index.html | 12 + tests/package-consumer/shared/src/main.tsx | 105 +++ tests/package-consumer/shared/tsconfig.json | 18 + tsconfig.json | 2 +- 32 files changed, 2112 insertions(+), 260 deletions(-) create mode 100644 playwright.config.ts create mode 100644 scripts/assert-lexical-versions.mjs create mode 100644 scripts/package-consumer-utils.mjs create mode 100644 scripts/verify-cross-version-markdown.mjs create mode 100644 scripts/verify-package-consumer.mjs create mode 100644 src/examples/lexical-compatibility.tsx create mode 100644 src/test/compatibility.test.tsx create mode 100644 src/test/fixtures/LexicalCompatibilityHarness.tsx create mode 100644 src/test/fixtures/lexicalCompatibility.035.md create mode 100644 src/test/fixtures/lexicalCompatibility.input.md create mode 100644 src/test/fixtures/lexicalCompatibility.ts create mode 100644 tests/browser/lexical-compatibility.spec.ts create mode 100644 tests/package-consumer/react-18/package.json create mode 100644 tests/package-consumer/react-19/package.json create mode 100644 tests/package-consumer/shared/index.html create mode 100644 tests/package-consumer/shared/src/main.tsx create mode 100644 tests/package-consumer/shared/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 63381ac9..cfaab06d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,42 @@ jobs: - name: Test run: npm run test:once + browser-compatibility: + name: Browser compatibility + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 'lts/*' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browsers + run: npm exec playwright install --with-deps chromium firefox webkit + + - name: Test browser compatibility + run: npm run test:browser + + - name: Upload browser failure artifacts + if: ${{ failure() && !cancelled() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: browser-compatibility-${{ github.run_attempt }} + path: | + playwright-report/ + test-results/ + if-no-files-found: ignore + retention-days: 14 + build-js: name: Build JS runs-on: ubuntu-latest @@ -58,9 +94,21 @@ jobs: - name: Install dependencies run: npm ci + - name: Install package-gate browser + run: npm exec playwright install --with-deps chromium + - name: Build run: npm run build + - name: Verify Lexical package graph + run: npm run test:lexical-versions + + - name: Verify packed React consumers + run: npm run test:package + + - name: Verify cross-version Markdown + run: npm run test:cross-version + build-docs: name: Build Docs runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index b477c5c0..0fec92c9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ tsdoc-metadata.json .vscode .env .vs +playwright-report/ +test-results/ +blob-report/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36e77edf..c3a20fd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,9 +33,61 @@ This starts Ladle on where you can browse and test the - `npm test` - Run Vitest in watch mode - `npm run test:once` - Run Vitest once (CI mode) +- `npm run test:compat` - Run the focused jsdom and three-browser Lexical compatibility gate +- `npm run test:lexical-versions` - Assert that the complete installed Lexical graph is on the supported lockstep version +- `npm run test:package` - Pack the built library and verify React 18 and React 19 consumers +- `npm run test:cross-version` - Replay the compatibility fixture through the packed package and the published legacy package Tests are located in `src/test/**/*.test.{ts,tsx}` +### Browser compatibility tests + +Install the Playwright-matched browsers once after installing dependencies: + +```bash +npm exec playwright install chromium firefox webkit +``` + +The browser tooling requires Node.js 18 or newer. This does not change the library's published Node.js engine support. + +Run the complete Lexical compatibility contract with `npm run test:compat`. The focused commands below localize a failure without replaying every passing phase: + +```bash +npm run test:compat:unit +npm run test:browser +npm run test:browser -- --project=chromium +npm run test:browser -- --project=firefox +npm run test:browser -- --project=webkit +npm run test:browser -- --project=chromium --grep CX-3 +``` + +Playwright owns the Ladle service and port `61000` for browser runs, including shutdown after success or failure. Do not start a separate Ladle process on that port while running the suite. Failure traces, screenshots, and videos are written under `test-results/`; the HTML report is written to `playwright-report/`. CI uploads both directories when the browser job fails. + +The paste scenario dispatches a real DOM `ClipboardEvent` with `text/plain` data as a deterministic browser-handling proxy. It does not certify OS clipboard permissions or integration. Mobile browsers and exhaustive IME behavior are outside this compatibility gate. + +### Packed-package compatibility tests + +Build the package and install the Playwright-matched Chromium browser before running the package gates: + +```bash +npm run build +npm exec playwright install chromium +npm run test:package +npm run test:cross-version +``` + +Both commands pack `dist`, create disposable consumers, and use an isolated temporary npm cache. `test:package` typechecks, Vite-bundles, serves, and renders the artifact with the pinned React 18 and React 19 toolchains. `test:cross-version` captures the packed package's public-ref Markdown, installs published `@mdxeditor/editor@4.0.4` with its complete Lexical graph pinned to 0.35.0, and directly replays the captured document. + +Use these failure-local commands while iterating: + +```bash +npm run test:package -- --react=18 +npm run test:package -- --react=19 +npm run test:cross-version +``` + +The gates require npm registry access for their disposable installations. They own their Chromium pages, preview servers, allocated loopback ports, tarballs, caches, and temporary applications, and clean them up on success or failure. + ## Project Architecture MDXEditor is built on: diff --git a/package-lock.json b/package-lock.json index 43fbf390..0067ff2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,15 +15,17 @@ "@codemirror/merge": "^6.4.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.23.0", - "@lexical/clipboard": "^0.35.0", - "@lexical/link": "^0.35.0", - "@lexical/list": "^0.35.0", - "@lexical/markdown": "^0.35.0", - "@lexical/plain-text": "^0.35.0", - "@lexical/react": "^0.35.0", - "@lexical/rich-text": "^0.35.0", - "@lexical/selection": "^0.35.0", - "@lexical/utils": "^0.35.0", + "@lexical/clipboard": "^0.48.0", + "@lexical/extension": "^0.48.0", + "@lexical/history": "^0.48.0", + "@lexical/link": "^0.48.0", + "@lexical/list": "^0.48.0", + "@lexical/markdown": "^0.48.0", + "@lexical/plain-text": "^0.48.0", + "@lexical/react": "^0.48.0", + "@lexical/rich-text": "^0.48.0", + "@lexical/selection": "^0.48.0", + "@lexical/utils": "^0.48.0", "@mdxeditor/gurx": "^1.2.4", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-dialog": "^1.1.11", @@ -39,7 +41,7 @@ "codemirror": "^6.0.1", "downshift": "^7.6.0", "js-yaml": "4.2.0", - "lexical": "^0.35.0", + "lexical": "^0.48.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", @@ -73,6 +75,7 @@ "@babel/preset-typescript": "^7.23.3", "@ladle/react": "^5.1.1", "@ngneat/falso": "^6.4.0", + "@playwright/test": "1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.1.6", "@testing-library/react": "^16.3.0", @@ -1735,32 +1738,29 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", - "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", - "license": "MIT", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", - "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", - "license": "MIT", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "dependencies": { - "@floating-ui/core": "^1.7.2", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react": { - "version": "0.27.14", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.14.tgz", - "integrity": "sha512-aSf9JXfyXpRQWMbtuW+CJQrnhzHu4Hg1Th9AkvR1o+wSW/vCUVMrtgXaRY5ToV5Fh5w3I7lXJdvlKVvYrQrppw==", - "license": "MIT", + "version": "0.27.20", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz", + "integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==", "dependencies": { - "@floating-ui/react-dom": "^2.1.4", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -1769,12 +1769,11 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", - "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", - "license": "MIT", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "dependencies": { - "@floating-ui/dom": "^1.7.2" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1782,10 +1781,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", - "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "license": "MIT" + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==" }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", @@ -2818,259 +2816,466 @@ } } }, - "node_modules/@lexical/clipboard": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.35.0.tgz", - "integrity": "sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==", - "license": "MIT", + "node_modules/@lexical/a11y": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/a11y/-/a11y-0.48.0.tgz", + "integrity": "sha512-18W4ehyipkUim4YVoDZitoH63Om3j6iCN4c84zdqE9RgkWf/PE4rvI/8BHTm6Ni7NkVE14nimXgkpaP5ok15zA==", "dependencies": { - "@lexical/html": "0.35.0", - "@lexical/list": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, - "node_modules/@lexical/code": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.35.0.tgz", - "integrity": "sha512-ox4DZwETQ9IA7+DS6PN8RJNwSAF7RMjL7YTVODIqFZ5tUFIf+5xoCHbz7Fll0Bvixlp12hVH90xnLwTLRGpkKw==", - "license": "MIT", + "node_modules/@lexical/clipboard": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.48.0.tgz", + "integrity": "sha512-xO2trk6+yBl8XXa/VNe20kXczmPxFoWtUHjidbBLEtlGBj+mo63pJj6H5o/WlZsoMKmIOJxwxsn2ejrS8G0/7A==", + "dependencies": { + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/list": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "@types/trusted-types": "^2.0.7", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/code-core": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.48.0.tgz", + "integrity": "sha512-+O1Ge06AuSo6+r8R2Xk6SkWG07H5/e4K/Scw9aqCM/BRjITxgvFTHTNvgQbaobCsP0uBJiwW1+ZGeWAWcBCY4w==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0", - "prismjs": "^1.30.0" + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/devtools-core": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.35.0.tgz", - "integrity": "sha512-C2wwtsMCR6ZTfO0TqpSM17RLJWyfHmifAfCTjFtOJu15p3M6NO/nHYK5Mt7YMQteuS89mOjB4ng8iwoLEZ6QpQ==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.48.0.tgz", + "integrity": "sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w==", "dependencies": { - "@lexical/html": "0.35.0", - "@lexical/link": "0.35.0", - "@lexical/mark": "0.35.0", - "@lexical/table": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/html": "0.48.0", + "@lexical/link": "0.48.0", + "@lexical/mark": "0.48.0", + "@lexical/table": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" }, "peerDependencies": { - "react": ">=17.x", - "react-dom": ">=17.x" + "react": ">=18.x", + "react-dom": ">=18.x", + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/dragon": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.35.0.tgz", - "integrity": "sha512-SL6mT5pcqrt6hEbJ16vWxip5+r3uvMd0bQV5UUxuk+cxIeuP86iTgRh0HFR7SM2dRTYovL6/tM/O+8QLAUGTIg==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.48.0.tgz", + "integrity": "sha512-uPuu7fVca9vmL/Oz30CRZ7FIPIodwMrTgNsRmV8jE6Qd6a7RNiTW7r3+EhbhIdkLb/sjcWsYyOMyUR1TJAB0wQ==", + "dependencies": { + "@lexical/extension": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/extension": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.48.0.tgz", + "integrity": "sha512-4uBObgz84mVbQWiumndmIhkuJL0ojHiMwFSvSUM/FCo1YMVIZvpI56blI0y+2Vix/oLui9EgVQSJjjWV4NAszw==", "dependencies": { - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "@preact/signals-core": "^1.14.1", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/hashtag": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.35.0.tgz", - "integrity": "sha512-LYJWzXuO2ZjKsvQwrLkNZiS2TsjwYkKjlDgtugzejquTBQ/o/nfSn/MmVx6EkYLOYizaJemmZbz3IBh+u732FA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.48.0.tgz", + "integrity": "sha512-hPQtdnbVoNAFsmfnCGfgY7mDbvk6mIznlCRmIR7tLeQKXqz/0Tb6eH3W24EESk9hAF8wFUYNKWE1/Kb3Hl2vEQ==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/text": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/history": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.35.0.tgz", - "integrity": "sha512-onjDRLLxGbCfHexSxxrQaDaieIHyV28zCDrbxR5dxTfW8F8PxjuNyuaG0z6o468AXYECmclxkP+P4aT6poHEpQ==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.48.0.tgz", + "integrity": "sha512-NllvUfO+u3mfi5uC8k2CodwdzeeopFFVtMZ/NMifzFbZFysdWi9m9mqfO46NrEA1rSFOydyefv6oMzC8ULXInA==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/html": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.35.0.tgz", - "integrity": "sha512-rXGFE5S5rKsg3tVnr1s4iEgOfCApNXGpIFI3T2jGEShaCZ5HLaBY9NVBXnE9Nb49e9bkDkpZ8FZd1qokCbQXbw==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.48.0.tgz", + "integrity": "sha512-uBxlgKl4YgSNEgHJSshdBqtGDzruWdx1ewop+u6faT67qHUdP3P0cUXIrG6NToDWvsL6fzCstAbN76PMER1Pnw==", "dependencies": { - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@lexical/internal": { + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/internal/-/internal-0.48.0.tgz", + "integrity": "sha512-sRwg53K7N0ZQ7KNAvcCY38LSwGizbXP1zlR1lIojZp0GoqHWNvR+vL49t1wYXu1nXx3Osf4ilHHm+aGcwq5hTw==", + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/link": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.35.0.tgz", - "integrity": "sha512-+0Wx6cBwO8TfdMzpkYFacsmgFh8X1rkiYbq3xoLvk3qV8upYxaMzK1s8Q1cpKmWyI0aZrU6z7fiK4vUqB7+69w==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.48.0.tgz", + "integrity": "sha512-E0UDmNLUXs/yMCnnE7hbFO0CvhWghmqa+qqPksFfzLkpMHdPpdS1yg59YEbYoNHLi/DXIu4cFRvpHIEuooxwNg==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/list": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.35.0.tgz", - "integrity": "sha512-owsmc8iwgExBX8sFe8fKTiwJVhYULt9hD1RZ/HwfaiEtRZZkINijqReOBnW2mJfRxBzhFSWc4NG3ISB+fHYzqw==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.48.0.tgz", + "integrity": "sha512-9Qe/Vur44v9F9enj55SUzf79FVsijcGOQug7SpiIU8ekLr7JNzcilKYBYcZ6etGEo7bqQHsYMHXeJcSBbCI2zA==", "dependencies": { - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/mark": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.35.0.tgz", - "integrity": "sha512-W0hwMTAVeexvpk9/+J6n1G/sNkpI/Meq1yeDazahFLLAwXLHtvhIAq2P/klgFknDy1hr8X7rcsQuN/bqKcKHYg==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.48.0.tgz", + "integrity": "sha512-DTtypWvnYSXyNUxEmsUnh4y0xXkmdk8Y72EZl8WDHcCwjqaLJlUakH7p/TuSJczs3uVSaoJzu0yh7oGkP1Vsvw==", "dependencies": { - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/markdown": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.35.0.tgz", - "integrity": "sha512-BlNyXZAt4gWidMw0SRWrhBETY1BpPglFBZI7yzfqukFqgXRh7HUQA28OYeI/nsx9pgNob8TiUduUwShqqvOdEA==", - "license": "MIT", - "dependencies": { - "@lexical/code": "0.35.0", - "@lexical/link": "0.35.0", - "@lexical/list": "0.35.0", - "@lexical/rich-text": "0.35.0", - "@lexical/text": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" - } - }, - "node_modules/@lexical/offset": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.35.0.tgz", - "integrity": "sha512-DRE4Df6qYf2XiV6foh6KpGNmGAv2ANqt3oVXpyS6W8hTx3+cUuAA1APhCZmLNuU107um4zmHym7taCu6uXW5Yg==", - "license": "MIT", - "dependencies": { - "lexical": "0.35.0" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.48.0.tgz", + "integrity": "sha512-1WasBenW4bEsa5xtnycVo8G2hcxIqyYLn3/r98yD2Y+54ZyeFuIQfOeJYhIgCh4YkKpfqyrFwkMQwAOsQDqZjA==", + "dependencies": { + "@lexical/code-core": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/link": "0.48.0", + "@lexical/list": "0.48.0", + "@lexical/rich-text": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/text": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/overflow": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.35.0.tgz", - "integrity": "sha512-B25YvnJQTGlZcrNv7b0PJBLWq3tl8sql497OHfYYLem7EOMPKKDGJScJAKM/91D4H/mMAsx5gnA/XgKobriuTg==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.48.0.tgz", + "integrity": "sha512-1YEvMz2tW3EbwrON9mjrkjMVl/vdTcPYSn9P1j6mf5gj0LOoLDNI4TbvSD4SViy+TDghxNdG8YdCISyU2b4YKA==", "dependencies": { - "lexical": "0.35.0" + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/plain-text": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.35.0.tgz", - "integrity": "sha512-lwBCUNMJf7Gujp2syVWMpKRahfbTv5Wq+H3HK1Q1gKH1P2IytPRxssCHvexw9iGwprSyghkKBlbF3fGpEdIJvQ==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.48.0.tgz", + "integrity": "sha512-q4f/4VZKVgCrIW2FhDFR2RII1BU0ljedPgEmJ8XQn1zc+JOFPom8Lp0lV5nyEvZaAJYwM/TfoJ9g2V7ESFKznA==", "dependencies": { - "@lexical/clipboard": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/clipboard": "0.48.0", + "@lexical/dragon": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/react": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.35.0.tgz", - "integrity": "sha512-uYAZSqumH8tRymMef+A0f2hQvMwplKK9DXamcefnk3vSNDHHqRWQXpiUo6kD+rKWuQmMbVa5RW4xRQebXEW+1A==", - "license": "MIT", - "dependencies": { - "@floating-ui/react": "^0.27.8", - "@lexical/devtools-core": "0.35.0", - "@lexical/dragon": "0.35.0", - "@lexical/hashtag": "0.35.0", - "@lexical/history": "0.35.0", - "@lexical/link": "0.35.0", - "@lexical/list": "0.35.0", - "@lexical/mark": "0.35.0", - "@lexical/markdown": "0.35.0", - "@lexical/overflow": "0.35.0", - "@lexical/plain-text": "0.35.0", - "@lexical/rich-text": "0.35.0", - "@lexical/table": "0.35.0", - "@lexical/text": "0.35.0", - "@lexical/utils": "0.35.0", - "@lexical/yjs": "0.35.0", - "lexical": "0.35.0", - "react-error-boundary": "^3.1.4" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.48.0.tgz", + "integrity": "sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA==", + "dependencies": { + "@floating-ui/react": "^0.27.19", + "@lexical/a11y": "0.48.0", + "@lexical/devtools-core": "0.48.0", + "@lexical/dragon": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/hashtag": "0.48.0", + "@lexical/history": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/link": "0.48.0", + "@lexical/list": "0.48.0", + "@lexical/mark": "0.48.0", + "@lexical/markdown": "0.48.0", + "@lexical/overflow": "0.48.0", + "@lexical/plain-text": "0.48.0", + "@lexical/rich-text": "0.48.0", + "@lexical/table": "0.48.0", + "@lexical/text": "0.48.0", + "@lexical/utils": "0.48.0", + "@lexical/yjs": "0.48.0", + "lexical": "0.48.0" }, "peerDependencies": { - "react": ">=17.x", - "react-dom": ">=17.x" + "react": ">=18.x", + "react-dom": ">=18.x", + "typescript": ">=5.2", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "yjs": { + "optional": true + } } }, "node_modules/@lexical/rich-text": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.35.0.tgz", - "integrity": "sha512-qEHu8g7vOEzz9GUz1VIUxZBndZRJPh9iJUFI+qTDHj+tQqnd5LCs+G9yz6jgNfiuWWpezTp0i1Vz/udNEuDPKQ==", - "license": "MIT", - "dependencies": { - "@lexical/clipboard": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.48.0.tgz", + "integrity": "sha512-QMXFnwCKAQ4yzxvx5FwmANcx3K+NaBkGTxAVD8s8pOKDD/U5rzDS1iIvhH6TLWaFp7VzvLmLB+Sl1Ie/RnkaDQ==", + "dependencies": { + "@lexical/clipboard": "0.48.0", + "@lexical/dragon": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/selection": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/selection": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.35.0.tgz", - "integrity": "sha512-mMtDE7Q0nycXdFTTH/+ta6EBrBwxBB4Tg8QwsGntzQ1Cq//d838dpXpFjJOqHEeVHUqXpiuj+cBG8+bvz/rPRw==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.48.0.tgz", + "integrity": "sha512-Uc0wTrEtHcYK6z/aHHjkgH3vX/R4Bf8mO+qH3VbxfSAKYzNYktM0j+ZGdq5kIEL4frnw/9SulNCXlH7xpjuDgA==", "dependencies": { - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/table": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.35.0.tgz", - "integrity": "sha512-9jlTlkVideBKwsEnEkqkdg7A3mije1SvmfiqoYnkl1kKJCLA5iH90ywx327PU0p+bdnURAytWUeZPXaEuEl2OA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.48.0.tgz", + "integrity": "sha512-t9Mz7q6ODLUz0lG5Xn9EY/5YiVpTHCqlPQP4EtFXlnBQT3DuKeDS3cC0Cn8sGSZc11YY5OLDfWpB64Frs9BL3g==", "dependencies": { - "@lexical/clipboard": "0.35.0", - "@lexical/utils": "0.35.0", - "lexical": "0.35.0" + "@lexical/clipboard": "0.48.0", + "@lexical/extension": "0.48.0", + "@lexical/html": "0.48.0", + "@lexical/internal": "0.48.0", + "@lexical/utils": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/text": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.35.0.tgz", - "integrity": "sha512-uaMh46BkysV8hK8wQwp5g/ByZW+2hPDt8ahAErxtf8NuzQem1FHG/f5RTchmFqqUDVHO3qLNTv4AehEGmXv8MA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.48.0.tgz", + "integrity": "sha512-ktTMRbsX4wKxdG2OpZCkrqtt8k9Vg/ZpWdukOQ0r1xPRtCuL1T+q91l7cy2ywIuCfMGYS0aoZGB4LdpUMe/H1g==", "dependencies": { - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/utils": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.35.0.tgz", - "integrity": "sha512-2H393EYDnFznYCDFOW3MHiRzwEO5M/UBhtUjvTT+9kc+qhX4U3zc8ixQalo5UmZ5B2nh7L/inXdTFzvSRXtsRA==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.48.0.tgz", + "integrity": "sha512-W4k4P+y6jmRfna8+ad4X+iMd5h8es5PC3bUw5tbi7MRApxaaFG/0w+uJiZVSwbT2Q6JnA2xhBaqzPgt/Gn6djg==", "dependencies": { - "@lexical/list": "0.35.0", - "@lexical/selection": "0.35.0", - "@lexical/table": "0.35.0", - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "@lexical/selection": "0.48.0", + "lexical": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lexical/yjs": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.35.0.tgz", - "integrity": "sha512-3DSP7QpmTGYU9bN/yljP0PIao4tNIQtsR4ycauWNSawxs/GQCZtSmAPcLRnCm6qpqsDDjUtKjO/1Ej8FRp0m0w==", - "license": "MIT", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.48.0.tgz", + "integrity": "sha512-fFsE8EnPM/2KK9rMJ0z6T+Da5UW5V4P+XiAA03LoHTY5YQ/Oy8Q0i7Wcmocv/B/SpsY2o8g07euZEfudl9MVKA==", "dependencies": { - "@lexical/offset": "0.35.0", - "@lexical/selection": "0.35.0", - "lexical": "0.35.0" + "@lexical/internal": "0.48.0", + "@lexical/selection": "0.48.0", + "lexical": "0.48.0" }, "peerDependencies": { + "typescript": ">=5.2", "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/@lezer/common": { @@ -3715,6 +3920,21 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@pnpm/config.env-replace": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", @@ -3760,6 +3980,15 @@ "node": ">=12" } }, + "node_modules/@preact/signals-core": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.4.tgz", + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@radix-ui/colors": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/colors/-/colors-3.0.0.tgz", @@ -6256,6 +6485,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "node_modules/@types/unist": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz", @@ -13581,10 +13815,20 @@ } }, "node_modules/lexical": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.35.0.tgz", - "integrity": "sha512-3VuV8xXhh5xJA6tzvfDvE0YBCMkIZUmxtRilJQDDdCgJCc+eut6qAv2qbN+pbqvarqcQqPN1UF+8YvsjmyOZpw==", - "license": "MIT" + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.48.0.tgz", + "integrity": "sha512-KK4Tyr/cPsleoZ7XvhGRiRmcrZidSmoFUdIXK9nPubIifoC+80Dc5THyc4xtGKtsW24S1TsHzk5gmfBU+TxmEg==", + "dependencies": { + "@lexical/internal": "0.48.0" + }, + "peerDependencies": { + "typescript": ">=5.2" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, "node_modules/lilconfig": { "version": "2.1.0", @@ -18904,6 +19148,50 @@ "pathe": "^1.1.0" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -19202,15 +19490,6 @@ "react": ">=16.0.0" } }, - "node_modules/prismjs": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", - "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -19416,21 +19695,6 @@ "react": "^19.2.1" } }, - "node_modules/react-error-boundary": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", - "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, - "peerDependencies": { - "react": ">=16.13.1" - } - }, "node_modules/react-hook-form": { "version": "7.56.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.56.1.tgz", @@ -21340,10 +21604,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "license": "MIT" + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==" }, "node_modules/tagged-tag": { "version": "1.0.0", diff --git a/package.json b/package.json index d8315b18..97477208 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,14 @@ "lint": "eslint src --ext .ts,.tsx", "test": "vitest", "test:once": "vitest --run", + "test:compat:unit": "vitest --run src/test/compatibility.test.tsx", + "test:browser:serve": "ladle dev --host 127.0.0.1 --port 61000 --noWatch", + "test:browser": "playwright test", + "test:browser:chromium": "playwright test --project=chromium", + "test:compat": "npm run test:compat:unit && npm run test:browser", + "test:lexical-versions": "node scripts/assert-lexical-versions.mjs", + "test:package": "node scripts/verify-package-consumer.mjs", + "test:cross-version": "node scripts/verify-cross-version-markdown.mjs", "semantic-release": "semantic-release", "image-upload-backend": "node ./src/examples/file-backend.js", "export-icons": "node ./scripts/export-figma-icons.mjs" @@ -44,15 +52,17 @@ "@codemirror/merge": "^6.4.0", "@codemirror/state": "^6.4.0", "@codemirror/view": "^6.23.0", - "@lexical/clipboard": "^0.35.0", - "@lexical/link": "^0.35.0", - "@lexical/list": "^0.35.0", - "@lexical/markdown": "^0.35.0", - "@lexical/plain-text": "^0.35.0", - "@lexical/react": "^0.35.0", - "@lexical/rich-text": "^0.35.0", - "@lexical/selection": "^0.35.0", - "@lexical/utils": "^0.35.0", + "@lexical/clipboard": "^0.48.0", + "@lexical/extension": "^0.48.0", + "@lexical/history": "^0.48.0", + "@lexical/link": "^0.48.0", + "@lexical/list": "^0.48.0", + "@lexical/markdown": "^0.48.0", + "@lexical/plain-text": "^0.48.0", + "@lexical/react": "^0.48.0", + "@lexical/rich-text": "^0.48.0", + "@lexical/selection": "^0.48.0", + "@lexical/utils": "^0.48.0", "@mdxeditor/gurx": "^1.2.4", "@radix-ui/colors": "^3.0.0", "@radix-ui/react-dialog": "^1.1.11", @@ -68,7 +78,7 @@ "codemirror": "^6.0.1", "downshift": "^7.6.0", "js-yaml": "4.2.0", - "lexical": "^0.35.0", + "lexical": "^0.48.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-frontmatter": "^2.0.1", @@ -106,6 +116,7 @@ "@babel/preset-typescript": "^7.23.3", "@ladle/react": "^5.1.1", "@ngneat/falso": "^6.4.0", + "@playwright/test": "1.61.1", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.1.6", "@testing-library/react": "^16.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..dfcac02b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,47 @@ +import { defineConfig, devices } from '@playwright/test' + +const isCI = Boolean(process.env.CI) +const baseURL = 'http://127.0.0.1:61000' + +export default defineConfig({ + testDir: './tests/browser', + fullyParallel: false, + forbidOnly: isCI, + retries: 0, + workers: isCI ? 1 : undefined, + reporter: isCI + ? [['line'], ['html', { open: 'never', outputFolder: 'playwright-report' }]] + : [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + }, + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] } + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] } + } + ], + webServer: { + command: 'npm run test:browser:serve', + url: `${baseURL}/?story=lexical-compatibility--compatibility&mode=preview`, + reuseExistingServer: !isCI, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + gracefulShutdown: { + signal: 'SIGTERM', + timeout: 5_000 + } + } +}) diff --git a/scripts/assert-lexical-versions.mjs b/scripts/assert-lexical-versions.mjs new file mode 100644 index 00000000..688ec49e --- /dev/null +++ b/scripts/assert-lexical-versions.mjs @@ -0,0 +1,71 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +const expectedVersion = '0.48.0' +const expectedRange = '^0.48.0' +const expectedDirectPackages = [ + '@lexical/clipboard', + '@lexical/extension', + '@lexical/history', + '@lexical/link', + '@lexical/list', + '@lexical/markdown', + '@lexical/plain-text', + '@lexical/react', + '@lexical/rich-text', + '@lexical/selection', + '@lexical/utils', + 'lexical' +] + +const packageJson = JSON.parse(await readFile('package.json', 'utf8')) +const packageLock = JSON.parse(await readFile('package-lock.json', 'utf8')) +const directLexicalPackages = Object.entries(packageJson.dependencies ?? {}) + .filter(([name]) => name === 'lexical' || name.startsWith('@lexical/')) + .sort(([left], [right]) => left.localeCompare(right)) + +const failures = [] + +if (JSON.stringify(directLexicalPackages.map(([name]) => name)) !== JSON.stringify(expectedDirectPackages)) { + failures.push(`direct Lexical declarations differ: ${directLexicalPackages.map(([name]) => name).join(', ')}`) +} + +for (const [name, range] of directLexicalPackages) { + if (range !== expectedRange) { + failures.push(`${name} declares ${range}; expected ${expectedRange}`) + } +} + +const lexicalPackagePath = /(?:^|\/)node_modules\/(lexical|@lexical\/[^/]+)$/ +const installedPackages = [] + +for (const [packagePath, lockEntry] of Object.entries(packageLock.packages ?? {})) { + const match = packagePath.match(lexicalPackagePath) + if (!match) continue + + const name = match[1] + installedPackages.push(`${name}@${lockEntry.version}`) + if (lockEntry.version !== expectedVersion) { + failures.push(`${packagePath} locks ${lockEntry.version}; expected ${expectedVersion}`) + } + + try { + const installedManifest = JSON.parse(await readFile(path.join(packagePath, 'package.json'), 'utf8')) + if (installedManifest.version !== expectedVersion) { + failures.push(`${packagePath} installs ${installedManifest.version}; expected ${expectedVersion}`) + } + } catch (error) { + failures.push(`${packagePath} is missing from node_modules (${error.message})`) + } +} + +if (installedPackages.length === 0) { + failures.push('no installed Lexical packages were found in package-lock.json') +} + +if (failures.length > 0) { + console.error(failures.join('\n')) + process.exitCode = 1 +} else { + console.log(`Verified ${installedPackages.length} installed Lexical packages at ${expectedVersion}.`) +} diff --git a/scripts/package-consumer-utils.mjs b/scripts/package-consumer-utils.mjs new file mode 100644 index 00000000..84321320 --- /dev/null +++ b/scripts/package-consumer-utils.mjs @@ -0,0 +1,223 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { cp, mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +export const repoRoot = process.cwd() +export const compatibilityInputPath = path.join(repoRoot, 'src/test/fixtures/lexicalCompatibility.input.md') +export const compatibilityExpected035Path = path.join(repoRoot, 'src/test/fixtures/lexicalCompatibility.035.md') +const activeProcesses = new Set() + +export function phase(message) { + console.log(`[package-gate] ${message}`) +} + +export async function run(command, args, options = {}) { + const child = spawn(command, args, { + cwd: options.cwd ?? repoRoot, + env: { ...process.env, ...options.env }, + detached: process.platform !== 'win32', + stdio: 'inherit' + }) + activeProcesses.add(child) + try { + const [code, signal] = await once(child, 'exit') + if (code !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with ${signal ?? `exit ${code}`}`) + } + } finally { + activeProcesses.delete(child) + } +} + +export async function createScratchRoot(prefix) { + return mkdtemp(path.join(os.tmpdir(), prefix)) +} + +export async function packCurrentPackage(scratchRoot, cacheRoot) { + phase('packing the built repository artifact') + await run('npm', ['pack', '--ignore-scripts', '--cache', cacheRoot, '--pack-destination', scratchRoot]) + const tarballs = (await readdir(scratchRoot)).filter((entry) => entry.endsWith('.tgz')) + if (tarballs.length !== 1) throw new Error(`Expected one package tarball, found ${tarballs.length}`) + return path.join(scratchRoot, tarballs[0]) +} + +export async function createConsumerApp({ scratchRoot, name, manifest, editorDependency, markdown, overrides }) { + const appRoot = path.join(scratchRoot, name) + await cp(path.join(repoRoot, 'tests/package-consumer/shared'), appRoot, { recursive: true }) + const packageJson = JSON.parse(await readFile(manifest, 'utf8')) + packageJson.dependencies['@mdxeditor/editor'] = editorDependency + if (overrides) packageJson.overrides = overrides + await writeFile(path.join(appRoot, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`) + await mkdir(path.join(appRoot, 'src'), { recursive: true }) + await writeFile(path.join(appRoot, 'src/compatibility.md'), markdown) + return appRoot +} + +export async function installAndBuildConsumer(appRoot, cacheRoot, label) { + phase(`${label}: installing with isolated npm cache`) + await run('npm', ['install', '--cache', cacheRoot, '--no-audit', '--no-fund'], { cwd: appRoot }) + phase(`${label}: typechecking`) + await run('npm', ['run', 'typecheck'], { cwd: appRoot }) + phase(`${label}: bundling with Vite`) + await run('npm', ['run', 'build'], { cwd: appRoot }) +} + +async function allocatePort() { + const server = net.createServer() + server.unref() + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + const address = server.address() + if (typeof address !== 'object' || address === null) throw new Error('Failed to allocate a preview port') + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + return address.port +} + +async function waitForPreview(url, child) { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Preview exited before becoming ready (${child.exitCode})`) + try { + const response = await fetch(url) + if (response.ok) return + } catch { + // The preview is still starting. + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + throw new Error(`Timed out waiting for ${url}`) +} + +async function stopProcessGroup(child) { + if (child.exitCode !== null || child.signalCode !== null) return + try { + if (process.platform === 'win32') child.kill('SIGTERM') + else process.kill(-child.pid, 'SIGTERM') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } + await Promise.race([once(child, 'exit'), new Promise((resolve) => setTimeout(resolve, 5_000))]) + if (child.exitCode === null && child.signalCode === null) { + try { + if (process.platform === 'win32') child.kill('SIGKILL') + else process.kill(-child.pid, 'SIGKILL') + } catch (error) { + if (error.code !== 'ESRCH') throw error + } + } +} + +export function installInterruptCleanup(cleanup) { + let handlingSignal = false + const handlers = new Map() + for (const [signal, exitCode] of [ + ['SIGINT', 130], + ['SIGTERM', 143] + ]) { + const handler = () => { + if (handlingSignal) return + handlingSignal = true + void Promise.all([...activeProcesses].map(stopProcessGroup)) + .then(cleanup) + .finally(() => process.exit(exitCode)) + } + handlers.set(signal, handler) + process.once(signal, handler) + } + return () => { + for (const [signal, handler] of handlers) process.removeListener(signal, handler) + } +} + +export async function captureConsumerMarkdown(appRoot, browser, label) { + const port = await allocatePort() + const url = `http://127.0.0.1:${port}` + phase(`${label}: serving packed consumer on ${url}`) + const preview = spawn('npm', ['run', 'preview', '--', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: appRoot, + env: process.env, + detached: process.platform !== 'win32', + stdio: ['ignore', 'pipe', 'pipe'] + }) + activeProcesses.add(preview) + preview.stdout.pipe(process.stdout) + preview.stderr.pipe(process.stderr) + + let page + try { + await waitForPreview(url, preview) + page = await browser.newPage() + const runtimeErrors = [] + page.on('pageerror', (error) => runtimeErrors.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') runtimeErrors.push(`console.error: ${message.text()}`) + }) + await page.goto(url) + await page.getByRole('heading', { name: 'Package consumer ready' }).waitFor() + await page.getByLabel('Package realm plugin').waitFor() + const realmPluginMarker = (await page.getByLabel('Package realm plugin').textContent()) ?? '' + if (realmPluginMarker !== 'ready') throw new Error(`${label} custom realm plugin did not initialize`) + const layoutRefReady = (await page.getByLabel('Package layout ref ready').textContent()) ?? '' + if (layoutRefReady !== 'true') throw new Error(`${label} public ref was unavailable in the parent layout effect`) + const effectRefReady = (await page.getByLabel('Package effect ref ready').textContent()) ?? '' + if (effectRefReady !== 'true') throw new Error(`${label} public ref was unavailable in the parent mount effect`) + await page.getByRole('button', { name: 'Read package Markdown' }).click() + await page.waitForFunction(() => document.querySelector('[aria-label="Package Markdown"]')?.textContent !== '') + const error = (await page.getByLabel('Package Error').textContent()) ?? '' + if (error !== '') throw new Error(`${label} reported editor error: ${error}`) + if (runtimeErrors.length > 0) throw new Error(`${label} runtime errors:\n${runtimeErrors.join('\n')}`) + return ((await page.getByLabel('Package Markdown').textContent()) ?? '').replaceAll('\r\n', '\n') + } finally { + await page?.close() + await stopProcessGroup(preview) + activeProcesses.delete(preview) + } +} + +export async function assertLexicalGraph(appRoot, expectedVersion, expectedNames) { + const lock = JSON.parse(await readFile(path.join(appRoot, 'package-lock.json'), 'utf8')) + const lexicalPath = /(?:^|\/)node_modules\/(lexical|@lexical\/[^/]+)$/ + const installedNames = [] + const failures = [] + for (const [packagePath, entry] of Object.entries(lock.packages ?? {})) { + const match = packagePath.match(lexicalPath) + if (!match) continue + installedNames.push(match[1]) + if (entry.version !== expectedVersion) failures.push(`${match[1]}@${entry.version}`) + const manifest = JSON.parse(await readFile(path.join(appRoot, packagePath, 'package.json'), 'utf8')) + if (manifest.version !== expectedVersion) failures.push(`${match[1]} installed as ${manifest.version}`) + } + const uniqueNames = [...new Set(installedNames)].sort() + if (expectedNames && JSON.stringify(uniqueNames) !== JSON.stringify([...expectedNames].sort())) { + failures.push(`package set differs: ${uniqueNames.join(', ')}`) + } + if (uniqueNames.length === 0 || failures.length > 0) { + throw new Error(`Lexical ${expectedVersion} graph assertion failed: ${failures.join('; ') || 'empty graph'}`) + } + phase(`verified ${uniqueNames.length} Lexical packages at ${expectedVersion}`) +} + +export function assertConstructAnchors(markdown, label) { + for (const anchor of [ + '# Compatibility heading', + '[safe link](https://example.com/safe)', + '* Bullet alpha', + '| Table | Stable |', + '***', + '```ts compatibility-meta', + ':::note', + '', + 'Nested compatibility content.' + ]) { + if (!markdown.includes(anchor)) throw new Error(`${label} lost construct anchor: ${anchor}`) + } +} + +export async function cleanupScratchRoot(scratchRoot) { + await rm(scratchRoot, { recursive: true, force: true }) +} diff --git a/scripts/verify-cross-version-markdown.mjs b/scripts/verify-cross-version-markdown.mjs new file mode 100644 index 00000000..ac34389a --- /dev/null +++ b/scripts/verify-cross-version-markdown.mjs @@ -0,0 +1,96 @@ +import path from 'node:path' +import { mkdir, readFile } from 'node:fs/promises' +import { chromium } from '@playwright/test' +import { + assertConstructAnchors, + assertLexicalGraph, + captureConsumerMarkdown, + cleanupScratchRoot, + compatibilityExpected035Path, + compatibilityInputPath, + createConsumerApp, + createScratchRoot, + installAndBuildConsumer, + installInterruptCleanup, + packCurrentPackage, + phase, + repoRoot +} from './package-consumer-utils.mjs' + +const lexical035Packages = [ + '@lexical/clipboard', + '@lexical/code', + '@lexical/devtools-core', + '@lexical/dragon', + '@lexical/hashtag', + '@lexical/history', + '@lexical/html', + '@lexical/link', + '@lexical/list', + '@lexical/mark', + '@lexical/markdown', + '@lexical/offset', + '@lexical/overflow', + '@lexical/plain-text', + '@lexical/react', + '@lexical/rich-text', + '@lexical/selection', + '@lexical/table', + '@lexical/text', + '@lexical/utils', + '@lexical/yjs', + 'lexical' +] +const lexical035Overrides = Object.fromEntries(lexical035Packages.map((name) => [name, '0.35.0'])) + +const scratchRoot = await createScratchRoot('mdxeditor-cross-version-') +let browser +const removeInterruptCleanup = installInterruptCleanup(async () => { + await browser?.close() + await cleanupScratchRoot(scratchRoot) +}) +try { + const cacheRoot = path.join(scratchRoot, 'npm-cache') + await mkdir(cacheRoot, { recursive: true }) + const tarball = await packCurrentPackage(scratchRoot, cacheRoot) + const input = await readFile(compatibilityInputPath, 'utf8') + const expected035 = (await readFile(compatibilityExpected035Path, 'utf8')).trimEnd() + const react18Manifest = path.join(repoRoot, 'tests/package-consumer/react-18/package.json') + browser = await chromium.launch() + + const currentApp = await createConsumerApp({ + scratchRoot, + name: 'current-048-writer', + manifest: react18Manifest, + editorDependency: `file:${tarball}`, + markdown: input + }) + await installAndBuildConsumer(currentApp, cacheRoot, '0.48 writer') + await assertLexicalGraph(currentApp, '0.48.0') + const currentOutput = (await captureConsumerMarkdown(currentApp, browser, '0.48 writer')).trimEnd() + assertConstructAnchors(currentOutput, '0.48 writer') + if (currentOutput !== expected035) { + throw new Error( + '0.48 canonical output differs from the checked-in 0.35 expectation; classify and check in an explicit 0.48 expectation' + ) + } + + const legacyApp = await createConsumerApp({ + scratchRoot, + name: 'legacy-035-reader', + manifest: react18Manifest, + editorDependency: '4.0.4', + markdown: currentOutput, + overrides: lexical035Overrides + }) + await installAndBuildConsumer(legacyApp, cacheRoot, '4.0.4 / Lexical 0.35 reader') + await assertLexicalGraph(legacyApp, '0.35.0', lexical035Packages) + const legacyOutput = (await captureConsumerMarkdown(legacyApp, browser, '4.0.4 / Lexical 0.35 reader')).trimEnd() + assertConstructAnchors(legacyOutput, 'legacy replay') + if (legacyOutput !== expected035) throw new Error('Legacy replay differs from the checked-in 0.35 canonical expectation') + phase('0.35 input -> packed 0.48 output -> published 4.0.4/0.35 replay passed') +} finally { + removeInterruptCleanup() + await browser?.close() + await cleanupScratchRoot(scratchRoot) +} diff --git a/scripts/verify-package-consumer.mjs b/scripts/verify-package-consumer.mjs new file mode 100644 index 00000000..e04f15b9 --- /dev/null +++ b/scripts/verify-package-consumer.mjs @@ -0,0 +1,60 @@ +import path from 'node:path' +import { chromium } from '@playwright/test' +import { + assertConstructAnchors, + assertLexicalGraph, + captureConsumerMarkdown, + cleanupScratchRoot, + compatibilityExpected035Path, + compatibilityInputPath, + createConsumerApp, + createScratchRoot, + installAndBuildConsumer, + installInterruptCleanup, + packCurrentPackage, + phase, + repoRoot +} from './package-consumer-utils.mjs' +import { mkdir, readFile } from 'node:fs/promises' + +const requestedReact = process.argv.find((argument) => argument.startsWith('--react='))?.split('=')[1] +const majors = requestedReact ? [requestedReact] : ['18', '19'] +if (majors.some((major) => major !== '18' && major !== '19')) { + throw new Error('--react must be 18 or 19') +} + +const scratchRoot = await createScratchRoot('mdxeditor-package-consumer-') +let browser +const removeInterruptCleanup = installInterruptCleanup(async () => { + await browser?.close() + await cleanupScratchRoot(scratchRoot) +}) +try { + const cacheRoot = path.join(scratchRoot, 'npm-cache') + await mkdir(cacheRoot, { recursive: true }) + const tarball = await packCurrentPackage(scratchRoot, cacheRoot) + const input = await readFile(compatibilityInputPath, 'utf8') + const expected = (await readFile(compatibilityExpected035Path, 'utf8')).trimEnd() + browser = await chromium.launch() + + for (const major of majors) { + const label = `React ${major}` + const appRoot = await createConsumerApp({ + scratchRoot, + name: `react-${major}`, + manifest: path.join(repoRoot, `tests/package-consumer/react-${major}/package.json`), + editorDependency: `file:${tarball}`, + markdown: input + }) + await installAndBuildConsumer(appRoot, cacheRoot, label) + await assertLexicalGraph(appRoot, '0.48.0') + const output = (await captureConsumerMarkdown(appRoot, browser, label)).trimEnd() + if (output !== expected) throw new Error(`${label} public ref output differs from the checked-in 0.35 expectation`) + assertConstructAnchors(output, label) + phase(`${label}: packed declarations, bundle, styles, runtime, and ref output passed`) + } +} finally { + removeInterruptCleanup() + await browser?.close() + await cleanupScratchRoot(scratchRoot) +} diff --git a/src/examples/lexical-compatibility.tsx b/src/examples/lexical-compatibility.tsx new file mode 100644 index 00000000..941261eb --- /dev/null +++ b/src/examples/lexical-compatibility.tsx @@ -0,0 +1,4 @@ +import React from 'react' +import { LexicalCompatibilityHarness } from '../test/fixtures/LexicalCompatibilityHarness' + +export const Compatibility = () => diff --git a/src/mdastUtilHtmlComment.ts b/src/mdastUtilHtmlComment.ts index 555c59ba..87684d05 100644 --- a/src/mdastUtilHtmlComment.ts +++ b/src/mdastUtilHtmlComment.ts @@ -3,10 +3,15 @@ import type { Handle, Transform } from 'mdast-util-from-markdown' import { factorySpace } from 'micromark-factory-space' import { markdownLineEnding } from 'micromark-util-character' -import { codes, types } from 'micromark-util-symbol' import type { Code, Extension, Tokenizer } from 'micromark-util-types' +// These are the only micromark symbols used by the tokenizer. Keeping their +// literal values typed locally avoids losing their declarations when tools +// resolve micromark-util-symbol's declaration-only default export. +const codes = { dash: 45, eof: null, exclamationMark: 33, greaterThan: 62 } as const +const types = { data: 'data', lineEnding: 'lineEnding', linePrefix: 'linePrefix' } as const + declare module 'micromark-util-types' { interface TokenTypeMap { comment: 'comment' diff --git a/src/plugins/codeblock/CodeBlockNode.tsx b/src/plugins/codeblock/CodeBlockNode.tsx index e4b73201..fa0b8f81 100644 --- a/src/plugins/codeblock/CodeBlockNode.tsx +++ b/src/plugins/codeblock/CodeBlockNode.tsx @@ -68,8 +68,10 @@ export class CodeBlockNode extends DecoratorNode { this.__focusEmitter = voidEmitter() } - static importJSON(serializedNode: SerializedCodeBlockNode): CodeBlockNode { - const { code, meta, language } = serializedNode + static importJSON(serializedNode: SerializedLexicalNode & Record): CodeBlockNode { + const code = typeof serializedNode.code === 'string' ? serializedNode.code : '' + const language = typeof serializedNode.language === 'string' ? serializedNode.language : '' + const meta = typeof serializedNode.meta === 'string' ? serializedNode.meta : '' return $createCodeBlockNode({ code, language, diff --git a/src/plugins/link-dialog/LinkDialog.tsx b/src/plugins/link-dialog/LinkDialog.tsx index 9959c7c3..c1203007 100644 --- a/src/plugins/link-dialog/LinkDialog.tsx +++ b/src/plugins/link-dialog/LinkDialog.tsx @@ -231,7 +231,8 @@ export const LinkDialog: React.FC = () => { <> { if (onClickLinkCallback !== null) { diff --git a/src/plugins/link-dialog/index.ts b/src/plugins/link-dialog/index.ts index 9ce38256..6a2dea8a 100644 --- a/src/plugins/link-dialog/index.ts +++ b/src/plugins/link-dialog/index.ts @@ -13,6 +13,7 @@ import { COMMAND_PRIORITY_LOW, KEY_DOWN_COMMAND, KEY_ESCAPE_COMMAND, + type LexicalEditor, type LexicalNode, type RangeSelection } from 'lexical' @@ -47,6 +48,8 @@ export interface PreviewLinkDialog { type: 'preview' title: string url: string + /** The fail-closed URL used only for automatic preview navigation. */ + href?: string linkNodeKey: string rectangle: RectData } @@ -83,6 +86,18 @@ function getLinkNodeInSelection(selection: RangeSelection | null) { return null } +function getPreviewHref(editor: LexicalEditor | null, linkNodeKey: string, url: string): string { + if (!editor) return 'about:blank' + + return editor.getEditorState().read(() => { + const keyedNode = linkNodeKey === '' ? null : $getNodeByKey(linkNodeKey) + const selection = $getSelection() + const selectedNode = $isRangeSelection(selection) ? getLinkNodeInSelection(selection) : null + const linkNode = $isLinkNode(keyedNode) ? keyedNode : selectedNode + return linkNode?.sanitizeUrl(url) ?? 'about:blank' + }) +} + /** * Emits when the window is resized. * @group Utils @@ -209,7 +224,8 @@ export const linkDialogState$ = Cell = { - testLexicalNode: $isHorizontalRuleNode, + testLexicalNode: (node): node is HorizontalRuleNode => node instanceof HorizontalRuleNode, visitLexicalNode({ actions }) { actions.addAndStepInto('thematicBreak') } diff --git a/src/plugins/thematic-break/MdastThematicBreakVisitor.ts b/src/plugins/thematic-break/MdastThematicBreakVisitor.ts index b1a36d51..33ef0b20 100644 --- a/src/plugins/thematic-break/MdastThematicBreakVisitor.ts +++ b/src/plugins/thematic-break/MdastThematicBreakVisitor.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- R2 intentionally retains the legacy React horizontal-rule path until R5. */ import { $createHorizontalRuleNode } from '@lexical/react/LexicalHorizontalRuleNode.js' import * as Mdast from 'mdast' import { MdastImportVisitor } from '../../importMarkdownToLexical' diff --git a/src/plugins/thematic-break/index.ts b/src/plugins/thematic-break/index.ts index 038c1872..8eb419cc 100644 --- a/src/plugins/thematic-break/index.ts +++ b/src/plugins/thematic-break/index.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-deprecated -- R2 intentionally retains the legacy React horizontal-rule path until R5. */ import { realmPlugin } from '../../RealmWithPlugins' import { HorizontalRuleNode, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode.js' import { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin.js' diff --git a/src/test/compatibility.test.tsx b/src/test/compatibility.test.tsx new file mode 100644 index 00000000..7074763b --- /dev/null +++ b/src/test/compatibility.test.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import React from 'react' +import { describe, expect, it } from 'vitest' +import { LexicalCompatibilityHarness } from './fixtures/LexicalCompatibilityHarness' +import { alternateCompatibilityMarkdown, compatibilityMarkdown035, maxLengthInitialMarkdown } from './fixtures/lexicalCompatibility' + +describe('Lexical 0.35 compatibility contract', () => { + it('round-trips the representative Markdown fixture through public methods', async () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Get Markdown' })) + expect(screen.getByLabelText('Current Markdown').textContent).toBe(compatibilityMarkdown035) + + fireEvent.click(screen.getByRole('button', { name: 'Set Alternate' })) + expect( + await screen.findByRole('heading', { + name: 'Alternate compatibility document' + }) + ).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Get Markdown' })) + await waitFor(() => { + expect(screen.getByLabelText('Current Markdown').textContent).toBe(alternateCompatibilityMarkdown.trimEnd()) + }) + + fireEvent.click(screen.getByRole('button', { name: 'Reset Compatibility Markdown' })) + expect(await screen.findByRole('heading', { name: 'Compatibility heading' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Get Markdown' })) + await waitFor(() => { + expect(screen.getByLabelText('Current Markdown').textContent).toBe(compatibilityMarkdown035) + }) + }) + + it('renders the supported custom constructs without private editor access', async () => { + render() + + expect(screen.getByLabelText('Compatibility Error')).toBeEmptyDOMElement() + expect(await screen.findByRole('heading', { name: 'Compatibility heading' })).toBeInTheDocument() + expect(await screen.findByText('Admonition compatibility content.')).toBeInTheDocument() + expect(screen.getByTestId('compatibility-nested-editor')).toHaveTextContent('Nested compatibility content.') + expect(screen.getByTestId('compatibility-code-block')).toHaveValue('const compatible = true') + expect(screen.getByRole('table')).toHaveTextContent('TableStable') + }) + + it('keeps collapsed selection empty and exposes the capped editor baseline', () => { + render() + + const rootEditor = screen.getByTestId('compatibility-root-editor') + const contentEditable = rootEditor.querySelector('[contenteditable="true"]') + expect(contentEditable).not.toBeNull() + contentEditable?.focus() + + fireEvent.click(screen.getByRole('button', { name: 'Get Selection Markdown' })) + expect(screen.getByLabelText('Selection Markdown')).toBeEmptyDOMElement() + + const cappedEditor = within(screen.getByTestId('compatibility-max-length-editor')) + expect(cappedEditor.getByText(maxLengthInitialMarkdown)).toBeInTheDocument() + }) +}) diff --git a/src/test/core.test.tsx b/src/test/core.test.tsx index 528e892e..a81120b6 100644 --- a/src/test/core.test.tsx +++ b/src/test/core.test.tsx @@ -1,6 +1,6 @@ import React from 'react' import { describe, expect, it, test, vi } from 'vitest' -import { codeBlockPlugin, codeMirrorPlugin, MDXEditor, MDXEditorMethods } from '../' +import { codeBlockPlugin, codeMirrorPlugin, MDXEditor, MDXEditorMethods, thematicBreakPlugin } from '../' import { render } from '@testing-library/react' import { $getRoot, createEditor, ParagraphNode, TextNode } from 'lexical' import { QuoteNode } from '@lexical/rich-text' @@ -186,6 +186,45 @@ After fence. expect(html).toContain('After fence.') expect(ref.current?.getMarkdown().trim()).toEqual(markdown) }) + + it('imports valid and malformed code block JSON without leaking invalid custom fields', () => { + const editor = createEditor({ + namespace: 'code-block-json-test', + nodes: [CodeBlockNode], + onError(error) { + throw error + } + }) + + editor.update( + () => { + const serializedNodes = [ + CodeBlockNode.importJSON({ + type: 'codeblock', + version: 1, + code: 'const valid = true', + language: 'ts', + meta: 'live' + }), + CodeBlockNode.importJSON({ type: 'codeblock', version: 1 }), + CodeBlockNode.importJSON({ type: 'codeblock', version: 1, code: 42, language: null, meta: false }) + ] + expect(serializedNodes.map((node) => node.exportJSON())).toEqual([ + { type: 'codeblock', version: 1, code: 'const valid = true', language: 'ts', meta: 'live' }, + { type: 'codeblock', version: 1, code: '', language: '', meta: '' }, + { type: 'codeblock', version: 1, code: '', language: '', meta: '' } + ]) + }, + { discrete: true } + ) + }) + + it('round-trips thematic breaks through the retained React node path', () => { + const ref = React.createRef() + render() + + expect(ref.current?.getMarkdown().trim()).toEqual('Before\n\n***\n\nAfter') + }) }) describe('List parsing and serialization', () => { diff --git a/src/test/fixtures/LexicalCompatibilityHarness.tsx b/src/test/fixtures/LexicalCompatibilityHarness.tsx new file mode 100644 index 00000000..c166f55e --- /dev/null +++ b/src/test/fixtures/LexicalCompatibilityHarness.tsx @@ -0,0 +1,203 @@ +import type { MdxJsxFlowElement } from 'mdast-util-mdx' +import React from 'react' +import { + AdmonitionDirectiveDescriptor, + type CodeBlockEditorDescriptor, + type CodeBlockEditorProps, + CreateLink, + JsxComponentDescriptor, + ListsToggle, + MDXEditor, + MDXEditorMethods, + NestedLexicalEditor, + UndoRedo, + codeBlockPlugin, + directivesPlugin, + frontmatterPlugin, + headingsPlugin, + jsxPlugin, + linkDialogPlugin, + linkPlugin, + listsPlugin, + markdownShortcutPlugin, + maxLengthPlugin, + quotePlugin, + tablePlugin, + thematicBreakPlugin, + toolbarPlugin, + useCodeBlockEditorContext +} from '../..' +import { + alternateCompatibilityMarkdown, + compatibilityMarkdown, + compatibilityMarkdown035, + maxLengthInitialMarkdown +} from './lexicalCompatibility' + +const GridEditor = () => ( +
+ + block + getContent={(node) => node.children} + getUpdatedMdastNode={(node, children) => ({ ...node, children: children as MdxJsxFlowElement['children'] })} + /> +
+) + +const jsxComponentDescriptors: JsxComponentDescriptor[] = [ + { + name: 'Grid', + kind: 'flow', + props: [], + hasChildren: true, + Editor: GridEditor + } +] + +const CompatibilityCodeBlockEditor: React.FC = ({ code }) => { + const codeBlockEditor = useCodeBlockEditorContext() + + return ( +