greentext

This commit is contained in:
Moon.eth
2026-07-30 20:22:52 +09:00
parent 657d517503
commit f735de5185
20 changed files with 530 additions and 26 deletions
+161 -1
View File
@@ -15,6 +15,8 @@ export interface MfmRenderOptions extends RenderOptions {
pause?: boolean
/** Scale effects against the surrounding custom-emoji size. */
scale?: boolean
/** Colour visual lines whose prose starts with `>`, following Pleroma-FE. */
greentext?: boolean
}
const LOOPING_OPERATORS = new Set([
@@ -27,6 +29,163 @@ const LOOPING_OPERATORS = new Set([
'rainbow',
])
const EMPTY_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'keygen',
'link',
'meta',
'param',
'source',
'track',
'wbr',
])
const BLOCK_ELEMENTS = new Set([
'address',
'article',
'aside',
'blockquote',
'details',
'dialog',
'dd',
'div',
'dl',
'dt',
'fieldset',
'figcaption',
'figure',
'footer',
'form',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'li',
'main',
'nav',
'ol',
'p',
'pre',
'section',
'table',
'ul',
])
const VISUAL_LINE_ELEMENTS = new Set([...BLOCK_ELEMENTS, 'br'])
const NON_EMPTY_LINE_ELEMENTS = new Set(
[...VISUAL_LINE_ELEMENTS].filter((element) => !EMPTY_ELEMENTS.has(element)),
)
const RECOGNIZED_LINE_ELEMENTS = new Set([
...NON_EMPTY_LINE_ELEMENTS,
...EMPTY_ELEMENTS,
])
interface HtmlLine {
level: string[]
text: string
}
function tagName(tag: string): string | null {
const match = /(?:<\/(\w+)>|<(\w+)\s?.*?\/?>)/is.exec(tag)
return (match?.[1] ?? match?.[2] ?? null)?.toLowerCase() ?? null
}
/**
* Pleroma-FE-compatible visual-line tokenizer. Inline markup remains in its
* line, while block elements, `<br>`, and literal newlines form boundaries.
*/
function htmlLines(html: string): Array<string | HtmlLine> {
const output: Array<string | HtmlLine> = []
const level: string[] = []
let text = ''
let tag: string | null = null
const flush = (): void => {
output.push(text.trim() ? { level: [...level], text } : text)
text = ''
}
for (const character of html) {
if (character === '<' && tag === null) {
tag = character
} else if (character !== '>' && tag !== null) {
tag += character
} else if (character === '>' && tag !== null) {
tag += character
const complete = tag
tag = null
const name = tagName(complete)
if (!name || !RECOGNIZED_LINE_ELEMENTS.has(name)) {
text += complete
} else if (name === 'br') {
flush()
output.push(complete)
} else if (NON_EMPTY_LINE_ELEMENTS.has(name)) {
if (complete[1] === '/') {
if (level[0] === name) {
flush()
output.push(complete)
level.shift()
} else {
text += complete
}
} else if (complete[complete.length - 2] === '/') {
flush()
output.push(complete)
} else {
flush()
output.push(complete)
level.unshift(name)
}
} else {
text += complete
}
} else if (character === '\n') {
flush()
output.push(character)
} else {
text += character
}
}
if (tag) text += tag
flush()
return output
}
/** Add trusted presentation spans after sanitization, never before it. */
export function enhanceGreentextHtml(html: string, enabled: boolean): string {
if (!enabled || !html.includes('&gt;')) return html
return htmlLines(html)
.map((line) => {
if (typeof line === 'string') return line
if (!line.level.every((element) => element === 'p' || element === 'div')) {
return line.text
}
const container = document.createElement('div')
container.innerHTML = line.text
const prose = (container.textContent ?? '').replace(/@\w+/gi, '').trim()
return prose.startsWith('>')
? `<span class="greentext">${line.text}</span>`
: line.text
})
.join('')
}
function numberAttribute(element: Element, name: string, fallback: number): number {
const parsed = Number.parseFloat(element.getAttribute(name) ?? '')
return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback
@@ -173,5 +332,6 @@ export function renderMfmHtml(
source: string | null | undefined,
options: MfmRenderOptions = {},
): string {
return enhanceMfmHtml(renderHtml(source, options), options)
const html = enhanceGreentextHtml(renderHtml(source, options), options.greentext === true)
return enhanceMfmHtml(html, options)
}