@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.
@@ -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
+ }
@@ -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
+ }