Date: Thu, 20 Aug 2026 15:52:20 +0000
Subject: [PATCH 2/5] [lexical-list] Feature: opt-in Word list paste overlay
(#9082)
---
dev-examples/dom-import/README.md | 10 +-
dev-examples/dom-import/src/App.tsx | 12 +-
dev-examples/dom-import/src/wordPaste.ts | 229 ----------------
.../src/WordListImportExtension.ts | 253 ++++++++++++++++++
.../unit/ListImportExtension.test.ts | 230 ++++------------
packages/lexical-list/src/index.ts | 1 +
.../docs/serialization/dom-import.md | 15 ++
7 files changed, 328 insertions(+), 422 deletions(-)
delete mode 100644 dev-examples/dom-import/src/wordPaste.ts
create mode 100644 packages/lexical-list/src/WordListImportExtension.ts
diff --git a/dev-examples/dom-import/README.md b/dev-examples/dom-import/README.md
index 418877a51df..e98132d57e7 100644
--- a/dev-examples/dom-import/README.md
+++ b/dev-examples/dom-import/README.md
@@ -23,14 +23,16 @@ all the configuration the pipeline needs —
paste consolidation preprocess).
- **Markdown shortcuts** — type `# `, `* `, `1. `, `> `, ``` ``` ```, etc.
to convert on the fly.
-- **MS Word paste** — a preprocess detects the
+- **MS Word paste** — `$installWordListPasteOverlay` from
+ `@lexical/list` detects the
`` tag and pushes
a Word-specific overlay onto `ImportOverlays`. The overlay groups
flat `` runs into proper nested
`ListNode` trees. Pastes from other sources pay nothing for Word
- handling. See [`src/wordPaste.ts`](src/wordPaste.ts) for the rule
- and preprocess; [`src/fixtures/word.html`](src/fixtures/word.html)
- is the bundled clipboard payload the dialog uses to demo it.
+ handling. It is opt-in — `ListExtension` does not install it, so
+ `App.tsx` adds it through the `DOMImportExtension` config.
+ [`src/fixtures/word.html`](src/fixtures/word.html) is the bundled
+ clipboard payload the dialog uses to demo it.
- **VS Code paste** — a preprocess shipped by `@lexical/code-core`
detects either browser's VS Code paste shape (Chrome's outer
monospace+pre wrapper, Safari's flat sibling run) and pushes a
diff --git a/dev-examples/dom-import/src/App.tsx b/dev-examples/dom-import/src/App.tsx
index 39f213ea510..2288221e510 100644
--- a/dev-examples/dom-import/src/App.tsx
+++ b/dev-examples/dom-import/src/App.tsx
@@ -16,7 +16,11 @@ import {
} from '@lexical/extension';
import {HistoryExtension} from '@lexical/history';
import {LinkExtension} from '@lexical/link';
-import {CheckListExtension, ListExtension} from '@lexical/list';
+import {
+ CheckListExtension,
+ ListExtension,
+ WordListImportExtension,
+} from '@lexical/list';
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
import {LexicalExtensionComposer} from '@lexical/react/LexicalExtensionComposer';
import {RichTextExtension} from '@lexical/rich-text';
@@ -27,7 +31,6 @@ import ExampleTheme from './ExampleTheme';
import {ImportHtmlButton} from './ImportHtmlDialog';
import {MarkdownShortcutsExtension} from './MarkdownShortcutsExtension';
import {Toolbar, ToolbarExtension} from './ToolbarExtension';
-import {WordPasteExtension} from './wordPaste';
const placeholder =
'Try pasting from Word, GitHub, a webpage, or click "Import HTML"…';
@@ -53,8 +56,9 @@ const editorExtension = defineExtension({
CodeShikiExtension,
MarkdownShortcutsExtension,
ToolbarExtension,
- // Word-only overlay, installed conditionally by a preprocess.
- WordPasteExtension,
+ // Word list handling is opt-in: the preprocess installs the
+ // overlay only when the paste carries Word's Generator meta tag.
+ WordListImportExtension,
// Route real `text/html` pastes through the DOMImportExtension
// pipeline so the rules / overlays above actually fire on pastes,
// not just on the "Import HTML" dialog.
diff --git a/dev-examples/dom-import/src/wordPaste.ts b/dev-examples/dom-import/src/wordPaste.ts
deleted file mode 100644
index deab65a4f39..00000000000
--- a/dev-examples/dom-import/src/wordPaste.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-/**
- * 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 {configExtension} from '@lexical/extension';
-import {
- createImportState,
- defineImportRule,
- defineOverlayRules,
- type DOMImportContext,
- DOMImportExtension,
- type DOMPreprocessFn,
- ImportOverlays,
- InlineSchema,
- sel,
-} from '@lexical/html';
-import {
- $createListItemNode,
- $createListNode,
- type ListNode,
-} from '@lexical/list';
-import {defineExtension, getStyleObjectFromCSS, isHTMLElement} from 'lexical';
-
-const WORD_LIST_CLASS_RE = /^MsoListParagraph(CxSp(First|Middle|Last))?$/;
-const WORD_NUMBERED_RE = /^[A-Za-z0-9]+[.)]/;
-const WORD_GENERATOR_RE = /Microsoft Word/i;
-
-// The default `$inlineStylesFromStyleSheets` preprocess mutates each
-// element's inline style through CSSStyleDeclaration.setProperty, which
-// makes JSDOM (and real browsers) re-serialize the style attribute and
-// drop unknown properties like `mso-list`. The Word preprocess runs
-// FIRST (it's appended to the stack so it's on top), so it stashes
-// `mso-list` onto a `data-*` attribute that survives the later
-// stylesheet-inlining pass.
-const MSO_LIST_DATA_ATTR = 'data-mso-list';
-
-function readMsoListAttr(el: Element): string {
- return (
- el.getAttribute(MSO_LIST_DATA_ATTR) ||
- getStyleObjectFromCSS(el.getAttribute('style') || '')['mso-list'] ||
- ''
- );
-}
-
-function readWordListLevel(el: HTMLElement): number {
- const m = readMsoListAttr(el).match(/level(\d+)/);
- return m ? parseInt(m[1], 10) : 1;
-}
-
-function $findMarkerSpan(el: HTMLElement): HTMLElement | null {
- for (const span of Array.from(el.querySelectorAll('span'))) {
- if (readMsoListAttr(span) === 'Ignore') {
- return span;
- }
- }
- return null;
-}
-
-function $readWordMarker(el: HTMLElement): string {
- const span = $findMarkerSpan(el);
- return span ? (span.textContent || '').trim() : '';
-}
-
-function classifyWordListType(marker: string): 'number' | 'bullet' {
- return WORD_NUMBERED_RE.test(marker) ? 'number' : 'bullet';
-}
-
-function $stripWordMarker(el: HTMLElement): void {
- // The marker span is wrapped in an outer directly under the
- // ; remove that outer wrapper.
- const inner = $findMarkerSpan(el);
- if (!inner) {
- return;
- }
- let outer: Element = inner;
- while (
- outer.parentElement &&
- outer.parentElement !== el &&
- outer.parentElement.nodeName === 'SPAN'
- ) {
- outer = outer.parentElement;
- }
- outer.remove();
-}
-
-function isWordListParagraph(node: Node): node is HTMLElement {
- return isHTMLElement(node) && WORD_LIST_CLASS_RE.test(node.className);
-}
-
-interface WordListItem {
- el: HTMLElement;
- level: number;
- marker: string;
-}
-
-function $buildWordListTree(
- ctx: DOMImportContext,
- items: readonly WordListItem[],
-): ListNode {
- const root = $createListNode(classifyWordListType(items[0].marker));
- type Frame = {list: ListNode; level: number};
- const stack: Frame[] = [{level: items[0].level, list: root}];
- for (const item of items) {
- while (stack.length > 1 && stack[stack.length - 1].level > item.level) {
- stack.pop();
- }
- if (item.level > stack[stack.length - 1].level) {
- // Lexical's nested-list convention (see `$isNestedListNode` in
- // @lexical/list): a sublist lives inside its OWN ListItemNode
- // wrapper that is a sibling of the items above it, not inside
- // the previous content item. The wrapper has no own content,
- // just the sublist as its first child.
- const sub = $createListNode(classifyWordListType(item.marker));
- const wrapper = $createListItemNode();
- wrapper.append(sub);
- stack[stack.length - 1].list.append(wrapper);
- stack.push({level: item.level, list: sub});
- }
- $stripWordMarker(item.el);
- const li = $createListItemNode();
- li.splice(0, 0, ctx.$importChildren(item.el, {schema: InlineSchema}));
- stack[stack.length - 1].list.append(li);
- }
- return root;
-}
-
-/**
- * Per-import session WeakSet tracking `
`
- * elements already absorbed by an earlier sibling's list-construction
- * pass, so the framework's normal child iteration treats them as
- * no-ops. The state's default is `null`; the rule lazily initializes
- * a fresh WeakSet into the session on first use, since
- * `createImportState`'s default factory is called once at state
- * creation and the result is shared (see ImportContext.ts).
- */
-const WordListConsumed = createImportState | null>(
- 'word/consumed-list-items',
- () => null,
-);
-
-const WordListParagraphRule = defineImportRule({
- $import: (ctx, el) => {
- let consumed = ctx.session.get(WordListConsumed);
- if (consumed === null) {
- consumed = new WeakSet();
- ctx.session.set(WordListConsumed, consumed);
- }
- if (consumed.has(el)) {
- return [];
- }
- const items: WordListItem[] = [];
- let cur: Node | null = el;
- while (cur && isWordListParagraph(cur)) {
- consumed.add(cur);
- items.push({
- el: cur,
- level: readWordListLevel(cur),
- marker: $readWordMarker(cur),
- });
- if (
- cur.classList.contains('MsoListParagraphCxSpLast') ||
- cur.className === 'MsoListParagraph'
- ) {
- break;
- }
- cur = cur.nextElementSibling;
- }
- return [$buildWordListTree(ctx, items)];
- },
- match: sel
- .tag('p')
- .classAny(
- 'MsoListParagraph',
- 'MsoListParagraphCxSpFirst',
- 'MsoListParagraphCxSpMiddle',
- 'MsoListParagraphCxSpLast',
- ),
- name: 'word/list-paragraph',
-});
-
-// is Office's "paragraph end" marker; always produces nothing.
-const WordOPRule = defineImportRule({
- $import: () => [],
- match: sel.tag('o:p'),
- name: 'word/o-p',
-});
-
-const WordPasteOverlay = defineOverlayRules([
- WordOPRule,
- WordListParagraphRule,
-]);
-
-const $installWordOverlay: DOMPreprocessFn = (dom, ctx, $next) => {
- const meta = dom.querySelector('meta[name="Generator"]');
- if (meta && WORD_GENERATOR_RE.test(meta.getAttribute('content') || '')) {
- // Snapshot `mso-list` onto data-mso-list before the later
- // stylesheet-inlining preprocess re-serializes the style attribute
- // and drops unknown CSS properties.
- for (const el of Array.from(dom.querySelectorAll('[style*="mso-list"]'))) {
- const msoList = getStyleObjectFromCSS(el.getAttribute('style') || '')[
- 'mso-list'
- ];
- if (msoList) {
- el.setAttribute(MSO_LIST_DATA_ATTR, msoList);
- }
- }
- ctx.session.update(ImportOverlays, prev => [...prev, WordPasteOverlay]);
- }
- $next();
-};
-
-/**
- * Extension that registers a DOM preprocess hook: if the input
- * carries ``, push a
- * Word-specific overlay onto {@link ImportOverlays} so the rest of the
- * walk picks it up. Pastes from other sources pay nothing.
- */
-export const WordPasteExtension = defineExtension({
- dependencies: [
- configExtension(DOMImportExtension, {
- preprocess: [$installWordOverlay],
- }),
- ],
- name: '@lexical/examples/word-paste',
-});
diff --git a/packages/lexical-list/src/WordListImportExtension.ts b/packages/lexical-list/src/WordListImportExtension.ts
new file mode 100644
index 00000000000..fccb33adfae
--- /dev/null
+++ b/packages/lexical-list/src/WordListImportExtension.ts
@@ -0,0 +1,253 @@
+/**
+ * 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 {
+ createImportState,
+ defineImportRule,
+ defineOverlayRules,
+ type DOMImportContext,
+ DOMImportExtension,
+ type DOMPreprocessFn,
+ ImportOverlays,
+ InlineSchema,
+ sel,
+} from '@lexical/html';
+import {
+ configExtension,
+ defineExtension,
+ getStyleObjectFromCSS,
+ isHTMLElement,
+} from 'lexical';
+
+import {ListExtension} from './LexicalListExtension';
+import {$createListItemNode} from './LexicalListItemNode';
+import {$createListNode, type ListNode} from './LexicalListNode';
+
+const WORD_LIST_CLASS_RE = /^MsoListParagraph(CxSp(First|Middle|Last))?$/;
+const WORD_NUMBERED_RE = /^[A-Za-z0-9]+[.)]/;
+const WORD_GENERATOR_RE = /Microsoft Word/i;
+
+/**
+ * `mso-list` is a Microsoft non-standard CSS property, so neither
+ * browsers nor JSDOM surface it through `el.style`. Worse, the default
+ * `$inlineStylesFromStyleSheets` preprocess writes each element's
+ * inline style through `CSSStyleDeclaration.setProperty`, which
+ * re-serializes the `style` attribute and drops the unknown property
+ * altogether. {@link $installWordListPasteOverlay} runs first (it is
+ * pushed onto the preprocess stack, so it sits on top) and snapshots
+ * `mso-list` onto this `data-*` attribute, which survives the later
+ * stylesheet-inlining pass.
+ */
+const MSO_LIST_DATA_ATTR = 'data-mso-list';
+
+function readWordListLevel(el: HTMLElement): number {
+ // mso-list looks like "l level lfo"; pluck the level number.
+ const m = (el.getAttribute(MSO_LIST_DATA_ATTR) || '').match(/level(\d+)/);
+ return m ? parseInt(m[1], 10) : 1;
+}
+
+function findMarkerSpan(el: HTMLElement): Element | null {
+ return el.querySelector(`span[${MSO_LIST_DATA_ATTR}="Ignore"]`);
+}
+
+function readWordMarker(el: HTMLElement): string {
+ const span = findMarkerSpan(el);
+ return span ? (span.textContent || '').trim() : '';
+}
+
+function classifyWordListType(marker: string): 'number' | 'bullet' {
+ return WORD_NUMBERED_RE.test(marker) ? 'number' : 'bullet';
+}
+
+function stripWordMarker(el: HTMLElement): void {
+ // The marker span is wrapped in an outer directly under the
+ // ; remove that outer wrapper.
+ const inner = findMarkerSpan(el);
+ if (!inner) {
+ return;
+ }
+ let outer: Element = inner;
+ while (
+ outer.parentElement &&
+ outer.parentElement !== el &&
+ outer.parentElement.nodeName === 'SPAN'
+ ) {
+ outer = outer.parentElement;
+ }
+ outer.remove();
+}
+
+function isWordListParagraph(node: Node): node is HTMLElement {
+ return isHTMLElement(node) && WORD_LIST_CLASS_RE.test(node.className);
+}
+
+interface WordListItem {
+ el: HTMLElement;
+ level: number;
+ marker: string;
+}
+
+function $buildWordListTree(
+ ctx: DOMImportContext,
+ items: readonly WordListItem[],
+): ListNode {
+ const root = $createListNode(classifyWordListType(items[0].marker));
+ type Frame = {list: ListNode; level: number};
+ const stack: Frame[] = [{level: items[0].level, list: root}];
+ for (const item of items) {
+ while (stack.length > 1 && stack[stack.length - 1].level > item.level) {
+ stack.pop();
+ }
+ if (item.level > stack[stack.length - 1].level) {
+ // Lexical's nested-list convention (see `$isNestedListNode`): a
+ // sublist lives inside its OWN ListItemNode wrapper that is a
+ // sibling of the items above it, not inside the previous content
+ // item. The wrapper has no own content, just the sublist as its
+ // first child.
+ const sub = $createListNode(classifyWordListType(item.marker));
+ stack[stack.length - 1].list.append($createListItemNode().append(sub));
+ stack.push({level: item.level, list: sub});
+ }
+ stripWordMarker(item.el);
+ stack[stack.length - 1].list.append(
+ $createListItemNode().splice(
+ 0,
+ 0,
+ ctx.$importChildren(item.el, {schema: InlineSchema}),
+ ),
+ );
+ }
+ return root;
+}
+
+/**
+ * Per-import session WeakSet tracking `
`
+ * elements already absorbed by an earlier sibling's list-construction
+ * pass, so the framework's normal child iteration treats them as
+ * no-ops. The default is `null` and the rule lazily installs a fresh
+ * WeakSet per session, because `createImportState`'s default factory
+ * runs once at state creation and its result is shared across sessions.
+ */
+const WordListConsumed =
+ /* @__PURE__ */ createImportState | null>(
+ '@lexical/list/word-consumed-list-items',
+ () => null,
+ );
+
+const WordListParagraphRule = /* @__PURE__ */ defineImportRule({
+ $import: (ctx, el) => {
+ let consumed = ctx.session.get(WordListConsumed);
+ if (consumed === null) {
+ consumed = new WeakSet();
+ ctx.session.set(WordListConsumed, consumed);
+ }
+ if (consumed.has(el)) {
+ return [];
+ }
+ const items: WordListItem[] = [];
+ let cur: Node | null = el;
+ while (cur && isWordListParagraph(cur)) {
+ consumed.add(cur);
+ items.push({
+ el: cur,
+ level: readWordListLevel(cur),
+ marker: readWordMarker(cur),
+ });
+ // MsoListParagraph (no CxSp suffix) is a single-item run.
+ if (
+ cur.classList.contains('MsoListParagraphCxSpLast') ||
+ cur.className === 'MsoListParagraph'
+ ) {
+ break;
+ }
+ cur = cur.nextElementSibling;
+ }
+ return [$buildWordListTree(ctx, items)];
+ },
+ match: /* @__PURE__ */ sel
+ .tag('p')
+ .classAny(
+ 'MsoListParagraph',
+ 'MsoListParagraphCxSpFirst',
+ 'MsoListParagraphCxSpMiddle',
+ 'MsoListParagraphCxSpLast',
+ ),
+ name: '@lexical/list/word-list-paragraph',
+});
+
+// is Office's "paragraph end" marker, emitted inside every Word
+// paragraph including the list ones; it always produces nothing.
+const WordOfficeParagraphRule = /* @__PURE__ */ defineImportRule({
+ $import: () => [],
+ match: /* @__PURE__ */ sel.tag('o:p'),
+ name: '@lexical/list/word-o-p',
+});
+
+const WordListPasteOverlay = /* @__PURE__ */ defineOverlayRules([
+ WordOfficeParagraphRule,
+ WordListParagraphRule,
+]);
+
+/**
+ * MS Word pastes have no ``/``/`- ` at all: a list is a flat
+ * run of `
` siblings whose marker ("1.",
+ * "·", "a)") lives in a nested ``, with
+ * `style="mso-list:l level lfo"` on the paragraph naming the
+ * list and its nesting depth.
+ *
+ * This preprocess looks once for
+ * `` and, only when it
+ * matches, snapshots the `mso-list` declarations (see
+ * {@link MSO_LIST_DATA_ATTR}) and pushes a Word-specific overlay onto
+ * {@link ImportOverlays}. The overlay's rule walks forward through
+ * siblings to collect a complete run and rebuilds it as a nested
+ * {@link ListNode} tree. Pastes from other sources pay only the
+ * detection cost.
+ *
+ * Installed by {@link WordListImportExtension}.
+ */
+const $installWordListPasteOverlay: DOMPreprocessFn = (dom, ctx, $next) => {
+ const meta = dom.querySelector('meta[name="Generator"]');
+ if (meta && WORD_GENERATOR_RE.test(meta.getAttribute('content') || '')) {
+ for (const el of Array.from(dom.querySelectorAll('[style*="mso-list"]'))) {
+ const msoList = getStyleObjectFromCSS(el.getAttribute('style') || '')[
+ 'mso-list'
+ ];
+ if (msoList) {
+ el.setAttribute(MSO_LIST_DATA_ATTR, msoList);
+ }
+ }
+ ctx.session.update(ImportOverlays, prev => [...prev, WordListPasteOverlay]);
+ }
+ $next();
+};
+
+/**
+ * Word list paste support for {@link ListNode}: opt in by adding this to
+ * an editor's dependencies. {@link ListExtension} does not depend on it,
+ * so an editor that never pastes from Word does not bundle any of it.
+ *
+ * ```ts
+ * defineExtension({
+ * dependencies: [WordListImportExtension],
+ * name: 'my-editor',
+ * });
+ * ```
+ *
+ * @experimental
+ */
+export const WordListImportExtension = /* @__PURE__ */ defineExtension({
+ dependencies: [
+ // The overlay builds ListNodes, so the nodes have to be registered.
+ ListExtension,
+ /* @__PURE__ */ configExtension(DOMImportExtension, {
+ preprocess: [$installWordListPasteOverlay],
+ }),
+ ],
+ name: '@lexical/list/WordListImport',
+});
diff --git a/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts b/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts
index 9710cc47e21..f6c3878dce0 100644
--- a/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts
+++ b/packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts
@@ -8,38 +8,24 @@
import {
buildEditorFromExtensions,
- configExtension,
getExtensionDependencyFromEditor,
HorizontalRuleExtension,
} from '@lexical/extension';
+import {DOMImportExtension} from '@lexical/html';
import {
- createImportState,
- defineImportRule,
- defineOverlayRules,
- type DOMImportContext,
- DOMImportExtension,
- type DOMPreprocessFn,
- ImportOverlays,
- InlineSchema,
- sel,
-} from '@lexical/html';
-import {
- $createListItemNode,
- $createListNode,
$isListItemNode,
$isListNode,
ListExtension,
ListImportExtension,
type ListItemNode,
type ListNode,
+ WordListImportExtension,
} from '@lexical/list';
import {JSDOM} from 'jsdom';
import {
$getEditor,
$getRoot,
defineExtension,
- getStyleObjectFromCSS,
- isHTMLElement,
type LexicalEditor,
type LexicalNode,
} from 'lexical';
@@ -167,7 +153,7 @@ describe('ListImportExtension', () => {
});
// ----------------------------------------------------------------------------
-// Word paste example
+// MS Word paste
//
// MS Word produces HTML where "lists" are actually flat runs of
// with a marker like "1." or "·" inside a
@@ -175,179 +161,17 @@ describe('ListImportExtension', () => {
// or
- elements. The
's carry style='mso-list:l level lfo'
// where identifies the list and the nesting depth.
//
-// The trick is twofold:
-// (1) The preprocess only installs the Word-paste overlay when the
-// generator meta tag is present, so pastes from other sources pay
-// nothing for Word handling.
-// (2) The overlay rule walks forward through siblings to collect a
-// complete list run, uses a session-tracked WeakSet to mark the
-// siblings as consumed, and builds a nested list tree from the
-// level transitions.
+// `WordListImportExtension` (shipped by this package, opt-in) installs the
+// preprocess that handles this: the overlay goes on only when the generator
+// meta tag is present, so pastes from other sources pay nothing, and its
+// rule walks forward through siblings to collect a complete list run and
+// rebuild it as a nested list tree.
// ----------------------------------------------------------------------------
-const WordListConsumed = createImportState>(
- 'word/consumed-list-items',
- () => new WeakSet(),
-);
-
-const WORD_LIST_CLASS_RE = /^MsoListParagraph(CxSp(First|Middle|Last))?$/;
-const WORD_NUMBERED_RE = /^[A-Za-z0-9]+[.)]/;
-
-function readMsoStyles(el: Element): Record {
- // mso-* are Microsoft non-standard CSS properties; browsers and JSDOM
- // don't surface them via el.style, so parse the raw style attribute.
- return getStyleObjectFromCSS(el.getAttribute('style') || '');
-}
-
-function readWordListLevel(el: HTMLElement): number {
- // mso-list looks like "l level lfo"; pluck the level number.
- const msoList = readMsoStyles(el)['mso-list'] || '';
- const m = msoList.match(/level(\d+)/);
- return m ? parseInt(m[1], 10) : 1;
-}
-
-function $findMarkerSpan(el: HTMLElement): HTMLElement | null {
- for (const span of Array.from(el.querySelectorAll('span'))) {
- if (readMsoStyles(span)['mso-list'] === 'Ignore') {
- return span;
- }
- }
- return null;
-}
-
-function $readWordMarker(el: HTMLElement): string {
- const span = $findMarkerSpan(el);
- return span ? (span.textContent || '').trim() : '';
-}
-
-function classifyWordListType(marker: string): 'number' | 'bullet' {
- return WORD_NUMBERED_RE.test(marker) ? 'number' : 'bullet';
-}
-
-function $stripWordMarker(el: HTMLElement): void {
- // The marker span is wrapped in an outer directly under the
- // ; remove that outer wrapper.
- const inner = $findMarkerSpan(el);
- if (!inner) {
- return;
- }
- let outer: Element = inner;
- while (
- outer.parentElement &&
- outer.parentElement !== el &&
- outer.parentElement.nodeName === 'SPAN'
- ) {
- outer = outer.parentElement;
- }
- outer.remove();
-}
-
-function isWordListParagraph(node: Node): node is HTMLElement {
- return isHTMLElement(node) && WORD_LIST_CLASS_RE.test(node.className);
-}
-
-function $buildWordListTree(
- ctx: DOMImportContext,
- items: readonly {el: HTMLElement; level: number; marker: string}[],
-): ListNode {
- const root = $createListNode(classifyWordListType(items[0].marker));
- type Frame = {list: ListNode; level: number};
- const stack: Frame[] = [{level: items[0].level, list: root}];
- for (const item of items) {
- // Close levels deeper than this one.
- while (stack.length > 1 && stack[stack.length - 1].level > item.level) {
- stack.pop();
- }
- // Open a new sublist if we just stepped deeper. Lexical's
- // nested-list convention (see `$isNestedListNode` in @lexical/list):
- // a sublist lives inside its OWN ListItemNode wrapper that is a
- // sibling of the content items above it, not inside the previous
- // one. The wrapper holds the sublist as its first (and only) child.
- if (item.level > stack[stack.length - 1].level) {
- const sub = $createListNode(classifyWordListType(item.marker));
- const wrapper = $createListItemNode();
- wrapper.append(sub);
- stack[stack.length - 1].list.append(wrapper);
- stack.push({level: item.level, list: sub});
- }
- $stripWordMarker(item.el);
- const li = $createListItemNode();
- li.splice(0, 0, ctx.$importChildren(item.el, {schema: InlineSchema}));
- stack[stack.length - 1].list.append(li);
- }
- return root;
-}
-
-const WordListParagraphRule = defineImportRule({
- $import: (ctx, el) => {
- const consumed = ctx.session.get(WordListConsumed);
- if (consumed.has(el)) {
- // Already collected by an earlier sibling's run.
- return [];
- }
- const items: {el: HTMLElement; level: number; marker: string}[] = [];
- let cur: Node | null = el;
- while (cur && isWordListParagraph(cur)) {
- consumed.add(cur);
- items.push({
- el: cur,
- level: readWordListLevel(cur),
- marker: $readWordMarker(cur),
- });
- // Stop at the explicitly-terminal class. The standalone
- // MsoListParagraph (no CxSp suffix) is a single-item run.
- if (
- cur.classList.contains('MsoListParagraphCxSpLast') ||
- cur.className === 'MsoListParagraph'
- ) {
- break;
- }
- cur = cur.nextElementSibling;
- }
- return [$buildWordListTree(ctx, items)];
- },
- match: sel
- .tag('p')
- .classAny(
- 'MsoListParagraph',
- 'MsoListParagraphCxSpFirst',
- 'MsoListParagraphCxSpMiddle',
- 'MsoListParagraphCxSpLast',
- ),
- name: 'word/list-paragraph',
-});
-
-// is Office's "paragraph end" marker; it always produces nothing.
-const WordOPRule = defineImportRule({
- $import: () => [],
- match: sel.tag('o:p'),
- name: 'word/o-p',
-});
-
-const WordPasteOverlay = defineOverlayRules([
- WordOPRule,
- WordListParagraphRule,
-]);
-
-const WORD_GENERATOR_RE = /Microsoft Word/i;
-
-const $installWordOverlay: DOMPreprocessFn = (dom, ctx, $next) => {
- const meta = dom.querySelector('meta[name="Generator"]');
- if (meta && WORD_GENERATOR_RE.test(meta.getAttribute('content') || '')) {
- ctx.session.update(ImportOverlays, prev => [...prev, WordPasteOverlay]);
- }
- $next();
-};
-
function buildWordPasteEditor() {
return buildEditorFromExtensions(
defineExtension({
- dependencies: [
- ListExtension,
- configExtension(DOMImportExtension, {
- preprocess: [$installWordOverlay],
- }),
- ],
+ dependencies: [WordListImportExtension],
name: 'word-paste-host',
}),
);
@@ -465,6 +289,42 @@ describe('MS Word paste — preprocess-installed overlay', () => {
});
});
+ test('survives the stylesheet-inlining preprocess', () => {
+ // Real Word output carries a `,
+ ),
+ );
+ editor.read(() => {
+ const lists = $getRoot().getChildren().filter($isListNode);
+ expect(lists).toHaveLength(3);
+ expect(lists[0].getListType()).toBe('number');
+ expect(lists[1].getListType()).toBe('bullet');
+ const outlineItems = $items(lists[2]);
+ expect(outlineItems).toHaveLength(3);
+ const nested = outlineItems[1].getFirstChild();
+ assert($isListNode(nested), 'expected nested sublist on wrapper item');
+ expect($items(nested).map(li => li.getTextContent().trim())).toEqual([
+ 'Outline numbered 1.a',
+ 'Outline numbered 1.b',
+ ]);
+ });
+ });
+
test('without the Generator meta the overlay is not installed', () => {
// Same body, no — the Word overlay must not
// fire, so the MsoListParagraph elements fall through to normal
diff --git a/packages/lexical-list/src/index.ts b/packages/lexical-list/src/index.ts
index 4543d46ad79..82a90a25e1c 100644
--- a/packages/lexical-list/src/index.ts
+++ b/packages/lexical-list/src/index.ts
@@ -45,6 +45,7 @@ export {
REMOVE_LIST_COMMAND,
UPDATE_LIST_START_COMMAND,
} from './registerList';
+export {WordListImportExtension} from './WordListImportExtension';
export {
$createListItemNode,
diff --git a/packages/lexical-website/docs/serialization/dom-import.md b/packages/lexical-website/docs/serialization/dom-import.md
index e5100dedcc5..3e594731b59 100644
--- a/packages/lexical-website/docs/serialization/dom-import.md
+++ b/packages/lexical-website/docs/serialization/dom-import.md
@@ -833,6 +833,21 @@ const $installWordOverlay: DOMPreprocessFn = (dom, ctx, $next) => {
};
```
+That is the pattern, not something you have to write for Word lists
+specifically: `@lexical/list` ships this exact preprocess as
+`WordListImportExtension`. It is opt-in — `ListExtension` does not depend
+on it, so an editor that never pastes from Word does not bundle it:
+
+```ts
+import {WordListImportExtension} from '@lexical/list';
+import {defineExtension} from 'lexical';
+
+defineExtension({
+ dependencies: [WordListImportExtension],
+ name: 'my-editor',
+});
+```
+
See `packages/lexical-list/src/__tests__/unit/ListImportExtension.test.ts`
("MS Word paste — preprocess-installed overlay") for a worked unit
test, and [`dev-examples/dom-import`](https://github.com/facebook/lexical/tree/main/dev-examples/dom-import)
From 21e768ae3f7c80e85eb82fa64fb7268550a713eb Mon Sep 17 00:00:00 2001
From: Nithin <52503252+nithin42@users.noreply.github.com>
Date: Thu, 20 Aug 2026 15:52:44 +0000
Subject: [PATCH 3/5] [lexical-link] Bug Fix: read the latest state in LinkNode
and AutoLinkNode getters (#9080)
---
packages/lexical-link/src/LexicalLinkNode.ts | 17 ++--
.../__tests__/unit/LinkNodeStaleState.test.ts | 86 +++++++++++++++++++
2 files changed, 94 insertions(+), 9 deletions(-)
create mode 100644 packages/lexical-link/src/__tests__/unit/LinkNodeStaleState.test.ts
diff --git a/packages/lexical-link/src/LexicalLinkNode.ts b/packages/lexical-link/src/LexicalLinkNode.ts
index 70a4c88aa0d..bb52158bb1e 100644
--- a/packages/lexical-link/src/LexicalLinkNode.ts
+++ b/packages/lexical-link/src/LexicalLinkNode.ts
@@ -303,22 +303,21 @@ export class LinkNode extends ElementNode {
}
isEmailURI(): boolean {
- return this.__url.startsWith('mailto:');
+ return this.getURL().startsWith('mailto:');
}
isWebSiteURI(): boolean {
- return (
- this.__url.startsWith('https://') || this.__url.startsWith('http://')
- );
+ const url = this.getURL();
+ return url.startsWith('https://') || url.startsWith('http://');
}
shouldMergeAdjacentLink(otherLink: LinkNode): boolean {
return (
this.getType() === otherLink.getType() &&
- this.__url === otherLink.__url &&
- this.__target === otherLink.__target &&
- this.__rel === otherLink.__rel &&
- this.__title === otherLink.__title
+ this.getURL() === otherLink.getURL() &&
+ this.getTarget() === otherLink.getTarget() &&
+ this.getRel() === otherLink.getRel() &&
+ this.getTitle() === otherLink.getTitle()
);
}
}
@@ -516,7 +515,7 @@ export class AutoLinkNode extends LinkNode {
}
getIsUnlinked(): boolean {
- return this.__isUnlinked;
+ return this.getLatest().__isUnlinked;
}
setIsUnlinked(value: boolean): this {
diff --git a/packages/lexical-link/src/__tests__/unit/LinkNodeStaleState.test.ts b/packages/lexical-link/src/__tests__/unit/LinkNodeStaleState.test.ts
new file mode 100644
index 00000000000..cc346394047
--- /dev/null
+++ b/packages/lexical-link/src/__tests__/unit/LinkNodeStaleState.test.ts
@@ -0,0 +1,86 @@
+/**
+ * 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 {
+ $createAutoLinkNode,
+ $createLinkNode,
+ AutoLinkNode,
+ LinkNode,
+} from '@lexical/link';
+import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+import {describe, expect, test} from 'vitest';
+
+describe('LinkNode stale state readers', () => {
+ initializeUnitTest(
+ testEnv => {
+ test('isEmailURI and isWebSiteURI agree with getURL after setURL', async () => {
+ const {editor} = testEnv;
+ let link!: LinkNode;
+
+ await editor.update(() => {
+ link = $createLinkNode('https://lexical.dev/');
+ link.append($createTextNode('Hello'));
+ $getRoot().append($createParagraphNode().append(link));
+ });
+
+ await editor.update(() => {
+ // setURL() goes through getWritable(), which in a later update
+ // clones the node. `link` still points at the previous version, so
+ // any reader that skips getLatest() answers from stale state.
+ link.setURL('mailto:someone@example.com');
+
+ expect(link.getURL()).toBe('mailto:someone@example.com');
+ expect(link.isEmailURI()).toBe(true);
+ expect(link.isWebSiteURI()).toBe(false);
+ });
+ });
+
+ test('shouldMergeAdjacentLink compares the latest urls', async () => {
+ const {editor} = testEnv;
+ let first!: LinkNode;
+ let second!: LinkNode;
+
+ await editor.update(() => {
+ const paragraph = $createParagraphNode();
+ first = $createLinkNode('https://lexical.dev/');
+ second = $createLinkNode('https://lexical.dev/');
+ first.append($createTextNode('a'));
+ second.append($createTextNode('b'));
+ paragraph.append(first, second);
+ $getRoot().append(paragraph);
+ });
+
+ await editor.update(() => {
+ first.setURL('https://example.com/');
+
+ expect(first.getURL()).not.toBe(second.getURL());
+ expect(first.shouldMergeAdjacentLink(second)).toBe(false);
+ });
+ });
+
+ test('getIsUnlinked reflects the latest value', async () => {
+ const {editor} = testEnv;
+ let autoLink!: AutoLinkNode;
+
+ await editor.update(() => {
+ autoLink = $createAutoLinkNode('https://lexical.dev/');
+ autoLink.append($createTextNode('Hello'));
+ $getRoot().append($createParagraphNode().append(autoLink));
+ });
+
+ await editor.update(() => {
+ autoLink.setIsUnlinked(true);
+
+ expect(autoLink.getIsUnlinked()).toBe(true);
+ });
+ });
+ },
+ {nodes: [LinkNode, AutoLinkNode]},
+ );
+});
From d4c8fdb039fc7c31d36aa68b449f181ed72cd23c Mon Sep 17 00:00:00 2001
From: Nithin <52503252+nithin42@users.noreply.github.com>
Date: Thu, 20 Aug 2026 17:46:37 +0000
Subject: [PATCH 4/5] [lexical-list] Bug Fix: remove the stale depth theme
class when a list changes depth (#9077)
---
packages/lexical-list/src/LexicalListNode.ts | 2 +-
.../unit/ListNodeDepthThemeClass.test.ts | 62 +++++++++++++++++++
2 files changed, 63 insertions(+), 1 deletion(-)
create mode 100644 packages/lexical-list/src/__tests__/unit/ListNodeDepthThemeClass.test.ts
diff --git a/packages/lexical-list/src/LexicalListNode.ts b/packages/lexical-list/src/LexicalListNode.ts
index 12221007d25..108bd909e3c 100644
--- a/packages/lexical-list/src/LexicalListNode.ts
+++ b/packages/lexical-list/src/LexicalListNode.ts
@@ -264,7 +264,7 @@ function $setListThemeClassNames(
classesToAdd.push(...normalizeClassNames(listLevelClassName));
for (let i = 0; i < listLevelsClassNames.length; i++) {
if (i !== normalizedListDepth) {
- classesToRemove.push(node.__tag + i);
+ classesToRemove.push(...normalizeClassNames(listLevelsClassNames[i]));
}
}
}
diff --git a/packages/lexical-list/src/__tests__/unit/ListNodeDepthThemeClass.test.ts b/packages/lexical-list/src/__tests__/unit/ListNodeDepthThemeClass.test.ts
new file mode 100644
index 00000000000..aa0f6e30655
--- /dev/null
+++ b/packages/lexical-list/src/__tests__/unit/ListNodeDepthThemeClass.test.ts
@@ -0,0 +1,62 @@
+/**
+ * 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 {
+ $createListItemNode,
+ $createListNode,
+ type ListNode,
+} from '@lexical/list';
+import {$createTextNode, $getRoot} from 'lexical';
+import {initializeUnitTest} from 'lexical/src/__tests__/utils';
+import {describe, expect, test} from 'vitest';
+
+describe('ListNode depth theme classes', () => {
+ initializeUnitTest(
+ testEnv => {
+ test('the previous depth class is removed when a list changes depth', async () => {
+ const {editor} = testEnv;
+ let listKey = '';
+
+ await editor.update(() => {
+ const list = $createListNode('bullet');
+ list.append($createListItemNode().append($createTextNode('a')));
+ $getRoot().append(list);
+ listKey = list.getKey();
+ });
+
+ expect(editor.getElementByKey(listKey)!.className).toBe(
+ 'my-ul-list-class depth-1',
+ );
+
+ // Nest the existing list one level deeper. The DOM element is reused,
+ // so updateDOM() has to swap depth-1 for depth-2.
+ await editor.update(() => {
+ const list = $getRoot().getFirstChild() as ListNode;
+ const outer = $createListNode('bullet');
+ const listItem = $createListItemNode();
+ outer.append(listItem);
+ $getRoot().append(outer);
+ listItem.append(list);
+ });
+
+ expect(editor.getElementByKey(listKey)!.className).toBe(
+ 'my-ul-list-class depth-2',
+ );
+ });
+ },
+ {
+ namespace: 'test',
+ theme: {
+ list: {
+ ul: 'my-ul-list-class',
+ ulDepth: ['depth-1', 'depth-2', 'depth-3'],
+ },
+ },
+ },
+ );
+});
From 0539e88c938f63eef2ecd16d86081a89319271d6 Mon Sep 17 00:00:00 2001
From: Gerard Rovira
Date: Thu, 20 Aug 2026 18:05:17 +0000
Subject: [PATCH 5/5] [ci] Extract the internal-registry OIDC exchange into a
reusable auth action (#9084)
---
.github/actions/internal-registry/README.md | 88 +++++++++
.../actions/internal-registry/auth/action.yml | 59 ++++++
.../actions/internal-registry/auth/mint.mjs | 171 ++++++++++++++++++
.../dependency-check/action.yml | 48 ++---
.github/workflows/internal-registry.yml | 7 +
5 files changed, 342 insertions(+), 31 deletions(-)
create mode 100644 .github/actions/internal-registry/README.md
create mode 100644 .github/actions/internal-registry/auth/action.yml
create mode 100644 .github/actions/internal-registry/auth/mint.mjs
diff --git a/.github/actions/internal-registry/README.md b/.github/actions/internal-registry/README.md
new file mode 100644
index 00000000000..3ec48cfeb80
--- /dev/null
+++ b/.github/actions/internal-registry/README.md
@@ -0,0 +1,88 @@
+# Internal registry actions
+
+Two composite actions that let a GitHub Actions job talk to the internal npm
+registry without a static token. The job's own OIDC identity is exchanged for a
+short-lived credential, per run.
+
+| action | what it does |
+| --- | --- |
+| [`auth`](./auth) | Mints the credential. Optionally writes it into the npm user config so later `install` / `publish` steps just work. |
+| [`dependency-check`](./dependency-check) | Asserts every version pinned in a lockfile is actually served by the registry. |
+
+Both are versioned with their own tags (`internal-registry-auth-v1`,
+`internal-registry-dependency-check-v2`). Pin a tag — do not track `main`.
+
+## Using it from another repository
+
+They are public actions, so any repository in any organization can use them.
+
+Read-only check:
+
+```yaml
+jobs:
+ dependency-check:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write # required, and it must be set on the JOB
+ steps:
+ - uses: actions/checkout@v7
+ - id: auth
+ uses: facebook/lexical/.github/actions/internal-registry/auth@internal-registry-auth-v1
+ with:
+ write-npmrc: 'false'
+ on-missing-oidc: skip
+ - uses: facebook/lexical/.github/actions/internal-registry/dependency-check@internal-registry-dependency-check-v2
+ with:
+ token: ${{ steps.auth.outputs.token }}
+```
+
+Installing or publishing scoped packages:
+
+```yaml
+ - uses: actions/setup-node@v6 # BEFORE auth: setup-node rewrites the npm config
+ with:
+ node-version: '24'
+ - uses: facebook/lexical/.github/actions/internal-registry/auth@internal-registry-auth-v1
+ with:
+ scopes: '@acme @acme-ui'
+ - run: npm publish
+```
+
+## Things that will bite you
+
+- **`permissions: id-token: write` must be on the job.** A job-level
+ `permissions` block *replaces* the workflow-level one rather than merging
+ with it, so setting it only at the workflow level leaves the job with no
+ OIDC identity.
+- **Run `actions/setup-node` before `auth`, not after.** When `setup-node` is
+ given a `registry-url` it rewrites the npm user config, which would discard
+ what `auth` wrote.
+- **`auth` writes *user* config, not a project-local `.npmrc`.** npm does not
+ walk up from the directory it publishes from, so a repo-root `.npmrc` is
+ invisible to `npm publish` run in a subdirectory. This is the usual cause of
+ a confusing `ENEEDAUTH` when the credential is demonstrably present.
+- **The two actions compose in the workflow; they do not nest.** A local
+ `uses: ./...` inside a composite action resolves against the *caller's*
+ workspace, not the repository the action came from, so a remote composite
+ action cannot reference a sibling. This is why `dependency-check` takes a
+ `token` input instead of calling `auth` itself.
+- **Fork pull requests have no OIDC identity.** Use `on-missing-oidc: skip` and
+ gate on a required `merge_group` run.
+
+## Diagnosing a rejected credential
+
+`auth` logs the OIDC subject it minted for:
+
+```
+Credential minted for subject: repo:my-org/my-repo:ref:refs/heads/main
+```
+
+That string is what the registry authorizes against, so quote it when asking
+for access. It is not a secret; the credential itself is masked. Note that some
+repositories are configured to emit an immutable subject that embeds numeric
+IDs (`repo:my-org@123/my-repo@456:...`) — check with:
+
+```bash
+gh api repos///actions/oidc/customization/sub
+```
diff --git a/.github/actions/internal-registry/auth/action.yml b/.github/actions/internal-registry/auth/action.yml
new file mode 100644
index 00000000000..6282cef82b0
--- /dev/null
+++ b/.github/actions/internal-registry/auth/action.yml
@@ -0,0 +1,59 @@
+name: Internal registry auth
+description: >-
+ Mints a short-lived registry credential from the job's GitHub OIDC identity,
+ and optionally writes it into the npm user config so later steps can install
+ or publish. No static token is involved: the credential is minted per run and
+ never persisted. The calling job must grant `permissions: id-token: write`.
+ Run `actions/setup-node` BEFORE this action — setup-node rewrites the npm
+ config that this action appends to.
+
+inputs:
+ registry-url:
+ description: Registry base URL.
+ required: false
+ default: https://registry.facebook.net
+ scopes:
+ description: >-
+ Space-separated npm scopes to route to the registry, for example
+ "@acme @acme-ui". Leave empty to mint a credential without routing any
+ scope, which is what a read-only check wants.
+ required: false
+ default: ''
+ write-npmrc:
+ description: >-
+ Whether to append the credential and the scope routing to the npm user
+ config. Set to false to receive the token as an output only.
+ required: false
+ default: 'true'
+ on-missing-oidc:
+ description: >-
+ What to do when the job has no OIDC identity, which is the case for a
+ fork pull request: "fail" or "skip". When skipping, the outputs are empty
+ so the caller can branch on them.
+ required: false
+ default: fail
+
+outputs:
+ token:
+ description: >-
+ The minted credential. Empty when the job had no OIDC identity and
+ on-missing-oidc was "skip".
+ value: ${{ steps.mint.outputs.token }}
+ subject:
+ description: >-
+ The OIDC subject the credential was minted for. This is the exact string
+ the registry authorizes against, so it is what to quote when a request is
+ rejected.
+ value: ${{ steps.mint.outputs.subject }}
+
+runs:
+ using: composite
+ steps:
+ - id: mint
+ shell: bash
+ env:
+ REGISTRY_URL: ${{ inputs.registry-url }}
+ REGISTRY_SCOPES: ${{ inputs.scopes }}
+ WRITE_NPMRC: ${{ inputs.write-npmrc }}
+ ON_MISSING_OIDC: ${{ inputs.on-missing-oidc }}
+ run: node "${{ github.action_path }}/mint.mjs"
diff --git a/.github/actions/internal-registry/auth/mint.mjs b/.github/actions/internal-registry/auth/mint.mjs
new file mode 100644
index 00000000000..99ae1e6d7de
--- /dev/null
+++ b/.github/actions/internal-registry/auth/mint.mjs
@@ -0,0 +1,171 @@
+/**
+ * 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.
+ *
+ */
+
+// Mints a short-lived registry credential from the job's GitHub OIDC identity.
+//
+// Two hops: the Actions token service issues an OIDC id_token for a fixed
+// audience, and the identity provider exchanges that for a registry
+// credential. Nothing is cached or persisted beyond the job.
+
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+const OIDC_AUDIENCE = 'meta_jwt_access_token';
+const EXCHANGE_URL =
+ 'https://www.internalfb.com/intern/crypto_jwt/access_token_exchange/';
+const EXCHANGE_PEER = 'metaccio';
+const EXCHANGE_AUDIENCE = 'metaccio';
+
+// Neither hop has a default timeout under Node's global fetch, and a hung
+// request would burn the job's whole timeout budget with no useful log line.
+const OIDC_TIMEOUT_MS = 20_000;
+const EXCHANGE_TIMEOUT_MS = 25_000;
+
+const REGISTRY_URL = (process.env.REGISTRY_URL ?? '').trim();
+const SCOPES = (process.env.REGISTRY_SCOPES ?? '').split(/\s+/).filter(Boolean);
+const WRITE_NPMRC = (process.env.WRITE_NPMRC ?? 'true') !== 'false';
+const ON_MISSING_OIDC = (process.env.ON_MISSING_OIDC ?? 'fail').trim();
+
+function fail(message) {
+ console.error(`::error::${message}`);
+ process.exit(1);
+}
+
+function setOutput(name, value) {
+ // A delimiter rather than `name=value`, so a value that is not what we
+ // expect cannot inject further workflow commands.
+ const delimiter = `ghadelim_${name}`;
+ fs.appendFileSync(
+ process.env.GITHUB_OUTPUT,
+ `${name}<<${delimiter}\n${value}\n${delimiter}\n`,
+ );
+}
+
+if (!REGISTRY_URL) {
+ fail('registry-url is empty.');
+}
+
+let registry;
+try {
+ registry = new URL(REGISTRY_URL);
+} catch {
+ fail(`registry-url is not a valid URL: ${REGISTRY_URL}`);
+}
+
+const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
+const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
+
+if (!requestUrl || !requestToken) {
+ if (ON_MISSING_OIDC === 'skip') {
+ console.warn(
+ '::notice::No OIDC identity available (most likely a fork pull request) — skipping.',
+ );
+ setOutput('token', '');
+ setOutput('subject', '');
+ process.exit(0);
+ }
+ fail(
+ 'This job has no OIDC identity. Add `permissions: id-token: write` to the ' +
+ 'job — a job-level permissions block replaces the workflow-level one, so ' +
+ 'it has to be set on the job itself, not only on the workflow.',
+ );
+}
+
+async function fetchIdToken() {
+ const response = await fetch(`${requestUrl}&audience=${OIDC_AUDIENCE}`, {
+ headers: {Authorization: `bearer ${requestToken}`},
+ signal: AbortSignal.timeout(OIDC_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ fail(`Could not obtain an OIDC id_token (HTTP ${response.status}).`);
+ }
+ const {value} = await response.json();
+ if (!value) {
+ fail('The OIDC token service returned an empty id_token.');
+ }
+ return value;
+}
+
+async function exchangeForCredential(idToken) {
+ const response = await fetch(EXCHANGE_URL, {
+ body: new URLSearchParams({
+ audience: EXCHANGE_AUDIENCE,
+ id_token: idToken,
+ peer: EXCHANGE_PEER,
+ }),
+ headers: {
+ Accept: 'application/jwt',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ method: 'POST',
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
+ });
+ if (!response.ok) {
+ fail(
+ `Token exchange failed (HTTP ${response.status}). A 403 usually means ` +
+ 'this repository has not been granted access to the registry yet.',
+ );
+ }
+ const credential = (await response.text()).trim();
+ // A JWS is three dot-separated segments; anything else is an error page.
+ if (credential.split('.').length !== 3) {
+ fail('Token exchange did not return a credential.');
+ }
+ return credential;
+}
+
+function subjectOf(credential) {
+ try {
+ const claims = JSON.parse(
+ Buffer.from(credential.split('.')[1], 'base64url').toString('utf8'),
+ );
+ return claims.sub;
+ } catch {
+ return undefined;
+ }
+}
+
+const credential = await exchangeForCredential(await fetchIdToken());
+
+// Mask before the credential can reach any later log line.
+process.stdout.write(`::add-mask::${credential}\n`);
+
+// The subject is not a secret, and it is the exact string the registry
+// authorizes against. Printing it turns an opaque rejection into a
+// self-diagnosing one.
+const subject = subjectOf(credential) ?? '';
+console.warn(`Credential minted for subject: ${subject}`);
+
+setOutput('token', credential);
+setOutput('subject', subject);
+
+if (WRITE_NPMRC) {
+ // setup-node points npm at a config under RUNNER_TEMP when its own
+ // `registry-url` is set; respect that, or npm reads ~/.npmrc and never sees
+ // these lines. Writing *user* config also means npm finds the credential
+ // from any working directory — a project-local .npmrc does not, because npm
+ // does not walk up from the directory it publishes from.
+ const npmrc =
+ process.env.NPM_CONFIG_USERCONFIG || path.join(os.homedir(), '.npmrc');
+ const authKey = `//${registry.host}${registry.pathname.replace(/\/?$/, '/')}:_authToken`;
+ const lines = [
+ ...SCOPES.map(scope => `${scope}:registry=${REGISTRY_URL}`),
+ `${authKey}=${credential}`,
+ ];
+ // Appended, not overwritten: setup-node may already have written an entry
+ // that a dual-target publish still needs. npm's ini parse is last-wins, so a
+ // scope written above is redirected by ours.
+ fs.mkdirSync(path.dirname(npmrc), {recursive: true});
+ fs.appendFileSync(npmrc, `\n${lines.join('\n')}\n`);
+ console.warn(
+ SCOPES.length > 0
+ ? `Routed ${SCOPES.join(', ')} to ${REGISTRY_URL} (${npmrc})`
+ : `Wrote the registry credential to ${npmrc}`,
+ );
+}
diff --git a/.github/actions/internal-registry/dependency-check/action.yml b/.github/actions/internal-registry/dependency-check/action.yml
index c52ad66626b..8a6873b65a7 100644
--- a/.github/actions/internal-registry/dependency-check/action.yml
+++ b/.github/actions/internal-registry/dependency-check/action.yml
@@ -1,12 +1,18 @@
name: Internal registry dependency check
description: >-
Checks that every dependency version pinned in the lockfile is available from
- the configured registry. Authenticates with a short-lived token minted from
- the job's GitHub OIDC identity. The calling job must grant
- `permissions: id-token: write` and check out the repo. Fork PRs can't mint
- OIDC and are skipped with a notice; enforce via a required merge_group run.
+ the configured registry. Takes a credential from the sibling `auth` action
+ rather than minting one itself, so there is a single implementation of the
+ OIDC exchange. The calling job must check out the repo. A fork pull request
+ cannot mint OIDC, so `auth` yields an empty token and this check is skipped
+ with a notice; enforce via a required merge_group run.
inputs:
+ token:
+ description: >-
+ Registry credential, as produced by the sibling `auth` action. An empty
+ value skips the check, which is what a fork pull request produces.
+ required: true
lockfile:
description: >-
Path to the lockfile to check. Format is auto-detected by filename:
@@ -28,35 +34,15 @@ runs:
- uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
+ - name: Skip when no credential is available
+ if: inputs.token == ''
+ shell: bash
+ run: echo "::notice::No registry credential (most likely a fork pull request) — deferring to the required merge-queue run."
- name: Check dependencies are available in the registry
+ if: inputs.token != ''
shell: bash
env:
REGISTRY_URL: ${{ inputs.registry-url }}
+ REGISTRY_TOKEN: ${{ inputs.token }}
LOCKFILE: ${{ inputs.lockfile }}
- run: |
- set -euo pipefail
-
- # Fork PRs get a read-only token with no OIDC, so they can't
- # authenticate. Pass with a notice (the required merge-queue run gates
- # them) so this check always reports a conclusion and is never stuck.
- if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]; then
- echo "::notice::No OIDC identity available (likely a fork PR) — deferring to the required merge-queue run."
- exit 0
- fi
-
- # 1) Mint this job's GitHub OIDC token.
- ID=$(curl -sS --max-time 20 \
- -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
- "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=meta_jwt_access_token" \
- | python3 -c "import sys,json;print(json.load(sys.stdin)['value'])")
-
- # 2) Exchange it for a short-lived registry token.
- TOKEN=$(curl -sS --max-time 25 -H "Accept: application/jwt" \
- --data-urlencode "id_token=$ID" \
- --data-urlencode "peer=metaccio" \
- --data-urlencode "audience=metaccio" \
- https://www.internalfb.com/intern/crypto_jwt/access_token_exchange/)
- [ "$(printf '%s' "$TOKEN" | tr -cd '.' | wc -c)" -eq 2 ] || { echo "::error::OIDC token exchange failed"; exit 1; }
-
- # 3) Assert every pinned dependency version is served by the registry.
- REGISTRY_TOKEN="$TOKEN" node "${{ github.action_path }}/check.mjs"
+ run: node "${{ github.action_path }}/check.mjs"
diff --git a/.github/workflows/internal-registry.yml b/.github/workflows/internal-registry.yml
index 83c325bc35b..9c1d7b23ea8 100644
--- a/.github/workflows/internal-registry.yml
+++ b/.github/workflows/internal-registry.yml
@@ -20,4 +20,11 @@ jobs:
id-token: write
steps:
- uses: actions/checkout@v7
+ - id: auth
+ uses: ./.github/actions/internal-registry/auth
+ with:
+ write-npmrc: 'false'
+ on-missing-oidc: skip
- uses: ./.github/actions/internal-registry/dependency-check
+ with:
+ token: ${{ steps.auth.outputs.token }}