Private
Public Access
Add heading IDs, subscript, and superscript to markdown rendering (#21)
markdown-to-jsx has no plugin hook for new inline/block syntax, but it
does correctly parse ordinary links and exposes a slugify callback for
heading anchors -- both get reused the same way this app's own
@mention/#room-reference highlighting already works: ~sub~/^sup^ are
rewritten to a link before compiling (the "URL" is just a carrier for
meaning the parser was never told about), then re-rendered as
<sub>/<sup> instead of an anchor; a heading's {#custom-id} suffix is
stripped from its own text before compiling, and slugify substitutes
the requested id for the auto-generated one.
Applied everywhere markdown renders (chat messages, file previews, the
Help page), not just chat -- MARKDOWN_OPTIONS became a per-render
createMarkdownOptions() since slugify needs each render's own heading
ids.
Definition lists deliberately left unsupported -- no block-level
equivalent to the link-trick exists, and faking one would mean either
reopening the disableParsingRawHTML XSS mitigation or unreliably
misusing blockquote syntax. Documented on the issue.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Markdown from 'markdown-to-jsx'
|
import Markdown from 'markdown-to-jsx'
|
||||||
import { getRoomFileUrl } from '../api/rooms'
|
import { getRoomFileUrl } from '../api/rooms'
|
||||||
import { useEscapeKey } from '../hooks/useEscapeKey'
|
import { useEscapeKey } from '../hooks/useEscapeKey'
|
||||||
import type { MessageFileInfo } from '../types'
|
import type { MessageFileInfo } from '../types'
|
||||||
import { MARKDOWN_OPTIONS } from './MessageContent'
|
import { createMarkdownOptions, preprocessMarkdown } from './MessageContent'
|
||||||
import './FilePreviewModal.css'
|
import './FilePreviewModal.css'
|
||||||
|
|
||||||
export type PreviewKind = 'markdown' | 'text' | 'pdf'
|
export type PreviewKind = 'markdown' | 'text' | 'pdf'
|
||||||
@@ -33,6 +33,12 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
|
|||||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const fileUrl = getRoomFileUrl(roomId, file.id)
|
const fileUrl = getRoomFileUrl(roomId, file.id)
|
||||||
|
// #21: subscript/superscript and heading-id support -- see MessageContent
|
||||||
|
// for why this needs to run before the Markdown component sees the text.
|
||||||
|
const markdownPreview = useMemo(
|
||||||
|
() => (content !== null ? preprocessMarkdown(content) : null),
|
||||||
|
[content],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -102,9 +108,11 @@ export function FilePreviewModal({ roomId, file, kind, onClose }: FilePreviewMod
|
|||||||
<div className={`file-preview-body${kind === 'pdf' ? ' file-preview-body-pdf' : ''}`}>
|
<div className={`file-preview-body${kind === 'pdf' ? ' file-preview-body-pdf' : ''}`}>
|
||||||
{error && <p className="file-preview-error">{error}</p>}
|
{error && <p className="file-preview-error">{error}</p>}
|
||||||
{!error && kind !== 'pdf' && content === null && <p className="file-preview-loading">Loading…</p>}
|
{!error && kind !== 'pdf' && content === null && <p className="file-preview-loading">Loading…</p>}
|
||||||
{!error && content !== null && kind === 'markdown' && (
|
{!error && kind === 'markdown' && markdownPreview && (
|
||||||
<div className="message-text file-preview-markdown">
|
<div className="message-text file-preview-markdown">
|
||||||
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
|
<Markdown options={createMarkdownOptions(markdownPreview.headingIds)}>
|
||||||
|
{markdownPreview.text}
|
||||||
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
|
{!error && content !== null && kind === 'text' && <pre className="file-preview-text">{content}</pre>}
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ interface MarkdownLinkProps {
|
|||||||
// span instead of an actual anchor. highlightRoomReferences does the same
|
// span instead of an actual anchor. highlightRoomReferences does the same
|
||||||
// trick for #roomname, but a room reference *is* meant to be navigable, so
|
// trick for #roomname, but a room reference *is* meant to be navigable, so
|
||||||
// it becomes a real (client-side-routed) Link instead of an inert span.
|
// it becomes a real (client-side-routed) Link instead of an inert span.
|
||||||
// Everything else renders as a real external link, same as before mentions
|
// convertSubSuperscript (#21) reuses the identical trick for `~sub~`/`^sup^`
|
||||||
// existed.
|
// -- markdown-to-jsx has no plugin hook for new inline syntax, but a link is
|
||||||
|
// something it already parses correctly, so `sub:`/`sup:` "URLs" are just
|
||||||
|
// another carrier for meaning the parser was never told about. Everything
|
||||||
|
// else renders as a real external link, same as before mentions existed.
|
||||||
function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
||||||
if (href?.startsWith('mention:')) {
|
if (href?.startsWith('mention:')) {
|
||||||
return <span className="message-mention">{children}</span>
|
return <span className="message-mention">{children}</span>
|
||||||
@@ -62,6 +65,12 @@ function MarkdownLink({ href, children }: MarkdownLinkProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (href === 'sub:') {
|
||||||
|
return <sub>{children}</sub>
|
||||||
|
}
|
||||||
|
if (href === 'sup:') {
|
||||||
|
return <sup>{children}</sup>
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||||
{children}
|
{children}
|
||||||
@@ -101,6 +110,70 @@ function convertShortcodes(text: string): string {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #21: single tilde/caret delimiters, no spaces inside, and not doubled --
|
||||||
|
// `~~text~~` is strikethrough (already natively supported) so a leading or
|
||||||
|
// trailing extra `~` excludes the match, matching markdownguide.org's
|
||||||
|
// extended syntax for both constructs. Converts a complete `~sub~`/`^sup^`
|
||||||
|
// span to `[sub](sub:)`/`[sup](sup:)` -- see MarkdownLink's comment for why
|
||||||
|
// a link is the carrier.
|
||||||
|
const SUBSCRIPT_PATTERN = /(?<!~)~([^~\s]+)~(?!~)/g
|
||||||
|
const SUPERSCRIPT_PATTERN = /\^([^^\s]+)\^/g
|
||||||
|
|
||||||
|
function convertSubSuperscript(text: string): string {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let inFence = false
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
if (/^\s*```/.test(line)) {
|
||||||
|
inFence = !inFence
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
if (inFence) return line
|
||||||
|
return line
|
||||||
|
.split(/(`+[^`]*`+)/g)
|
||||||
|
.map((part, i) =>
|
||||||
|
i % 2 === 0
|
||||||
|
? part
|
||||||
|
.replace(SUBSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sub:)`)
|
||||||
|
.replace(SUPERSCRIPT_PATTERN, (_match, inner: string) => `[${inner}](sup:)`)
|
||||||
|
: part,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
// #21: markdown-to-jsx has no option for an explicit heading anchor --
|
||||||
|
// every heading already gets an auto-generated slug from its own text
|
||||||
|
// (useful for linking within a message), and `{#custom-id}` is meant to
|
||||||
|
// *override* that slug, not add a second id next to it. There's no plugin
|
||||||
|
// hook for new block syntax either, so this strips the marker from the
|
||||||
|
// heading's own text (same fence-skipping convention as the functions
|
||||||
|
// above) and remembers the association by that now-bare text -- the one
|
||||||
|
// hook markdown-to-jsx *does* expose, `slugify` (see createMarkdownOptions
|
||||||
|
// below), gets called with exactly that text, letting the requested id
|
||||||
|
// stand in for the auto-generated one.
|
||||||
|
const HEADING_ID_PATTERN = /^(#{1,6}\s+.*?)\s*\{#([a-zA-Z0-9_-]+)\}\s*$/
|
||||||
|
|
||||||
|
function extractHeadingIds(text: string): { text: string; headingIds: Map<string, string> } {
|
||||||
|
const headingIds = new Map<string, string>()
|
||||||
|
const lines = text.split('\n')
|
||||||
|
let inFence = false
|
||||||
|
const nextLines = lines.map((line) => {
|
||||||
|
if (/^\s*```/.test(line)) {
|
||||||
|
inFence = !inFence
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
if (inFence) return line
|
||||||
|
const match = line.match(HEADING_ID_PATTERN)
|
||||||
|
if (!match) return line
|
||||||
|
const [, headingLine, customId] = match
|
||||||
|
headingIds.set(headingLine.replace(/^#{1,6}\s+/, ''), customId)
|
||||||
|
return headingLine
|
||||||
|
})
|
||||||
|
return { text: nextLines.join('\n'), headingIds }
|
||||||
|
}
|
||||||
|
|
||||||
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
const MENTION_PATTERN = /@([a-zA-Z0-9_.-]+)/g
|
||||||
|
|
||||||
// Turns a validated @username into `[@username](mention:username)` --
|
// Turns a validated @username into `[@username](mention:username)` --
|
||||||
@@ -194,9 +267,13 @@ function preserveLineBreaks(text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shared with FilePreviewModal so both render paths carry the exact same
|
// Shared with FilePreviewModal so both render paths carry the exact same
|
||||||
// XSS mitigation (disableParsingRawHTML) -- duplicating this object would
|
// XSS mitigation (disableParsingRawHTML) and #21's heading-id/sub/superscript
|
||||||
// risk the two drifting out of sync if one gets edited later.
|
// support -- duplicating this would risk the two drifting out of sync if
|
||||||
export const MARKDOWN_OPTIONS = {
|
// one gets edited later. A function, not a plain constant, since `slugify`
|
||||||
|
// needs each render's own headingIds map (see extractHeadingIds above) --
|
||||||
|
// there's no per-render state to close over in a module-level object.
|
||||||
|
export function createMarkdownOptions(headingIds: Map<string, string>) {
|
||||||
|
return {
|
||||||
// The core XSS mitigation: raw HTML in message content is escaped
|
// The core XSS mitigation: raw HTML in message content is escaped
|
||||||
// and printed literally instead of being parsed into elements.
|
// and printed literally instead of being parsed into elements.
|
||||||
disableParsingRawHTML: true,
|
disableParsingRawHTML: true,
|
||||||
@@ -204,10 +281,22 @@ export const MARKDOWN_OPTIONS = {
|
|||||||
a: { component: MarkdownLink },
|
a: { component: MarkdownLink },
|
||||||
img: { component: MarkdownImageLink },
|
img: { component: MarkdownImageLink },
|
||||||
},
|
},
|
||||||
|
slugify: (input: string, defaultFn: (input: string) => string) =>
|
||||||
|
headingIds.get(input) ?? defaultFn(input),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #21: preprocessing shared by MessageContent and FilePreviewModal --
|
||||||
|
// subscript/superscript and heading-id overrides are general markdown
|
||||||
|
// features, not chat-specific like mentions/shortcodes/room-references, so
|
||||||
|
// a plain file preview gets them too.
|
||||||
|
export function preprocessMarkdown(text: string): { text: string; headingIds: Map<string, string> } {
|
||||||
|
return extractHeadingIds(convertSubSuperscript(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
|
export function MessageContent({ content, memberUsernames, myRooms }: MessageContentProps) {
|
||||||
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
const withMentions = memberUsernames ? highlightMentions(content, memberUsernames) : content
|
||||||
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
const withRoomRefs = myRooms ? highlightRoomReferences(withMentions, myRooms) : withMentions
|
||||||
return <Markdown options={MARKDOWN_OPTIONS}>{preserveLineBreaks(convertShortcodes(withRoomRefs))}</Markdown>
|
const { text, headingIds } = preprocessMarkdown(convertShortcodes(withRoomRefs))
|
||||||
|
return <Markdown options={createMarkdownOptions(headingIds)}>{preserveLineBreaks(text)}</Markdown>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import Markdown from 'markdown-to-jsx'
|
import Markdown from 'markdown-to-jsx'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { MARKDOWN_OPTIONS } from '../components/MessageContent'
|
import { createMarkdownOptions, preprocessMarkdown } from '../components/MessageContent'
|
||||||
import { TopBar } from '../components/TopBar'
|
import { TopBar } from '../components/TopBar'
|
||||||
import './HelpPage.css'
|
import './HelpPage.css'
|
||||||
|
|
||||||
@@ -13,6 +13,12 @@ const GUIDE_URL = '/USER_GUIDE.md'
|
|||||||
export function HelpPage() {
|
export function HelpPage() {
|
||||||
const [content, setContent] = useState<string | null>(null)
|
const [content, setContent] = useState<string | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
// #21: heading-id/sub/superscript support -- the user guide is exactly
|
||||||
|
// the kind of document that benefits from an explicit anchor override.
|
||||||
|
const markdownPreview = useMemo(
|
||||||
|
() => (content !== null ? preprocessMarkdown(content) : null),
|
||||||
|
[content],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
@@ -44,9 +50,11 @@ export function HelpPage() {
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className="admin-error">{error}</p>}
|
{error && <p className="admin-error">{error}</p>}
|
||||||
{!error && content === null && <p className="help-loading">Loading…</p>}
|
{!error && content === null && <p className="help-loading">Loading…</p>}
|
||||||
{!error && content !== null && (
|
{!error && markdownPreview && (
|
||||||
<div className="message-text help-content">
|
<div className="message-text help-content">
|
||||||
<Markdown options={MARKDOWN_OPTIONS}>{content}</Markdown>
|
<Markdown options={createMarkdownOptions(markdownPreview.headingIds)}>
|
||||||
|
{markdownPreview.text}
|
||||||
|
</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user