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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Editors support the following options, configurable using presets and element at
- `toolbar`: Pass `false` to disable the toolbar entirely, pass the ID of a `<lexxy-toolbar>` element to use as an external toolbar, or pass an object to configure individual toolbar buttons. By default, the toolbar is bootstrapped and displayed above the editor.
- `toolbar.upload`: Control which upload button(s) appear in the toolbar. Accepts `"file"`, `"image"`, or `"both"` (default). The image button restricts the file picker to images and videos (`accept="image/*,video/*"`), which triggers the native photo/video picker on iOS and Android. The file button opens an unrestricted file picker.
- `attachments`: Pass `false` to disable attachments completely. By default, attachments are supported, including paste and drag & drop support.
- `additionalAllowedAttributes`: Pass an array of extra HTML attribute names to preserve when Lexxy serializes HTML. This is additive: the built-in safe defaults still apply. Rails apps should use `config.lexxy.additional_allowed_attributes` instead (see [Rails Sanitization](#rails-sanitization) below).
- `markdown`: Pass `false` to disable Markdown support.
- `multiLine`: Pass `false` to force single line editing.
- `richText`: Pass `false` to disable rich text editing.
Expand All @@ -66,3 +67,14 @@ Global options apply to all editors in your app and are configured using `Lexxy.

{: .important }
When overriding configuration, call `Lexxy.configure` immediately after your import statement. Editor elements are registered after the import's call stack completes, so configuration must happen synchronously to take effect.

## Rails Sanitization

Rails apps should configure extra allowed HTML attributes in one place using the gem config:

```ruby
# config/application.rb
config.lexxy.additional_allowed_attributes = %w[start]
```

Lexxy uses that setting for both Action Text sanitization on the server and the rendered editor's HTML serialization in the browser, so Rails apps do not need a separate `Lexxy.configure(...)` call for the same attributes.
17 changes: 17 additions & 0 deletions lib/lexxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
require "lexxy/engine"

module Lexxy
ACTION_TEXT_ALLOWED_TAGS = %w[ video audio source embed table tbody tr th td ].freeze
ACTION_TEXT_ALLOWED_ATTRIBUTES = %w[ controls poster data-language style ].freeze

def self.override_action_text_defaults
ActionText::TagHelper.module_eval do
alias_method :rich_textarea_tag, :lexxy_rich_textarea_tag
Expand All @@ -22,4 +25,18 @@ def self.override_action_text_defaults
alias_method :render, :lexxy_render
end
end

def self.additional_allowed_attributes(config = Rails.application.config.lexxy)
Array(config.additional_allowed_attributes).map(&:to_s)
end

def self.configure_action_text_sanitizer!(config = Rails.application.config.lexxy)
action_text_content_helper = Class.new.include(ActionText::ContentHelper).new

ActionText::ContentHelper.allowed_tags = (action_text_content_helper.sanitizer_allowed_tags.dup + ACTION_TEXT_ALLOWED_TAGS).uniq
ActionText::ContentHelper.allowed_attributes = (action_text_content_helper.sanitizer_allowed_attributes.dup + ACTION_TEXT_ALLOWED_ATTRIBUTES + additional_allowed_attributes(config)).uniq

css_functions = Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS
css_functions << "var" unless css_functions.include?("var")
end
end
9 changes: 2 additions & 7 deletions lib/lexxy/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class Engine < ::Rails::Engine
isolate_namespace Lexxy

config.lexxy = ActiveSupport::OrderedOptions.new
config.lexxy.additional_allowed_attributes = []
config.lexxy.override_action_text_defaults = true

initializer "lexxy.initialize" do |app|
Expand All @@ -35,13 +36,7 @@ class Engine < ::Rails::Engine

initializer "lexxy.sanitization" do |app|
ActiveSupport.on_load(:action_text_content) do
default_allowed_tags = Class.new.include(ActionText::ContentHelper).new.sanitizer_allowed_tags
ActionText::ContentHelper.allowed_tags = default_allowed_tags + %w[ video audio source embed table tbody tr th td ]

default_allowed_attributes = Class.new.include(ActionText::ContentHelper).new.sanitizer_allowed_attributes
ActionText::ContentHelper.allowed_attributes = default_allowed_attributes + %w[ controls poster data-language style ]

Loofah::HTML5::SafeList::ALLOWED_CSS_FUNCTIONS << "var" # Allow CSS variables
Lexxy.configure_action_text_sanitizer!(app.config.lexxy)
end
end

Expand Down
2 changes: 2 additions & 0 deletions lib/lexxy/rich_text_area_tag.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ def lexxy_rich_textarea_tag(name, value = nil, options = {}, &block)
options[:name] ||= name
options[:value] ||= value
options[:class] ||= "lexxy-content"
extra_attrs = Lexxy.additional_allowed_attributes
options[:"additional-allowed-attributes"] ||= extra_attrs.to_json if extra_attrs.any?
options[:data] ||= {}
options[:data][:direct_upload_url] ||= main_app.rails_direct_uploads_url
options[:data][:blob_url_template] ||= main_app.rails_service_blob_url(":signed_id", ":filename")
Expand Down
9 changes: 6 additions & 3 deletions src/config/dom_purify.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Lexxy from "./lexxy"
const ALLOWED_HTML_TAGS = [ "a", "b", "blockquote", "br", "code", "div", "em",
"figcaption", "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "li", "mark", "ol", "p", "pre", "q", "s", "strong", "u", "ul", "table", "tbody", "tr", "th", "td" ]

const ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
const DEFAULT_ALLOWED_HTML_ATTRIBUTES = [ "alt", "caption", "class", "content", "content-type", "contenteditable",
"data-direct-upload-id", "data-sgid", "filename", "filesize", "height", "href", "presentation",
"previewable", "sgid", "src", "style", "title", "url", "width" ]

Expand Down Expand Up @@ -38,10 +38,13 @@ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
}
})

export function buildConfig() {
export function buildConfig({ additionalAllowedAttributes = [] } = {}) {
return {
ALLOWED_TAGS: ALLOWED_HTML_TAGS.concat(Lexxy.global.get("attachmentTagName")),
ALLOWED_ATTR: ALLOWED_HTML_ATTRIBUTES,
ALLOWED_ATTR: Array.from(new Set([
...DEFAULT_ALLOWED_HTML_ATTRIBUTES,
...additionalAllowedAttributes,
])),
ADD_URI_SAFE_ATTR: [ "caption", "filename" ],
SAFE_FOR_XML: false // So that it does not strip attributes that contains serialized HTML (like content)
}
Expand Down
1 change: 1 addition & 0 deletions src/config/lexxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const global = new Configuration({

const presets = new Configuration({
default: {
additionalAllowedAttributes: [],
attachments: true,
markdown: true,
multiLine: true,
Expand Down
4 changes: 3 additions & 1 deletion src/elements/editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,9 @@ export class LexicalEditorElement extends HTMLElement {
get value() {
if (!this.cachedValue) {
this.editor?.getEditorState().read(() => {
this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null))
this.cachedValue = sanitize($generateHtmlFromNodes(this.editor, null), {
additionalAllowedAttributes: this.config.get("additionalAllowedAttributes"),
})
})
}

Expand Down
4 changes: 2 additions & 2 deletions src/helpers/sanitization_helper.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import DOMPurify from "dompurify"
import { buildConfig } from "../config/dom_purify"

export function sanitize(html) {
return DOMPurify.sanitize(html, buildConfig())
export function sanitize(html, config) {
return DOMPurify.sanitize(html, buildConfig(config))
}
24 changes: 24 additions & 0 deletions test/browser/fixtures/allowed-html-attributes.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Lexxy Test</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<form>
<div class="title">
<input type="text" name="post[title]" placeholder="Post title" aria-label="Post title">
</div>

<div class="body">
<lexxy-editor class="lexxy-content" placeholder="Write something..." additional-allowed-attributes='["start"]' required></lexxy-editor>
</div>

<div class="events"></div>
</form>

<script type="module" src="/editor.js"></script>
</body>
</html>
23 changes: 23 additions & 0 deletions test/browser/tests/editor/load_html.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,29 @@ test.describe("Load HTML", () => {
await assertEditorHtml(editor, "<p>hello</p><p>there</p>")
})

test("allows configuring additional html attributes", async ({ page, editor }) => {
// Initially stripped
await editor.setValue('<ol start="3"><li>Third</li><li>Fourth</li></ol>')

await assertEditorHtml(editor, "<ol><li>Third</li><li>Fourth</li></ol>")

// When configured they're maintained
await page.goto("/allowed-html-attributes.html")
await page.waitForSelector("lexxy-editor[connected]")

await editor.setValue('<ol start="3"><li>Third</li><li>Fourth</li></ol>')

await assertEditorHtml(
editor,
'<ol start="3"><li>Third</li><li>Fourth</li></ol>',
)
await assertEditorContent(editor, async (content) => {
const items = content.locator("li")
await expect(items.first()).toHaveText("Third")
await expect(items.last()).toHaveText("Fourth")
})
})

test("load HTML with newlines between div elements does not crash", async ({ editor }) => {
// Whitespace text nodes (\n) between block elements like <div> are common in email HTML.
// Previously this threw Lexical error #282 because the \n became a TextNode at the root level.
Expand Down
22 changes: 22 additions & 0 deletions test/system/action_text_load_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,26 @@ class ActionTextLoadTest < ApplicationSystemTestCase
assert_selector "action-text-attachment[content-type='video/mp4']", count: 1
end
end

test "preserves configured HTML attributes through the Action Text round trip" do
with_lexxy_config(additional_allowed_attributes: %w[start]) do
post = Post.create!(
title: "Ordered list",
body: '<ol start="3"><li>Third</li><li>Fourth</li></ol>'
)

visit edit_post_path(post)

assert_editor_html '<ol start="3"><li>Third</li><li>Fourth</li></ol>'

click_on "Update Post"

assert_selector "ol[start='3'] li", text: "Third"
assert_selector "ol[start='3'] li", text: "Fourth"

click_on "Edit this post"

assert_editor_html '<ol start="3"><li>Third</li><li>Fourth</li></ol>'
end
end
end
2 changes: 1 addition & 1 deletion test/test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,6 @@

module ActiveSupport
class TestCase
include EditorHelper, FocusHelper, HtmlHelper, ToolbarHelper, TrixHelper
include EditorHelper, FocusHelper, HtmlHelper, LexxyConfigurationHelper, ToolbarHelper, TrixHelper
end
end
22 changes: 22 additions & 0 deletions test/test_helpers/lexxy_configuration_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module LexxyConfigurationHelper
def with_lexxy_config(**options)
config = Rails.application.config.lexxy
original_values = options.transform_values { nil }

options.each_key do |key|
original_values[key] = config.public_send(key)
end

options.each do |key, value|
config.public_send("#{key}=", value)
end
Lexxy.configure_action_text_sanitizer!(config)

yield
ensure
options.each_key do |key|
config.public_send("#{key}=", original_values[key])
end
Lexxy.configure_action_text_sanitizer!(config)
end
end
Loading