From ecaf1f6223fdea049ba7539fcb5737231a183f0d Mon Sep 17 00:00:00 2001 From: Gerard Rovira Date: Wed, 8 Jul 2026 19:08:20 -0400 Subject: [PATCH 1/2] Make dependency-check resilient to transient registry errors (#8818) --- .../dependency-check/check.mjs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/actions/internal-registry/dependency-check/check.mjs b/.github/actions/internal-registry/dependency-check/check.mjs index 701fd68b6e3..82c1de63c39 100644 --- a/.github/actions/internal-registry/dependency-check/check.mjs +++ b/.github/actions/internal-registry/dependency-check/check.mjs @@ -18,7 +18,13 @@ const REGISTRY = ( process.env.REGISTRY_URL ?? 'https://registry.facebook.net' ).replace(/\/$/, ''); const LOCKFILE = process.env.LOCKFILE ?? 'pnpm-lock.yaml'; -const CONCURRENCY = Number(process.env.CHECK_CONCURRENCY ?? 20); +const CONCURRENCY = Number(process.env.CHECK_CONCURRENCY ?? 50); +// Total attempts per request (1 initial + retries) and backoff bounds. The +// registry can return sporadic transient errors under load; retrying with +// backoff keeps a healthy run from failing on a momentary hiccup. +const MAX_ATTEMPTS = Number(process.env.CHECK_MAX_ATTEMPTS ?? 4); +const BASE_DELAY_MS = Number(process.env.CHECK_RETRY_BASE_MS ?? 500); +const MAX_DELAY_MS = Number(process.env.CHECK_RETRY_MAX_MS ?? 10_000); const TOKEN = process.env.REGISTRY_TOKEN; if (!TOKEN) { @@ -148,18 +154,38 @@ function parseYarn(text) { return byName; } +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +// Wait before the next attempt: honor a server-provided Retry-After when +// present, otherwise exponential backoff with full jitter (a random delay in +// [0, cap]) so parallel workers don't retry in lockstep. +function backoff(attempt, res) { + const header = res && res.headers ? res.headers.get('retry-after') : null; + const retryAfter = Number(header); + const cap = + Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : Math.min(BASE_DELAY_MS * 2 ** attempt, MAX_DELAY_MS); + return sleep(Math.random() * cap); +} + +// Fetch a packument, retrying transient failures (5xx and network/connection +// errors) with backoff. Definitive outcomes (2xx/4xx) return immediately so a +// genuinely missing package still fails fast. async function fetchPackument(name, attempt = 0) { const url = `${REGISTRY}/${name.replace(/\//g, '%2F')}`; try { const res = await fetch(url, { headers: {Accept: 'application/json', Authorization: `Bearer ${TOKEN}`}, }); - if (res.status >= 500 && attempt < 1) { + if (res.status >= 500 && attempt < MAX_ATTEMPTS - 1) { + await backoff(attempt, res); return fetchPackument(name, attempt + 1); } return res; } catch (e) { - if (attempt < 1) { + if (attempt < MAX_ATTEMPTS - 1) { + await backoff(attempt); return fetchPackument(name, attempt + 1); } throw e; From 294dfb567947345f0bc39eac5add36cdd711b4db Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Wed, 8 Jul 2026 16:10:06 -0700 Subject: [PATCH 2/2] [lexical-mdast][lexical-rich-text] Feature: @lexical/mdast, a micromark/mdast-based alternative to @lexical/markdown (#8794) Co-authored-by: Claude --- .flowconfig | 1 + .gitignore | 2 + dev-examples/mdast-editor/README.md | 93 ++ dev-examples/mdast-editor/index.html | 12 + dev-examples/mdast-editor/package.json | 44 + .../mdast-editor/size/legacy-entry.ts | 48 ++ dev-examples/mdast-editor/size/mdast-entry.ts | 53 ++ .../mdast-editor/size/mdast-import-entry.ts | 45 + dev-examples/mdast-editor/size/measure.mjs | 87 ++ .../mdast-editor/size/vite.size.config.ts | 30 + dev-examples/mdast-editor/src/Editor.tsx | 158 ++++ dev-examples/mdast-editor/src/ThemeToggle.tsx | 56 ++ .../MarkdownPersistenceExtension.ts | 102 +++ .../src/extensions/MdastEditorExtension.ts | 131 +++ .../src/extensions/ToolbarStateExtension.ts | 128 +++ dev-examples/mdast-editor/src/main.tsx | 25 + .../src/plugins/MarkdownSourcePlugin.tsx | 57 ++ .../src/plugins/ToolbarPlugin.tsx | 148 ++++ dev-examples/mdast-editor/src/styles.css | 2 + dev-examples/mdast-editor/src/vite-env.d.ts | 9 + dev-examples/mdast-editor/tsconfig.json | 26 + dev-examples/mdast-editor/tsconfig.node.json | 11 + dev-examples/mdast-editor/vite.config.ts | 16 + eslint.config.mjs | 2 + package.json | 2 + packages/lexical-devtools/tsconfig.json | 1 + packages/lexical-mdast/LICENSE | 21 + packages/lexical-mdast/LexicalMdast.js | 11 + packages/lexical-mdast/README.md | 208 +++++ .../lexical-mdast/flow/LexicalMdast.js.flow | 228 +++++ packages/lexical-mdast/package.json | 95 ++ packages/lexical-mdast/src/MdastExport.ts | 545 ++++++++++++ .../lexical-mdast/src/MdastExportExtension.ts | 122 +++ packages/lexical-mdast/src/MdastExtension.ts | 30 + .../lexical-mdast/src/MdastGfmExtension.ts | 38 + packages/lexical-mdast/src/MdastImport.ts | 236 +++++ .../lexical-mdast/src/MdastImportExtension.ts | 635 ++++++++++++++ packages/lexical-mdast/src/MdastShortcuts.ts | 453 ++++++++++ packages/lexical-mdast/src/MdastStream.ts | 187 ++++ .../lexical-mdast/src/MdastTableExtension.ts | 144 ++++ .../src/__tests__/unit/MdastExtension.test.ts | 166 ++++ .../__tests__/unit/MdastImportExport.test.ts | 814 ++++++++++++++++++ .../src/__tests__/unit/MdastShortcuts.test.ts | 362 ++++++++ packages/lexical-mdast/src/compile.ts | 44 + packages/lexical-mdast/src/handlers.ts | 648 ++++++++++++++ packages/lexical-mdast/src/index.ts | 69 ++ packages/lexical-mdast/src/state.ts | 124 +++ packages/lexical-mdast/src/types.ts | 197 +++++ .../flow/LexicalRichText.js.flow | 12 +- .../src/RichTextImportExtension.ts | 33 +- .../unit/QuoteNodeShadowRoot.test.ts | 116 +++ .../unit/RichTextImportExtension.test.ts | 59 ++ packages/lexical-rich-text/src/index.ts | 86 +- .../docs/extensions/included-extensions.md | 23 + .../docs/serialization/markdown-mdast.md | 154 ++++ packages/lexical-website/package.json | 2 +- packages/lexical-website/sidebars.js | 1 + packages/lexical-website/tsconfig.json | 1 + pnpm-lock.yaml | 331 +++++++ scripts/build-dev-examples.mjs | 89 ++ scripts/build.mjs | 25 +- tsconfig.json | 3 + tsconfig.test.json | 3 + 63 files changed, 7595 insertions(+), 9 deletions(-) create mode 100644 dev-examples/mdast-editor/README.md create mode 100644 dev-examples/mdast-editor/index.html create mode 100644 dev-examples/mdast-editor/package.json create mode 100644 dev-examples/mdast-editor/size/legacy-entry.ts create mode 100644 dev-examples/mdast-editor/size/mdast-entry.ts create mode 100644 dev-examples/mdast-editor/size/mdast-import-entry.ts create mode 100644 dev-examples/mdast-editor/size/measure.mjs create mode 100644 dev-examples/mdast-editor/size/vite.size.config.ts create mode 100644 dev-examples/mdast-editor/src/Editor.tsx create mode 100644 dev-examples/mdast-editor/src/ThemeToggle.tsx create mode 100644 dev-examples/mdast-editor/src/extensions/MarkdownPersistenceExtension.ts create mode 100644 dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts create mode 100644 dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts create mode 100644 dev-examples/mdast-editor/src/main.tsx create mode 100644 dev-examples/mdast-editor/src/plugins/MarkdownSourcePlugin.tsx create mode 100644 dev-examples/mdast-editor/src/plugins/ToolbarPlugin.tsx create mode 100644 dev-examples/mdast-editor/src/styles.css create mode 100644 dev-examples/mdast-editor/src/vite-env.d.ts create mode 100644 dev-examples/mdast-editor/tsconfig.json create mode 100644 dev-examples/mdast-editor/tsconfig.node.json create mode 100644 dev-examples/mdast-editor/vite.config.ts create mode 100644 packages/lexical-mdast/LICENSE create mode 100644 packages/lexical-mdast/LexicalMdast.js create mode 100644 packages/lexical-mdast/README.md create mode 100644 packages/lexical-mdast/flow/LexicalMdast.js.flow create mode 100644 packages/lexical-mdast/package.json create mode 100644 packages/lexical-mdast/src/MdastExport.ts create mode 100644 packages/lexical-mdast/src/MdastExportExtension.ts create mode 100644 packages/lexical-mdast/src/MdastExtension.ts create mode 100644 packages/lexical-mdast/src/MdastGfmExtension.ts create mode 100644 packages/lexical-mdast/src/MdastImport.ts create mode 100644 packages/lexical-mdast/src/MdastImportExtension.ts create mode 100644 packages/lexical-mdast/src/MdastShortcuts.ts create mode 100644 packages/lexical-mdast/src/MdastStream.ts create mode 100644 packages/lexical-mdast/src/MdastTableExtension.ts create mode 100644 packages/lexical-mdast/src/__tests__/unit/MdastExtension.test.ts create mode 100644 packages/lexical-mdast/src/__tests__/unit/MdastImportExport.test.ts create mode 100644 packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts create mode 100644 packages/lexical-mdast/src/compile.ts create mode 100644 packages/lexical-mdast/src/handlers.ts create mode 100644 packages/lexical-mdast/src/index.ts create mode 100644 packages/lexical-mdast/src/state.ts create mode 100644 packages/lexical-mdast/src/types.ts create mode 100644 packages/lexical-rich-text/src/__tests__/unit/QuoteNodeShadowRoot.test.ts create mode 100644 packages/lexical-website/docs/serialization/markdown-mdast.md create mode 100644 scripts/build-dev-examples.mjs diff --git a/.flowconfig b/.flowconfig index 8d8f188b8e6..4dcf210f332 100644 --- a/.flowconfig +++ b/.flowconfig @@ -50,6 +50,7 @@ module.name_mapper='^@lexical/link$' -> '/packages/lexical-link/fl module.name_mapper='^@lexical/list$' -> '/packages/lexical-list/flow/LexicalList.js.flow' module.name_mapper='^@lexical/mark$' -> '/packages/lexical-mark/flow/LexicalMark.js.flow' module.name_mapper='^@lexical/markdown$' -> '/packages/lexical-markdown/flow/LexicalMarkdown.js.flow' +module.name_mapper='^@lexical/mdast$' -> '/packages/lexical-mdast/flow/LexicalMdast.js.flow' module.name_mapper='^@lexical/offset$' -> '/packages/lexical-offset/flow/LexicalOffset.js.flow' module.name_mapper='^@lexical/overflow$' -> '/packages/lexical-overflow/flow/LexicalOverflow.js.flow' module.name_mapper='^@lexical/plain-text$' -> '/packages/lexical-plain-text/flow/LexicalPlainText.js.flow' diff --git a/.gitignore b/.gitignore index fd21baa848c..e55acc306ab 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,5 @@ npm-debug.log* examples/*/pnpm-lock.yaml scripts/__tests__/integration/fixtures/*/pnpm-lock.yaml *.tsbuildinfo +dist-size/ +packages/lexical-website/static/dev-examples/ diff --git a/dev-examples/mdast-editor/README.md b/dev-examples/mdast-editor/README.md new file mode 100644 index 00000000000..4f7e58e061b --- /dev/null +++ b/dev-examples/mdast-editor/README.md @@ -0,0 +1,93 @@ +# Mdast Editor (dev example) + +A WYSIWYG Markdown editor with an editable Markdown source pane +(synchronized in both directions, exercising import and export), built on +the unreleased +[`@lexical/mdast`](../../packages/lexical-mdast) package — the +micromark/mdast-based alternative to `@lexical/markdown`. It is a port of +[`examples/markdown-editor`](../../examples/markdown-editor), which is the +same app built on the legacy bespoke implementation; comparing the two shows +what the extension-first design removes. + +```bash +pnpm run start:dev-example mdast-editor +``` + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/facebook/lexical/tree/main?file=dev-examples%2Fmdast-editor%2Fsrc%2FEditor.tsx&startScript=start:dev-example:mdast-editor) + +Because this example depends on unreleased workspace packages, the +StackBlitz link opens the monorepo root and boots the example with the +`start:dev-example:mdast-editor` script. + +Deployed builds of every dev example are served from the lexical +website at `/dev-examples//` (this one at +[/dev-examples/mdast-editor/](https://lexical.dev/dev-examples/mdast-editor/)), +including on every Cloudflare/Vercel deploy preview. To build them +locally: `pnpm run build && pnpm run build:dev-examples` at the +monorepo root, which outputs into +`packages/lexical-website/static/dev-examples/`. These are built from +the development artifacts on purpose — they are demos, and the +development builds keep Lexical's full error messages. + +## What got simpler vs. `examples/markdown-editor` + +The legacy example's `MarkdownExtension.ts` is ~240 lines; the equivalent +[`MdastEditorExtension.ts`](./src/extensions/MdastEditorExtension.ts) here is +example plumbing only (the preview signal and two toolbar commands), because +`@lexical/mdast` ships extension-ready: + +- **No transformer list to curate.** Depending on `MdastShortcutsExtension` + pulls in the CommonMark + GFM grammar and every node it needs. +- **No `registerMarkdownShortcuts` call.** The extension registers the + streaming shortcuts itself, driven by the same micromark grammar as + document import. +- **No checklist workaround.** The legacy example needed a custom + `text-match` transformer (`CHECK_LIST_ITEM`, ~50 lines) so `[ ] ` typed in + an existing bullet item would convert; that behavior is built in. +- **No newline-handling configuration.** `shouldPreserveNewlines` / + `mergeAdjacentNewlines` don't exist; micromark parses CommonMark as-is + and the original syntax is preserved on the nodes via `NodeState`. + +It also does more: blockquotes, links, strikethrough, thematic breaks, and +fenced code blocks round-trip out of the box (the legacy example's default +set covered headings, lists, and inline formatting), and the code blocks +are syntax-highlighted by adding `CodeShikiExtension` — highlighting +composes with the Markdown machinery without any extra wiring. + +## Bundle size + +The flip side of parsing with micromark is bundle weight. Build the +production dist artifacts first, then run the comparison: + +```bash +pnpm run build-prod # at the monorepo root +npm run size # in this directory +``` + +It builds functionally-equivalent headless bundles — editor + markdown +node set + typing shortcuts + import/export — one per implementation, from +the production dist artifacts, and prints their sizes. All include the +lexical core, so the deltas are the markdown machinery itself. Snapshot at +the time of writing: + +| bundle | minified | min+gzip | +| ------------------------------ | --------- | -------- | +| legacy `@lexical/markdown` | 287.3 kB | 77.3 kB | +| `@lexical/mdast` | 408.6 kB | 103.6 kB | +| `@lexical/mdast` (import only) | 393.5 kB | 99.9 kB | +| delta (full vs legacy) | +121.4 kB | +26.3 kB | + +Two packaging decisions keep the delta down: + +- The dist build keeps the micromark/mdast dependencies **external**, so + the app bundler resolves them with browser export conditions (named + character references decode through the DOM instead of shipping a + ~36 kB entity table) and tree-shakes what's unused. +- Import and export are **separate extensions**; the import-only row + omits `MdastExportExtension` and with it most of + `mdast-util-to-markdown`. + +That ~26 kB (gzip) buys spec-compliant CommonMark + GFM parsing, a single +grammar shared by import and typing shortcuts, and the micromark/mdast +extension ecosystem (footnotes, frontmatter, directives, ...) as the path +for new syntax. diff --git a/dev-examples/mdast-editor/index.html b/dev-examples/mdast-editor/index.html new file mode 100644 index 00000000000..94ce7bc96ae --- /dev/null +++ b/dev-examples/mdast-editor/index.html @@ -0,0 +1,12 @@ + + + + + + Lexical Mdast Editor Example + + +
+ + + diff --git a/dev-examples/mdast-editor/package.json b/dev-examples/mdast-editor/package.json new file mode 100644 index 00000000000..ca4c7a9379b --- /dev/null +++ b/dev-examples/mdast-editor/package.json @@ -0,0 +1,44 @@ +{ + "name": "@lexical/dev-mdast-editor-example", + "private": true, + "version": "0.46.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "size": "node ./size/measure.mjs" + }, + "dependencies": { + "@lexical/code-core": "workspace:*", + "@lexical/code-shiki": "workspace:*", + "@lexical/extension": "workspace:*", + "@lexical/history": "workspace:*", + "@lexical/link": "workspace:*", + "@lexical/list": "workspace:*", + "@lexical/markdown": "workspace:*", + "@lexical/mdast": "workspace:*", + "@lexical/react": "workspace:*", + "@lexical/rich-text": "workspace:*", + "@lexical/selection": "workspace:*", + "@lexical/utils": "workspace:*", + "lexical": "workspace:*", + "react": "^19.1.0", + "react-dom": "^19.1.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^6.0.2", + "tailwindcss": "^4.2.1", + "typescript": "^6.0.3", + "vite": "^8.0.16" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.61.0", + "@rollup/rollup-darwin-arm64": "4.61.0", + "@rollup/rollup-win32-x64-msvc": "4.61.0", + "@rollup/wasm-node": "4.61.0" + } +} diff --git a/dev-examples/mdast-editor/size/legacy-entry.ts b/dev-examples/mdast-editor/size/legacy-entry.ts new file mode 100644 index 00000000000..5d7b9c4c9a0 --- /dev/null +++ b/dev-examples/mdast-editor/size/legacy-entry.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Bundle-size probe: the legacy @lexical/markdown stack. Functionally + * equivalent surface to ./mdast-entry.ts — an editor with the markdown + * node set, typing shortcuts, and markdown import/export. + */ + +import {CodeNode} from '@lexical/code-core'; +import {LinkNode} from '@lexical/link'; +import {ListItemNode, ListNode} from '@lexical/list'; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + registerMarkdownShortcuts, + TRANSFORMERS, +} from '@lexical/markdown'; +import {HeadingNode, QuoteNode} from '@lexical/rich-text'; +import {createEditor, type LexicalEditor} from 'lexical'; + +export function createMarkdownEditor(): LexicalEditor { + const editor = createEditor({ + namespace: 'size-probe', + nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode, CodeNode, LinkNode], + onError: (error: Error) => { + throw error; + }, + }); + registerMarkdownShortcuts(editor, TRANSFORMERS); + return editor; +} + +export function markdownToEditor( + editor: LexicalEditor, + markdown: string, +): void { + editor.update(() => $convertFromMarkdownString(markdown, TRANSFORMERS)); +} + +export function editorToMarkdown(editor: LexicalEditor): string { + return editor.read(() => $convertToMarkdownString(TRANSFORMERS)); +} diff --git a/dev-examples/mdast-editor/size/mdast-entry.ts b/dev-examples/mdast-editor/size/mdast-entry.ts new file mode 100644 index 00000000000..4a3adf8aeb7 --- /dev/null +++ b/dev-examples/mdast-editor/size/mdast-entry.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Bundle-size probe: the @lexical/mdast markdown stack. Functionally + * equivalent surface to ./legacy-entry.ts — an editor with the markdown + * node set, streaming typing shortcuts, and markdown import/export. + */ + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $convertFromMarkdownString, + $convertToMarkdownString, + MdastCommonMarkExtension, + MdastExportExtension, + MdastShortcutsExtension, + MdastStrikethroughExtension, + MdastTaskListExtension, +} from '@lexical/mdast'; +import {defineExtension, type LexicalEditor} from 'lexical'; + +export function createMarkdownEditor(): LexicalEditor { + return buildEditorFromExtensions( + defineExtension({ + // Feature parity with the legacy TRANSFORMERS set: CommonMark plus + // strikethrough and task lists (no tables, no literal autolinks). + dependencies: [ + MdastCommonMarkExtension, + MdastStrikethroughExtension, + MdastTaskListExtension, + MdastShortcutsExtension, + MdastExportExtension, + ], + name: '[root]', + }), + ); +} + +export function markdownToEditor( + editor: LexicalEditor, + markdown: string, +): void { + editor.update(() => $convertFromMarkdownString(markdown)); +} + +export function editorToMarkdown(editor: LexicalEditor): string { + return editor.read(() => $convertToMarkdownString()); +} diff --git a/dev-examples/mdast-editor/size/mdast-import-entry.ts b/dev-examples/mdast-editor/size/mdast-import-entry.ts new file mode 100644 index 00000000000..b44d79e5978 --- /dev/null +++ b/dev-examples/mdast-editor/size/mdast-import-entry.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * Bundle-size probe: @lexical/mdast import only. An editor with the + * markdown node set, streaming typing shortcuts, and markdown import — no + * serialization back to Markdown, so `MdastExportExtension` (and with it + * `mdast-util-to-markdown`) should be tree-shaken away. + */ + +import {buildEditorFromExtensions} from '@lexical/extension'; +import { + $convertFromMarkdownString, + MdastCommonMarkExtension, + MdastShortcutsExtension, + MdastStrikethroughExtension, + MdastTaskListExtension, +} from '@lexical/mdast'; +import {defineExtension, type LexicalEditor} from 'lexical'; + +export function createMarkdownEditor(): LexicalEditor { + return buildEditorFromExtensions( + defineExtension({ + dependencies: [ + MdastCommonMarkExtension, + MdastStrikethroughExtension, + MdastTaskListExtension, + MdastShortcutsExtension, + ], + name: '[root]', + }), + ); +} + +export function markdownToEditor( + editor: LexicalEditor, + markdown: string, +): void { + editor.update(() => $convertFromMarkdownString(markdown)); +} diff --git a/dev-examples/mdast-editor/size/measure.mjs b/dev-examples/mdast-editor/size/measure.mjs new file mode 100644 index 00000000000..9a27f05beb2 --- /dev/null +++ b/dev-examples/mdast-editor/size/measure.mjs @@ -0,0 +1,87 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/* eslint-disable no-console -- CLI script; the printed table is the output */ + +// Builds two functionally-equivalent markdown-editor bundles — one on +// @lexical/mdast, one on the legacy @lexical/markdown — and prints their +// minified and gzipped sizes. Run from the example directory: +// +// npm run size +// +// Both bundles include the lexical core and the markdown node set, so the +// delta between them is attributable to the markdown machinery itself +// (micromark + mdast-util vs. the bespoke regex implementation). + +import {execFileSync} from 'node:child_process'; +import {existsSync, readFileSync} from 'node:fs'; +import * as path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {gzipSync} from 'node:zlib'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const exampleDir = path.resolve(here, '..'); +const monorepoRoot = path.resolve(exampleDir, '..', '..'); + +// vite build resolves the monorepo's PRODUCTION dist artifacts; without them +// the module resolution silently falls back to development builds and the +// measurement is meaningless. Fail loudly instead. +if ( + !existsSync( + path.join(monorepoRoot, 'packages', 'lexical', 'dist', 'Lexical.prod.mjs'), + ) +) { + console.error( + 'Production dist artifacts are missing. Run `pnpm run build-prod` (or ' + + '`node scripts/build.mjs --prod`) at the monorepo root first.', + ); + process.exit(1); +} + +const entries = ['legacy', 'mdast', 'mdast-import']; +const results = {}; + +for (const entry of entries) { + console.log(`\nBuilding ${entry} bundle...`); + execFileSync( + 'npx', + ['vite', 'build', '-c', 'size/vite.size.config.ts', '--logLevel', 'warn'], + { + cwd: exampleDir, + env: {...process.env, SIZE_ENTRY: entry}, + stdio: 'inherit', + }, + ); + const file = path.join(exampleDir, 'dist-size', entry, `${entry}.js`); + const contents = readFileSync(file); + results[entry] = { + gzip: gzipSync(contents, {level: 9}).length, + min: contents.length, + }; +} + +const kb = n => `${(n / 1024).toFixed(1)} kB`; +const labels = { + legacy: 'legacy `@lexical/markdown`', + mdast: '`@lexical/mdast`', + 'mdast-import': '`@lexical/mdast` (import only)', +}; +console.log('\n| bundle | minified | min+gzip |'); +console.log('| --- | --- | --- |'); +for (const entry of entries) { + const {min, gzip} = results[entry]; + console.log(`| ${labels[entry]} | ${kb(min)} | ${kb(gzip)} |`); +} +const dMin = results.mdast.min - results.legacy.min; +const dGzip = results.mdast.gzip - results.legacy.gzip; +const sign = n => (n >= 0 ? '+' : ''); +console.log( + `| delta (full vs legacy) | ${sign(dMin)}${kb(dMin)} | ${sign(dGzip)}${kb( + dGzip, + )} |`, +); diff --git a/dev-examples/mdast-editor/size/vite.size.config.ts b/dev-examples/mdast-editor/size/vite.size.config.ts new file mode 100644 index 00000000000..4e4b14781f6 --- /dev/null +++ b/dev-examples/mdast-editor/size/vite.size.config.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import * as path from 'node:path'; +import {defineConfig} from 'vite'; + +import lexicalMonorepoPlugin from '../../../scripts/vite/lexicalMonorepoPlugin'; + +// Selected by size/measure.mjs: 'legacy', 'mdast', or 'mdast-import'. +const ENTRIES = ['legacy', 'mdast', 'mdast-import']; +const envEntry = process.env.SIZE_ENTRY ?? 'mdast'; +const entry = ENTRIES.includes(envEntry) ? envEntry : 'mdast'; + +export default defineConfig({ + build: { + emptyOutDir: true, + lib: { + entry: path.resolve(__dirname, `${entry}-entry.ts`), + fileName: () => `${entry}.js`, + formats: ['es'], + }, + minify: true, + outDir: path.resolve(__dirname, '..', 'dist-size', entry), + }, + plugins: [lexicalMonorepoPlugin()], +}); diff --git a/dev-examples/mdast-editor/src/Editor.tsx b/dev-examples/mdast-editor/src/Editor.tsx new file mode 100644 index 00000000000..60037209b0a --- /dev/null +++ b/dev-examples/mdast-editor/src/Editor.tsx @@ -0,0 +1,158 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer'; +import {configExtension, defineExtension} from 'lexical'; + +import { + MarkdownPersistenceExtension, + RESET_MARKDOWN_COMMAND, +} from './extensions/MarkdownPersistenceExtension'; +import {ToolbarStateExtension} from './extensions/ToolbarStateExtension'; +import {MarkdownSourcePlugin} from './plugins/MarkdownSourcePlugin'; +import {ToolbarPlugin} from './plugins/ToolbarPlugin'; + +const STORAGE_KEY = '@lexical/dev-mdast-editor-example/document'; + +const DEMO_MARKDOWN = `# Mdast Editor + +This is a **WYSIWYG** editor built on [@lexical/mdast](https://github.com/facebook/lexical), +which parses with *micromark* — the same CommonMark + GFM parser used by remark — +and keeps the markdown on the right in sync as you type. + +## Try the inline shortcuts + +- \`# \`, \`## \`, \`### \` for headings +- \`**bold**\`, \`*italic*\`, \`***both***\`, \`~~strikethrough~~\` +- Wrap text in \`backticks\` for inline code +- \`[text](url)\` for links +- \`- \` or \`1. \` for lists, \`- [ ] \` / \`- [x] \` for check lists +- \`> \` for a blockquote, \`\`\`\`\`js\`\`\` + Enter for a code block + +## Round-trip fidelity + +The original syntax is preserved on the nodes, so what you paste is what +you export — bullet styles, code fences, setext headings, hard breaks: + +> Blockquotes survive round-trips, +> including *inline formatting*. + +1. Ordered list item one +2. Ordered list item two + +- [x] Streaming shortcuts share the import grammar +- [x] Syntax preserved via NodeState +- [ ] Ship it + +--- + +\`\`\`ts +const editor = buildEditorFromExtensions({ + name: 'editor', + dependencies: [MdastShortcutsExtension], +}); +\`\`\` +`; + +const theme = { + code: 'my-2 block rounded-md bg-zinc-100 p-3 font-mono text-sm whitespace-pre dark:bg-zinc-900', + heading: { + h1: 'mb-2 text-3xl font-bold', + h2: 'mb-2 text-2xl font-bold', + h3: 'mb-1 text-xl font-semibold', + }, + link: 'text-blue-600 underline dark:text-blue-400', + list: { + checklist: 'list-none pl-0', + listitem: 'mx-6 my-0.5', + listitemChecked: + 'relative list-none pl-6 line-through text-zinc-500 before:absolute before:top-0.5 before:left-0 before:flex before:h-4 before:w-4 before:items-center before:justify-center before:rounded-sm before:border before:border-solid before:border-zinc-400 before:bg-blue-500 before:text-[10px] before:leading-none before:text-white before:content-["✓"]', + listitemUnchecked: + 'relative list-none pl-6 before:absolute before:top-0.5 before:left-0 before:h-4 before:w-4 before:rounded-sm before:border before:border-solid before:border-zinc-400 before:bg-transparent before:content-[""]', + nested: { + listitem: 'list-none', + }, + ol: 'm-0 list-decimal pl-6', + ul: 'm-0 list-disc pl-6', + }, + paragraph: 'my-1', + quote: + 'my-2 border-l-4 border-solid border-zinc-300 pl-3 text-zinc-600 dark:border-zinc-600 dark:text-zinc-300', + text: { + bold: 'font-bold', + code: 'rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[0.9em] dark:bg-zinc-700/60', + italic: 'italic', + strikethrough: 'line-through', + }, +}; + +const mdastEditorExtension = defineExtension({ + dependencies: [ + configExtension(MarkdownPersistenceExtension, { + defaultMarkdown: DEMO_MARKDOWN, + storageKey: STORAGE_KEY, + }), + ToolbarStateExtension, + ], + name: '@lexical/dev-mdast-editor-example/Editor', + namespace: '@lexical/dev-mdast-editor-example', + theme, +}); + +function ResetButton() { + const [editor] = useLexicalComposerContext(); + return ( + + ); +} + +export default function Editor() { + return ( + +
+
+
+ +
+
+ + Start writing markdown... +
+ } + /> +
+
+
+
+ + Markdown + + +
+
+ +
+
+ +
+ ); +} diff --git a/dev-examples/mdast-editor/src/ThemeToggle.tsx b/dev-examples/mdast-editor/src/ThemeToggle.tsx new file mode 100644 index 00000000000..44f2edc47e8 --- /dev/null +++ b/dev-examples/mdast-editor/src/ThemeToggle.tsx @@ -0,0 +1,56 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {useCallback, useState} from 'react'; + +export function ThemeToggle() { + const [isDark, setIsDark] = useState( + () => + typeof window !== 'undefined' && + document.documentElement.classList.contains('dark'), + ); + + const toggle = useCallback(() => { + setIsDark(prev => { + const next = !prev; + document.documentElement.classList.toggle('dark', next); + return next; + }); + }, []); + + return ( + + ); +} diff --git a/dev-examples/mdast-editor/src/extensions/MarkdownPersistenceExtension.ts b/dev-examples/mdast-editor/src/extensions/MarkdownPersistenceExtension.ts new file mode 100644 index 00000000000..37df88db883 --- /dev/null +++ b/dev-examples/mdast-editor/src/extensions/MarkdownPersistenceExtension.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {effect, getExtensionDependencyFromEditor} from '@lexical/extension'; +import {$convertFromMarkdownString} from '@lexical/mdast'; +import {mergeRegister} from '@lexical/utils'; +import { + COMMAND_PRIORITY_EDITOR, + createCommand, + defineExtension, + type LexicalEditor, + safeCast, +} from 'lexical'; + +import {MdastEditorExtension} from './MdastEditorExtension'; + +export interface MarkdownPersistenceConfig { + /** localStorage key the markdown document is stored under. */ + storageKey: string; + /** Markdown to seed the editor with when nothing is stored, and the + * value the {@link RESET_MARKDOWN_COMMAND} restores. */ + defaultMarkdown: string; +} + +/** + * Resets the document back to the configured default markdown and + * removes the persisted copy from localStorage. + */ +export const RESET_MARKDOWN_COMMAND = createCommand( + 'RESET_MARKDOWN_COMMAND', +); + +function readStoredMarkdown(storageKey: string): string | null { + if (typeof window === 'undefined' || storageKey === '') { + return null; + } + return window.localStorage.getItem(storageKey); +} + +function $loadMarkdown(editor: LexicalEditor): void { + const persistence = getExtensionDependencyFromEditor( + editor, + MarkdownPersistenceExtension, + ).config; + const stored = readStoredMarkdown(persistence.storageKey); + $convertFromMarkdownString(stored ?? persistence.defaultMarkdown); +} + +/** + * Owns the localStorage <-> editor markdown sync: + * + * - Provides the editor's `$initialEditorState` so the document is + * seeded from `localStorage[storageKey]` (falling back to + * `defaultMarkdown`). + * - Persists the markdown output signal back to localStorage on every + * change. + * - Registers {@link RESET_MARKDOWN_COMMAND} which clears the storage + * entry and re-imports the default markdown. + */ +export const MarkdownPersistenceExtension = defineExtension({ + $initialEditorState: $loadMarkdown, + config: safeCast({ + defaultMarkdown: '', + storageKey: '', + }), + dependencies: [MdastEditorExtension], + name: '@lexical/dev-mdast-editor-example/MarkdownPersistence', + register(editor, {storageKey, defaultMarkdown}, state) { + const {markdown} = state.getDependency(MdastEditorExtension).output; + const hasStorage = typeof window !== 'undefined' && storageKey !== ''; + + let initial = true; + return mergeRegister( + effect(() => { + const value = markdown.value; + if (initial) { + initial = false; + return; + } + if (hasStorage) { + window.localStorage.setItem(storageKey, value); + } + }), + editor.registerCommand( + RESET_MARKDOWN_COMMAND, + () => { + if (hasStorage) { + window.localStorage.removeItem(storageKey); + } + editor.update(() => $convertFromMarkdownString(defaultMarkdown)); + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); + }, +}); diff --git a/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts b/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts new file mode 100644 index 00000000000..256d0044852 --- /dev/null +++ b/dev-examples/mdast-editor/src/extensions/MdastEditorExtension.ts @@ -0,0 +1,131 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {ReadonlySignal} from '@lexical/extension'; + +import {CodeShikiExtension} from '@lexical/code-shiki'; +import { + computed, + EditorStateExtension, + TabIndentationExtension, +} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import {CheckListExtension, ListExtension} from '@lexical/list'; +import { + $convertToMarkdownString, + MdastCommonMarkExtension, + MdastExportExtension, + MdastGfmExtension, + MdastShortcutsExtension, +} from '@lexical/mdast'; +import { + $createHeadingNode, + type HeadingTagType, + RichTextExtension, +} from '@lexical/rich-text'; +import {$setBlocksType} from '@lexical/selection'; +import {mergeRegister} from '@lexical/utils'; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_EDITOR, + createCommand, + defineExtension, + type LexicalCommand, +} from 'lexical'; + +/** + * Reformats the current selection's blocks as a paragraph. Toolbars + * and other UI should dispatch this command rather than calling + * `$setBlocksType` directly so this extension is the single owner of + * block-formatting behavior. + */ +export const FORMAT_PARAGRAPH_COMMAND: LexicalCommand = createCommand( + 'FORMAT_PARAGRAPH_COMMAND', +); + +/** + * Reformats the current selection's blocks as the given heading. + */ +export const FORMAT_HEADING_COMMAND: LexicalCommand = + createCommand('FORMAT_HEADING_COMMAND'); + +export interface MdastEditorOutput { + /** A signal observing the editor's contents as a Markdown string. */ + markdown: ReadonlySignal; +} + +/** + * The Markdown wiring for this example. Compare with the legacy + * `examples/markdown-editor` `MarkdownExtension`: because + * `@lexical/mdast` ships extension-ready, there is no transformer list + * to curate, no `registerMarkdownShortcuts` call, no checklist + * text-match workaround (typing `[ ] ` / `[x] ` in a list item is + * built in), and no newline-normalization configuration — + * {@link MdastShortcutsExtension} pulls in the CommonMark + GFM + * grammar, its node dependencies, and the streaming shortcuts on its + * own. What's left here is example-specific plumbing: the editing + * behavior extensions, the `markdown` preview signal, and the block + * format commands the toolbar dispatches. + */ +export const MdastEditorExtension = defineExtension({ + build(editor, _config, state): MdastEditorOutput { + const editorState = state.getDependency(EditorStateExtension).output; + return { + markdown: computed(() => + editorState.value.read(() => $convertToMarkdownString(), {editor}), + ), + }; + }, + dependencies: [ + // The grammar is granular: CommonMark and GFM are separate bundles, + // export is split from import (the live preview serializes, so opt + // in), and the typing shortcuts follow whatever grammar is present. + MdastCommonMarkExtension, + MdastGfmExtension, + MdastExportExtension, + MdastShortcutsExtension, + // Shiki syntax highlighting for the code blocks (brings its own + // CodeExtension / CodeIndentExtension dependencies). + CodeShikiExtension, + RichTextExtension, + ListExtension, + CheckListExtension, + HistoryExtension, + TabIndentationExtension, + EditorStateExtension, + ], + name: '@lexical/dev-mdast-editor-example/MdastEditor', + register(editor) { + return mergeRegister( + editor.registerCommand( + FORMAT_PARAGRAPH_COMMAND, + () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + FORMAT_HEADING_COMMAND, + tag => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(tag)); + } + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); + }, +}); diff --git a/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts b/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts new file mode 100644 index 00000000000..aa1e0a20516 --- /dev/null +++ b/dev-examples/mdast-editor/src/extensions/ToolbarStateExtension.ts @@ -0,0 +1,128 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {computed, EditorStateExtension} from '@lexical/extension'; +import {HistoryExtension} from '@lexical/history'; +import {$isListNode} from '@lexical/list'; +import {$isHeadingNode} from '@lexical/rich-text'; +import {$findMatchingParent} from '@lexical/utils'; +import { + $getSelection, + $isRangeSelection, + $isRootOrShadowRoot, + defineExtension, +} from 'lexical'; + +import {MdastEditorExtension} from './MdastEditorExtension'; + +export type BlockType = + | 'paragraph' + | 'h1' + | 'h2' + | 'h3' + | 'bullet' + | 'number' + | 'check'; + +interface ToolbarSelectionState { + blockType: BlockType; + isBold: boolean; + isItalic: boolean; + isCode: boolean; +} + +const DEFAULT_SELECTION_STATE: ToolbarSelectionState = { + blockType: 'paragraph', + isBold: false, + isCode: false, + isItalic: false, +}; + +function $readSelectionState(): ToolbarSelectionState { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return DEFAULT_SELECTION_STATE; + } + const anchorNode = selection.anchor.getNode(); + const topLevelElement = + $findMatchingParent(anchorNode, e => { + const parent = e.getParent(); + return parent !== null && $isRootOrShadowRoot(parent); + }) ?? anchorNode.getTopLevelElementOrThrow(); + let blockType: BlockType = 'paragraph'; + if ($isHeadingNode(topLevelElement)) { + const tag = topLevelElement.getTag(); + if (tag === 'h1' || tag === 'h2' || tag === 'h3') { + blockType = tag; + } + } else if ($isListNode(topLevelElement)) { + blockType = topLevelElement.getListType(); + } + return { + blockType, + isBold: selection.hasFormat('bold'), + isCode: selection.hasFormat('code'), + isItalic: selection.hasFormat('italic'), + }; +} + +/** + * Owns all of the reactive state the formatting toolbar reads, all + * derived as `computed()` signals off the existing extension outputs: + * + * - The selection-derived signals (`blockType`, `isBold`, `isItalic`, + * `isCode`) are computed off the {@link EditorStateExtension} signal, + * so they recompute lazily whenever the editor state changes. + * - `canUndo` / `canRedo` are computed off the {@link HistoryExtension} + * `historyState` signal. The history state object is mutated in + * place, so we also read the editor-state signal as a change + * trigger — every editor update is a chance for the stacks to have + * changed. + * + * The React `` component only consumes these signals + * via `useExtensionSignalValue` and otherwise dispatches commands. + */ +export const ToolbarStateExtension = defineExtension({ + build(editor, _config, state) { + const editorState = state.getDependency(EditorStateExtension).output; + const historyState = + state.getDependency(HistoryExtension).output.historyState; + const selection = computed(() => + editorState.value.read($readSelectionState, {editor}), + ); + return { + blockType: computed(() => selection.value.blockType), + canRedo: computed(() => { + // The HistoryState object the signal holds is mutated in + // place by registerHistory (push/pop on the stack arrays, + // field reassignments on the object), so the signal itself + // never fires. Read the editor-state signal as a change + // trigger — every editor update is when the stacks may have + // changed. + // + // TODO: a future version of @lexical/history is expected to + // expose `canUndo` / `canRedo` signals directly from + // HistoryExtension, at which point this trigger and the + // historyState read here can be replaced with a direct + // dependency on those signals. + void editorState.value; + return historyState.value.redoStack.length > 0; + }), + canUndo: computed(() => { + // See the comment on `canRedo` above. + void editorState.value; + return historyState.value.undoStack.length > 0; + }), + isBold: computed(() => selection.value.isBold), + isCode: computed(() => selection.value.isCode), + isItalic: computed(() => selection.value.isItalic), + }; + }, + dependencies: [MdastEditorExtension, EditorStateExtension, HistoryExtension], + name: '@lexical/dev-mdast-editor-example/ToolbarState', +}); diff --git a/dev-examples/mdast-editor/src/main.tsx b/dev-examples/mdast-editor/src/main.tsx new file mode 100644 index 00000000000..67f8f6a3ad7 --- /dev/null +++ b/dev-examples/mdast-editor/src/main.tsx @@ -0,0 +1,25 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import './styles.css'; + +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import Editor from './Editor.tsx'; +import {ThemeToggle} from './ThemeToggle.tsx'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + +
+ +
+ +
+
+
, +); diff --git a/dev-examples/mdast-editor/src/plugins/MarkdownSourcePlugin.tsx b/dev-examples/mdast-editor/src/plugins/MarkdownSourcePlugin.tsx new file mode 100644 index 00000000000..8849cb72e70 --- /dev/null +++ b/dev-examples/mdast-editor/src/plugins/MarkdownSourcePlugin.tsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {$convertFromMarkdownString} from '@lexical/mdast'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {useExtensionSignalValue} from '@lexical/react/useExtensionSignalValue'; +import {HISTORY_MERGE_TAG, SKIP_DOM_SELECTION_TAG} from 'lexical'; +import {useState} from 'react'; + +import {MdastEditorExtension} from '../extensions/MdastEditorExtension'; + +/** + * An editable Markdown source pane, synchronized in both directions: + * editing the rich text editor re-serializes into this pane + * ($convertToMarkdownString via the `markdown` signal), and typing in this + * pane re-imports into the editor ($convertFromMarkdownString) on every + * keystroke. + * + * While the pane is focused it shows the literal text being typed (a + * `draft`) rather than the canonical serialization, so normalization + * can't fight the caret mid-edit; on blur it snaps back to the canonical + * round-trip output. + */ +export function MarkdownSourcePlugin() { + const [editor] = useLexicalComposerContext(); + const markdown = useExtensionSignalValue(MdastEditorExtension, 'markdown'); + const [draft, setDraft] = useState(null); + return ( +