@meith/markdown 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +165 -0
- package/package.json +21 -0
- package/src/attribution.ts +77 -0
- package/src/bbcode.ts +243 -0
- package/src/blocks.ts +459 -0
- package/src/body.ts +88 -0
- package/src/budget.ts +23 -0
- package/src/escape-source.ts +11 -0
- package/src/escape.ts +27 -0
- package/src/extensions.ts +96 -0
- package/src/features.ts +47 -0
- package/src/index.ts +80 -0
- package/src/inline.ts +525 -0
- package/src/limits.ts +15 -0
- package/src/mentions.ts +66 -0
- package/src/nodes.ts +71 -0
- package/src/pipeline.ts +47 -0
- package/src/plain.ts +78 -0
- package/src/quote.ts +47 -0
- package/src/render.ts +194 -0
- package/src/url.ts +31 -0
- package/src/vocabulary.ts +68 -0
- package/src/word-filter.ts +64 -0
package/src/mentions.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { attributedAuthor } from './attribution'
|
|
2
|
+
import { type ParseOptions, parse } from './blocks'
|
|
3
|
+
import type { Block, Inline } from './nodes'
|
|
4
|
+
|
|
5
|
+
function mentionsIn(nodes: readonly Inline[], out: Set<string>): void {
|
|
6
|
+
for (const node of nodes) {
|
|
7
|
+
if (node.kind === 'mention') out.add(node.name)
|
|
8
|
+
else if ('children' in node) mentionsIn(node.children, out)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mentionBlocks(blocks: readonly Block[], out: Set<string>): void {
|
|
13
|
+
for (const block of blocks) {
|
|
14
|
+
switch (block.kind) {
|
|
15
|
+
case 'paragraph':
|
|
16
|
+
case 'heading':
|
|
17
|
+
mentionsIn(block.inline, out)
|
|
18
|
+
break
|
|
19
|
+
case 'list':
|
|
20
|
+
for (const item of block.items) mentionBlocks(item.children, out)
|
|
21
|
+
break
|
|
22
|
+
case 'table':
|
|
23
|
+
for (const cell of block.head) mentionsIn(cell.inline, out)
|
|
24
|
+
for (const row of block.rows) for (const cell of row) mentionsIn(cell.inline, out)
|
|
25
|
+
break
|
|
26
|
+
case 'directive':
|
|
27
|
+
mentionBlocks(block.children, out)
|
|
28
|
+
break
|
|
29
|
+
case 'quote':
|
|
30
|
+
case 'code':
|
|
31
|
+
case 'rule':
|
|
32
|
+
break
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function extractMentions(source: string, options: ParseOptions = {}): readonly string[] {
|
|
38
|
+
const out = new Set<string>()
|
|
39
|
+
mentionBlocks(parse(source, options).blocks, out)
|
|
40
|
+
return [...out]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function attributionsIn(nodes: readonly Inline[], out: Set<string>): void {
|
|
44
|
+
let atLineStart = true
|
|
45
|
+
for (const node of nodes) {
|
|
46
|
+
if (atLineStart) {
|
|
47
|
+
const author = attributedAuthor(node)
|
|
48
|
+
if (author !== null) out.add(author.name)
|
|
49
|
+
}
|
|
50
|
+
atLineStart = node.kind === 'break'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extractQuotedAuthors(
|
|
55
|
+
source: string,
|
|
56
|
+
options: ParseOptions = {},
|
|
57
|
+
): readonly string[] {
|
|
58
|
+
const out = new Set<string>()
|
|
59
|
+
for (const block of parse(source, options).blocks) {
|
|
60
|
+
if (block.kind !== 'quote') continue
|
|
61
|
+
for (const child of block.children) {
|
|
62
|
+
if (child.kind === 'paragraph') attributionsIn(child.inline, out)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return [...out]
|
|
66
|
+
}
|
package/src/nodes.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type Alignment = 'left' | 'center' | 'right' | null
|
|
2
|
+
|
|
3
|
+
export type Inline =
|
|
4
|
+
| { readonly kind: 'text'; readonly value: string }
|
|
5
|
+
| { readonly kind: 'code'; readonly value: string }
|
|
6
|
+
| { readonly kind: 'emphasis'; readonly children: readonly Inline[] }
|
|
7
|
+
| { readonly kind: 'strong'; readonly children: readonly Inline[] }
|
|
8
|
+
| { readonly kind: 'strike'; readonly children: readonly Inline[] }
|
|
9
|
+
| {
|
|
10
|
+
readonly kind: 'link'
|
|
11
|
+
readonly href: string
|
|
12
|
+
readonly title: string | null
|
|
13
|
+
readonly children: readonly Inline[]
|
|
14
|
+
}
|
|
15
|
+
| { readonly kind: 'image'; readonly src: string; readonly alt: string }
|
|
16
|
+
| { readonly kind: 'break' }
|
|
17
|
+
| { readonly kind: 'directive'; readonly name: string; readonly children: readonly Inline[] }
|
|
18
|
+
| { readonly kind: 'mention'; readonly name: string }
|
|
19
|
+
| { readonly kind: 'attachment'; readonly id: number }
|
|
20
|
+
|
|
21
|
+
export interface TableCell {
|
|
22
|
+
readonly inline: readonly Inline[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Block =
|
|
26
|
+
| { readonly kind: 'paragraph'; readonly inline: readonly Inline[] }
|
|
27
|
+
| {
|
|
28
|
+
readonly kind: 'heading'
|
|
29
|
+
readonly level: 1 | 2 | 3 | 4 | 5 | 6
|
|
30
|
+
readonly inline: readonly Inline[]
|
|
31
|
+
}
|
|
32
|
+
| { readonly kind: 'quote'; readonly children: readonly Block[] }
|
|
33
|
+
| {
|
|
34
|
+
readonly kind: 'list'
|
|
35
|
+
readonly ordered: boolean
|
|
36
|
+
readonly start: number
|
|
37
|
+
readonly tight: boolean
|
|
38
|
+
readonly items: readonly ListItem[]
|
|
39
|
+
}
|
|
40
|
+
| { readonly kind: 'code'; readonly language: string | null; readonly value: string }
|
|
41
|
+
| { readonly kind: 'rule' }
|
|
42
|
+
| {
|
|
43
|
+
readonly kind: 'table'
|
|
44
|
+
readonly head: readonly TableCell[]
|
|
45
|
+
readonly align: readonly Alignment[]
|
|
46
|
+
readonly rows: readonly (readonly TableCell[])[]
|
|
47
|
+
}
|
|
48
|
+
| { readonly kind: 'directive'; readonly name: string; readonly children: readonly Block[] }
|
|
49
|
+
|
|
50
|
+
export interface ListItem {
|
|
51
|
+
readonly checked: boolean | null
|
|
52
|
+
readonly children: readonly Block[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface MarkdownDocument {
|
|
56
|
+
readonly blocks: readonly Block[]
|
|
57
|
+
readonly truncated: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function textOf(nodes: readonly Inline[]): string {
|
|
61
|
+
let out = ''
|
|
62
|
+
for (const node of nodes) {
|
|
63
|
+
if (node.kind === 'text' || node.kind === 'code') out += node.value
|
|
64
|
+
else if (node.kind === 'image') out += node.alt
|
|
65
|
+
else if (node.kind === 'break') out += '\n'
|
|
66
|
+
else if (node.kind === 'mention') out += `@${node.name}`
|
|
67
|
+
else if (node.kind === 'attachment') out += ''
|
|
68
|
+
else out += textOf(node.children)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
package/src/pipeline.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type MarkdownRenderOptions, type RenderedBody, renderMarkdown } from './body'
|
|
2
|
+
import type { VocabularySource } from './vocabulary'
|
|
3
|
+
|
|
4
|
+
export type MarkdownSourceKind = 'post' | 'signature' | 'pm'
|
|
5
|
+
|
|
6
|
+
export interface MarkdownAuthorRef {
|
|
7
|
+
readonly userId: number | null
|
|
8
|
+
readonly isGuest: boolean
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MarkdownRenderContext {
|
|
12
|
+
readonly source: MarkdownSourceKind
|
|
13
|
+
readonly viewer: MarkdownAuthorRef
|
|
14
|
+
readonly postId?: number | null
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MarkdownPipeline {
|
|
18
|
+
readonly text: (text: string, context: MarkdownRenderContext) => Promise<string>
|
|
19
|
+
readonly html: (html: string, context: MarkdownRenderContext) => Promise<string>
|
|
20
|
+
readonly vocabulary: (source: VocabularySource) => Promise<VocabularySource>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const CORE_RENDERING: MarkdownPipeline = {
|
|
24
|
+
text: async (text) => text,
|
|
25
|
+
html: async (html) => html,
|
|
26
|
+
vocabulary: async (source) => source,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const NO_VOCABULARY_SOURCE: VocabularySource = {
|
|
30
|
+
revision: 0,
|
|
31
|
+
smilies: [],
|
|
32
|
+
directives: [],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function authorRef(userId: number | null): MarkdownAuthorRef {
|
|
36
|
+
return { userId, isGuest: userId === null }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function renderThrough(
|
|
40
|
+
pipeline: MarkdownPipeline,
|
|
41
|
+
text: string,
|
|
42
|
+
context: MarkdownRenderContext,
|
|
43
|
+
options: MarkdownRenderOptions = {},
|
|
44
|
+
): Promise<RenderedBody> {
|
|
45
|
+
const rendered = renderMarkdown(await pipeline.text(text, context), options)
|
|
46
|
+
return { ...rendered, html: await pipeline.html(rendered.html, context) }
|
|
47
|
+
}
|
package/src/plain.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { quoteAttribution } from './attribution'
|
|
2
|
+
import { type ParseOptions, parse } from './blocks'
|
|
3
|
+
import type { Block, Inline } from './nodes'
|
|
4
|
+
|
|
5
|
+
function fromInline(nodes: readonly Inline[]): string {
|
|
6
|
+
let out = ''
|
|
7
|
+
for (const node of nodes) {
|
|
8
|
+
switch (node.kind) {
|
|
9
|
+
case 'text':
|
|
10
|
+
case 'code':
|
|
11
|
+
out += node.value
|
|
12
|
+
break
|
|
13
|
+
case 'image':
|
|
14
|
+
out += node.alt
|
|
15
|
+
break
|
|
16
|
+
case 'break':
|
|
17
|
+
out += ' '
|
|
18
|
+
break
|
|
19
|
+
case 'mention':
|
|
20
|
+
out += `@${node.name}`
|
|
21
|
+
break
|
|
22
|
+
case 'attachment':
|
|
23
|
+
break
|
|
24
|
+
default:
|
|
25
|
+
out += fromInline(node.children)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function fromBlocks(blocks: readonly Block[]): string[] {
|
|
32
|
+
const parts: string[] = []
|
|
33
|
+
for (const block of blocks) {
|
|
34
|
+
switch (block.kind) {
|
|
35
|
+
case 'paragraph':
|
|
36
|
+
case 'heading':
|
|
37
|
+
parts.push(fromInline(block.inline))
|
|
38
|
+
break
|
|
39
|
+
case 'code':
|
|
40
|
+
parts.push(block.value)
|
|
41
|
+
break
|
|
42
|
+
case 'quote': {
|
|
43
|
+
const attribution = quoteAttribution(block.children[0])
|
|
44
|
+
if (attribution !== null) parts.push(`${attribution.name} wrote:`)
|
|
45
|
+
parts.push(...fromBlocks(attribution === null ? block.children : block.children.slice(1)))
|
|
46
|
+
break
|
|
47
|
+
}
|
|
48
|
+
case 'directive':
|
|
49
|
+
parts.push(...fromBlocks(block.children))
|
|
50
|
+
break
|
|
51
|
+
case 'list':
|
|
52
|
+
for (const item of block.items) parts.push(...fromBlocks(item.children))
|
|
53
|
+
break
|
|
54
|
+
case 'table':
|
|
55
|
+
parts.push(block.head.map((cell) => fromInline(cell.inline)).join(' '))
|
|
56
|
+
for (const row of block.rows)
|
|
57
|
+
parts.push(row.map((cell) => fromInline(cell.inline)).join(' '))
|
|
58
|
+
break
|
|
59
|
+
case 'rule':
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return parts
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function plainText(source: string, options: ParseOptions = {}): string {
|
|
67
|
+
return fromBlocks(parse(source, options).blocks).join(' ').replace(/\s+/g, ' ').trim()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function summarise(source: string | null, limit = 300): string {
|
|
71
|
+
if (source === null) return ''
|
|
72
|
+
const flat = plainText(source)
|
|
73
|
+
if (flat.length <= limit) return flat
|
|
74
|
+
|
|
75
|
+
const cut = flat.slice(0, limit)
|
|
76
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
77
|
+
return `${lastSpace > limit / 2 ? cut.slice(0, lastSpace) : cut}…`
|
|
78
|
+
}
|
package/src/quote.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { memberByNameHref } from './attribution'
|
|
2
|
+
import { plainAuthorName } from './escape-source'
|
|
3
|
+
|
|
4
|
+
export interface QuoteInput {
|
|
5
|
+
readonly author?: string | null
|
|
6
|
+
/** Defaults to the author's profile; `null` leaves the name as plain text. */
|
|
7
|
+
readonly authorHref?: string | null
|
|
8
|
+
/** The post being quoted, written into the quote as a link back to it. */
|
|
9
|
+
readonly sourceHref?: string | null
|
|
10
|
+
readonly sourceLabel?: string
|
|
11
|
+
readonly markdown: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_SOURCE_LABEL = 'View post'
|
|
15
|
+
|
|
16
|
+
function plainLabel(value: string): string {
|
|
17
|
+
return value.replace(/[[\]()*`\\]/g, '').trim()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function quoteBlock(input: QuoteInput): string {
|
|
21
|
+
const author = input.author == null ? '' : plainAuthorName(input.author)
|
|
22
|
+
const body = input.markdown.replace(/\r\n?/g, '\n').replace(/\s+$/, '')
|
|
23
|
+
|
|
24
|
+
const quoted = body
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => `> ${line}`.trimEnd())
|
|
27
|
+
.join('\n')
|
|
28
|
+
|
|
29
|
+
if (author === '') return quoted
|
|
30
|
+
|
|
31
|
+
return `> **${named(author, input)} wrote:**${source(input)}\n>\n${quoted}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function named(author: string, input: QuoteInput): string {
|
|
35
|
+
const href =
|
|
36
|
+
input.authorHref === undefined ? memberByNameHref(input.author ?? author) : input.authorHref
|
|
37
|
+
if (href === null || href.trim() === '') return author
|
|
38
|
+
return `[${author}](${href.trim()})`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function source(input: QuoteInput): string {
|
|
42
|
+
const href = input.sourceHref
|
|
43
|
+
if (href == null || href.trim() === '') return ''
|
|
44
|
+
|
|
45
|
+
const label = plainLabel(input.sourceLabel ?? DEFAULT_SOURCE_LABEL)
|
|
46
|
+
return label === '' ? '' : ` [${label}](${href.trim()})`
|
|
47
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { memberByNameHref, type QuoteAttribution, quoteAttribution } from './attribution'
|
|
2
|
+
import { escapeAttribute, escapeHtml, unescapeHtml } from './escape'
|
|
3
|
+
import { type CompiledSmilies, renderSmilies } from './extensions'
|
|
4
|
+
import type { Alignment, Block, Inline, ListItem, MarkdownDocument } from './nodes'
|
|
5
|
+
import { safeImageUrl, safeUrl } from './url'
|
|
6
|
+
|
|
7
|
+
export interface RenderContext {
|
|
8
|
+
readonly smilies?: CompiledSmilies | undefined
|
|
9
|
+
readonly headingOffset?: number
|
|
10
|
+
readonly quoteAttribution?: ((author: string) => string) | undefined
|
|
11
|
+
readonly spoilerLabel?: string | undefined
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const LANGUAGE = /^[a-z0-9][a-z0-9+#._-]{0,23}$/i
|
|
15
|
+
|
|
16
|
+
function alignmentClass(alignment: Alignment): string {
|
|
17
|
+
if (alignment === null) return ''
|
|
18
|
+
return ` class="md-align-${alignment}"`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function anchor(href: string, title: string | null, inner: string, className?: string): string {
|
|
22
|
+
const titleAttribute = title === null || title === '' ? '' : ` title="${escapeAttribute(title)}"`
|
|
23
|
+
const classAttribute = className === undefined ? '' : ` class="${className}"`
|
|
24
|
+
return `<a${classAttribute} href="${escapeAttribute(href)}" rel="nofollow ugc noopener noreferrer"${titleAttribute}>${inner}</a>`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderInline(nodes: readonly Inline[], context: RenderContext = {}): string {
|
|
28
|
+
let html = ''
|
|
29
|
+
for (const node of nodes) {
|
|
30
|
+
switch (node.kind) {
|
|
31
|
+
case 'text':
|
|
32
|
+
html += renderSmilies(node.value, context.smilies)
|
|
33
|
+
break
|
|
34
|
+
case 'code':
|
|
35
|
+
html += `<code class="md-code-span">${escapeHtml(node.value)}</code>`
|
|
36
|
+
break
|
|
37
|
+
case 'emphasis':
|
|
38
|
+
html += `<em>${renderInline(node.children, context)}</em>`
|
|
39
|
+
break
|
|
40
|
+
case 'strong':
|
|
41
|
+
html += `<strong>${renderInline(node.children, context)}</strong>`
|
|
42
|
+
break
|
|
43
|
+
case 'strike':
|
|
44
|
+
html += `<s>${renderInline(node.children, context)}</s>`
|
|
45
|
+
break
|
|
46
|
+
case 'break':
|
|
47
|
+
html += '<br>\n'
|
|
48
|
+
break
|
|
49
|
+
case 'link': {
|
|
50
|
+
const href = safeUrl(node.href, { allowMailto: true })
|
|
51
|
+
const inner = renderInline(node.children, context)
|
|
52
|
+
html += href === null ? inner : anchor(href, node.title, inner)
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
case 'image': {
|
|
56
|
+
const src = safeImageUrl(node.src)
|
|
57
|
+
html +=
|
|
58
|
+
src === null
|
|
59
|
+
? escapeHtml(node.alt)
|
|
60
|
+
: `<img src="${escapeAttribute(src)}" alt="${escapeAttribute(node.alt)}" loading="lazy" class="md-image">`
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
case 'directive':
|
|
64
|
+
html += `<span class="md-directive md-directive-${escapeAttribute(node.name)}">${renderInline(node.children, context)}</span>`
|
|
65
|
+
break
|
|
66
|
+
case 'mention': {
|
|
67
|
+
const href = memberByNameHref(node.name)
|
|
68
|
+
html += `<a class="md-mention" href="${escapeAttribute(href)}">@${escapeHtml(node.name)}</a>`
|
|
69
|
+
break
|
|
70
|
+
}
|
|
71
|
+
case 'attachment':
|
|
72
|
+
html += `<span class="md-attachment" data-attachment-id="${node.id}"></span>`
|
|
73
|
+
break
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return html
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderSpoiler(children: readonly Block[], context: RenderContext): string {
|
|
80
|
+
const label = escapeHtml(context.spoilerLabel ?? 'Spoiler')
|
|
81
|
+
return `<details class="md-spoiler"><summary>${label}</summary>${renderBlocks(children, context)}</details>\n`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function renderAttribution(attribution: QuoteAttribution, context: RenderContext): string {
|
|
85
|
+
const name = escapeHtml(attribution.name)
|
|
86
|
+
const profile = attribution.href === null ? null : safeUrl(attribution.href)
|
|
87
|
+
const author = profile === null ? name : anchor(profile, null, name, 'md-quote-author')
|
|
88
|
+
|
|
89
|
+
const source = attribution.sourceHref === null ? null : safeUrl(attribution.sourceHref)
|
|
90
|
+
const citation =
|
|
91
|
+
source === null
|
|
92
|
+
? ''
|
|
93
|
+
: anchor(source, null, escapeHtml(attribution.sourceLabel), 'md-quote-source')
|
|
94
|
+
|
|
95
|
+
const label = context.quoteAttribution?.(author) ?? `${author} wrote:`
|
|
96
|
+
return `<p class="md-quote-attribution"><strong data-quote-author="${escapeAttribute(author)}">${label}</strong>${citation}</p>\n`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const QUOTE_ATTRIBUTION_STRONG = /<strong data-quote-author="([^"]*)">[\s\S]*?<\/strong>/g
|
|
100
|
+
|
|
101
|
+
export function localizeQuoteAttribution(
|
|
102
|
+
html: string,
|
|
103
|
+
quoteAttribution: (author: string) => string,
|
|
104
|
+
): string {
|
|
105
|
+
if (!html.includes('data-quote-author')) return html
|
|
106
|
+
return html.replace(
|
|
107
|
+
QUOTE_ATTRIBUTION_STRONG,
|
|
108
|
+
(_whole, escapedAuthor: string) =>
|
|
109
|
+
`<strong>${quoteAttribution(unescapeHtml(escapedAuthor))}</strong>`,
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function renderItem(item: ListItem, tight: boolean, context: RenderContext): string {
|
|
114
|
+
const inner = renderBlocks(item.children, context, tight)
|
|
115
|
+
if (item.checked === null) return `<li>${inner}</li>`
|
|
116
|
+
const checked = item.checked ? ' checked' : ''
|
|
117
|
+
return `<li class="md-task"><input type="checkbox" disabled${checked}> ${inner}</li>`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function renderBlocks(blocks: readonly Block[], context: RenderContext, tight = false): string {
|
|
121
|
+
let html = ''
|
|
122
|
+
for (const block of blocks) {
|
|
123
|
+
switch (block.kind) {
|
|
124
|
+
case 'paragraph':
|
|
125
|
+
html += tight
|
|
126
|
+
? renderInline(block.inline, context)
|
|
127
|
+
: `<p>${renderInline(block.inline, context)}</p>\n`
|
|
128
|
+
break
|
|
129
|
+
case 'heading': {
|
|
130
|
+
const level = Math.min(6, block.level + (context.headingOffset ?? 1))
|
|
131
|
+
html += `<h${level}>${renderInline(block.inline, context)}</h${level}>\n`
|
|
132
|
+
break
|
|
133
|
+
}
|
|
134
|
+
case 'quote': {
|
|
135
|
+
const attribution = quoteAttribution(block.children[0])
|
|
136
|
+
const body = attribution === null ? block.children : block.children.slice(1)
|
|
137
|
+
html += `<blockquote class="md-quote">${attribution === null ? '' : renderAttribution(attribution, context)}${renderBlocks(body, context)}</blockquote>\n`
|
|
138
|
+
break
|
|
139
|
+
}
|
|
140
|
+
case 'list': {
|
|
141
|
+
const items = block.items.map((item) => renderItem(item, block.tight, context)).join('')
|
|
142
|
+
html += block.ordered
|
|
143
|
+
? `<ol class="md-list"${block.start === 1 ? '' : ` start="${block.start}"`}>${items}</ol>\n`
|
|
144
|
+
: `<ul class="md-list">${items}</ul>\n`
|
|
145
|
+
break
|
|
146
|
+
}
|
|
147
|
+
case 'code': {
|
|
148
|
+
const language =
|
|
149
|
+
block.language !== null && LANGUAGE.test(block.language) ? block.language : null
|
|
150
|
+
const languageClass =
|
|
151
|
+
language === null
|
|
152
|
+
? ''
|
|
153
|
+
: ` class="md-code-lang-${escapeAttribute(language.toLowerCase())}"`
|
|
154
|
+
html += `<pre class="md-code"><code${languageClass}>${escapeHtml(block.value)}\n</code></pre>\n`
|
|
155
|
+
break
|
|
156
|
+
}
|
|
157
|
+
case 'rule':
|
|
158
|
+
html += '<hr class="md-rule">\n'
|
|
159
|
+
break
|
|
160
|
+
case 'table': {
|
|
161
|
+
const head = block.head
|
|
162
|
+
.map(
|
|
163
|
+
(cell, column) =>
|
|
164
|
+
`<th${alignmentClass(block.align[column] ?? null)}>${renderInline(cell.inline, context)}</th>`,
|
|
165
|
+
)
|
|
166
|
+
.join('')
|
|
167
|
+
const rows = block.rows
|
|
168
|
+
.map(
|
|
169
|
+
(row) =>
|
|
170
|
+
`<tr>${row
|
|
171
|
+
.map(
|
|
172
|
+
(cell, column) =>
|
|
173
|
+
`<td${alignmentClass(block.align[column] ?? null)}>${renderInline(cell.inline, context)}</td>`,
|
|
174
|
+
)
|
|
175
|
+
.join('')}</tr>`,
|
|
176
|
+
)
|
|
177
|
+
.join('')
|
|
178
|
+
html += `<div class="md-table-scroll"><table class="md-table"><thead><tr>${head}</tr></thead><tbody>${rows}</tbody></table></div>\n`
|
|
179
|
+
break
|
|
180
|
+
}
|
|
181
|
+
case 'directive':
|
|
182
|
+
html +=
|
|
183
|
+
block.name === 'spoiler'
|
|
184
|
+
? renderSpoiler(block.children, context)
|
|
185
|
+
: `<div class="md-directive md-directive-${escapeAttribute(block.name)}">${renderBlocks(block.children, context)}</div>\n`
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return html
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function renderDocument(document: MarkdownDocument, context: RenderContext = {}): string {
|
|
193
|
+
return renderBlocks(document.blocks, context).trimEnd()
|
|
194
|
+
}
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { DEFAULT_LIMITS } from './limits'
|
|
2
|
+
|
|
3
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: URL smuggling relies on exactly these
|
|
4
|
+
const FORBIDDEN = /[\u0000-\u0020\u007f"'<>`\\{}|^]/
|
|
5
|
+
|
|
6
|
+
const ABSOLUTE = /^https?:\/\/[^/]/i
|
|
7
|
+
const MAILTO = /^mailto:[^\s@]+@[^\s@]+\.[^\s@]+$/i
|
|
8
|
+
|
|
9
|
+
export interface UrlPolicy {
|
|
10
|
+
readonly maxLength?: number
|
|
11
|
+
readonly allowMailto?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function safeUrl(value: string, policy: UrlPolicy = {}): string | null {
|
|
15
|
+
const url = value.trim()
|
|
16
|
+
const maxLength = policy.maxLength ?? DEFAULT_LIMITS.maxUrlLength
|
|
17
|
+
|
|
18
|
+
if (url.length === 0 || url.length > maxLength) return null
|
|
19
|
+
if (FORBIDDEN.test(url)) return null
|
|
20
|
+
|
|
21
|
+
if (ABSOLUTE.test(url)) return url
|
|
22
|
+
if (policy.allowMailto === true && MAILTO.test(url)) return url
|
|
23
|
+
|
|
24
|
+
if (url.startsWith('/') && !url.startsWith('//')) return url
|
|
25
|
+
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function safeImageUrl(value: string): string | null {
|
|
30
|
+
return safeUrl(value)
|
|
31
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompiledSmilies,
|
|
3
|
+
compileSmilies,
|
|
4
|
+
createDirectiveRegistry,
|
|
5
|
+
type DirectiveDefinition,
|
|
6
|
+
type DirectiveRegistry,
|
|
7
|
+
NO_DIRECTIVES,
|
|
8
|
+
type SmileyDefinition,
|
|
9
|
+
} from './extensions'
|
|
10
|
+
|
|
11
|
+
export interface VocabularySource {
|
|
12
|
+
readonly revision: number
|
|
13
|
+
readonly smilies: readonly SmileyDefinition[]
|
|
14
|
+
readonly directives: readonly DirectiveDefinition[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BoardVocabulary {
|
|
18
|
+
readonly revision: number
|
|
19
|
+
readonly directives: DirectiveRegistry
|
|
20
|
+
readonly smilies: CompiledSmilies | undefined
|
|
21
|
+
readonly rejected: readonly {
|
|
22
|
+
readonly kind: 'smiley' | 'directive'
|
|
23
|
+
readonly name: string
|
|
24
|
+
readonly reason: string
|
|
25
|
+
}[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const EMPTY_VOCABULARY: BoardVocabulary = {
|
|
29
|
+
revision: 0,
|
|
30
|
+
directives: NO_DIRECTIVES,
|
|
31
|
+
smilies: undefined,
|
|
32
|
+
rejected: [],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function reasonOf(error: unknown): string {
|
|
36
|
+
return error instanceof Error ? error.message : String(error)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function compileVocabulary(source: VocabularySource): BoardVocabulary {
|
|
40
|
+
const rejected: { kind: 'smiley' | 'directive'; name: string; reason: string }[] = []
|
|
41
|
+
|
|
42
|
+
const accepted: DirectiveDefinition[] = []
|
|
43
|
+
for (const directive of source.directives) {
|
|
44
|
+
try {
|
|
45
|
+
createDirectiveRegistry([...accepted, directive])
|
|
46
|
+
accepted.push(directive)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
rejected.push({ kind: 'directive', name: directive.name, reason: reasonOf(error) })
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const smilies: SmileyDefinition[] = []
|
|
53
|
+
for (const smiley of source.smilies) {
|
|
54
|
+
try {
|
|
55
|
+
compileSmilies([...smilies, smiley])
|
|
56
|
+
smilies.push(smiley)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
rejected.push({ kind: 'smiley', name: smiley.code, reason: reasonOf(error) })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
revision: source.revision,
|
|
64
|
+
directives: createDirectiveRegistry(accepted),
|
|
65
|
+
smilies: smilies.length === 0 ? undefined : compileSmilies(smilies),
|
|
66
|
+
rejected,
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface WordFilterRule {
|
|
2
|
+
readonly pattern: string
|
|
3
|
+
readonly replacement: string
|
|
4
|
+
readonly wholeWord: boolean
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CompiledWordFilter {
|
|
8
|
+
readonly rules: readonly { readonly matcher: RegExp; readonly replacement: string }[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function escapeRegExp(value: string): string {
|
|
12
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function compileWordFilter(rules: readonly WordFilterRule[]): CompiledWordFilter {
|
|
16
|
+
return {
|
|
17
|
+
rules: rules
|
|
18
|
+
.filter((rule) => rule.pattern !== '')
|
|
19
|
+
.map((rule) => ({
|
|
20
|
+
matcher: new RegExp(
|
|
21
|
+
rule.wholeWord ? `\\b${escapeRegExp(rule.pattern)}\\b` : escapeRegExp(rule.pattern),
|
|
22
|
+
'gi',
|
|
23
|
+
),
|
|
24
|
+
replacement: rule.replacement,
|
|
25
|
+
})),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function applyWordFilter(html: string, filter: CompiledWordFilter): string {
|
|
30
|
+
if (filter.rules.length === 0 || html === '') return html
|
|
31
|
+
|
|
32
|
+
let output = ''
|
|
33
|
+
let index = 0
|
|
34
|
+
|
|
35
|
+
while (index < html.length) {
|
|
36
|
+
const tagStart = html.indexOf('<', index)
|
|
37
|
+
|
|
38
|
+
if (tagStart === -1) {
|
|
39
|
+
output += substitute(html.slice(index), filter)
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
output += substitute(html.slice(index, tagStart), filter)
|
|
44
|
+
|
|
45
|
+
const tagEnd = html.indexOf('>', tagStart)
|
|
46
|
+
if (tagEnd === -1) {
|
|
47
|
+
output += html.slice(tagStart)
|
|
48
|
+
break
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
output += html.slice(tagStart, tagEnd + 1)
|
|
52
|
+
index = tagEnd + 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return output
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function substitute(text: string, filter: CompiledWordFilter): string {
|
|
59
|
+
let result = text
|
|
60
|
+
for (const rule of filter.rules) {
|
|
61
|
+
result = result.replace(rule.matcher, rule.replacement)
|
|
62
|
+
}
|
|
63
|
+
return result
|
|
64
|
+
}
|