Commit e822c659890f
tarball9 files changed+118-79
@@ -8,9 +8,9 @@
88
"name": "malice-web",99
"version": "0.1.0",1010
"dependencies": {11
+ "@cloudrac3r/discord-markdown": "2.9.1",1112
"@twemoji/svg": "^15.0.0",1213
"emojibase-data": "^18.0.0",13
- "marked": "^18.1.1",1414
"svelte": "^5.39.0"1515
},1616
"devDependencies": {@@ -67,6 +67,15 @@
6767
"node": ">=6.9.0"6868
}6969
},70
+ "node_modules/@cloudrac3r/discord-markdown": {71
+ "version": "2.9.1",72
+ "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.1.tgz",73
+ "integrity": "sha512-sYaDfRBaS7uMVQx4wJwT68F6OzJZX84DF2XvHkQr8vdwTCvnpeaT26obqvzNawVwVoQ4xjIMZxafgMrAEK4Oxw==",74
+ "license": "MIT",75
+ "dependencies": {76
+ "simple-markdown": "^0.7.3"77
+ }78
+ },7079
"node_modules/@emnapi/core": {7180
"version": "1.9.2",7281
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",@@ -2077,9 +2086,7 @@
20772086
"version": "19.3.0",20782087
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",20792088
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",2080
- "dev": true,20812089
"license": "MIT",2082
- "peer": true,20832090
"dependencies": {20842091
"csstype": "^3.2.2"20852092
}@@ -2315,9 +2322,7 @@
23152322
"version": "3.2.3",23162323
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",23172324
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",2318
- "dev": true,2319
- "license": "MIT",2320
- "peer": true2325
+ "license": "MIT"23212326
},23222327
"node_modules/debug": {23232328
"version": "4.4.3",@@ -2703,18 +2708,6 @@
27032708
"@jridgewell/sourcemap-codec": "^1.6.0"27042709
}27052710
},2706
- "node_modules/marked": {2707
- "version": "18.1.1",2708
- "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",2709
- "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",2710
- "license": "MIT",2711
- "bin": {2712
- "marked": "bin/marked.js"2713
- },2714
- "engines": {2715
- "node": ">= 20"2716
- }2717
- },27182711
"node_modules/min-indent": {27192712
"version": "1.0.1",27202713
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",@@ -3097,6 +3090,15 @@
30973090
"node": ">=10"30983091
}30993092
},3093
+ "node_modules/simple-markdown": {3094
+ "version": "0.7.3",3095
+ "resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.7.3.tgz",3096
+ "integrity": "sha512-uGXIc13NGpqfPeFJIt/7SHHxd6HekEJYtsdoCM06mEBPL9fQH/pSD7LRM6PZ7CKchpSvxKL4tvwMamqAaNDAyg==",3097
+ "license": "MIT",3098
+ "dependencies": {3099
+ "@types/react": ">=16.0.0"3100
+ }3101
+ },31003102
"node_modules/source-map": {31013103
"version": "0.6.1",31023104
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",@@ -14,9 +14,9 @@
1414
"build-storybook": "storybook build && chmod -R u+w storybook-static"1515
},1616
"dependencies": {17
+ "@cloudrac3r/discord-markdown": "2.9.1",1718
"@twemoji/svg": "^15.0.0",1819
"emojibase-data": "^18.0.0",19
- "marked": "^18.1.1",2020
"svelte": "^5.39.0"2121
},2222
"devDependencies": {@@ -88,7 +88,6 @@
8888
SIDE_WIDTH,8989
lastSpaceRooms,9090
lineHeight,91
- markdownEnabled,9291
membersWidth,9392
messageSpacing,9493
navFolded,@@ -729,7 +728,7 @@
729728
if (image?.previewUrl) URL.revokeObjectURL(image.previewUrl);730729
});731730
} else if (attachments.length === 0) {732
- queueSend(roomId, text, reply, mentions, $markdownEnabled, resolveEmotes(text, roomEmotes));731
+ queueSend(roomId, text, reply, mentions, resolveEmotes(text, roomEmotes));733732
} else {734733
const { [roomId]: _sent, ...rest } = staged;735734
staged = rest;@@ -771,7 +770,7 @@
771770
case 'me':772771
case 'notice': {773772
const said = required('something to say');774
- await sendMessage(roomId, said, null, mentions, $markdownEnabled, name === 'me' ? 'emote' : 'notice', resolveEmotes(said, roomEmotes));773
+ await sendMessage(roomId, said, null, mentions, name === 'me' ? 'emote' : 'notice', resolveEmotes(said, roomEmotes));775774
break;776775
}777776
case 'msg': {@@ -779,7 +778,7 @@
779778
const res = (await runCommand({ op: 'direct', user_id })) as { room_id: string };780779
await refreshRooms();781780
await selectRoom(res.room_id);782
- if (rest) queueSend(res.room_id, rest, null, [], $markdownEnabled);781
+ if (rest) queueSend(res.room_id, rest);783782
break;784783
}785784
case 'join':@@ -881,7 +880,7 @@
881880
for (const attachment of attachments) {882881
uploadName = attachment.file.name || 'attachment';883882
try {884
- if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, $markdownEnabled, resolveEmotes(body, roomEmotes));883
+ if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, resolveEmotes(body, roomEmotes));885884
else await uploadAttachment(roomId, attachment.file);886885
first = false;887886
} catch (e) {@@ -954,7 +953,7 @@
954953
const body = composer.trim();955954
cancelEdit();956955
if (!body || body === original.trim()) return;957
- editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), $markdownEnabled, resolveEmotes(body, roomEmotes)).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));956
+ editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), resolveEmotes(body, roomEmotes)).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));958957
}959958
960959
function editLastOwn(): boolean {@@ -2614,19 +2613,6 @@
26142613
></textarea>26152614
</div>26162615
<button2617
- type="button"2618
- class="icon-btn"2619
- aria-pressed={$markdownEnabled}2620
- aria-label={$markdownEnabled ? 'Turn markdown off' : 'Turn markdown on'}2621
- title={$markdownEnabled ? 'Markdown on' : 'Markdown off'}2622
- on:click={() => markdownEnabled.update((v) => !v)}2623
- >2624
- <svg viewBox="0 0 24 16" width="21" height="14" aria-hidden="true">2625
- <rect x="1" y="1" width="22" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.6" />2626
- <path d="M4.5 11.5v-7l2.75 3 2.75-3v7M15.5 4.5v6.5M13 8.5l2.5 2.75L18 8.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />2627
- </svg>2628
- </button>2629
- <button26302616
type="submit"26312617
class="primary-btn"26322618
disabled={(!composer.trim() && currentStaged.length === 0) ||@@ -41,10 +41,9 @@ export const sendMessage = (
4141
body: string,4242
reply_to: string | null = null,4343
mentions: Mention[] = [],44
- markdown = false,4544
kind: SendKind = 'text',4645
emotes: Emote[] = []47
-) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown, emotes), reply_to, mentionedUserIds(mentions), kind);46
+) => transport.sendMessage(room_id, body, formatBody(body, mentions, true, emotes), reply_to, mentionedUserIds(mentions), kind);4847
export const runCommand = (request: CommandRequest) => transport.command(request);4948
export const uploadAvatar = (file: File) => transport.uploadAvatar(file);5049
export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);@@ -73,8 +72,8 @@ export function createRoom(name: string, topic?: string): Promise<{ room_id: str
7372
export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);7473
export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string) =>7574
transport.reactToMessage(room_id, event_id, key, shortcode);76
-export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], markdown = false, emotes: Emote[] = []) =>77
- transport.editMessage(room_id, event_id, body, formatBody(body, mentions, markdown, emotes), mentionedUserIds(mentions));75
+export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], emotes: Emote[] = []) =>76
+ transport.editMessage(room_id, event_id, body, formatBody(body, mentions, true, emotes), mentionedUserIds(mentions));7877
export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);7978
8079
export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {@@ -121,12 +120,11 @@ export function uploadAttachment(
121120
caption: string | null = null,122121
reply_to: string | null = null,123122
mentions: Mention[] = [],124
- markdown = false,125123
emotes: Emote[] = []126124
): Promise<void> {127125
return transport.uploadAttachment(room_id, file, {128126
caption,129
- formatted_caption: caption ? formatBody(caption, mentions, markdown, emotes) : null,127
+ formatted_caption: caption ? formatBody(caption, mentions, true, emotes) : null,130128
reply_to,131129
mentions: mentionedUserIds(mentions)132130
});@@ -28,7 +28,6 @@ const isSpacing = (v: unknown): v is number =>
2828
2929
export const navFolded = persisted('malice.navFolded', false, isBool);3030
export const railFolded = persisted('malice.railFolded', false, isBool);31
-export const markdownEnabled = persisted('malice.markdown', true, isBool);3231
3332
export const SIDE_WIDTH = { min: 200, max: 480, default: 260 };3433
export const MEMBERS_WIDTH = { min: 200, max: 420, default: 240 };@@ -0,0 +1,7 @@
1
+declare module '@cloudrac3r/discord-markdown' {2
+ import type { ParserRule, SingleASTNode } from 'simple-markdown';3
+4
+ export type MarkdownNode = SingleASTNode;5
+ export const rules: Record<string, ParserRule>;6
+ export const markdownEngine: Pick<typeof import('simple-markdown'), 'parserFor' | 'sanitizeUrl'>;7
+}@@ -1,4 +1,4 @@
1
-import { Marked, type Token, type Tokens } from 'marked';1
+import { markdownEngine, rules, type MarkdownNode } from '@cloudrac3r/discord-markdown';22
import type { Emote } from './emotes';33
import type { Mention } from './mentions';44
@@ -33,36 +33,86 @@ function textToHtml(text: string, link: (escaped: string) => string): string {
3333
return link(escapeHtml(text)).replace(/\n/g, '<br>');3434
}3535
36
-const PLAIN_INLINE = new Set(['text', 'escape', 'br']);36
+const parseMarkdown = markdownEngine.parserFor(37
+ Object.fromEntries(Object.entries(rules).filter(([name]) => !name.startsWith('discord')))38
+);39
+const INLINE_TAGS: Record<string, string> = { em: 'em', strong: 'strong', u: 'u', strike: 'del', small: 'sub' };40
+const BLOCK_TYPES = new Set(['blockQuote', 'codeBlock', 'heading', 'list', 'paragraph']);3741
38
-function isPlain(tokens: Token[]): boolean {39
- const blocks = tokens.filter((t) => t.type !== 'space');40
- if (blocks.length !== 1 || blocks[0].type !== 'paragraph') return blocks.length === 0;41
- return (blocks[0] as Tokens.Paragraph).tokens.every((t) => PLAIN_INLINE.has(t.type) && !('tokens' in t && t.tokens));42
+function renderMarkdown(nodes: MarkdownNode[], link: (escaped: string) => string): string {43
+ let html = '';44
+ let text = '';45
+ const flushText = () => {46
+ html += textToHtml(text, link);47
+ text = '';48
+ };49
+ for (const node of nodes) {50
+ if (node.type === 'text') {51
+ text += node.content;52
+ continue;53
+ }54
+ flushText();55
+ const children = () => renderMarkdown(node.content, link);56
+ const tag = INLINE_TAGS[node.type];57
+ if (tag) {58
+ html += `<${tag}>${children()}</${tag}>`;59
+ continue;60
+ }61
+ switch (node.type) {62
+ case 'br':63
+ case 'newline':64
+ html += '<br>';65
+ break;66
+ case 'inlineCode':67
+ html += `<code>${escapeHtml(node.content)}</code>`;68
+ break;69
+ case 'codeBlock': {70
+ const lang = /^[\w+#-]+$/.test(node.lang ?? '') ? ` class="language-${escapeHtml(node.lang)}"` : '';71
+ html += `<pre><code${lang}>${escapeHtml(node.content)}\n</code></pre>`;72
+ break;73
+ }74
+ case 'spoiler':75
+ html += `<span data-mx-spoiler>${renderMarkdown(node.parsed, link)}</span>`;76
+ break;77
+ case 'blockQuote':78
+ html += `<blockquote>${children()}</blockquote>`;79
+ break;80
+ case 'paragraph':81
+ html += `<p>${children()}</p>`;82
+ break;83
+ case 'heading': {84
+ const level = Math.max(1, Math.min(6, node.level));85
+ html += `<h${level}>${children()}</h${level}>`;86
+ break;87
+ }88
+ case 'list': {89
+ const tag = node.ordered ? 'ol' : 'ul';90
+ const start = node.ordered && Number.isSafeInteger(node.start) && node.start > 0 ? ` start="${node.start}"` : '';91
+ html += `<${tag}${start}>${node.items.map((item: MarkdownNode[]) => `<li>${renderMarkdown(item, link)}</li>`).join('')}</${tag}>`;92
+ break;93
+ }94
+ case 'link':95
+ case 'url':96
+ case 'autolink': {97
+ const label = renderMarkdown(node.content, (escaped) => escaped);98
+ const href = markdownEngine.sanitizeUrl(node.target);99
+ const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';100
+ html += href && /^(https?:|mailto:)/i.test(href) ? `<a href="${escapeHtml(href)}"${title}>${label}</a>` : label;101
+ break;102
+ }103
+ }104
+ }105
+ flushText();106
+ return html;42107
}43108
44109
function markdownToHtml(body: string, link: (escaped: string) => string): string | null {45
- const marked = new Marked({ gfm: true, breaks: true });46
- marked.use({47
- renderer: {48
- text(token) {49
- if ('tokens' in token && token.tokens) return false;50
- return link('escaped' in token && token.escaped ? token.text : escapeHtml(token.text));51
- },52
- html(token) {53
- return escapeHtml(token.text);54
- },55
- image(token) {56
- return `<a href="${escapeHtml(token.href)}">${escapeHtml(token.text || token.href)}</a>`;57
- },58
- checkbox(token) {59
- return token.checked ? '[x] ' : '[ ] ';60
- }61
- }62
- });63
- const tokens = marked.lexer(body);64
- if (isPlain(tokens)) return null;65
- return marked.parser(tokens).trim();110
+ const nodes = parseMarkdown(body, { inline: true });111
+ const html = renderMarkdown(nodes, link);112
+ if (html === textToHtml(body, (escaped) => escaped)) return null;113
+ if (nodes.every((node) => node.type === 'text' || node.type === 'br' || node.type === 'newline') ||114
+ nodes.some((node) => BLOCK_TYPES.has(node.type))) return html;115
+ return `<p>${html}</p>`;66116
}67117
68118
/** Matrix `formatted_body` for a composed message, or null when plain text says it all. */@@ -21,7 +21,6 @@ export const pendingByRoom = writable<Record<string, PendingMessage[]>>({});
2121
interface PendingRecord extends PendingMessage {2222
roomId: string;2323
mentions: Mention[];24
- markdown: boolean;2524
emotes: Emote[];2625
knownIds: Set<string>;2726
}@@ -79,7 +78,7 @@ async function attemptSend(record: PendingRecord): Promise<void> {
7978
if (!records.has(record.id)) return;8079
updateRecord(record.id, { status: 'sending', error: null });8180
try {82
- await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, record.markdown, 'text', record.emotes);81
+ await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, 'text', record.emotes);8382
} catch (e) {8483
updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });8584
}@@ -96,17 +95,15 @@ export function queueSend(
9695
body: string,9796
replyTo: ReplyReference | null = null,9897
mentions: Mention[] = [],99
- markdown = false,10098
emotes: Emote[] = []10199
): void {102100
const record: PendingRecord = {103101
id: nextId(),104102
roomId,105103
body,106
- formatted: formatBody(body, mentions, markdown, emotes),104
+ formatted: formatBody(body, mentions, true, emotes),107105
replyTo,108106
mentions,109
- markdown,110107
emotes,111108
status: 'sending',112109
error: null,@@ -9,7 +9,7 @@ test('plain text stays unformatted', () => {
99
1010
test('renders the common chat markdown', () => {1111
assert.equal(formatBody('*it* **bold** ~~gone~~', [], true), '<p><em>it</em> <strong>bold</strong> <del>gone</del></p>');12
- assert.equal(formatBody('> quoted\nstill', [], true), '<blockquote>\n<p>quoted<br>still</p>\n</blockquote>');12
+ assert.equal(formatBody('> quoted\nstill', [], true), '<blockquote>quoted<br></blockquote>still');1313
assert.equal(formatBody('run `ls <x>`', [], true), '<p>run <code>ls <x></code></p>');1414
assert.equal(1515
formatBody('```js\nlet a = "<b>";\n```', [], true),@@ -19,7 +19,7 @@ test('renders the common chat markdown', () => {
1919
2020
test('raw html is escaped, not passed through', () => {2121
assert.equal(formatBody('<b>hi</b> **x**', [], true), '<p><b>hi</b> <strong>x</strong></p>');22
- assert.equal(formatBody('<script>alert(1)</script>', [], true), '<script>alert(1)</script>');22
+ assert.equal(formatBody('<script>alert(1)</script>', [], true), null);2323
});2424
2525
test('enter stays a newline inside an open fence', () => {