diff --git a/assets/javascripts/discourse/api-initializers/bbcode-rich-editor.js b/assets/javascripts/discourse/api-initializers/bbcode-rich-editor.js
new file mode 100644
index 0000000..dbda0b3
--- /dev/null
+++ b/assets/javascripts/discourse/api-initializers/bbcode-rich-editor.js
@@ -0,0 +1,6 @@
+import { apiInitializer } from "discourse/lib/api";
+import richEditorExtension from "../lib/rich-editor-extension";
+
+export default apiInitializer((api) => {
+ api.registerRichEditorExtension(richEditorExtension);
+});
diff --git a/assets/javascripts/discourse/lib/rich-editor-extension.js b/assets/javascripts/discourse/lib/rich-editor-extension.js
new file mode 100644
index 0000000..9243434
--- /dev/null
+++ b/assets/javascripts/discourse/lib/rich-editor-extension.js
@@ -0,0 +1,511 @@
+import { serializeBBCodeAttr } from "discourse/lib/text";
+import { parseAttributesString } from "discourse/lib/wrap-utils";
+import { i18n } from "discourse-i18n";
+import {
+ ALIGNMENTS,
+ COLOR,
+ FONT,
+ SIZE,
+} from "discourse/plugins/discourse-bbcode/lib/discourse-markdown/bbcode-values";
+
+const SIZE_VALUE = new RegExp(`^${SIZE}$`);
+const FONT_VALUE = new RegExp(`^${FONT}$`);
+const COLOR_VALUE = new RegExp(`^${COLOR}$`);
+
+// from a browser these mean "no color", unlike an authored [color=transparent]
+const NON_COLORS = [
+ "transparent",
+ "currentcolor",
+ "inherit",
+ "initial",
+ "unset",
+ "revert",
+];
+
+function normalizeColor(value) {
+ const rgb = value.match(
+ /^rgba?\((\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\)/
+ );
+ if (rgb) {
+ // bbcode can't express partial transparency
+ if (rgb[4] !== undefined && parseFloat(rgb[4]) < 1) {
+ return null;
+ }
+ return (
+ "#" +
+ rgb
+ .slice(1, 4)
+ .map((channel) => (+channel).toString(16).padStart(2, "0"))
+ .join("")
+ );
+ }
+ if (NON_COLORS.includes(value.toLowerCase())) {
+ return null;
+ }
+ return COLOR_VALUE.test(value) ? value : null;
+}
+
+function unquoteFont(value) {
+ const font = value.match(/^(['"]?)(.+)\1$/)?.[2];
+ return font && FONT_VALUE.test(font) ? font : null;
+}
+
+// a Map, so a property named like an Object member can't hit the prototype
+const SPAN_MARKS = new Map([
+ [
+ "font-size",
+ (value, marks) =>
+ value === "x-small"
+ ? marks.bbcode_small.create()
+ : SIZE_VALUE.test(value) &&
+ marks.bbcode_size.create({ size: parseInt(value, 10) }),
+ ],
+ [
+ "font-family",
+ (value, marks) => {
+ const font = unquoteFont(value);
+ return font && marks.bbcode_font.create({ font });
+ },
+ ],
+ [
+ "color",
+ (value, marks) =>
+ COLOR_VALUE.test(value) && marks.bbcode_color.create({ color: value }),
+ ],
+ [
+ "background-color",
+ (value, marks) =>
+ COLOR_VALUE.test(value) && marks.bbcode_bgcolor.create({ color: value }),
+ ],
+]);
+
+const INLINE_TAGS = ["span", "a"];
+
+function inlineMarkFor(token, schema) {
+ if (token.tag === "span") {
+ const [, property, value] =
+ token.attrGet("style")?.match(/^([\w-]+):(.+)$/) ?? [];
+
+ return SPAN_MARKS.get(property)?.(value, schema.marks) || null;
+ }
+
+ if (token.tag === "a") {
+ const name = serializableAttr(token.attrGet("name"));
+ if (name) {
+ return schema.marks.bbcode_aname.create({ name });
+ }
+
+ const href = token.attrGet("href");
+ if (href?.startsWith("#")) {
+ const anchor = serializableAttr(href.slice(1));
+ return anchor && schema.marks.bbcode_jumpto.create({ anchor });
+ }
+ }
+
+ return null;
+}
+
+// a bbcode tag is a single line, so no quoting can hold a newline. a value
+// needing quotes that leaves no quote pair unused loses its double quotes to
+// the serializer's fallback, so accept only what parses back to itself
+function serializableAttr(value) {
+ if (!value || value.includes("\n")) {
+ return null;
+ }
+
+ // the attribute name plays no part in how the value is quoted
+ const written = serializeBBCodeAttr(value, "attr");
+ return parseAttributesString(written).attr === value ? value : null;
+}
+
+// every open we see pushes an entry, so the matching close knows whether it was
+// ours: a mark we opened, null for one we swallowed, false for one we passed on
+// to another extension. without that an unclaimed open would leave its close to
+// end whichever mark happened to be on top.
+function openInlineMark(state, mark) {
+ const open = (state.bbcodeInlineMarks ??= []);
+ const enclosing = mark && open.find((entry) => entry?.type === mark.type);
+
+ // a mark set holds one per type: an identical nesting adds nothing, a
+ // differing one can't be represented. declining leaves the token to the other
+ // bbcode_open handlers, and with none of them claiming it the parse fails and
+ // the post stays in the markdown editor with its source intact.
+ if (!mark || (enclosing && !enclosing.eq(mark))) {
+ open.push(false);
+ return false;
+ }
+
+ if (!enclosing) {
+ state.openMark(mark);
+ }
+
+ open.push(enclosing ? null : mark);
+ return true;
+}
+
+function closeInlineMark(state) {
+ if (!state.bbcodeInlineMarks?.length) {
+ return false;
+ }
+
+ const mark = state.bbcodeInlineMarks.pop();
+ if (mark) {
+ state.closeMark(mark);
+ }
+
+ return mark !== false;
+}
+
+function inSepquote(state) {
+ return state.top()?.type.name === "bbcode_sepquote";
+}
+
+function wrapInTag(state, node, tag) {
+ state.write(`[${tag}]\n`);
+ state.renderContent(node);
+ state.flushClose(1);
+ state.write(`[/${tag}]`);
+ state.closeBlock(node);
+}
+
+function colorMark(property) {
+ return {
+ attrs: { color: {} },
+ parseDOM: [
+ {
+ style: property,
+ getAttrs: (value) => {
+ const color = normalizeColor(value);
+ return color ? { color } : false;
+ },
+ },
+ ],
+ toDOM: (mark) => ["span", { style: `${property}:${mark.attrs.color}` }, 0],
+ };
+}
+
+// String() so a numeric value isn't mistaken for an absent one
+function bbcodeTag(tag, value) {
+ return `[${serializeBBCodeAttr(String(value), tag).trim()}]`;
+}
+
+function attrSerializer(tag, attr) {
+ return {
+ open: (state, mark) => bbcodeTag(tag, mark.attrs[attr]),
+ close: `[/${tag}]`,
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ };
+}
+
+/** @type {RichEditorExtension} */
+const extension = {
+ markSpec: {
+ bbcode_size: {
+ attrs: { size: {} },
+ parseDOM: [
+ {
+ style: "font-size",
+ getAttrs: (value) =>
+ SIZE_VALUE.test(value) ? { size: parseInt(value, 10) } : false,
+ },
+ ],
+ toDOM: (mark) => ["span", { style: `font-size:${mark.attrs.size}%` }, 0],
+ },
+ bbcode_font: {
+ attrs: { font: {} },
+ parseDOM: [
+ {
+ style: "font-family",
+ getAttrs: (value) => {
+ const font = unquoteFont(value);
+ return font ? { font } : false;
+ },
+ },
+ ],
+ toDOM: (mark) => [
+ "span",
+ { style: `font-family:'${mark.attrs.font}'` },
+ 0,
+ ],
+ },
+ bbcode_color: colorMark("color"),
+ bbcode_bgcolor: colorMark("background-color"),
+ bbcode_highlight: {
+ parseDOM: [{ tag: "span.highlight" }],
+ toDOM: () => ["span", { class: "highlight" }, 0],
+ },
+ bbcode_small: {
+ parseDOM: [{ style: "font-size=x-small" }],
+ toDOM: () => ["span", { style: "font-size:x-small" }, 0],
+ },
+ bbcode_aname: {
+ attrs: { name: {} },
+ parseDOM: [
+ {
+ tag: "a[name]",
+ getAttrs: (dom) => {
+ const name = serializableAttr(dom.getAttribute("name"));
+ return name ? { name } : false;
+ },
+ },
+ ],
+ toDOM: (mark) => ["a", { name: mark.attrs.name }, 0],
+ },
+ bbcode_jumpto: {
+ attrs: { anchor: {} },
+ parseDOM: [
+ {
+ tag: "a[href^='#']",
+ priority: 60,
+ getAttrs: (dom) => {
+ const anchor = serializableAttr(dom.getAttribute("href").slice(1));
+ return anchor ? { anchor } : false;
+ },
+ },
+ ],
+ toDOM: (mark) => ["a", { href: `#${mark.attrs.anchor}` }, 0],
+ },
+ },
+
+ nodeSpec: {
+ bbcode_align: {
+ attrs: { align: {} },
+ group: "block",
+ content: "block+",
+ defining: true,
+ createGapCursor: true,
+ parseDOM: [
+ {
+ tag: "div[style*=text-align]",
+ getAttrs: (dom) =>
+ ALIGNMENTS.includes(dom.style.textAlign)
+ ? { align: dom.style.textAlign }
+ : false,
+ },
+ ],
+ toDOM: (node) => ["div", { style: `text-align:${node.attrs.align}` }, 0],
+ },
+ bbcode_indent: {
+ group: "block",
+ content: "block+",
+ defining: true,
+ createGapCursor: true,
+ parseDOM: [{ tag: "blockquote.indent", priority: 60 }],
+ toDOM: () => ["blockquote", { class: "indent" }, 0],
+ },
+ bbcode_sepquote: {
+ attrs: { tag: { default: "ot" } },
+ group: "block",
+ content: "block+",
+ defining: true,
+ createGapCursor: true,
+ parseDOM: [
+ {
+ tag: "div.sepquote",
+ getAttrs: (dom) => {
+ if (["edit", "ot"].includes(dom.dataset.tag)) {
+ return { tag: dom.dataset.tag };
+ }
+
+ const label = dom
+ .querySelector("span.smallfont")
+ ?.textContent.trim();
+ if (label === i18n("bbcode.edit")) {
+ return { tag: "edit" };
+ }
+ if (label === i18n("bbcode.ot")) {
+ return { tag: "ot" };
+ }
+
+ return false;
+ },
+ },
+ // cooked sepquotes decorate the content with a localized label
+ { tag: "div.sepquote > span.smallfont", ignore: true },
+ ],
+ toDOM: (node) => [
+ "div",
+ { class: "sepquote", "data-tag": node.attrs.tag },
+ 0,
+ ],
+ },
+ // an ordered list with an explicit list-style type, e.g. [list=a]
+ bbcode_list: {
+ attrs: { type: {}, tight: { default: true } },
+ group: "block",
+ content: "list_item+",
+ parseDOM: [
+ {
+ tag: "ol[type]",
+ priority: 60,
+ getAttrs: (dom) => {
+ const type = serializableAttr(dom.getAttribute("type"));
+ return type ? { type } : false;
+ },
+ },
+ ],
+ toDOM: (node) => ["ol", { type: node.attrs.type }, 0],
+ },
+ },
+
+ parse: {
+ bbcode_open(state, token) {
+ return (
+ INLINE_TAGS.includes(token.tag) &&
+ openInlineMark(state, inlineMarkFor(token, state.schema))
+ );
+ },
+
+ bbcode_close(state, token) {
+ return INLINE_TAGS.includes(token.tag) && closeInlineMark(state);
+ },
+
+ bbcode_highlight_open(state) {
+ return openInlineMark(
+ state,
+ state.schema.marks.bbcode_highlight.create()
+ );
+ },
+
+ bbcode_highlight_close(state) {
+ return closeInlineMark(state);
+ },
+
+ // shared with any wrapping block bbcode tag, so track which opens were ours
+ wrap_bbcode(state, token) {
+ if (token.nesting === 1) {
+ let opened = false;
+
+ if (token.tag === "div") {
+ const align = token
+ .attrGet("style")
+ ?.match(/^text-align:(\w+)$/)?.[1];
+
+ if (ALIGNMENTS.includes(align)) {
+ state.openNode(state.schema.nodes.bbcode_align, { align });
+ opened = true;
+ }
+ } else if (
+ token.tag === "blockquote" &&
+ token.attrGet("class") === "indent"
+ ) {
+ state.openNode(state.schema.nodes.bbcode_indent);
+ opened = true;
+ }
+
+ (state.bbcodeWraps ??= []).push(opened);
+ if (opened) {
+ return true;
+ }
+ } else if (token.nesting === -1 && state.bbcodeWraps?.length) {
+ if (state.bbcodeWraps.pop()) {
+ state.closeNode();
+ return true;
+ }
+ }
+ },
+
+ sepquote_open(state, token, tokens, i) {
+ // the localized label is regenerated on cook; expected token order:
+ // sepquote_open span_open text span_close soft_break²
+ const label = tokens[i + 2];
+ if (label?.type === "text") {
+ label.content = "";
+ }
+
+ state.openNode(state.schema.nodes.bbcode_sepquote, {
+ tag: token.attrGet("data-tag") === "edit" ? "edit" : "ot",
+ });
+ return true;
+ },
+
+ sepquote_close(state) {
+ if (inSepquote(state)) {
+ state.closeNode();
+ return true;
+ }
+ },
+
+ span_open(state, token) {
+ return token.attrGet("class") === "smallfont" && inSepquote(state);
+ },
+
+ span_close(state) {
+ return inSepquote(state);
+ },
+
+ soft_break(state) {
+ return inSepquote(state);
+ },
+
+ bbcode_list: {
+ block: "bbcode_list",
+ getAttrs: (token) => ({ type: token.attrGet("type") }),
+ },
+ },
+
+ plugins: ({ pmState: { Plugin } }) =>
+ new Plugin({
+ props: {
+ // an ignored
still opens a textblock while parsing, so the
+ // cooked label's separators have to go before that
+ transformPastedHTML(html) {
+ if (!html.includes("sepquote")) {
+ return html;
+ }
+
+ const doc = new DOMParser().parseFromString(html, "text/html");
+ doc
+ .querySelectorAll("div.sepquote > br")
+ .forEach((br) => br.remove());
+ return doc.body.innerHTML;
+ },
+ },
+ }),
+
+ serializeMark: {
+ bbcode_size: attrSerializer("size", "size"),
+ bbcode_font: attrSerializer("font", "font"),
+ bbcode_color: attrSerializer("color", "color"),
+ bbcode_bgcolor: attrSerializer("bgcolor", "color"),
+ bbcode_aname: attrSerializer("aname", "name"),
+ bbcode_jumpto: attrSerializer("jumpto", "anchor"),
+ bbcode_highlight: {
+ open: "[highlight]",
+ close: "[/highlight]",
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ },
+ bbcode_small: {
+ open: "[small]",
+ close: "[/small]",
+ mixable: true,
+ expelEnclosingWhitespace: true,
+ },
+ },
+
+ serializeNode: {
+ bbcode_align(state, node) {
+ wrapInTag(state, node, node.attrs.align);
+ },
+
+ bbcode_indent(state, node) {
+ wrapInTag(state, node, "indent");
+ },
+
+ bbcode_sepquote(state, node) {
+ wrapInTag(state, node, node.attrs.tag);
+ },
+
+ bbcode_list(state, node) {
+ state.write(`${bbcodeTag("list", node.attrs.type)}\n`);
+ state.renderList(node, "", () => "[*]");
+ state.flushClose(1);
+ state.write("[/list]");
+ state.closeBlock(node);
+ },
+ },
+};
+
+export default extension;
diff --git a/assets/javascripts/lib/discourse-markdown/bbcode-values.js b/assets/javascripts/lib/discourse-markdown/bbcode-values.js
new file mode 100644
index 0000000..080f9ee
--- /dev/null
+++ b/assets/javascripts/lib/discourse-markdown/bbcode-values.js
@@ -0,0 +1,14 @@
+// the charsets a tag value may use. the cook sanitizer builds its allowlist
+// from these, and the rich editor declines anything looser, so the editor can
+// never show styling the rendered post drops.
+//
+// lives under discourse-markdown/ because only that path is loaded into the
+// server-side cooking context; exporting no setup keeps it out of the feature list.
+
+export const SIZE = "\\d{1,3}%";
+export const ABSOLUTE_SIZE =
+ "xx-small|x-small|small|medium|large|x-large|xx-large";
+export const COLOR = "#?[a-zA-Z0-9]+";
+export const FONT = "[a-zA-Z0-9\\s-]+";
+
+export const ALIGNMENTS = ["left", "right", "center"];
diff --git a/assets/javascripts/lib/discourse-markdown/bbcode.js b/assets/javascripts/lib/discourse-markdown/bbcode.js
index f8c8492..f74daca 100644
--- a/assets/javascripts/lib/discourse-markdown/bbcode.js
+++ b/assets/javascripts/lib/discourse-markdown/bbcode.js
@@ -1,4 +1,16 @@
import { i18n } from "discourse-i18n";
+import {
+ ABSOLUTE_SIZE,
+ ALIGNMENTS,
+ COLOR,
+ FONT,
+ SIZE,
+} from "discourse/plugins/discourse-bbcode/lib/discourse-markdown/bbcode-values";
+
+const SPAN_STYLE = new RegExp(
+ `^(font-size:(${ABSOLUTE_SIZE}|${SIZE})|background-color:${COLOR}|color:${COLOR}|font-family:'${FONT}')$`
+);
+const DIV_STYLE = new RegExp(`^text-align:(${ALIGNMENTS.join("|")})$`);
function wrap(tag, attr, callback) {
return function (startToken, finishToken, tagInfo) {
@@ -80,7 +92,7 @@ function setupMarkdownIt(md) {
wrap: wrap("a", "href", (tagInfo) => "#" + tagInfo.attrs._default),
});
- ["left", "right", "center"].forEach((dir) => {
+ ALIGNMENTS.forEach((dir) => {
md.block.bbcode.ruler.push(dir, {
tag: dir,
wrap: function (token) {
@@ -100,7 +112,10 @@ function setupMarkdownIt(md) {
tag,
before: function (state) {
let token = state.push("sepquote_open", "div", 1);
- token.attrs = [["class", "sepquote"]];
+ token.attrs = [
+ ["class", "sepquote"],
+ ["data-tag", tag],
+ ];
token = state.push("span_open", "span", 1);
token.block = false;
@@ -125,13 +140,14 @@ function setupMarkdownIt(md) {
tag,
replace: function (state, tagInfo, content) {
let ol = tag === "ol" || (tag === "list" && tagInfo.attrs._default);
+ let type = ol ? tagInfo.attrs._default : null;
let token;
- if (ol) {
- token = state.push("ordered_list_open", "ol", 1);
- if (tagInfo.attrs._default) {
- token.attrs = [["type", tagInfo.attrs._default]];
- }
+ if (type) {
+ token = state.push("bbcode_list_open", "ol", 1);
+ token.attrs = [["type", type]];
+ } else if (ol) {
+ state.push("ordered_list_open", "ol", 1);
} else {
state.push("bullet_list_open", "ul", 1);
}
@@ -167,17 +183,27 @@ function setupMarkdownIt(md) {
list.forEach((li) => {
if (li !== null) {
state.push("list_item_open", "li", 1);
+
+ // hidden, as markdown-it wraps tight list items: renders as
+ // nothing, but makes the item's content a paragraph like anywhere
+ // else, instead of a bare inline token
+ state.push("paragraph_open", "p", 1).hidden = true;
+
// a bit lazy, we could use a block parser here
// but it means a lot of fussing with line marks
token = state.push("inline", "", 0);
token.content = li;
token.children = [];
+ state.push("paragraph_close", "p", -1).hidden = true;
+
state.push("list_item_close", "li", -1);
}
});
- if (ol) {
+ if (type) {
+ state.push("bbcode_list_close", "ol", -1);
+ } else if (ol) {
state.push("ordered_list_close", "ol", -1);
} else {
state.push("bullet_list_close", "ul", -1);
@@ -202,13 +228,11 @@ export function setup(helper) {
helper.allowList({
custom(tag, name, value) {
if (tag === "span" && name === "style") {
- return /^(font-size:(xx-small|x-small|small|medium|large|x-large|xx-large|[0-9]{1,3}%)|background-color:#?[a-zA-Z0-9]+|color:#?[a-zA-Z0-9]+|font-family:'[a-zA-Z0-9\s-]+')$/.exec(
- value
- );
+ return SPAN_STYLE.exec(value);
}
if (tag === "div" && name === "style") {
- return /^text-align:(center|left|right)$/.exec(value);
+ return DIV_STYLE.exec(value);
}
},
});
diff --git a/assets/stylesheets/bbcode.scss b/assets/stylesheets/bbcode.scss
index 7fcb37f..aad9ff7 100644
--- a/assets/stylesheets/bbcode.scss
+++ b/assets/stylesheets/bbcode.scss
@@ -1,5 +1,6 @@
.d-editor-preview,
-.cooked {
+.cooked,
+.ProseMirror {
span.highlight {
background-color: var(--bbcode-highlight);
padding: 2px;
diff --git a/spec/pretty_text_spec.rb b/spec/pretty_text_spec.rb
index 1cefe16..1c4b16a 100644
--- a/spec/pretty_text_spec.rb
+++ b/spec/pretty_text_spec.rb
@@ -110,13 +110,13 @@
cooked = PrettyText.cook(markdown)
html = <<~HTML
-
test
Some large text
', + "Some [size=150]large[/size] text", + ], + font: [ + "In [font=courier]monospace[/font] rendering", + "In monospace rendering
", + "In [font=courier]monospace[/font] rendering", + ], + "font with spaces": [ + "In [font='Times New Roman']serif[/font] rendering", + "In serif rendering
", + 'In [font="Times New Roman"]serif[/font] rendering', + ], + color: [ + "Some [color=red]red text[/color] here", + 'Some red text here
', + "Some [color=red]red text[/color] here", + ], + "hex color": [ + "Some [color=#eeff00]colored text[/color] here", + 'Some colored text here
', + "Some [color=#eeff00]colored text[/color] here", + ], + // pasting the same value is declined, since from a browser it means + // "no color" rather than an authored choice + "transparent color": [ + "Some [color=transparent]text[/color] here", + 'Some text here
', + "Some [color=transparent]text[/color] here", + ], + bgcolor: [ + "Some [bgcolor=yellow]highlighted text[/bgcolor] here", + 'Some highlighted text here
', + "Some [bgcolor=yellow]highlighted text[/bgcolor] here", + ], + highlight: [ + "Some [highlight]highlighted text[/highlight] here", + 'Some highlighted text here
', + "Some [highlight]highlighted text[/highlight] here", + ], + // a same-type tag with the same value adds nothing, so dropping it + // renders identically + "nested highlight": [ + "[highlight]outer [highlight]inner[/highlight] outer[/highlight]", + 'outer inner outer
', + "[highlight]outer inner outer[/highlight]", + ], + "nested identical color": [ + "[color=red]outer [color=red]inner[/color] outer[/color]", + 'outer inner outer
', + "[color=red]outer inner outer[/color]", + ], + "highlight nested in color": [ + "[color=red]a [highlight]b[/highlight] c[/color]", + 'a b c
', + "[color=red]a [highlight]b[/highlight] c[/color]", + ], + small: [ + "Some [small]tiny text[/small] here", + 'Some tiny text here
', + "Some [small]tiny text[/small] here", + ], + "nested inline bbcode": [ + "[color=red][size=200]both[/size][/color]", + 'both
', + "[size=200][color=red]both[/color][/size]", + ], + "sequential same-type marks": [ + "[color=red]outer[/color] [color=blue]inner[/color] [color=red]outer[/color]", + 'outer inner outer
', + "[color=red]outer[/color] [color=blue]inner[/color] [color=red]outer[/color]", + ], + aname: [ + "An [aname=target]anchor[/aname] here", + 'An anchor here
', + "An [aname=target]anchor[/aname] here", + ], + "aname with a bracket": [ + "[aname='a]b']anchor[/aname]", + '', + '[aname="a]b"]anchor[/aname]', + ], + jumpto: [ + "A [jumpto=target]jump link[/jumpto] here", + 'A jump link here
', + "A [jumpto=target]jump link[/jumpto] here", + ], + "jumpto with a bracket": [ + "[jumpto='a]b']jump[/jumpto]", + '', + '[jumpto="a]b"]jump[/jumpto]', + ], + left: [ + "[left]\n\naligned left\n\n[/left]", + 'aligned left
aligned center
aligned right
', + "[indent]\nindented text\n[/indent]", + ], + ot: [ + "[ot]\n\nan off-topic aside\n\n[/ot]", + 'indented text
an off-topic aside
an edit note
first
second
first
second
item
first
second
first
second
Some red text here
', + "Some [color=red]red text[/color] here", + ], + "ul list": [ + "[ul]\n[*]first\n[*]second\n[/ul]", + 'first
second
first
second
first
second
first
second
', + "[indent]\nindented\n[/indent]", + ], + "pasted typed list html": [ + 'indented
first
second
go here
', + "go [jumpto=target]here[/jumpto]", + ], + "pasted edit html": [ + 'an edit note
serif
', + '[font="Times New Roman"]serif[/font]', + ], + "pasted bare font html": [ + 'mono
', + "[font=courier]mono[/font]", + ], + // the size and small marks both claim font-size, so the keyword has to + // fall past the percentage rule + "pasted x-small html": [ + 'tiny
', + "[small]tiny[/small]", + ], + "pasted rgb color html": [ + 'bright
', + "[color=#eeff00]bright[/color]", + ], + "pasted rgb bgcolor html": [ + 'marked
', + "[bgcolor=#ffff00]marked[/bgcolor]", + ], + "pasted normalized align html": [ + 'middle
stacked
', + "stacked", + ], + "pasted cooked edit html": [ + `an edit note
\nan edit note
\nplain
', + "plain", + ], + "pasted unknown sepquote tag html": [ + 'note
An anchor here
', + 'An [aname="O\'Brien x"]anchor[/aname] here', + ], + // a value needing quotes that uses every delimiter leaves the serializer + // no pair to wrap it in, and its fallback drops the double quotes. the + // trailing one is the boundary: the wrapper's closing quote lands where + // it was, so the written tag still looks like it holds the value + "pasted aname that can't be quoted losslessly": [ + `An anchor here
`, + "An anchor here", + ], + "pasted quoted jumpto html": [ + 'go here now
', + "go [jumpto=tar'get]here[/jumpto] now", + ], + "pasted quoted list type html": [ + 'first
first