diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000..0f8af58
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+/test/javascripts/recook-corpus.js
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..0fe32d7
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,212 @@
+# BBCode plugin: goals, decisions and open work
+
+Working notes for continuing the native parser work. `README.md` describes how the parser works; this file records why it works that way, what was measured, what was tried and rejected, and what is still open. State as of commit `79abe87`.
+
+## Goals
+
+- **XenForo fidelity.** Existing RpNation posts were written against XenForo and must look the same after a rebake. The reference is XenForo's own rendered HTML for real posts (see [Verification](#verification)), not the old BBob output.
+- **BBCode and markdown in one parse.** Tags are markdown-it rules on the same instance Discourse cooks with, so markdown works inside tags and tags inside markdown (lists, headings, `**bold**`, polls, details, math). This replaced BBob, which ran as a whole-document HTML pre-processor and broke core markdown around tags.
+- **Same code on server and in the composer.** `PrettyText.cook` and the composer preview must produce the same HTML. Every cooking change is checked on both.
+- **Malformed input stays visible.** An unclosed tag or an unmatched close is left as literal text; nothing is swallowed or emptied.
+- **No core changes unless they improve core on their own.** Plugin needs are met from the plugin. A change goes upstream only when it improves Discourse's default behavior without this plugin, such as the Nokogiri fragment search below.
+- **Posts up to 500,000 characters** (production raises `max_post_length` with a separate plugin). Cooking must not fail or approach the 25s JavaScript timeout for any input of that size.
+
+## Design decisions
+
+### Tag syntax and matching
+
+- **Loose tag grammar, ported from BBob's lexer.** Everything up to the first `]` is the tag, unless the `]` is inside a value quoted right after `=` (`[div style="a]b"]`); a quote that never closes, or that a blank line or an unquoted `[` interrupts, is read as before. If there is no space before the first `=`, the rest is one raw value (`[div=height:auto; width:100%]`), up to the first key the tag reads (`[font=Open Sans style=bold]`); otherwise it is `key=value` pairs, whose keys are case-insensitive. An unquoted value ends at whitespace, as in BBob. A tag without any `=` takes only the bare flags it declares (`[slide open]`), so `[center is cool]` stays text. Tags declare their keys and flags in `attributes` (see `define.js`). `parseLooseTag` is the one reader of openers: literal regions and `[code]` use it too. Core's `parseBBCodeTag` stops at whitespace, so the plugin has its own scanner (`scanner.js`) and its own rules instead of core's bbcode ruler.
+- **Closes are matched by name and depth.** Each text is scanned once per tag (`tagPairs`), and a "next lower running balance" array turns every `findClose` into a binary-search lookup, so unmatched tags scale linearly.
+- **Mis-nesting is repaired, "close and drop", as BBob did:** `[b][i]x[/b] y[/i]` reads `[b][i]x[/i][/b] y`. Only tags closed somewhere take part. Core `[url]`/`[quote]`, `[tab]`/`[slide]`, and every block tag another rule registers on core's block bbcode ruler (`[details]`, `[wrap]`, `[poll]`, ...) count as parents. When the parent's close is alone on its line, the inner closes go at the end of the content before it, so the close stays alone: core's block tags only match it there.
+- **Literal regions win.** Block-level ones first: fences and the HTML blocks markdown leaves alone (`
diff --git a/spec/fixtures/recook/tags.txt b/spec/fixtures/recook/tags.txt
new file mode 100644
index 0000000..a31bf42
--- /dev/null
+++ b/spec/fixtures/recook/tags.txt
@@ -0,0 +1,32 @@
+==== boxes
+[block=warning]w[/block]
+[print=line]p[/print]
+[note]n[/note]
+[fieldset=Legend]f[/fieldset]
+[progress=40]p[/progress]
+[check=x]c[/check]
+[side=right]s[/side]
+[newspaper]n[/newspaper]
+==== fonts
+[font=Georgia]a[/font] [font=Lato style=bold italic]b[/font] [font family=Roboto wdth=80 style=300]c[/font]
+==== headings
+[h]a[/h]
+[sh]b[/sh]
+[h3]c[/h3]
+[h7]d[/h7]
+==== inline
+x[sub]s[/sub][sup]t[/sup] [inlinespoiler] hidden [/inlinespoiler] [a=top]anchor[/a] [goto=top]go[/goto] [highlight]h[/highlight] [pindent]p[/pindent]
+==== textmessage
+[textmessage=Bob]
+[message=them]hi[/message]
+[message=me]yo[/message]
+[/textmessage]
+[message=me]bare[/message]
+==== wrappers
+[left]l[/left]
+[right]r[/right]
+[justify]j[/justify]
+[border=1px solid red]b[/border]
+[bg=red]g[/bg]
+[centerblock=40]c[/centerblock]
+[imagefloat=left]i[/imagefloat]
diff --git a/spec/integration/tags_with_other_features_spec.rb b/spec/integration/tags_with_other_features_spec.rb
new file mode 100644
index 0000000..9250678
--- /dev/null
+++ b/spec/integration/tags_with_other_features_spec.rb
@@ -0,0 +1,282 @@
+# frozen_string_literal: true
+
+# Other rules and plugins act on a post after it is cooked: they create
+# records, notify people, edit it by position, or rewrite it for emails. These
+# create real posts with those features inside bbcode tags. The recook
+# snapshots show the HTML; this shows the features still work.
+RSpec.describe "Other features inside bbcode tags" do
+ fab!(:user) { Fabricate(:user, refresh_auto_groups: true) }
+ fab!(:other_user) { Fabricate(:user, username: "other_user", refresh_auto_groups: true) }
+
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ Jobs.run_immediately!
+ # post processing fetches link titles and avatar sizes
+ stub_request(:any, %r{\Ahttps?://(example\.com|test\.localhost)/}).to_return(
+ status: 200,
+ body: "",
+ )
+ end
+
+ after { PrettyText.reset_context }
+
+ def create_post(raw, topic_id: nil, author: user)
+ title = "A topic with bbcode in it #{SecureRandom.alphanumeric(8)}"
+ PostCreator.create!(author, raw:, topic_id:, title:)
+ end
+
+ describe "polls" do
+ it "creates a poll inside a layout tag, with bbcode in its options, that takes votes" do
+ post = create_post("[center]\n[poll]\n* [b]one[/b]\n* two\n[/poll]\n[/center]")
+ poll = Poll.find_by(post:)
+
+ expect(poll.poll_options.map(&:html)).to eq([%(one), "two"])
+
+ DiscoursePoll::Poll.vote(other_user, post.id, "poll", [poll.poll_options.first.digest])
+
+ expect(poll.poll_votes.pluck(:user_id)).to eq([other_user.id])
+ end
+
+ it "creates every poll in a tabbed post, each under its own name" do
+ post =
+ create_post(
+ "[tabs]\n[tab=Day 1]\n[poll name=day1]\n* yes\n* no\n[/poll]\n[/tab]\n" \
+ "[tab=Day 2]\n[poll name=day2]\n* stay\n* go\n[/poll]\n[/tab]\n[/tabs]",
+ )
+
+ expect(Poll.where(post:).pluck(:name)).to contain_exactly("day1", "day2")
+ end
+ end
+
+ describe "checklists" do
+ def checkbox_toggled(raw, index)
+ post = create_post(raw)
+ boxes = Nokogiri::HTML5.fragment(post.cooked).css("span.chcklst-box")
+ result =
+ Checklist::ToggleCheckbox.call(
+ guardian: user.guardian,
+ params: {
+ post_id: post.id,
+ toggles: [
+ {
+ checkbox_index: index,
+ checkbox_count: boxes.size,
+ checkbox_source: boxes[index]&.[]("data-chk-src"),
+ checked: true,
+ },
+ ],
+ expected_raw: post.raw,
+ expected_updated_at: post.updated_at.iso8601(3),
+ mutation_id: SecureRandom.hex,
+ },
+ )
+ expect(result).to be_a_success
+ post.reload.raw
+ end
+
+ it "toggles the box clicked when the boxes are inside a block tag" do
+ expect(checkbox_toggled("intro\n[center]\n[ ] one\n[ ] two\n[/center]", 1)).to eq(
+ "intro\n[center]\n[ ] one\n[x] two\n[/center]",
+ )
+ end
+
+ it "toggles the box clicked when a block tag comes first" do
+ expect(checkbox_toggled("[center]\nx\n\ny\n[/center]\n[ ] one\n[ ] two", 1)).to eq(
+ "[center]\nx\n\ny\n[/center]\n[ ] one\n[x] two",
+ )
+ end
+
+ it "toggles the box clicked after an inline tag spanning lines" do
+ expect(checkbox_toggled("[b]a\nb[/b]\n[ ] one\n[ ] two", 1)).to eq(
+ "[b]a\nb[/b]\n[ ] one\n[x] two",
+ )
+ end
+
+ it "toggles the box clicked two tags deep" do
+ raw = "[center]\n[spoiler=Tasks]\n[ ] one\n[ ] two\n[/spoiler]\n[/center]"
+
+ expect(checkbox_toggled(raw, 1)).to eq(raw.sub("[ ] two", "[x] two"))
+ end
+
+ it "leaves a box inside an inline tag as text, as inside Markdown emphasis" do
+ post = create_post("[b][ ] one[/b] **[ ] two** [ ] three")
+
+ expect(Nokogiri::HTML5.fragment(post.cooked).css("span.chcklst-box").size).to eq(1)
+ end
+
+ it "toggles the box clicked after a block tag that starts mid-line" do
+ expect(checkbox_toggled("text [center]a\nb[/center]\n[ ] one\n[ ] two", 1)).to eq(
+ "text [center]a\nb[/center]\n[ ] one\n[x] two",
+ )
+ end
+
+ it "toggles the first box, not the next one, after a block tag that starts mid-line" do
+ expect(checkbox_toggled("text [center]a\nb[/center]\n[ ] one\n[ ] two", 0)).to eq(
+ "text [center]a\nb[/center]\n[x] one\n[ ] two",
+ )
+ end
+
+ it "toggles the box clicked inside a block tag in a blockquote" do
+ expect(checkbox_toggled("> [center]\n> [ ] one\n> [ ] two\n> [/center]", 1)).to eq(
+ "> [center]\n> [ ] one\n> [x] two\n> [/center]",
+ )
+ end
+
+ it "toggles the box clicked in a to-do list inside a spoiler" do
+ expect(checkbox_toggled("[spoiler=Tasks]\n[ ] pack\n[ ] leave\n[/spoiler]", 1)).to eq(
+ "[spoiler=Tasks]\n[ ] pack\n[x] leave\n[/spoiler]",
+ )
+ end
+
+ it "skips a box shown as plain text" do
+ expect(checkbox_toggled("[plain][ ][/plain] shown\n[ ] one", 0)).to eq(
+ "[plain][ ][/plain] shown\n[x] one",
+ )
+ end
+ end
+
+ describe "oneboxes" do
+ let(:url) { "https://example.com/story" }
+
+ before do
+ stub_request(:any, url).to_return(
+ status: 200,
+ body:
+ "" \
+ "",
+ )
+ end
+
+ it "oneboxes a URL on its own line inside a block tag, as at the top level" do
+ post = create_post("[center]\n#{url}\n[/center]\n[spoiler=Link]\n#{url}\n[/spoiler]")
+ doc = Nokogiri::HTML5.fragment(post.reload.cooked)
+
+ expect(doc.css("div.bb-center aside.onebox, div.bb-spoiler-content aside.onebox").size).to eq(
+ 2,
+ )
+ end
+
+ it "keeps a URL inline when it shares its line with text or a tag, or is in a Markdown quote" do
+ post =
+ create_post(
+ "[center]\nread #{url}\n[/center]\n[center]#{url}[/center]\n\n> [center]\n> #{url}\n> [/center]",
+ )
+
+ expect(Nokogiri::HTML5.fragment(post.reload.cooked).css("aside.onebox")).to be_empty
+ end
+ end
+
+ describe "details" do
+ it "reduces details inside a bbcode tag to a link in emails" do
+ post = create_post("[center]\n[details=Summary]\nsecret [b]text[/b]\n[/details]\n[/center]")
+ email = PrettyText.format_for_email(post.cooked, post)
+
+ expect(email).to include("Summary", I18n.t("details.excerpt_details"))
+ expect(email).not_to include("secret")
+ end
+ end
+
+ describe "footnotes" do
+ it "links each footnote reference inside a tag to its footnote" do
+ post =
+ create_post("[b]claim[^1][/b] and [center]another[^2][/center]\n\n[^1]: one\n[^2]: two")
+ doc = Nokogiri::HTML5.fragment(post.cooked)
+ targets = doc.css("sup.footnote-ref a").map { |link| link["href"].delete_prefix("#") }
+
+ expect(targets.size).to eq(2)
+ expect(targets).to all(satisfy { |id| doc.at_css("[id='#{id}']") })
+ end
+ end
+
+ describe "emoji" do
+ it "turns shortcodes and unicode emoji into images inside tags, but not in plain text" do
+ post = create_post("[center]:smile: 😀[/center] [plain]:smile: 😀[/plain]")
+ emoji = Nokogiri::HTML5.fragment(post.cooked).css("img.emoji").map { |img| img["title"] }
+
+ expect(emoji).to eq(%w[:smile: :grinning_face:])
+ expect(post.cooked).to include(":smile: 😀").or include(":smile: 😀")
+ end
+ end
+
+ describe "quotes" do
+ it "records and notifies a quote inside a tag, with the quoted user's avatar" do
+ quoted = create_post("original words", author: other_user)
+ reply =
+ create_post(
+ "[center]\n[quote=\"other_user, post:1, topic:#{quoted.topic_id}\"]\noriginal words\n[/quote]\n[/center]",
+ )
+
+ expect(QuotedPost.where(post_id: reply.id).pluck(:quoted_post_id)).to eq([quoted.id])
+ expect(Nokogiri::HTML5.fragment(reply.cooked).at_css("aside.quote img.avatar")).to be_present
+ expect(
+ Notification.where(user: other_user, notification_type: Notification.types[:quoted]),
+ ).to exist
+ end
+ end
+
+ describe "mentions" do
+ it "notifies a user mentioned in a character sheet's accordion" do
+ create_post("[accordion]\n[slide=Allies]\nFriends with @other_user\n[/slide]\n[/accordion]")
+
+ expect(
+ Notification.where(user: other_user, notification_type: Notification.types[:mentioned]),
+ ).to exist
+ end
+
+ it "notifies a user mentioned inside a tag, but not one shown as literal text" do
+ fab_literal = Fabricate(:user, username: "literal_user")
+ create_post("[center]hi @other_user[/center] [plain]@literal_user[/plain]")
+
+ mentioned = Notification.where(notification_type: Notification.types[:mentioned])
+ expect(mentioned.pluck(:user_id)).to eq([other_user.id])
+ expect(mentioned.where(user: fab_literal)).not_to exist
+ end
+ end
+
+ describe "links" do
+ it "records the links inside tags, but not a URL shown as literal text" do
+ post =
+ create_post(
+ "[b]https://example.com/bold[/b] [url=https://example.com/url]x[/url] " \
+ "[center][text](https://example.com/md)[/center] [plain]https://example.com/plain[/plain]",
+ )
+
+ expect(TopicLink.where(post:).pluck(:url)).to contain_exactly(
+ "https://example.com/bold",
+ "https://example.com/url",
+ "https://example.com/md",
+ )
+ end
+ end
+
+ describe "hashtags" do
+ fab!(:category) { Fabricate(:category, slug: "rp-lounge") }
+
+ it "links a category hashtag inside a tag" do
+ post = create_post("[b]see #rp-lounge[/b]")
+
+ expect(Nokogiri::HTML5.fragment(post.cooked).at_css("a.hashtag-cooked")["href"]).to eq(
+ category.url,
+ )
+ end
+ end
+
+ describe "uploads" do
+ fab!(:upload) { Fabricate(:image_upload, user:) }
+
+ it "references an upload shown inside a tag" do
+ post = create_post("[center][/center]")
+
+ expect(UploadReference.where(target: post).pluck(:upload_id)).to eq([upload.id])
+ end
+ end
+
+ describe "local dates" do
+ it "records a date written inside a tag" do
+ post = create_post(%([b][date=2030-01-02 time=10:00:00 timezone="UTC"][/b]))
+
+ expect(post.reload.local_dates.map { |date| date["date"] }).to eq(["2030-01-02"])
+ end
+ end
+end
diff --git a/spec/lib/cleanup_spec.rb b/spec/lib/cleanup_spec.rb
new file mode 100644
index 0000000..e95a397
--- /dev/null
+++ b/spec/lib/cleanup_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+RSpec.describe BbCode::Cleanup do
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ end
+
+ after { PrettyText.reset_context }
+
+ it "cleans up a cooked post in one wrapper, with the same output as without it" do
+ html = PrettyText.markdown("[b]a[/b] @someone\nhttps://example.com\n b\n" * 3)
+ allow(described_class).to receive(:wrapped).and_call_original
+ wrapped = PrettyText.cleanup(html)
+
+ expect(described_class).to have_received(:wrapped)
+ SiteSetting.bbcode_enabled = false
+ expect(wrapped).to eq(PrettyText.cleanup(html))
+ end
+
+ it "runs core's cleanup when the post cooks" do
+ allow(described_class).to receive(:wrapped).and_call_original
+
+ expect(PrettyText.cook("a\nb")).to eq("a \nb")
+ expect(described_class).to have_received(:wrapped)
+ end
+
+ it "cleans up unwrapped when the HTML names the wrapper" do
+ html = "a b c"
+ cleaned = PrettyText.cleanup(html)
+
+ SiteSetting.bbcode_enabled = false
+ expect(cleaned).to eq(PrettyText.cleanup(html))
+ end
+end
diff --git a/spec/lib/comments_spec.rb b/spec/lib/comments_spec.rb
new file mode 100644
index 0000000..d50ae7d
--- /dev/null
+++ b/spec/lib/comments_spec.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+RSpec.describe BbCode::Comments do
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ end
+
+ after { PrettyText.reset_context }
+
+ it "cooks a comment to a template, which the post's cook turns into an HTML comment" do
+ raw = "a [comment]note --> y & \"q\"[/comment] b"
+
+ expect(PrettyText.cook(raw)).to include(" b",
+ )
+ end
+
+ it "leaves cooked HTML without comments untouched" do
+ cooked = %(
a .x{}
)
+
+ expect(described_class.restore(cooked)).to equal(cooked)
+ end
+end
diff --git a/spec/lib/hidden_content_spec.rb b/spec/lib/hidden_content_spec.rb
new file mode 100644
index 0000000..a389d7f
--- /dev/null
+++ b/spec/lib/hidden_content_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+RSpec.describe BbCode::HiddenContent do
+ let(:css) { "outline: 6px solid magenta" }
+ let(:script) { "show secretbox" }
+ let(:inline_secret) { "inline secret" }
+ let(:block_secret) { "block secret" }
+ let(:raw) { <<~RAW }
+ intro [spoiler=Title]#{block_secret}[/spoiler] mid [inlinespoiler]#{inline_secret}[/inlinespoiler] end
+ [class name=box]
+ #{css};
+ [/class]
+ [script class=box on=click]
+ #{script}
+ [/script]
+ RAW
+
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ end
+
+ after { PrettyText.reset_context }
+
+ it "keeps templates and spoilers out of excerpts" do
+ excerpt = PrettyText.excerpt(PrettyText.cook(raw), 500)
+
+ expect(excerpt).to include("intro", "Spoiler: Title", "mid", "end")
+ expect(excerpt).not_to include(css, script, inline_secret, block_secret)
+ end
+
+ it "keeps templates and spoilers out of emails, linking to the post instead" do
+ post = Fabricate(:post, raw: raw)
+
+ email = PrettyText.format_for_email(post.cooked, post)
+
+ expect(email).to include("intro", "Title", %(spoiler))
+ expect(email).not_to include(css, script, inline_secret, block_secret)
+ end
+
+ it "keeps templates out of the search index and spoilers in it" do
+ SearchIndexer.enable
+ post = Fabricate(:post, raw: raw)
+
+ search_data = post.post_search_data.raw_data
+
+ expect(search_data).to include(inline_secret, block_secret)
+ expect(search_data).not_to include(css.split.last, script.split.last)
+ end
+end
diff --git a/spec/lib/malformed_input_spec.rb b/spec/lib/malformed_input_spec.rb
new file mode 100644
index 0000000..e1a9dd1
--- /dev/null
+++ b/spec/lib/malformed_input_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+# Posts may be 500,000 characters. Each of these once scanned the rest of the
+# text per tag, which at this size ran past the 25s JavaScript timeout; linear,
+# each cooks in well under a second.
+RSpec.describe PrettyText do
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ end
+
+ after { PrettyText.reset_context }
+
+ def repeated(unit, tail = "")
+ unit * ((500_000 - tail.size) / unit.size) + tail
+ end
+
+ it "cooks 500,000 characters of unclosed or unterminated tags within the timeout" do
+ inputs = {
+ "unclosed core inline tags" => repeated("[code]x[url]x[img]x[email]x[wrap=a]x"),
+ "unclosed code on separate lines" => repeated("[code]x\n"),
+ "unterminated url openers" => repeated("[url="),
+ "unclosed literal tags" => repeated("[plain]x[icode]x[comment]x[fa]x"),
+ "unterminated openers before one bracket" => repeated("[div=a ", "]"),
+ "quoted openers whose close a code span hides" => repeated("> [div]\n", "> `[/div]`"),
+ }
+
+ inputs.each { |name, raw| expect { PrettyText.markdown(raw) }.not_to raise_error, name }
+ end
+
+ it "leaves an unclosed tag as written wherever it appears, math included" do
+ SiteSetting.discourse_math_enabled = true
+ PrettyText.reset_context
+
+ expect(PrettyText.cook("$[img]x$ `[url]y` [code]z")).to eq(
+ %([img]x[url]y [code]z),
+ )
+ end
+end
diff --git a/spec/lib/native_tags_spec.rb b/spec/lib/native_tags_spec.rb
new file mode 100644
index 0000000..4b03a34
--- /dev/null
+++ b/spec/lib/native_tags_spec.rb
@@ -0,0 +1,576 @@
+# frozen_string_literal: true
+
+RSpec.describe PrettyText do
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ end
+
+ after { PrettyText.reset_context }
+
+ # as a post is cooked, [comment]s included
+ def cook(raw)
+ BbCode::Comments.restore(PrettyText.cook(raw)).gsub(/post-[a-z0-9]{5}/, "post-GUID")
+ end
+
+ it "reads unquoted multi-token and multi-line attribute values as a single value" do
+ html = cook("[div=height:auto; width:100%;\n\npadding:7px]text[/div]")
+
+ expect(html).to include(%(style="height:auto; width:100%;\n\npadding:7px"))
+ expect(html).to end_with(">text")
+ end
+
+ it "keeps brackets inside a quoted attribute value in the value" do
+ expect(cook(%([div style="a]b"]x[/div]))).to eq(%(
))
+ end
+
+ it "reads attribute keys regardless of case" do
+ expect(cook(%([div Style="color:red"]x[/div]))).to eq(%(
x
))
+ end
+
+ it "ends a default value before a key the tag reads" do
+ expect(cook("[font=Open Sans style=bold]a[/font]")).to include(
+ "font-family: 'Open Sans'; font-weight: 700",
+ )
+ end
+
+ it "reads a tag's declared flags without any key=value" do
+ expect(cook("[accordion]\n[slide open]body[/slide]\n[/accordion]")).to include(
+ %(),
+ )
+ expect(cook("[center is cool]x[/center]")).to eq("[center is cool]x[/center]")
+ end
+
+ it "reads quoted key=value attributes and suffixes class names per post" do
+ html = cook(%([div class="a b" style="color:blue"]hi[/div]))
+
+ expect(html).to include(%(class="a__post-GUID b__post-GUID"))
+ expect(html).to include(%(style="color:blue"))
+ end
+
+ it "parses block markdown inside a container that starts its own line" do
+ html = cook("[div=color:red]\n# Heading\n\n- one\n- two\n[/div]")
+
+ expect(html).to include("
")
+ expect(html).to include("
one
")
+ end
+
+ it "keeps flow text across blank lines as one styled run with line breaks" do
+ html = cook("[b]para one\n\npara two[/b]")
+
+ expect(html).to include(%(para one \n \npara two))
+ end
+
+ it "pairs nested tags that span a blank line" do
+ html = cook("[b]outer [b]nested\n\nacross[/b] more[/b]")
+
+ expect(html.scan('').length).to eq(2)
+ expect(html).not_to include("\\")
+ end
+
+ it "closes a mis-nested tag with its parent and drops its later close" do
+ expect(cook("[b]bold [i]both[/b] italic[/i]")).to eq(
+ %(bold both italic),
+ )
+ expect(cook("[center][b]x[/center] y[/b]")).to match(
+ %r{x\s*y\z},
+ )
+ expect(cook("[url=https://e.com][b]x[/url] y[/b]")).to include(
+ %(x y),
+ )
+ expect(cook("[b]a[plain][i]x[/b][/plain] c[/b]")).to include("a[i]x[/b] c")
+ expect(cook("[b][i]x[/b]")).to eq(%([i]x))
+ end
+
+ it "repairs a tag left open in another rule's block tag as if it were closed there" do
+ expect(cook("[quote]\n[b]x\n[/quote]\ny[/b]")).to eq(cook("[quote]\n[b]x[/b]\n[/quote]\ny"))
+ expect(cook("[details=a]\n[b]x\n[/details]\ny[/b]")).to eq(
+ cook("[details=a]\n[b]x[/b]\n[/details]\ny"),
+ )
+ end
+
+ it "leaves unclosed and unmatched tags as literal text" do
+ expect(cook("[div=a:b]unclosed")).to eq("[div=a:b]unclosed")
+ expect(cook("text [/b] more")).to eq("text [/b] more")
+ expect(cook("[b]a [b]b[/b] c")).to eq(%([b]a b c))
+ end
+
+ it "reads a backslash-escaped opener as text, as markdown does" do
+ expect(cook("\\[b][i]x[/b] y[/i]")).to eq(%([b]x[/b] y))
+ expect(cook("[b]a \\[b] c[/b] d[/b]")).to eq(%(a [b] c d[/b]))
+ end
+
+ it "keeps a backslash before a close as text, as XenForo does" do
+ expect(cook("[div=x]a \\[/div] b")).to match(%r{\A
a \\
\s*b\z})
+ end
+
+ it "does not touch core markdown" do
+ html = cook("# Title\n\n---\n\n- a\n- b")
+
+ expect(html).to include("
", "", "
a
")
+ end
+
+ it "turns every newline between blocks and inside containers into a line break" do
+ expect(cook("a\n[div=x]b[/div]")).to eq(%(a
b
))
+ expect(cook("[div=x]\nb\n[/div]")).to include(" \nb \n")
+ end
+
+ it "counts one blank line around a markdown block as its margin" do
+ visible = ->(raw) { cook(raw).gsub(%r{]*>}, "").delete("\n") }
+
+ expect(visible["a\n\n# H\n\nb"]).to eq("a
")
+ end
+
+ it "suppresses line breaks inside nobr" do
+ expect(cook("[nobr]a\nb\n\n[div=x]c[/div][/nobr]")).not_to include(" ")
+ expect(cook("[b]x [nobr]a\nb[/nobr][/b]")).to eq(%(x a\nb))
+ end
+
+ it "keeps every newline inside nobr as a newline" do
+ expect(cook("[nobr]a\n\nb[/nobr]")).to eq("a\n\nb")
+ expect(cook("x [nobr]a\n\nb[/nobr] y")).to eq("x a\n\nb y")
+ expect(cook("[b]\n# H\nx [nobr]a\nb[/nobr]\n[/b]")).to include("\nx a\nb ")
+ expect(cook("[nobr]\n[b]a\n\nb[/b]\n[/nobr]")).to eq(%(a\n\nb))
+ end
+
+ it "scopes a post's ids by its id, so cooking it again gives the same HTML" do
+ raw = "[class name=x]color:red[/class][div class=x]y[/div]\n[tabs]\n[tab=A]a[/tab]\n[/tabs]"
+ cooked = PrettyText.cook(raw, post_id: 42)
+
+ expect(cooked).to include(%(
y
), %(name="tab-group-post-42-1"))
+ expect(PrettyText.cook(raw, post_id: 42)).to eq(cooked)
+ end
+
+ it "drops the private-use characters the parser uses as placeholders" do
+ expect(cook("a\uE000b [b]c\uE001d[/b] e\uE002f")).to eq(
+ %(ab cd ef),
+ )
+ end
+
+ it "renders class templates with the same suffix as native tags" do
+ html = cook("[class name=x]\ncolor:red;\n[/class]\n[div class=x]hi[/div]")
+
+ expect(html).to include(%(.x__post-GUID {))
+ expect(html).to include(%(
))
+ end
+
+ it "renders script and animation templates and fa icons" do
+ html = cook("[script class=box on=click version=2]\nshow box\n[/script]")
+ expect(html).to include(
+ %(),
+ )
+
+ animation =
+ cook("[animation=spin][keyframe=0]a: b;[/keyframe][keyframe=to]c: d;[/keyframe][/animation]")
+ expect(animation).to include("@keyframes post-GUIDspin { 0%{ a: b; }\nto{ c: d; } }")
+ expect(cook("[keyframe=0]stray[/keyframe]")).to eq("[keyframe=0]stray[/keyframe]")
+
+ expect(cook(%(x [fa style="color:red" primary-color=red]fa-star[/fa] y))).to include(
+ %(x))
+ inline = cook(%(a .d-header{display:none} b))
+
+ expect(block).to eq(%(x))
+ expect(inline).to eq("a .d-header{display:none} b")
+ end
+
+ it "keeps keyframes written as decimals, adding the percent sign a bare number needs" do
+ html =
+ cook(
+ "[animation=fade][keyframe=.5%]a:b;[/keyframe][keyframe=0.25]c:d;[/keyframe][/animation]",
+ )
+
+ expect(html).to include(".5%{ a:b; }", "0.25%{ c:d; }")
+ end
+
+ it "drops class, animation and keyframe rules whose name could escape the rule" do
+ html =
+ cook(
+ "[class name=\"x{} .d-header{display:none} .y\"]color:red[/class]" \
+ "[animation=x{} .d-header{display:none} y][keyframe=0]a: b;[/keyframe][/animation]",
+ )
+ frames =
+ cook(
+ "[animation=spin][keyframe=\"0{}}.d-header{display:none}@keyframes z{0\"]a:b;[/keyframe]" \
+ "[keyframe=25%, 75%]c:d;[/keyframe][/animation]",
+ )
+
+ expect(html).not_to include("template")
+ expect(frames).to eq(
+ %(@keyframes post-GUIDspin { 25%, 75%{ c:d; } }),
+ )
+ end
+
+ it "blanks a script class that isn't a single class name" do
+ html = cook(%([script class="a, .d-header, .b"](hide)[/script]))
+
+ expect(html).to include(%(data-bbscript-class=""))
+ end
+
+ it "escapes script and class bodies so they can't close their template" do
+ script = cook(%([script class=box](print "hi" "x")[/script]))
+ style = cook("[class name=x]color:redleak[/class]")
+
+ expect(script).to eq(
+ %((print "<b>hi</b>" "</template><b>x</b>")),
+ )
+ expect(style).to eq(
+ %(.x__post-GUID {color:red</template><b>leak</b>}),
+ )
+ end
+
+ it "renders heading tags" do
+ expect(cook("[h1]Title[/h1]")).to match(%r{
})
+ end
+
+ it "renders comments as HTML comments and still loads the Google fonts named inside" do
+ html = cook("a [comment]note [font=Poppins]x[/font] --> y[/comment] b")
+
+ expect(html).to include(
+ %(),
+ "",
+ )
+ expect(html).not_to include("")
+ end
+
+ it "writes every newline after a tag as a line break" do
+ expect(cook("[div=x]a[/div]\nnext").scan(" ").size).to eq(1)
+ expect(cook("[div=x]a[/div]\n\nnext").scan(" ").size).to eq(2)
+ expect(cook("[center]\na\n[/center]\nnext").scan(" ").size).to eq(3)
+ end
+
+ it "drops the line break right after tags XenForo trims after, unless text comes first" do
+ expect(cook("text[divide][/divide]\nnext")).not_to include(" ")
+ expect(cook("text[divide][/divide]\n\nnext").scan(" ").size).to eq(1)
+ expect(cook("[divide][/divide] tail\nnext").scan(" ").size).to eq(1)
+ expect(cook("[quote]\na\n[/quote]\n\nnext").scan(" ").size).to eq(1)
+ expect(cook("[code]\na\n[/code]\nnext")).not_to include(" ")
+ expect(cook("[spoiler=T]a[/spoiler]\nnext")).not_to include(" ")
+ end
+
+ it "trims the line breaks just inside spoilers and quotes" do
+ expect(cook("[spoiler=T]\n\na\n\n[/spoiler]")).to match(%r{bb-spoiler-content">\s*a
})
+ expect(cook("x [inlinespoiler]\na\n[/inlinespoiler] y")).to include(
+ %(a),
+ )
+ end
+
+ it "renders a code tag spanning lines as a code block wherever its tags sit" do
+ expect(cook("[code]a\n\nb[/code] tail")).to include(
+ %(
\s*y\z},
+ )
+ expect(cook("x [code]a[/code] y")).to eq("x a y")
+ end
+
+ it "never reads content on the same line as both tags as markdown blocks" do
+ expect(cook("[div=x]+[/div]")).to eq(%(
),
+ )
+ end
+
+ it "trims the line breaks just inside blockquote, ooc and progress" do
+ expect(cook("[blockquote=Al]\n\nhi\n\n[/blockquote]")).to match(
+ /bb-blockquote-content">\s*hi
))
+ end
+
+ it "keeps code content as written, apart from the lines the tags sit on" do
+ expect(cook("[code]\n\n a\n[/code]")).to include(%(\n a))
+ expect(cook("x [icode]\na\n[/icode] y")).to include("a")
+ end
+
+ it "keeps icode and plain spanning a blank line mid-paragraph literal" do
+ expect(cook("x [icode][b]a\n\nb[/b][/icode] y")).to include("[b]a")
+ expect(cook("x [plain][b]a[/b]\n\nb[/plain] y")).to include("[b]a[/b]")
+ end
+
+ it "shows plain text exactly as written" do
+ expect(cook("[plain]:smile: @system #general x & https://e.com[/plain]")).to eq(
+ ":smile: @system #general <b>x</b> & https://e.com",
+ )
+ end
+
+ it "ignores closes inside HTML comments, autolinks, raw HTML blocks and fences in lists" do
+ expect(cook("[b]x y[/b]")).to eq(%(x y))
+ expect(cook("[b] y[/b]")).to include("[/b] y")
+ expect(cook("[b]x\n
")
+ end
+
+ it "ends a fence in a blockquote with the blockquote" do
+ expect(cook("> ```\n> [b]x\n\n[i]after[/i]")).to end_with(
+ %(after),
+ )
+ end
+
+ it "finds a close in a Markdown quote that a code span hides only before the quote's markers are taken out" do
+ expect(cook("> [div]\n> `x\n>\n> [/div]\n> `")).to start_with("
\n
")
+ end
+
+ it "doesn't start a code span at an escaped backtick when finding a section's close" do
+ expect(cook("[accordion]{slide=Title}a \\`{/slide} x`[/accordion]")).to include(
+ %(Title),
+ )
+ end
+
+ it "ignores tags inside literal tags when matching others" do
+ expect(cook("[div=x]a [comment]old [div] start[/comment] b[/div]")).to eq(
+ %(
),
+ )
+ end
+
+ it "reads a literal tag shown in a code span as code, not as the start of literal text" do
+ expect(cook("`[plain]`\n[color=red]red[/color]\n`[/plain]`")).to eq(
+ %([plain] \nred \n[/plain]),
+ )
+ expect(cook("[plain]a `b[/plain] [b]c[/b]`")).to eq(%(a `b c`))
+ end
+
+ it "keeps the line break after plain and icode that start a line" do
+ expect(cook("[icode]a[/icode]\nnext")).to include("a ")
+ expect(cook("[plain]a[/plain]\nnext")).to include("a ")
+ end
+
+ it "keeps the space between a literal tag that starts a line and the text after it" do
+ expect(cook("[plain]x[/plain] b")).to eq("x b")
+ expect(cook("[icode]a[/icode] [icode]b[/icode]")).to eq("ab")
+ end
+
+ it "writes the newlines in plain text as line breaks and drops the one after a fence" do
+ expect(cook("x\n[plain]\na\n[/plain]\ny").scan(" ").size).to eq(4)
+ expect(cook("```\na\n```\nnext")).not_to include(" ")
+ end
+
+ it "matches tags regardless of case" do
+ expect(cook("[CENTER]a[/center]")).to include(%(
a
))
+ end
+
+ it "leaves tags nested more than 100 deep as text, counting block and inline tags together" do
+ inline = cook("[color=red]" * 101 + "x" + "[/color]" * 101)
+ expect(inline.scan("[color=blue]x[/color]))
+ end
+
+ it "renders any number of tags on one line, and tags around many tokens" do
+ expect(cook("[div=a]a[/div]" * 150).scan("
.txt holds raw posts,
+# each after a "==== " line; .cooked holds their HTML as last
+# recorded, in the same format. Any change to any case fails here. When the
+# change is intended, rewrite the snapshots with
+# UPDATE_RECOOK=1 LOAD_PLUGINS=1 bin/rspec plugins/bbcode/spec/lib/recook_spec.rb
+# and review the .cooked diff. The tag-*.txt groups are generated by
+# spec/fixtures/recook/generate.rb; edit that, not them.
+#
+# The same run writes test/javascripts/recook-corpus.js: each case with the
+# markdown engine's HTML before the server's cleanup, and the settings the
+# server gave the engine, which the composer test (bbcode-recook-test.js)
+# cooks with and must match. Only its cases are checked here, so a change to
+# core's settings alone doesn't fail this spec.
+RSpec.describe PrettyText do
+ before do
+ SiteSetting.bbcode_enabled = true
+ PrettyText.reset_context
+ end
+
+ after { PrettyText.reset_context }
+
+ let(:fixtures) { File.expand_path("../fixtures/recook", __dir__) }
+ let(:corpus_path) { File.expand_path("../../test/javascripts/recook-corpus.js", __dir__) }
+
+ # the per-post id is random
+ def normalize(html)
+ html.gsub(/post-[a-z0-9]{5}/, "post-GUID")
+ end
+
+ # [cooked as a post is, [comment]s included; the engine's HTML]
+ def cook(raw)
+ html = PrettyText.markdown(raw)
+ [normalize(BbCode::Comments.restore(PrettyText.cleanup(html))), normalize(html)]
+ end
+
+ # text before the first case is a comment
+ def read_cases(path)
+ _comment, *pairs = File.read(path).split(/^==== (.+)\n/, -1)
+ pairs.each_slice(2).to_h { |name, text| [name, text.delete_suffix("\n")] }
+ end
+
+ def write_cases(path, cases)
+ File.write(path, cases.map { |name, text| "==== #{name}\n#{text}\n" }.join)
+ end
+
+ def corpus_js(cases)
+ settings = {
+ siteSettings: SiteSetting.client_settings_hash,
+ additionalOptions: Site.markdown_additional_options,
+ }
+ "// Generated by spec/lib/recook_spec.rb with UPDATE_RECOOK=1; don't edit.\n" \
+ "export const options = #{JSON.generate(settings)};\n" \
+ "// cases\n" \
+ "// { group: { case: [raw, the markdown engine's HTML] } }\n" \
+ "export default #{JSON.pretty_generate(cases)};\n"
+ end
+
+ # the module without its options, which only a settings change alters
+ def corpus_cases(text)
+ text && text[text.index("// cases\n").to_i..]
+ end
+
+ it "cooks every bbcode case as its recook snapshot" do
+ groups = Dir[File.join(fixtures, "*.txt")].sort
+ expect(groups).not_to be_empty
+
+ changed = []
+ corpus = {}
+ groups.each do |input|
+ group = File.basename(input, ".txt")
+ raws = read_cases(input)
+ results = raws.transform_values { |raw| cook(raw) }
+ corpus[group] = raws.to_h { |name, raw| [name, [raw, results[name][1]]] }
+ cooked = results.transform_values(&:first)
+ snapshot = input.delete_suffix(".txt") + ".cooked"
+ if ENV["UPDATE_RECOOK"]
+ write_cases(snapshot, cooked)
+ next
+ end
+
+ expected = File.exist?(snapshot) ? read_cases(snapshot) : {}
+ (cooked.keys | expected.keys).each do |name|
+ changed << "#{group}/#{name}" if cooked[name] != expected[name]
+ end
+ end
+
+ if ENV["UPDATE_RECOOK"]
+ File.write(corpus_path, corpus_js(corpus))
+ elsif corpus_cases(File.exist?(corpus_path) ? File.read(corpus_path) : nil) !=
+ corpus_cases(corpus_js(corpus))
+ changed << "test/javascripts/recook-corpus.js"
+ end
+
+ expect(changed).to be_empty,
+ "#{changed.size} cases cooked differently from their snapshot: " \
+ "#{changed.first(30).join(", ")}#{changed.size > 30 ? ", ..." : ""}. " \
+ "If intended, rerun with UPDATE_RECOOK=1 and review the diff."
+ end
+end
diff --git a/test/javascripts/.gitkeep b/test/javascripts/.gitkeep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/javascripts/lib/bbcode-native-test.js b/test/javascripts/lib/bbcode-native-test.js
new file mode 100644
index 0000000..eb1ea1c
--- /dev/null
+++ b/test/javascripts/lib/bbcode-native-test.js
@@ -0,0 +1,146 @@
+import { setupTest } from "ember-qunit";
+import { module, test } from "qunit";
+import { cook } from "discourse/lib/text";
+
+// the server's output, so the composer preview can't drift from the post
+module("Unit | Lib | bbcode-native", function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.lookup("service:site-settings").bbcode_enabled = true;
+ });
+
+ async function assertCooks(assert, input, expected, message) {
+ assert.strictEqual((await cook(input)).toString(), expected, message);
+ }
+
+ test("line breaks", async function (assert) {
+ await assertCooks(
+ assert,
+ "[b]para one\n\npara two[/b]",
+ `para one \n \npara two`,
+ "an inline tag stays one run across a blank line"
+ );
+ await assertCooks(
+ assert,
+ "a\n\n\n# H\n\n\nb",
+ `a
H
\n b`,
+ "a markdown block's margin stands for one blank line"
+ );
+ await assertCooks(
+ assert,
+ "[spoiler]secret[/spoiler]\nafter",
+ `\n\nSpoiler\n
secret
\n\nafter`,
+ "the line break after a trimAfter tag is dropped"
+ );
+ await assertCooks(
+ assert,
+ "[nobr]a\nb[/nobr]",
+ "a\nb",
+ "newlines inside nobr are not line breaks"
+ );
+ });
+
+ test("markdown inside tags", async function (assert) {
+ await assertCooks(
+ assert,
+ "[div=color:red]\n# Heading\n\n- one\n- two\n[/div]",
+ `
\n \n
Heading
\n
\n
one
\n
two
\n
\n \n
`,
+ "a container starting its own line holds markdown blocks"
+ );
+ await assertCooks(
+ assert,
+ "[b]a\n[wrap=x]\ny\n[/wrap]\nb[/b]",
+ `
\na
\ny
\nb
`,
+ "an inline tag holding another plugin's block renders as a block"
+ );
+ await assertCooks(
+ assert,
+ "> [center]\n> x\n> [/center]",
+ `
\n
\n \nx \n
\n
`,
+ "a block tag spanning lines stays in its blockquote"
+ );
+ });
+
+ test("tag matching", async function (assert) {
+ await assertCooks(
+ assert,
+ "[b]bold [i]both[/b] italic[/i]",
+ `bold both italic`,
+ "a mis-nested tag closes with its parent"
+ );
+ await assertCooks(
+ assert,
+ "[quote]\n[b]x\n[/quote]\ny[/b]",
+ `\ny`,
+ "a tag left open in a quote closes with it, and the quote still renders"
+ );
+ await assertCooks(
+ assert,
+ "[div=a]unclosed",
+ "[div=a]unclosed",
+ "an unclosed tag stays literal"
+ );
+ await assertCooks(
+ assert,
+ "\\[b][i]x[/b] y[/i]",
+ `[b]x[/b] y`,
+ "a backslash-escaped opener is text"
+ );
+ await assertCooks(
+ assert,
+ "[b]x [nobr]a\nb[/nobr][/b]",
+ `x a\nb`,
+ "a nested nobr keeps its newlines from becoming line breaks"
+ );
+
+ const deep = (
+ await cook(
+ "[div=a]\n".repeat(99) +
+ "[color=red][color=blue]x[/color][/color]\n" +
+ "[/div]\n".repeat(99)
+ )
+ ).toString();
+ assert.strictEqual(deep.split("
[color=blue]x[/color]`),
+ "a tag nested more than 100 deep stays text"
+ );
+ });
+
+ test("bbcode-plus templates", async function (assert) {
+ await assertCooks(
+ assert,
+ `a .d-header{display:none} b`,
+ "a .d-header{display:none} b",
+ "a template written as raw HTML loses its marker"
+ );
+ await assertCooks(
+ assert,
+ `[class name="x{} .d-header{display:none} .y"]color:red[/class]`,
+ "",
+ "a class name that could escape its rule is dropped"
+ );
+ });
+
+ test("literal content", async function (assert) {
+ await assertCooks(
+ assert,
+ "x\n[code]\n [div=x]\n[/div]\n[/code]\nafter",
+ `x
[div=x]\n[/div]
\nafter`,
+ "code keeps its content as written"
+ );
+ await assertCooks(
+ assert,
+ "[plain][b]x[/b] :smile:[/plain]",
+ "[b]x[/b] :smile:",
+ "plain text is read as neither bbcode nor markdown"
+ );
+ await assertCooks(
+ assert,
+ "`[plain]`\n[color=red]red[/color]\n`[/plain]`",
+ `[plain] \nred \n[/plain]`,
+ "a literal tag shown in a code span doesn't start literal text"
+ );
+ });
+});
diff --git a/test/javascripts/lib/bbcode-recook-test.js b/test/javascripts/lib/bbcode-recook-test.js
new file mode 100644
index 0000000..6f5f6a7
--- /dev/null
+++ b/test/javascripts/lib/bbcode-recook-test.js
@@ -0,0 +1,77 @@
+import { setupTest } from "ember-qunit";
+import { module, test } from "qunit";
+import { generateCookFunction } from "discourse/lib/text";
+import corpus, { options } from "discourse/plugins/bbcode/recook-corpus";
+
+// A plugin's test run loads no other plugin's markdown, and the bare engine
+// has none of the server's lookups, so these cases can't match here. A case
+// listed here that starts matching fails too, so the list stays current.
+const OTHER_PLUGINS = "needs another plugin's markdown, not loaded here";
+const EMOJI = "unicode emoji become images with the server's table only";
+const AVATAR = "the quote's avatar comes from the server's lookup";
+const DIFFERENT_HERE = {
+ "tag-details/alone": OTHER_PLUGINS,
+ "tag-details/own-lines": OTHER_PLUGINS,
+ "tag-details/own-lines-between-text": OTHER_PLUGINS,
+ "tag-details/blank-line-inside": OTHER_PLUGINS,
+ "tag-details/markdown-blocks-inside": OTHER_PLUGINS,
+ "tag-details/inline-markdown-inside": OTHER_PLUGINS,
+ "tag-details/in-blockquote": OTHER_PLUGINS,
+ "tag-details/in-list-item": OTHER_PLUGINS,
+ "tag-details/around-b": OTHER_PLUGINS,
+ "tag-details/in-core-quote": OTHER_PLUGINS,
+ "tag-details/misnested-with-i": OTHER_PLUGINS,
+ "tag-details/nested-in-itself": OTHER_PLUGINS,
+ "tag-details/repeated": OTHER_PLUGINS,
+ "tag-details/blank-lines-around": OTHER_PLUGINS,
+ "tag-details/[details] alone": OTHER_PLUGINS,
+ "tag-details/[details] own-lines": OTHER_PLUGINS,
+ "matching/repair-core-details": OTHER_PLUGINS,
+ "features/details-in-center": OTHER_PLUGINS,
+ "features/center-in-details": OTHER_PLUGINS,
+ "features/poll-in-center": OTHER_PLUGINS,
+ "features/poll-around-tags": OTHER_PLUGINS,
+ "features/footnote-like": OTHER_PLUGINS,
+ "features/task-like-list": OTHER_PLUGINS,
+ "malformed/brackets-alone": OTHER_PLUGINS,
+ "features/emoji-unicode-and-shortcode-in-plain": EMOJI,
+ "malformed/unicode-and-rtl": EMOJI,
+ "features/quote-with-attribution": AVATAR,
+ "scenarios/reply-quoting-with-bbcode": AVATAR,
+ 'tag-quote/[quote="system, post:1, topic:1"] alone': AVATAR,
+ 'tag-quote/[quote="system, post:1, topic:1"] own-lines': AVATAR,
+};
+
+// The recook baseline (spec/lib/recook_spec.rb) cooked by the composer, which
+// must match the server's markdown engine before its cleanup, given the same
+// settings. One engine for every case, as the composer keeps, so state left by
+// one cook shows too.
+module("Unit | Lib | bbcode recook corpus", function (hooks) {
+ setupTest(hooks);
+
+ hooks.beforeEach(function () {
+ this.owner.lookup("service:site-settings").bbcode_enabled = true;
+ });
+
+ // the per-post id is random
+ const normalize = (html) => html.replace(/post-[a-z0-9]{5}/g, "post-GUID");
+
+ for (const [group, cases] of Object.entries(corpus)) {
+ test(group, async function (assert) {
+ const cookFn = await generateCookFunction(options);
+ const entries = Object.entries(cases);
+ const reason = ([name]) => DIFFERENT_HERE[`${group}/${name}`];
+ for (const [name, [raw, expected]] of entries.filter((e) => !reason(e))) {
+ assert.strictEqual(normalize(cookFn(raw)), expected, name);
+ }
+ for (const entry of entries.filter(reason)) {
+ const [name, [raw, expected]] = entry;
+ assert.notStrictEqual(
+ normalize(cookFn(raw)),
+ expected,
+ `${name}: ${reason(entry)}`
+ );
+ }
+ });
+ }
+});
diff --git a/test/javascripts/lib/bbscript-test.js b/test/javascripts/lib/bbscript-test.js
new file mode 100644
index 0000000..90254a8
--- /dev/null
+++ b/test/javascripts/lib/bbscript-test.js
@@ -0,0 +1,238 @@
+import { setupTest } from "ember-qunit";
+import { module, test } from "qunit";
+import loadScript from "discourse/lib/load-script";
+
+const PARSER_URL = "/plugins/bbcode/javascripts/bbscript-parser.min.js";
+const CALLER_ID = "post-test1";
+
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+// exercises the built bundle, so rebuild it (pnpm build) after changing bbscript-src
+module("Unit | Lib | bbscript", function (hooks) {
+ setupTest(hooks);
+
+ let parser;
+
+ hooks.before(async function () {
+ await loadScript(PARSER_URL);
+ parser = window.bbscriptParser;
+ });
+
+ function runV2(source, options = {}) {
+ const data = options.data ?? parser.createStore();
+ parser.bbscriptProcessorV2.execAll(
+ parser.bbscript2Parser.parse(source).ast,
+ options.callerId ?? CALLER_ID,
+ "",
+ { ...options, data }
+ );
+ return data;
+ }
+
+ async function runV1(source, options = {}) {
+ const data = options.data ?? parser.createStore();
+ await parser.bbscriptProcessorV1.execAll(
+ parser.bbscriptProcessorV1.parse(source),
+ options.callerId ?? CALLER_ID,
+ "",
+ { ...options, data }
+ );
+ return data;
+ }
+
+ function fixture(html) {
+ const root = document.createElement("div");
+ root.innerHTML = html;
+ document.querySelector("#qunit-fixture").append(root);
+ return root;
+ }
+
+ test("syntax", function (assert) {
+ assert.deepEqual(
+ { ...runV2("(= a 1) (stop) (= b 2)") },
+ { a: 1 },
+ "stop ends the script"
+ );
+ assert.deepEqual(
+ { ...runV2("; a comment\n(= a 1) // trailing\n(= b ; inner\n 2)") },
+ { a: 1, b: 2 },
+ "; and // start comments"
+ );
+ assert.strictEqual(
+ runV2('(= s "a\\nb\\tc\\"d\\\\e\\qf")').s,
+ 'a\nb\tc"d\\e\\qf',
+ "known escapes are decoded and unknown ones are kept"
+ );
+ assert.strictEqual(
+ runV2('(= x 5) (= s "x=${x} y=${y}")').s,
+ "x=5 y=${y}",
+ "${name} interpolates set variables only"
+ );
+ assert.strictEqual(
+ runV2('(= f false) (= r (if f "yes" "no"))').r,
+ "no",
+ "false is falsy"
+ );
+ assert.deepEqual(
+ { ...runV2("(= true 5)") },
+ {},
+ "true and false can't be assigned"
+ );
+ assert.deepEqual(
+ parser.bbscript2Parser.parse("(addClass is-open item1)").formattedErrors,
+ [],
+ "names may contain digits and hyphens"
+ );
+ });
+
+ test("functions", function (assert) {
+ const data = runV2(`
+ (= n (+ (number "5") 1))
+ (= empty (number ""))
+ (= r (range 1 7 2))
+ (= tooLong (range 1000000))
+ (= tail (slice "hello" 1))
+ (= at (find "hello" "l"))
+ (= has (contain "hello" "x"))
+ (= neg (not true))
+ (= _ "kept")
+ (each [1 2] (= last _))
+ (= missing (slice))
+ `);
+
+ assert.strictEqual(data.n, 6, "number converts text");
+ assert.true(Number.isNaN(data.empty), "number of blank text is NaN");
+ assert.deepEqual(data.r, [1, 3, 5], "range takes a start, end and step");
+ assert.strictEqual(data.tooLong, undefined, "range refuses huge lists");
+ assert.strictEqual(data.tail, "ello", "slice's end is optional");
+ assert.strictEqual(data.at, 2, "find works on text");
+ assert.false(data.has, "contain works on text");
+ assert.false(data.neg, "not negates");
+ assert.strictEqual(data._, "kept", "each restores its loop variable");
+ assert.strictEqual(data.last, 2, "each visits every item");
+ assert.strictEqual(
+ data.missing,
+ undefined,
+ "a call without its required arguments does nothing"
+ );
+ });
+
+ test("v1", async function (assert) {
+ const data = await runV1(
+ 'set a 5\nif (leq a 1) (set wrong "yes")\nif (leq 1 a) (set right "yes")'
+ );
+ assert.deepEqual(
+ { ...data },
+ { a: 5, right: "yes" },
+ "leq compares less than or equal"
+ );
+
+ await runV1('print "unterminated', { data });
+ assert.true(true, "a missing closing quote doesn't throw");
+ });
+
+ test("v1 and v2 share a post's variables", async function (assert) {
+ const data = parser.createStore();
+ await runV1("set a 5", { data });
+ runV2('(++ a) (= b "from v2") (= s "${a}")', { data });
+ await runV1('inc a\nset joined "${a} ${b}"', { data });
+
+ assert.deepEqual(
+ { ...data },
+ { a: 7, b: "from v2", s: "6", joined: "7 from v2" }
+ );
+ });
+
+ test("isolation", function (assert) {
+ assert.deepEqual(
+ { ...runV2("(= a 1)", { callerId: "__proto__" }) },
+ {},
+ "ids cooking can't produce are refused"
+ );
+
+ const data = runV2("(= __proto__ 1) (= c (constructor))");
+ assert.true(
+ Object.hasOwn(data, "__proto__"),
+ "__proto__ is an ordinary name"
+ );
+ assert.strictEqual(data.c, undefined, "constructor isn't a function");
+ assert.strictEqual(
+ {}.polluted,
+ undefined,
+ "nothing reaches Object.prototype"
+ );
+
+ const other = runV2("(= seen a)");
+ assert.strictEqual(other.seen, "a", "another store can't see a variable");
+ });
+
+ test("elements are looked up inside the post only", function (assert) {
+ const outside = fixture(
+ `
"
+ ],
+ "flags-edges": [
+ "some [center is cool] text [/center]\n[accordion]\n[slide OPEN center]body[/slide]\n[slide nope]literal[/slide]\n[/accordion]",
+ "some [center is cool] text [/center]
"
+ ],
+ "fa": [
+ "[fa style=\"color:red\" primary-color=blue fa-transform=grow-2]fas fa-dice[/fa]",
+ ""
+ ],
+ "forged-marker": [
+ "a .d-header{display:none} b",
+ "a .d-header{display:none} b"
+ ],
+ "review-fractional-keyframe": [
+ "[animation=fade]\n[keyframe=.5%]opacity:0;[/keyframe]\n[keyframe=100%]opacity:1;[/keyframe]\n[/animation]",
+ "@keyframes post-GUIDfade { .5%{ opacity:0; }\n100%{ opacity:1; } }"
+ ],
+ "review-link-in-inline-spoiler": [
+ "[inlinespoiler][url=https://example.com]open link[/url][/inlinespoiler]",
+ "open link"
+ ]
+ },
+ "scenarios": {
+ "character-sheet": [
+ "[center][size=6][font=Cinzel][color=#8b0000]Aldric Thorne[/color][/font][/size]\n[img]https://example.com/aldric.png[/img]\n[i]\"The road remembers every step.\"[/i][/center]\n\n[divide][/divide]\n\n[b]Age:[/b] 34\n[b]Race:[/b] Human\n[b]Occupation:[/b] Wandering knight\n\n[fieldset=Appearance]\nTall, scarred, with [color=gray]ash-grey[/color] hair.\n[/fieldset]\n\n[fieldset=Personality]\n- Loyal to a fault\n- Quick to anger\n- [s]Trusting[/s] Wary of strangers\n[/fieldset]\n\n[spoiler=Backstory]\nBorn in the northern marches...\n\nHe left home at sixteen.\n[/spoiler]",
+ "
\nAldric Thorne \n \n"The road remembers every step."
\n
\n Age: 34 \nRace: Human \nOccupation: Wandering knight
\n\n
\n \nTall, scarred, with ash-grey hair. \n
\n
\n
\n\n
\n \n
\n
Loyal to a fault
\n
Quick to anger
\n
Trusting Wary of strangers
\n
\n \n
\n
\n
\n\nSpoiler: Backstory\n
\nBorn in the northern marches...
He left home at sixteen.
\n"
+ ],
+ "tabbed-profile": [
+ "[tabs]\n[tab=Overview]\n[center][b]Mira Vale[/b][/center]\nA cartographer from the coast.\n[/tab]\n[tab=Stats]\n| Stat | Value |\n|---|---|\n| STR | 8 |\n| INT | 16 |\n[/tab]\n[tab=Relationships]\n[list]\n@system - mentor\n[/list]\n[/tab]\n[/tabs]",
+ "
\n\n\n
\n \n
Mira Vale
\n A cartographer from the coast. \n
\n\n\n
\n \n
\n
\n\n
\n
Stat
\n
Value
\n
\n\n\n
\n
STR
\n
8
\n
\n
\n
INT
\n
16
\n
\n\n
\n
\n
\n\n\n
\n \n[list] \n@system - mentor \n[/list] \n
\n
"
+ ],
+ "lore-accordion": [
+ "[accordion]\n[slide=The First Age]\nBefore the sun, there was the [i]Long Dark[/i].\n[/slide]\n[slide=The Sundering open]\n[quote]The mountains fell into the sea.[/quote]\n[/slide]\n{slide=Legacy format|right}Older posts wrote slides like this.{/slide}\n[/accordion]",
+ "
\n\nThe First Age\n
\n \nBefore the sun, there was the Long Dark. \n
\n\n\nThe Sundering open\n
\n \n\n\n
\n\n\nLegacy format\n
Older posts wrote slides like this.
\n\n
"
+ ],
+ "styled-card-with-class": [
+ "[class name=card]\nbackground: #222;\ncolor: #eee;\npadding: 12px;\nborder-radius: 8px;\n[/class]\n[class name=card state=hover]\nbackground: #333;\n[/class]\n[div class=card]\n[h3]Guild Notice[/h3]\nAll members report to the hall at dusk.\n[/div]",
+ ".card__post-GUID {\nbackground: #222;\ncolor: #eee;\npadding: 12px;\nborder-radius: 8px;\n}\n.card__post-GUID:hover {\nbackground: #333;\n}\n
\n \n
Guild Notice
\n All members report to the hall at dusk. \n
"
+ ],
+ "text-message-conversation": [
+ "[textmessage=Jess]\n[message=them]where are you??[/message]\n[message=me]almost there[/message]\n[message=them]hurry up :sweat_smile:[/message]\n[/textmessage]",
+ "
"
+ ],
+ "ooc-and-in-character": [
+ "[ooc]Sorry for the late post, busy week![/ooc]\n\nRain hammered the shutters as she stepped inside.\n\n\"[i]You're late,[/i]\" he said.",
+ "
Sorry for the late post, busy week!
\n Rain hammered the shutters as she stepped inside.
"You're late," he said."
+ ],
+ "image-float-with-text": [
+ "[imagefloat=left][img]https://example.com/portrait.png[/img][/imagefloat]\n[justify]Lorem ipsum dolor sit amet, the text wraps around the portrait on the left and keeps going for several lines.[/justify]",
+ "
\n
Lorem ipsum dolor sit amet, the text wraps around the portrait on the left and keeps going for several lines.
"
+ ],
+ "scroll-box-log": [
+ "[scroll=200]\nDay 1: Arrived at the village.\nDay 2: Met the elder.\nDay 3: The well was poisoned.\nDay 4: Found the culprit.\nDay 5: Left at dawn.\n[/scroll]",
+ "
\n \nDay 1: Arrived at the village. \nDay 2: Met the elder. \nDay 3: The well was poisoned. \nDay 4: Found the culprit. \nDay 5: Left at dawn. \n
"
+ ],
+ "spoiler-with-heading-and-list": [
+ "[spoiler=Rules]\n## Posting rules\n1. Stay in character\n2. Use [ooc] for out of character\n3. No godmodding\n[/spoiler]",
+ "\n\nSpoiler: Rules\n
"
+ ],
+ "xenforo-style-quote-and-attach": [
+ "[QUOTE=\"Someone, post: 123, member: 45\"]old quote[/QUOTE]\n[ATTACH=full]6789[/ATTACH]",
+ "\n[ATTACH=full]6789[/ATTACH]"
+ ],
+ "xenforo-url-and-email": [
+ "[URL='https://example.com']link[/URL] [EMAIL]a@example.com[/EMAIL]",
+ "linka@example.com"
+ ],
+ "comment-for-authors": [
+ "[comment]TODO: update stats after the arc[/comment]\nVisible text.",
+ "TODO: update stats after the arc \nVisible text."
+ ],
+ "plain-and-icode-teaching": [
+ "To make text bold, write [icode][b]text[/b][/icode].\n[plain]Example: [color=red]red[/color][/plain]",
+ "To make text bold, write [b]text[/b]. Example: [color=red]red[/color]"
+ ],
+ "long-post-many-sections": [
+ "[center][h1]Session Log[/h1][/center]\n[spoiler=Part 1]\n[b]Scene:[/b] The tavern.\n[/spoiler]\n[spoiler=Part 2]\n[b]Scene:[/b] The road.\n[/spoiler]\n[spoiler=Part 3]\n[b]Scene:[/b] The castle.\n[/spoiler]\n[divide][/divide]\n[right][i]- End of log -[/i][/right]",
+ "
Session Log
\n \n\nSpoiler: Part 1\n
\nScene: The tavern.
\n\n\n\nSpoiler: Part 2\n
\nScene: The road.
\n\n\n\nSpoiler: Part 3\n
\nScene: The castle.
\n\n\n
- End of log -
"
+ ],
+ "nobr-poetry": [
+ "[nobr]\nRoses are red,\nviolets are blue.\n[/nobr]",
+ "Roses are red,\nviolets are blue."
+ ],
+ "inline-spoilers-in-dialogue": [
+ "\"The killer is [inlinespoiler]the butler[/inlinespoiler],\" she whispered.",
+ ""The killer is the butler," she whispered."
+ ],
+ "mail-in-character": [
+ "[mail type=receive person=\"The Duke\" subject=\"Summons\"]\nYou are to appear before the court at noon.\n[/mail]",
+ "
\n\n
\nThe Duke
\n
\nSummons
\n
\n \nYou are to appear before the court at noon. \n