Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ module.name_mapper='^@lexical/link$' -> '<PROJECT_ROOT>/packages/lexical-link/fl
module.name_mapper='^@lexical/list$' -> '<PROJECT_ROOT>/packages/lexical-list/flow/LexicalList.js.flow'
module.name_mapper='^@lexical/mark$' -> '<PROJECT_ROOT>/packages/lexical-mark/flow/LexicalMark.js.flow'
module.name_mapper='^@lexical/markdown$' -> '<PROJECT_ROOT>/packages/lexical-markdown/flow/LexicalMarkdown.js.flow'
module.name_mapper='^@lexical/mdast$' -> '<PROJECT_ROOT>/packages/lexical-mdast/flow/LexicalMdast.js.flow'
module.name_mapper='^@lexical/offset$' -> '<PROJECT_ROOT>/packages/lexical-offset/flow/LexicalOffset.js.flow'
module.name_mapper='^@lexical/overflow$' -> '<PROJECT_ROOT>/packages/lexical-overflow/flow/LexicalOverflow.js.flow'
module.name_mapper='^@lexical/plain-text$' -> '<PROJECT_ROOT>/packages/lexical-plain-text/flow/LexicalPlainText.js.flow'
Expand Down
32 changes: 29 additions & 3 deletions .github/actions/internal-registry/dependency-check/check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
93 changes: 93 additions & 0 deletions dev-examples/mdast-editor/README.md
Original file line number Diff line number Diff line change
@@ -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/<name>/` (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.
12 changes: 12 additions & 0 deletions dev-examples/mdast-editor/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lexical Mdast Editor Example</title>
</head>
<body>
<div id="root"></div>
<script src="/src/main.tsx" type="module"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions dev-examples/mdast-editor/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
48 changes: 48 additions & 0 deletions dev-examples/mdast-editor/size/legacy-entry.ts
Original file line number Diff line number Diff line change
@@ -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));
}
53 changes: 53 additions & 0 deletions dev-examples/mdast-editor/size/mdast-entry.ts
Original file line number Diff line number Diff line change
@@ -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());
}
45 changes: 45 additions & 0 deletions dev-examples/mdast-editor/size/mdast-import-entry.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Loading