Skip to content

Export Lexical and document allowedElements in Extensions#1047

Open
excid3 wants to merge 1 commit into
basecamp:mainfrom
excid3:export-lexical
Open

Export Lexical and document allowedElements in Extensions#1047
excid3 wants to merge 1 commit into
basecamp:mainfrom
excid3:export-lexical

Conversation

@excid3

@excid3 excid3 commented May 8, 2026

Copy link
Copy Markdown

This addresses a couple things:

  1. The docs previously showed importing defineExtension from lexical. However, that means we need to pin the lexical package which could be a different version. Instead, we can export Lexical through Lexxy so users have access to the exact same version.

We could export just defineExtension to keep it minimal, but I wasn't sure if extensions may need access to other parts of Lexical. I can quickly refactor this if that's the best approach.

  1. I am unfurling links with Stimulus and allowing iframe embeds for YouTube, Spotify, etc. This broke recently because of Build sanitizer allowlist dynamically #909 I believe (it worked in 0.9.3.beta but not 0.9.4.beta+), which lead me to trying to write an extension to allow iframe tags. Trying to create an extension lead me first to trying to access defineExtension cleanly.

Does this make sense as an approach @zachasme @samuelpecher @jorgemanrubia?

Copilot AI review requested due to automatic review settings May 8, 2026 18:20

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR exposes Lexical from Lexxy’s public JS entrypoint so extension authors can access the same Lexical package instance/version that Lexxy uses, and updates the extension docs to use that export while documenting allowedElements.

Changes:

  • Re-export lexical as Lexxy.Lexical from the package entrypoint.
  • Update the extensions documentation to use Lexxy.Lexical.defineExtension instead of importing from lexical.
  • Document allowedElements in extensions (example includes allowing <iframe>).

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/index.js Re-exports the lexical namespace as part of Lexxy’s public API.
docs/extensions.md Updates extension authoring docs to use Lexxy.Lexical and documents allowedElements (incl. iframe example).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/index.js

export const configure = Lexxy.configure
export { default as Extension } from "./extensions/lexxy_extension"
export * as Lexical from "lexical"
Comment thread docs/extensions.md
return this.#config.enableCoolFeature
}

// optional: allow additional elements
Comment thread docs/extensions.md
Comment on lines +59 to +61
// optional: allow additional elements
get allowedElements() {
return [ { tag: "iframe", attributes: [ "src", "loading", "allow" ] } ]
@samuelpecher

Copy link
Copy Markdown
Collaborator

Hey @excid3 👋 I'm for this in principle. My one concern about package size was unfounded.

I've been asking myself the same question since our extensions make use of commands, tags, and mergeRegister. Lexical helpfully exports all of these at the top level.

I'm not a huge fan of return Lexxy.Lexical.defineExtension(..., perhaps Lexxy.Extension should provide a defineLexicalExtension wrapper for this common use case.

For other imports, I wonder whether exporting lexical at a different path (like highlightCode) looks better.

import { mergeRegister } from "@37signals/lexxy/lexical"

rather than:

import Lexxy from "@37signals/lexxy"
const { mergeRegister } = Lexxy.Lexical
// or at the callsite:
Lexxy.Lexical.mergeRegister(//...

My only concern would have been on bundle size, but it looks like the hit is ~6k and there won't be any impact on the npm package.


Without export

lexxy main  ❯ yarn build && ls -l app/assets/javascript/
yarn run v1.22.22
$ rollup -c

./src/index.js → ./app/assets/javascript/lexxy.js, ./app/assets/javascript/lexxy.min.js...
created ./app/assets/javascript/lexxy.js, ./app/assets/javascript/lexxy.min.js in 3.2s
Done in 3.36s.
Permissions Size User Date Modified Name
.rw-r--r--  855k slp  11 May 12:09   lexxy.js
.rw-r--r--  195k slp  11 May 12:09   lexxy.js.br
.rw-r--r--  238k slp  11 May 12:09   lexxy.js.gz
.rw-r--r--  2.0M slp  11 May 12:09   lexxy.js.map
.rw-r--r--  572k slp  11 May 12:09   lexxy.min.js
.rw-r--r--  146k slp  11 May 12:09   lexxy.min.js.br
.rw-r--r--  175k slp  11 May 12:09   lexxy.min.js.gz

With export

lexxy main  ❯ yarn build && ls -l app/assets/javascript/
yarn run v1.22.22
$ rollup -c

./src/index.js → ./app/assets/javascript/lexxy.js, ./app/assets/javascript/lexxy.min.js...
created ./app/assets/javascript/lexxy.js, ./app/assets/javascript/lexxy.min.js in 3s
Done in 3.21s.
Permissions Size User Date Modified Name
.rw-r--r--  861k slp  11 May 12:10   lexxy.js
.rw-r--r--  197k slp  11 May 12:10   lexxy.js.br
.rw-r--r--  240k slp  11 May 12:10   lexxy.js.gz
.rw-r--r--  2.0M slp  11 May 12:10   lexxy.js.map
.rw-r--r--  578k slp  11 May 12:10   lexxy.min.js
.rw-r--r--  148k slp  11 May 12:10   lexxy.min.js.br
.rw-r--r--  177k slp  11 May 12:10   lexxy.min.js.gz

@excid3

excid3 commented Jun 4, 2026

Copy link
Copy Markdown
Author

@samuelpecher that's not as much as I expected!

I'm not super familiar with Lexical, but my thinking was by exposing all of Lexical, you give people the freedom to access any functionality easily. That may not be necessary or common enough, so a wrapper could be a simpler option for users.

@jorgemanrubia jorgemanrubia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @excid3 could you detail the limitations you found with the current extension support?

I would like to keep Lexical encapsulated as much as possible and favor an extension system that covers most needs, unless we can make a strong case to give full access to Lexical.

Thanks!

@excid3

excid3 commented Jun 5, 2026

Copy link
Copy Markdown
Author

@jorgemanrubia there didn't seem to be a way to create a Lexxy extension using importmaps without also pinning Lexical separately. Could just be my lack of frontend knowledge.

The docs show:

pin "lexxy", to: "lexxy.js"
import { defineExtension } from "lexical"
import * as Lexxy from "@37signals/lexxy"

I was trying to build an extension that lets me paste in like a YouTube or Bunny.net video and allow the iframe tag.

import * as Lexxy from "lexxy"
import * as Lexical from "lexical"

class EmbedsExtension extends Lexxy.Extension {
  get lexicalExtension() {
    return Lexical.defineExtension({ name: "lexxy/embeds_extension" })
  }

  get allowedElements() {
    return [ { tag: "iframe", attributes: [ "src", "loading", "width", "height", "style", "allow" ] } ]
  }
}

Lexxy.configure({
  global: {
    extensions: [EmbedsExtension],
  }
})

The trouble is I'd have to pin a second copy of Lexical in order to do this currently?

@samuelpecher

samuelpecher commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

by exposing all of Lexical, you give people the freedom to access any functionality easily.

Yep. Lexical's functions are the "knives" we need to expose for extension writers to cut into Lexical.


@jorgemanrubia I think the import for our own VoiceNotes implementation illustrates the issue:

import { $getNodeByKey, $getRoot } from "lexical"
import { mergeRegister } from "@lexical/utils"
import { defineExtension } from "@lexical/extension"
// AudioRecorderNode imports DecoratorNode from "lexical"
import { $createAudioRecorderNode, AudioRecorderNode } from "./audio_recorder_node"

Or our slash-command extension:

import { RootNode, $isParagraphNode } from "lexical"
import { $createCodeNode } from "@lexical/code"

If Lexical was OO, it could simply be a case of calling functions on objects we could expose within the extension. With Lexical's implementation, those messages are the functions and types exposed from lexical, and potentially @lexical/code etc. A straightforward example would be handling paste: an extension will need to import PASTE_COMMAND from lexical.

Since we build Lexxy from npm through yarn, we can easily import { $getNodeByKey, $getRoot } from "lexical". The situation gets more complicated with importmaps since—as @excid3 points out—Lexical will need to be updated in lock-step with Lexxy. The same situation would occur if using a non-flat node_modules, as pnpm does.

@jorgemanrubia jorgemanrubia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good points @samuelpecher @excid3. I am fine with going forward with this 👍.

I'm not a huge fan of return Lexxy.Lexical.defineExtension(..., perhaps Lexxy.Extension should provide a defineLexicalExtension wrapper for this common use case.

Yes, I think so. In this case I think it is worth adding a wrapper.

We could also add a little section documenting how to access Lexical elements as part of the extensions article, as part of this PR.

@fillipeppalhares

Copy link
Copy Markdown

Hello guys! I would like to add my 2 cents about what happened today. I ran into both sides of this problem while writing custom extensions for Lexxy.

Lexical runtime conflict: My extensions imported defineExtension, DecoratorNode, etc. directly from "lexical". Because yarn hoisted my pinned lexical@0.45.0 separately from Lexxy's own copy, I ended up with two Lexical runtimes. Commands dispatched from one instance were invisible to nodes registered via the other — icons inserted fine but importJSON/exportJSON round-trips silently broke. My workaround: a thin ./lexical.js re-export file that all my extensions import instead of "lexical" directly, ensuring esbuild deduplicates to the same module.

Same class of problem with DOMPurify: Lexxy bundles its own DOMPurify and uses it internally. My text-align sanitization hook (DOMPurify.addHook(...)) landed on a different DOMPurify instance than the one Lexxy's styleFilterHook runs on, so the hook was never called during sanitization. Fix: pin "dompurify": "3.4.5" as a direct dep so yarn hoists exactly Lexxy's version for everyone.

These were my imports:

// bootstrap_icon_extension.js
import { defineExtension, DecoratorNode, $getSelection, $isRangeSelection, COMMAND_PRIORITY_LOW } from "lexical"
import * as Lexxy from "@37signals/lexxy"

// subscript_superscript_extension.js
import { defineExtension, FORMAT_TEXT_COMMAND, COMMAND_PRIORITY_LOW } from "lexical"
import * as Lexxy from "@37signals/lexxy"

// text_alignment_extension.js
import { defineExtension, FORMAT_ELEMENT_COMMAND, COMMAND_PRIORITY_LOW } from "lexical"
import * as Lexxy from "@37signals/lexxy"
import DOMPurify from "dompurify"

// config.js
import * as Lexxy from "@37signals/lexxy"
import { TextAlignmentExtension } from "./text_alignment_extension"
import { SubscriptSuperscriptExtension } from "./subscript_superscript_extension"
import { BootstrapIconExtension } from "./bootstrap_icon_extension"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants