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 }} 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-html/src/__tests__/unit/DOMImportExtension.test.ts b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts index 7f983f7b79b..12e69705ecb 100644 --- a/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts +++ b/packages/lexical-html/src/__tests__/unit/DOMImportExtension.test.ts @@ -433,6 +433,17 @@ describe('DOMImportExtension', () => { expect(importedTags('p, .foo, article')).toEqual(['p', 'div', 'article']); }); + test('CSS parser rejects an empty selector or a hole in a selector list', () => { + for (const selector of ['', ' ', 'h1,', ',h1', 'h1,,h2', 'h1, ,h2']) { + expect(() => parseSelector(selector)).toThrow(/expected a selector/); + } + // A lone `*` is the one group that legitimately has neither tag nor + // refinement, so it and any list containing it still parse. + expect(() => parseSelector('*')).not.toThrow(); + expect(() => parseSelector('h1, *')).not.toThrow(); + expect(() => parseSelector('*.foo')).not.toThrow(); + }); + test('isElementOfTag narrows correctly without instanceof', () => { const dom = new JSDOM( '

', diff --git a/packages/lexical-html/src/import/parseCss.ts b/packages/lexical-html/src/import/parseCss.ts index 8bb024e18c7..0c6bc011b63 100644 --- a/packages/lexical-html/src/import/parseCss.ts +++ b/packages/lexical-html/src/import/parseCss.ts @@ -85,12 +85,14 @@ function parseSimpleSelector(c: Cursor): ParsedSimpleSelector { const tags = new Set(); const predicates: Predicate[] = []; const classes: string[] = []; + let isUniversal = false; c.skipWhitespace(); // Optional tag or '*' if (c.peek() === '*') { c.consume(); + isUniversal = true; } else if (IDENT_CHAR.test(c.peek())) { const tag = c.readIdent(); if (tag) { @@ -142,6 +144,14 @@ function parseSimpleSelector(c: Cursor): ParsedSimpleSelector { predicates.push(buildClassAllPredicate(classes)); } + // Neither tag nor refinement is only legitimate when the group came from a + // lone `*`. Otherwise the source is empty or its list has a hole, and + // accepting it would silently turn a typo into a universal selector. + c.assert( + isUniversal || tags.size > 0 || predicates.length > 0, + 'expected a selector', + ); + return {predicates, tags}; } @@ -168,13 +178,7 @@ export function parseSelector( const groups: ParsedSimpleSelector[] = []; while (true) { - const group = parseSimpleSelector(c); - if (group.tags.size === 0 && group.predicates.length === 0) { - // Empty group with neither tag nor refinement — only OK if it came - // from the lone `*` (which produces zero tags but no preds either). - // We accept this as "wildcard element". - } - groups.push(group); + groups.push(parseSimpleSelector(c)); c.skipWhitespace(); if (c.eof()) { break; 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]}, + ); +}); 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/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/__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'], + }, + }, + }, + ); +}); 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)