CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m19s
The topic editor's hand-rolled markdown treated any single _ or * as emphasis, inside URLs and #alias:server / @user:server too, and built the plain topic by stripping tags, so the underscores were lost for every client. It also left " unescaped in link targets. buildTopicContent() now uses the composer's inline markdown parser (links, bare URLs and HTML escaping as for messages) and treats _ between two letters/digits as literal (CommonMark), except inside code, link targets and URLs. The plain topic drops the markers but keeps links as "text (url)" and decodes entities. Fixes #262 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
64 lines
2.4 KiB
TypeScript
64 lines
2.4 KiB
TypeScript
import { parseInlineMD } from '../plugins/markdown';
|
|
import { sanitizeText } from './sanitize';
|
|
|
|
/**
|
|
* Room topic markdown → `m.room.topic` content (#262).
|
|
*
|
|
* Uses the composer's inline markdown parser (the same rules as messages: links,
|
|
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
|
|
* two letters/digits is literal, as in CommonMark. Topics are full of
|
|
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
|
|
* shared parser would otherwise turn into italics and, worse, drop from the
|
|
* plain `topic` that every other client shows.
|
|
*/
|
|
|
|
// Spans the guard must not touch: inline code, link targets, bare URLs.
|
|
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
|
|
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
|
|
|
|
const escapeIntrawordUnderscores = (line: string): string =>
|
|
line
|
|
.split(PROTECTED)
|
|
.map((part, i) =>
|
|
// split() with one capture group puts the protected spans at odd indexes.
|
|
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
|
|
)
|
|
.join('');
|
|
|
|
const ENTITIES: Record<string, string> = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
''': "'",
|
|
};
|
|
const decodeEntities = (s: string): string =>
|
|
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
|
|
|
|
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
|
|
const htmlToPlain = (html: string): string =>
|
|
decodeEntities(
|
|
html
|
|
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
|
|
const text = label.replace(/<[^>]+>/g, '');
|
|
return text === href ? href : `${text} (${href})`;
|
|
})
|
|
.replace(/<br>/g, '\n')
|
|
.replace(/<[^>]+>/g, ''),
|
|
);
|
|
|
|
export type TopicContent =
|
|
| { topic: string }
|
|
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
|
|
|
|
export function buildTopicContent(text: string): TopicContent {
|
|
const lines = text.split('\n');
|
|
const html = lines
|
|
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
|
|
.join('<br>');
|
|
const plainHtml = lines.map(sanitizeText).join('<br>');
|
|
// No markdown at all: send the text as typed, without a formatted body.
|
|
if (html === plainHtml) return { topic: text };
|
|
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
|
|
}
|