diff --git a/plugins/chat/app/models/chat/message.rb b/plugins/chat/app/models/chat/message.rb index bd8d5b96955..87412b296d7 100644 --- a/plugins/chat/app/models/chat/message.rb +++ b/plugins/chat/app/models/chat/message.rb @@ -200,6 +200,8 @@ module Chat text-post-process upload-protocol watched-words + chat-html-inline + chat-html-block ] MARKDOWN_IT_RULES = %w[ diff --git a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-html-block.js b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-html-block.js new file mode 100644 index 00000000000..a450046e71d --- /dev/null +++ b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-html-block.js @@ -0,0 +1,114 @@ +// inspired from https://github.com/markdown-it/markdown-it/blob/master/lib/rules_block/html_block.mjs +// note that allow lister will run on top of it, so if a tag is allowed here but not on +// the allow list, then it won't show up + +const block_names = ["details"]; + +const attr_name = "[a-zA-Z_:][a-zA-Z0-9:._-]*"; +const unquoted = "[^\"'=<>`\\x00-\\x20]+"; +const single_quoted = "'[^']*'"; +const double_quoted = '"[^"]*"'; +const attr_value = + "(?:" + unquoted + "|" + single_quoted + "|" + double_quoted + ")"; +const attribute = "(?:\\s+" + attr_name + "(?:\\s*=\\s*" + attr_value + ")?)"; +const open_tag = "<[A-Za-z][A-Za-z0-9\\-]*" + attribute + "*\\s*\\/?>"; +const close_tag = "<\\/[A-Za-z][A-Za-z0-9\\-]*\\s*>"; + +const HTML_OPEN_CLOSE_TAG_RE = new RegExp( + "^(?:" + open_tag + "|" + close_tag + ")" +); + +// An array of opening and corresponding closing sequences for html tags, +// last argument defines whether it can terminate a paragraph or not +// +const HTML_SEQUENCES = [ + [ + /^<(script|pre|style|textarea)(?=(\s|>|$))/i, + /<\/(script|pre|style|textarea)>/i, + true, + ], + [/^/, true], + [/^<\?/, /\?>/, true], + [/^/, true], + [/^/, true], + [ + new RegExp("^|$))", "i"), + /^$/, + true, + ], + [new RegExp(HTML_OPEN_CLOSE_TAG_RE.source + "\\s*$"), /^$/, false], +]; + +function chatHtmlBlock(state, startLine, endLine, silent) { + let pos = state.bMarks[startLine] + state.tShift[startLine]; + let max = state.eMarks[startLine]; + + // if it's indented more than 3 spaces, it should be a code block + if (state.sCount[startLine] - state.blkIndent >= 4) { + return false; + } + + if (!state.md.options.html) { + return false; + } + + if (state.src.charCodeAt(pos) !== 0x3c /* < */) { + return false; + } + + let lineText = state.src.slice(pos, max); + + let i = 0; + for (; i < HTML_SEQUENCES.length; i++) { + if (HTML_SEQUENCES[i][0].test(lineText)) { + break; + } + } + if (i === HTML_SEQUENCES.length) { + return false; + } + + if (silent) { + // true if this sequence can be a terminator, false otherwise + return HTML_SEQUENCES[i][2]; + } + + let nextLine = startLine + 1; + + // If we are here - we detected HTML block. + // Let's roll down till block end. + if (!HTML_SEQUENCES[i][1].test(lineText)) { + for (; nextLine < endLine; nextLine++) { + if (state.sCount[nextLine] < state.blkIndent) { + break; + } + + pos = state.bMarks[nextLine] + state.tShift[nextLine]; + max = state.eMarks[nextLine]; + lineText = state.src.slice(pos, max); + + if (HTML_SEQUENCES[i][1].test(lineText)) { + if (lineText.length !== 0) { + nextLine++; + } + break; + } + } + } + + state.line = nextLine; + + const token = state.push("html_block", "", 0); + token.map = [startLine, nextLine]; + token.content = state.getLines(startLine, nextLine, state.blkIndent, true); + + return true; +} + +export function setup(helper) { + helper.registerPlugin((md) => { + if (md.options.discourse.features["chat-html-block"]) { + md.block.ruler.push("chat-html-block", chatHtmlBlock); + } + }); +} diff --git a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-html-inline.js b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-html-inline.js new file mode 100644 index 00000000000..26e5b5a2aae --- /dev/null +++ b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-html-inline.js @@ -0,0 +1,106 @@ +// inspired from https://github.com/markdown-it/markdown-it/blob/master/lib/rules_inline/html_inline.mjs +// note that allow lister will run on top of it, so if a tag is allowed here but not on +// the allow list, then it won't show up + +const inline_names = ["kbd"]; + +const patterns = inline_names.join("|"); +const attr_name = "[a-zA-Z_:][a-zA-Z0-9:._-]*"; +const unquoted = "[^\"'=<>`\\x00-\\x20]+"; +const single_quoted = "'[^']*'"; +const double_quoted = '"[^"]*"'; +const attr_value = + "(?:" + unquoted + "|" + single_quoted + "|" + double_quoted + ")"; +const attribute = "(?:\\s+" + attr_name + "(?:\\s*=\\s*" + attr_value + ")?)"; +const open_tag = `<(${patterns})` + attribute + "*\\s*\\/?>"; +const close_tag = `<\\/(${patterns})\\s*>`; +const comment = ""; +const processing = "<[?][\\s\\S]*?[?]>"; +const declaration = "]*>"; +const cdata = ""; +const HTML_TAG_RE = new RegExp( + "^(?:" + + open_tag + + "|" + + close_tag + + "|" + + comment + + "|" + + processing + + "|" + + declaration + + "|" + + cdata + + ")" +); + +function isLinkOpen(str) { + return /^\s]/i.test(str); +} +function isLinkClose(str) { + return /^<\/a\s*>/i.test(str); +} + +function isLetter(ch) { + /*eslint no-bitwise:0*/ + let lc = ch | 0x20; // to lower case + return lc >= 0x61 /* a */ && lc <= 0x7a /* z */; +} + +function chatHtmlInlineRule(state, silent) { + let ch, + match, + max, + token, + pos = state.pos; + + if (!state.md.options.html) { + return false; + } + + // Check start + max = state.posMax; + if (state.src.charCodeAt(pos) !== 0x3c /* < */ || pos + 2 >= max) { + return false; + } + + // Quick fail on second char + ch = state.src.charCodeAt(pos + 1); + if ( + ch !== 0x21 /* ! */ && + ch !== 0x3f /* ? */ && + ch !== 0x2f /* / */ && + !isLetter(ch) + ) { + return false; + } + + match = state.src.slice(pos).match(HTML_TAG_RE); + + if (!match) { + return false; + } + + if (!silent) { + token = state.push("html_inline", "", 0); + + token.content = match[0]; + + if (isLinkOpen(token.content)) { + state.linkLevel++; + } + if (isLinkClose(token.content)) { + state.linkLevel--; + } + } + state.pos += match[0].length; + return true; +} + +export function setup(helper) { + helper.registerPlugin((md) => { + if (md.options.discourse.features["chat-html-inline"]) { + md.inline.ruler.push("chat-html-inline", chatHtmlInlineRule); + } + }); +} diff --git a/plugins/chat/spec/lib/chat/pretty_text_spec.rb b/plugins/chat/spec/lib/chat/pretty_text_spec.rb index 094daf97c18..84a6bc40578 100644 --- a/plugins/chat/spec/lib/chat/pretty_text_spec.rb +++ b/plugins/chat/spec/lib/chat/pretty_text_spec.rb @@ -6,10 +6,10 @@ RSpec.describe PrettyText do [chat quote="jan;101;2023-12-01T21:10:53Z" channel="Tech Talks" channelId="5" multiQuote="true" chained="true"] message 1 [/chat] - + [chat quote="Kai;102;2023-12-01T21:10:53Z" chained="true"] message 2 - + message 3 [/chat] MD @@ -24,10 +24,10 @@ RSpec.describe PrettyText do [chat quote="jan;101;2023-12-01T21:10:53Z" channel="Tech Talks" channelId="5"] message 1 [/chat] - + [chat quote="Kai;102;2023-12-01T21:10:53Z"] message 2 - + message 3 [/chat] MD @@ -69,15 +69,15 @@ RSpec.describe PrettyText do cooked = PrettyText.cook <<~MD [chat quote="jan;274;2023-12-06T04:15:00Z" channelId="2" threadId="140"] original message - + [chat quote="kai;274;2023-12-06T04:30:04Z" chained="true"] thread reply 1 [/chat] - + [chat quote="jan;274;2023-12-06T05:00:00Z" chained="true"] thread reply 2 [/chat] - + [/chat] MD expect(cooked).to include('class="chat-transcript-thread"') @@ -97,4 +97,28 @@ RSpec.describe PrettyText do "
\n

original message

", ) end + + it "renders kbd inline tag" do + cooked = PrettyText.cook <<~MD + Esc is pressed + MD + + expect(cooked).to include("

Esc is pressed

") + end + + it "renders details block tag" do + cooked = PrettyText.cook <<~MD +
+ Dog + Cat +
+ MD + + expect(cooked).to include(<<~HTML.strip) +
+ Dog + Cat +
+ HTML + end end