DEV: allows chat to render specific html tags (#26591)

This commit adds two custom markdown rules:
- chat-html-block
- chat-html-inline

For now it only allows `<kbd>` for inline and `<details>` for block.
This commit is contained in:
Joffrey JAFFEUX
2024-04-10 21:23:23 +02:00
committed by GitHub
parent 9d88f80f26
commit 107015ff4b
4 changed files with 253 additions and 7 deletions

View File

@ -200,6 +200,8 @@ module Chat
text-post-process text-post-process
upload-protocol upload-protocol
watched-words watched-words
chat-html-inline
chat-html-block
] ]
MARKDOWN_IT_RULES = %w[ MARKDOWN_IT_RULES = %w[

View File

@ -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],
[/^<![A-Z]/, />/, true],
[/^<!\[CDATA\[/, /\]\]>/, true],
[
new RegExp("^</?(" + block_names.join("|") + ")(?=(\\s|/?>|$))", "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);
}
});
}

View File

@ -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 = "<![A-Za-z][^>]*>";
const cdata = "<!\\[CDATA\\[[\\s\\S]*?\\]\\]>";
const HTML_TAG_RE = new RegExp(
"^(?:" +
open_tag +
"|" +
close_tag +
"|" +
comment +
"|" +
processing +
"|" +
declaration +
"|" +
cdata +
")"
);
function isLinkOpen(str) {
return /^<a[>\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);
}
});
}

View File

@ -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"] [chat quote="jan;101;2023-12-01T21:10:53Z" channel="Tech Talks" channelId="5" multiQuote="true" chained="true"]
message 1 message 1
[/chat] [/chat]
[chat quote="Kai;102;2023-12-01T21:10:53Z" chained="true"] [chat quote="Kai;102;2023-12-01T21:10:53Z" chained="true"]
message 2 message 2
message 3 message 3
[/chat] [/chat]
MD MD
@ -24,10 +24,10 @@ RSpec.describe PrettyText do
[chat quote="jan;101;2023-12-01T21:10:53Z" channel="Tech Talks" channelId="5"] [chat quote="jan;101;2023-12-01T21:10:53Z" channel="Tech Talks" channelId="5"]
message 1 message 1
[/chat] [/chat]
[chat quote="Kai;102;2023-12-01T21:10:53Z"] [chat quote="Kai;102;2023-12-01T21:10:53Z"]
message 2 message 2
message 3 message 3
[/chat] [/chat]
MD MD
@ -69,15 +69,15 @@ RSpec.describe PrettyText do
cooked = PrettyText.cook <<~MD cooked = PrettyText.cook <<~MD
[chat quote="jan;274;2023-12-06T04:15:00Z" channelId="2" threadId="140"] [chat quote="jan;274;2023-12-06T04:15:00Z" channelId="2" threadId="140"]
original message original message
[chat quote="kai;274;2023-12-06T04:30:04Z" chained="true"] [chat quote="kai;274;2023-12-06T04:30:04Z" chained="true"]
thread reply 1 thread reply 1
[/chat] [/chat]
[chat quote="jan;274;2023-12-06T05:00:00Z" chained="true"] [chat quote="jan;274;2023-12-06T05:00:00Z" chained="true"]
thread reply 2 thread reply 2
[/chat] [/chat]
[/chat] [/chat]
MD MD
expect(cooked).to include('class="chat-transcript-thread"') expect(cooked).to include('class="chat-transcript-thread"')
@ -97,4 +97,28 @@ RSpec.describe PrettyText do
"<div class=\"chat-transcript-messages\">\n<p>original message</p></div>", "<div class=\"chat-transcript-messages\">\n<p>original message</p></div>",
) )
end end
it "renders kbd inline tag" do
cooked = PrettyText.cook <<~MD
<kbd>Esc</kbd> is pressed
MD
expect(cooked).to include("<p><kbd>Esc</kbd> is pressed</p>")
end
it "renders details block tag" do
cooked = PrettyText.cook <<~MD
<details>
<summary>Dog</summary>
Cat
</details>
MD
expect(cooked).to include(<<~HTML.strip)
<details>
<summary>Dog</summary>
Cat
</details>
HTML
end
end end