Skip to content

Instantly share code, notes, and snippets.

@nurdism
Last active July 7, 2024 20:17
Show Gist options
  • Save nurdism/a6baac5dcaadff024b58f0c7f6101239 to your computer and use it in GitHub Desktop.
Save nurdism/a6baac5dcaadff024b58f0c7f6101239 to your computer and use it in GitHub Desktop.
This is a faithful recreation of discord flavored markdown as of 2024.
/**
* Discord Markdown
* ===============
*
* This is a faithful recreation of discord flavored markdown as of 2024.
*
* Required: [vue-markdown.ts](https://gist.github.com/nurdism/f2e8a8d21aa11969b595f6b7698d7d62)
* Made by [nurdism](https://github.com/nurdism)
*/
import type { State as MDState, VueOutput, HtmlOutput, Capture } from './markdown'
import { defaultRules, outputFor, parserFor, anyScopeRegex, htmlTag, inlineRegex } from './markdown'
import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'
import { format as formatDate } from 'date-fns/format'
import tinycolor from 'tinycolor2'
import { h } from 'vue'
type SingleASTNode = {
type: string
[key: string]: any
}
type UnTypedASTNode = {
[key: string]: any
}
type ASTNode = SingleASTNode | Array<SingleASTNode>
interface State extends MDState {
allowHeading?: boolean
allowEscape?: boolean
allowList?: boolean
resolve?: {
user?: ResolveUserFn
channel?: ResolveChannelFn
role?: ResolveRoleFn
customEmoji?: ResolveCustomEmojiFn
emoji?: ResolveEmojiFn
}
}
type MatchFunction = {
regex?: RegExp
} & ((source: string, state: State, prevCapture: string) => Capture | null | undefined)
type Parser = (source: string, state?: State | null | undefined) => Array<SingleASTNode>
type SingleNodeParseFunction = (capture: Capture, nestedParse: Parser, state: State) => UnTypedASTNode
type Output<Result> = (node: ASTNode, state?: State | null | undefined) => Result
type NodeOutput<Result> = (node: SingleASTNode, nestedOutput: Output<Result>, state: State) => Result
type VueNodeOutput = NodeOutput<VNode | string | number>
type HtmlNodeOutput = NodeOutput<string>
type SingleNodeParserRule = {
readonly order: number
readonly match: MatchFunction
readonly requiredFirstCharacters?: string[]
readonly quality?: (capture: Capture, state: State, prevCapture: string) => number
readonly parse: SingleNodeParseFunction
}
type VueOutputRule = {
// we allow null because some rules are never output results, and that's
// legal as long as no parsers return an AST node matching that rule.
// We don't use ? because this makes it be explicitly defined as either
// a valid function or null, so it can't be forgotten.
readonly vue: VueNodeOutput | null
}
type HtmlOutputRule = {
readonly html: HtmlNodeOutput | null
}
type ElementVueOutputRule = {
readonly vue: NodeOutput<VNode>
}
type TextVueOutputRule = {
readonly vue: NodeOutput<VNode | string>
}
type NonNullHtmlOutputRule = {
readonly html: HtmlNodeOutput
}
type DefaultInRule = SingleNodeParserRule & VueOutputRule & HtmlOutputRule
type TextInOutRule = SingleNodeParserRule & TextVueOutputRule & NonNullHtmlOutputRule
type DefaultInOutRule = SingleNodeParserRule & ElementVueOutputRule & NonNullHtmlOutputRule
type Rules = {
readonly newline: TextInOutRule
readonly paragraph: DefaultInOutRule
readonly escape: DefaultInRule
readonly blockQuote: DefaultInOutRule
readonly link: DefaultInOutRule
readonly autolink: DefaultInRule
readonly url: DefaultInRule
readonly strong: DefaultInOutRule
readonly em: DefaultInOutRule
readonly u: DefaultInOutRule
readonly br: DefaultInOutRule
readonly text: TextInOutRule
readonly inlineCode: DefaultInOutRule
readonly emoticon: DefaultInRule
readonly codeBlock: DefaultInOutRule
readonly roleMention: DefaultInRule
readonly channelMention: DefaultInRule
readonly mention: DefaultInOutRule
readonly customEmoji: DefaultInRule
readonly emote: DefaultInRule
readonly emoji: DefaultInOutRule
readonly timestamp: DefaultInOutRule
readonly strike: DefaultInOutRule
readonly spoiler: DefaultInOutRule
readonly heading: DefaultInOutRule
readonly list: DefaultInOutRule
}
const BLOCKQUOTE = /^( *>>> +([\s\S]*))|^( *>(?!>>) +[^\n]*(\n *>(?!>>) +[^\n]*)*\n?)/
const BLOCKQUOTE_MULTILINE = /^ *>>> ?/
const BLOCKQUOTE_MULTILINE_REPLACE = /^ *> ?/gm
const LINES = /^$|\n *$/
// recognize a `*` `-`, `1.`, `2.`... list bullet
const LIST_BULLET = '(?:[*-]|\\d+\\.)'
// recognize the start of a list item:
// leading space plus a bullet plus a space (` * `)
const LIST_ITEM_PREFIX = '( *)(' + LIST_BULLET + ') +'
const LIST_ITEM_PREFIX_R = new RegExp('^' + LIST_ITEM_PREFIX)
// recognize an individual list item:
// * hi
// this is part of the same item
//
// as is this, which is a new paragraph in the same item
//
// * but this is not part of the same item
const LIST_ITEM_R = new RegExp(LIST_ITEM_PREFIX + '[^\\n]*(?:\\n(?!\\1' + LIST_BULLET + ' )[^\\n]*)*(\n|$)', 'gm')
const BLOCK_END_R = /\n{2,}$/
// recognize the end of a paragraph block inside a list item:
// two or more newlines at end end of the item
const LIST_BLOCK_END_R = BLOCK_END_R
const LIST_ITEM_END_R = / *\n+$/
// check whether a list item has paragraphs: if it does,
// we leave the newlines at the end
const LIST_R = new RegExp('^( *)(' + LIST_BULLET + ') [\\s\\S]+?(?:\\n(?! )(?!\\1' + LIST_BULLET + ' )\\n*|\\s*\n*$)')
const LIST_LOOKBEHIND_R = /(?:^|\n)( *)$/
const LIST_WHITESPACE = /^[ \t\v\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]+$/
const EMOTE_REGEX =
/^(>:\(|>:\-\(|>=\(|>=\-\(|:"\)|:\-"\)|="\)|=\-"\)|<\/3|<\\3|:\-\\|:\-\/|=\-\\|=\-\/|:'\(|:'\-\(|:,\(|:,\-\(|='\(|='\-\(|=,\(|=,\-\(|:\(|:\-\(|=\(|=\-\(|<3|♡|\]:\(|\]:\-\(|\]=\(|\]=\-\(|o:\)|O:\)|o:\-\)|O:\-\)|0:\)|0:\-\)|o=\)|O=\)|o=\-\)|O=\-\)|0=\)|0=\-\)|:'D|:'\-D|:,D|:,\-D|='D|='\-D|=,D|=,\-D|:\*|:\-\*|=\*|=\-\*|x\-\)|X\-\)|:\||:\-\||=\||=\-\||:o|:\-o|:O|:\-O|=o|=\-o|=O|=\-O|:@|:\-@|=@|=\-@|:D|:\-D|=D|=\-D|:'\)|:'\-\)|:,\)|:,\-\)|='\)|='\-\)|=,\)|=,\-\)|:\)|:\-\)|=\)|=\-\)|\]:\)|\]:\-\)|\]=\)|\]=\-\)|:,'\(|:,'\-\(|;\(|;\-\(|=,'\(|=,'\-\(|:P|:\-P|=P|=\-P|8\-\)|B\-\)|,:\(|,:\-\(|,=\(|,=\-\(|,:\)|,:\-\)|,=\)|,=\-\)|:s|:\-S|:z|:\-Z|:\$|:\-\$|=s|=\-S|=z|=\-Z|=\$|=\-\$|;\)|;\-\))/
const EMOTES: Record<string, string> = {
'>:(': 'angry',
'>:-(': 'angry',
'>=(': 'angry',
'>=-(': 'angry',
':")': 'blush',
':-")': 'blush',
'=")': 'blush',
'=-")': 'blush',
'</3': 'broken_heart',
'<\\3': 'broken_heart',
':-\\': 'confused',
':-/': 'confused',
'=-\\': 'confused',
'=-/': 'confused',
":'(": 'cry',
":'-(": 'cry',
':,(': 'cry',
':,-(': 'cry',
"='(": 'cry',
"='-(": 'cry',
'=,(': 'cry',
'=,-(': 'cry',
':(': 'frowning',
':-(': 'frowning',
'=(': 'frowning',
'=-(': 'frowning',
'<3': 'heart',
'♡': 'heart',
']:(': 'imp',
']:-(': 'imp',
']=(': 'imp',
']=-(': 'imp',
'o:)': 'innocent',
'O:)': 'innocent',
'o:-)': 'innocent',
'O:-)': 'innocent',
'0:)': 'innocent',
'0:-)': 'innocent',
'o=)': 'innocent',
'O=)': 'innocent',
'o=-)': 'innocent',
'O=-)': 'innocent',
'0=)': 'innocent',
'0=-)': 'innocent',
":'D": 'joy',
":'-D": 'joy',
':,D': 'joy',
':,-D': 'joy',
"='D": 'joy',
"='-D": 'joy',
'=,D': 'joy',
'=,-D': 'joy',
':*': 'kissing',
':-*': 'kissing',
'=*': 'kissing',
'=-*': 'kissing',
'x-)': 'laughing',
'X-)': 'laughing',
':|': 'neutral_face',
':-|': 'neutral_face',
'=|': 'neutral_face',
'=-|': 'neutral_face',
':o': 'open_mouth',
':-o': 'open_mouth',
':O': 'open_mouth',
':-O': 'open_mouth',
'=o': 'open_mouth',
'=-o': 'open_mouth',
'=O': 'open_mouth',
'=-O': 'open_mouth',
':@': 'rage',
':-@': 'rage',
'=@': 'rage',
'=-@': 'rage',
':D': 'smile',
':-D': 'smile',
'=D': 'smile',
'=-D': 'smile',
":')": 'smiling_face_with_tear',
":'-)": 'smiling_face_with_tear',
':,)': 'smiling_face_with_tear',
':,-)': 'smiling_face_with_tear',
"=')": 'smiling_face_with_tear',
"='-)": 'smiling_face_with_tear',
'=,)': 'smiling_face_with_tear',
'=,-)': 'smiling_face_with_tear',
':)': 'slight_smile',
':-)': 'slight_smile',
'=)': 'slight_smile',
'=-)': 'slight_smile',
']:)': 'smiling_imp',
']:-)': 'smiling_imp',
']=)': 'smiling_imp',
']=-)': 'smiling_imp',
":,'(": 'sob',
":,'-(": 'sob',
';(': 'sob',
';-(': 'sob',
"=,'(": 'sob',
"=,'-(": 'sob',
':P': 'stuck_out_tongue',
':-P': 'stuck_out_tongue',
'=P': 'stuck_out_tongue',
'=-P': 'stuck_out_tongue',
'8-)': 'sunglasses',
'B-)': 'sunglasses',
',:(': 'sweat',
',:-(': 'sweat',
',=(': 'sweat',
',=-(': 'sweat',
',:)': 'sweat_smile',
',:-)': 'sweat_smile',
',=)': 'sweat_smile',
',=-)': 'sweat_smile',
':s': 'unamused',
':-S': 'unamused',
':z': 'unamused',
':-Z': 'unamused',
':$': 'unamused',
':-$': 'unamused',
'=s': 'unamused',
'=-S': 'unamused',
'=z': 'unamused',
'=-Z': 'unamused',
'=$': 'unamused',
'=-$': 'unamused',
';)': 'wink',
';-)': 'wink',
}
type ResolveRoleFn = (id: string) => Role | null
interface Role {
id: string
name: string
color: string
}
const defaultRoleResolver: ResolveRoleFn = () => null
type ResolveChannelFn = (id: string) => Channel | null
interface Channel {
id: string
name: string
}
const defaultChannelResolver: ResolveChannelFn = () => null
type ResolveUserFn = (id: string) => User | null
interface User {
id: string
name: string
color?: string
}
const defaultUserResolver: ResolveUserFn = () => null
type ResolveCustomEmojiFn = (id: string, name: string, animated: boolean) => CustomEmoji
interface CustomEmoji {
id: string
name: string
url: string
animated: boolean
}
const defaultCustomEmojiResolver: ResolveCustomEmojiFn = (id, name, animated) => ({
id,
name,
url: `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`,
animated,
})
type ResolveEmojiFn = (name: string) => Emoji | null
interface Emoji {
name: string
char?: string
}
const defaultEmojiResolver: ResolveEmojiFn = (name: string) => ({ name })
const timestampFormats = {
t: (date: Date) => formatDate(date, 'h:mm aa'), // LT
T: (date: Date) => formatDate(date, 'h:mm:ss aa'), // LTS
d: (date: Date) => formatDate(date, 'MM/dd/yyyy'), // L
D: (date: Date) => formatDate(date, 'MMMM d, yyyy'), // LL
f: (date: Date) => formatDate(date, 'MMMM d, yyyy h:mm aa'), // LLL
F: (date: Date) => formatDate(date, 'iiii, MMMM d, yyyy h:mm aa'), // LLLL
R: (date: Date) =>
formatDistanceToNow(date, {
includeSeconds: true,
addSuffix: true,
}),
} as const
type TimestampFormat = keyof typeof timestampFormats
interface Timestamp {
timestamp: string
format: TimestampFormat
date: Date
full: string
formatted: string
}
function parseTimestamp(timestamp: string, format: TimestampFormat): Timestamp | null {
const parsed = parseInt(timestamp.trim())
if (Number.isNaN(parsed)) {
return null
}
const date = new Date(parsed * 1000)
let formatted = ''
if (timestampFormats[format]) {
formatted = timestampFormats[format](date)
}
return {
timestamp,
format,
date,
full: timestampFormats.F(date),
formatted,
}
}
const discordRules: Rules = {
newline: defaultRules.newline,
paragraph: defaultRules.paragraph,
escape: {
...defaultRules.escape,
match(source, state, prev) {
return state.allowEscape !== false ? defaultRules.escape.match(source, state, prev) : null
},
},
blockQuote: {
...defaultRules.blockQuote,
requiredFirstCharacters: [' ', '>'],
match(source, state) {
if (state.inQuote || state.nested) {
return null
}
if (state.prevCapture === null) {
return BLOCKQUOTE.exec(source)
}
return LINES.test(state.prevCapture[0]) ? BLOCKQUOTE.exec(source) : null
},
parse(capture, parse, state) {
const matched = capture[0]
const isMultiline = !!BLOCKQUOTE_MULTILINE.exec(matched)
const cleaned = matched.replace(isMultiline ? BLOCKQUOTE_MULTILINE : BLOCKQUOTE_MULTILINE_REPLACE, '')
const inQuote = state.inQuote || false
const inline = state.inline || false
state.inQuote = true
if (!isMultiline) {
state.inline = true
}
state.inQuote = inQuote
state.inline = inline
const nested = parse(cleaned, state)
if (nested.length === 0) {
nested.push({
type: 'text',
content: ' ',
})
}
return {
content: nested,
type: 'blockQuote',
}
},
},
link: defaultRules.link,
autolink: defaultRules.autolink,
url: defaultRules.url,
strong: defaultRules.strong,
em: defaultRules.em,
u: defaultRules.u,
br: defaultRules.br,
text: {
...defaultRules.text,
match: anyScopeRegex(/^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]|\n\n| {2,}\n|\w+:\S|[0-9]+\.|$)/),
},
inlineCode: defaultRules.inlineCode,
codeBlock: {
...defaultRules.codeBlock,
requiredFirstCharacters: ['`'],
match: (source) => /^```(?:([a-z0-9_+\-.#]+?)\n)?\n*([^\n][^]*?)\n*```/i.exec(source),
parse(capture, parse, state) {
return {
lang: capture[1] || '',
content: capture[2] || '',
inQuote: state.inQuote || false,
}
},
},
roleMention: {
order: defaultRules.text.order,
requiredFirstCharacters: ['<'],
match: (source) => /^<@&(\d+)>/.exec(source),
parse(capture, parse, state) {
const [mention, id] = capture
const resolve: ResolveRoleFn = state.resolve?.role || defaultRoleResolver
const role = resolve(id)
let rgba = null
if (role?.color && role.color !== '#b9bbbe') {
const color = tinycolor(role.color)
if (color.isValid()) {
rgba = color.toRgb()
}
}
return {
type: 'mention',
id,
context: 'role',
info: role,
color: rgba,
content: [
{
type: 'text',
content: `@${role ? role.name : 'role'}`,
},
],
}
},
vue: null,
html: null,
},
channelMention: {
order: defaultRules.text.order,
requiredFirstCharacters: ['<'],
match: (source) => /^<#(\d+)>/.exec(source),
parse(capture, parse, state) {
const [mention, id] = capture
const resolve: ResolveChannelFn = state.resolve?.channel || defaultChannelResolver
const channel = resolve(id)
return {
type: 'mention',
id,
context: 'channel',
info: channel,
content: [
{
type: 'text',
content: `#${channel ? channel.name : 'channel'}`,
},
],
}
},
vue: null,
html: null,
},
mention: {
...defaultRules.text,
match: (source) => /^<@!?(\d+)>|^(@(?:everyone|here))/.exec(source),
parse(capture, parse, state) {
const [mention, id, _everyone] = capture
const everyone = _everyone ? _everyone.substring(1) : null
if (everyone) {
return {
type: 'mention',
context: everyone,
content: [
{
type: 'text',
content: `@${everyone}`,
},
],
}
}
const resolve: ResolveUserFn = state.resolve?.user || defaultUserResolver
const user = resolve(id)
let rgba = null
if (user?.color && user.color !== '#b9bbbe') {
const color = tinycolor(user.color)
if (color.isValid()) {
rgba = color.toRgb()
}
}
return {
type: 'mention',
id,
context: 'user',
info: user,
color: rgba,
content: [
{
type: 'text',
content: `@${user ? user.name : 'user'}`,
},
],
}
},
vue: function (node, output, state) {
return h(
state.components?.mention || 'span',
{
dataId: node.id,
class: `mention ${node.context}`,
style: node.color
? `background-color:rgba(${node.color.r},${node.color.g},${node.color.b},0.1);color:rgba(${node.color.r},${node.color.g},${node.color.b})`
: null,
},
output(node.content, state),
)
},
html: function (node, output, state) {
return htmlTag('span', output(node.content, state), {
dataId: node.id,
class: `mention ${node.context}`,
style: node.color
? `background-color:rgba(${node.color.r},${node.color.g},${node.color.b},0.1);color:rgba(${node.color.r},${node.color.g},${node.color.b})`
: null,
})
},
},
emote: {
order: defaultRules.text.order,
match(source, state) {
if (state.allowEmote === false) {
return null
}
return EMOTE_REGEX.exec(source)
},
parse: (capture) => {
const [emote] = capture
return EMOTES[emote]
? {
type: 'emoji',
custom: false,
animated: false,
name: EMOTES[emote],
emoji: null,
content: emote,
}
: {
type: 'text',
content: emote,
}
},
vue: null,
html: null,
},
emoticon: {
order: defaultRules.text.order,
requiredFirstCharacters: ['\xaf'],
match: (source) => /^(¯\\_\(ツ\)_\/¯)/.exec(source),
parse: (capture) => ({
type: 'text',
content: capture[1],
}),
vue: null,
html: null,
},
customEmoji: {
order: defaultRules.text.order,
match: (source) => /^<(a?):(\w+):(\d+)>/.exec(source),
parse(capture, parse, state) {
const [matched, _animated, name, id] = capture
const animated = _animated === 'a'
const resolve: ResolveCustomEmojiFn = state.resolve?.customEmoji || defaultCustomEmojiResolver
const emoji = resolve(id, name, animated)
return {
type: 'emoji',
custom: true,
emoji: null,
...emoji,
}
},
vue: null,
html: null,
},
emoji: {
order: defaultRules.text.order,
requiredFirstCharacters: [':'],
match: (source) => /^:([^\s:]+?(?:::skin-tone-\d)?):/.exec(source),
parse(capture, parse, state) {
const [match, name] = capture
const resolve: ResolveEmojiFn = state.resolve?.emoji || defaultEmojiResolver
const emoji = resolve(name)
return emoji
? {
type: 'emoji',
custom: false,
animated: false,
name: name,
emoji: emoji,
}
: {
type: 'text',
content: match,
}
},
vue: function (node, output, state) {
if (state.components?.emoji) {
return h(state.components.emoji, { id: node.id, name: node.name, custom: node.custom, animated: node.animated, content: node.content })
}
if (node.custom) {
return h('img', { 'data-id': node.id, 'data-emoji': node.name, class: `emoji-custom`, src: node.url, alt: `:${node.name}:` })
}
return h('span', { 'data-id': node.id, 'data-emoji': node.name, class: `emoji`, alt: `:${node.name}:` }, node.content ? node.content : `:${node.name}:`)
},
html: function (node, output, state) {
if (node.custom) {
return htmlTag('img', '', { 'data-id': node.id, 'data-emoji': node.name, class: `emoji-custom`, src: node.url, alt: `:${node.name}:` })
}
return htmlTag('span', '', { 'data-id': node.id, 'data-emoji': node.name, class: `emoji`, alt: node.content ? node.content : `:${node.name}:` })
},
},
timestamp: {
order: defaultRules.text.order - 1,
match: (source) => /^<t:(-?\d{1,17})(?::(t|T|d|D|f|F|R))?>/.exec(source),
parse(capture, parse, state) {
const [matched, time, fn] = capture
let timestamp: Timestamp | null = null
try {
timestamp = parseTimestamp(time, fn as TimestampFormat)
} catch (e) {
console.error(e)
}
return !timestamp
? {
type: 'text',
content: matched,
}
: {
type: 'timestamp',
...timestamp,
}
},
vue: function (node, output, state) {
return state.components?.timestamp
? h(state.components.timestamp, {
timestamp: node.timestamp,
format: node.format,
date: node.date,
full: node.full,
formatted: node.formatted,
})
: h('span', { class: 'timestamp', 'aria-label': node.full }, node.formatted)
},
html: function (node, output, state) {
return htmlTag('span', node.formatted, { class: 'timestamp' })
},
},
strike: {
order: defaultRules.u.order,
requiredFirstCharacters: ['~'],
match: inlineRegex(/^~~([\s\S]+?)~~(?!_)/),
parse: defaultRules.u.parse,
vue: function (node, output, state) {
return h(state.components?.strike || 's', output(node.content, state))
},
html: function (node, output, state) {
return htmlTag('s', output(node.content, state))
},
},
spoiler: {
order: defaultRules.text.order,
requiredFirstCharacters: ['|'],
match: inlineRegex(/^\|\|([\s\S]+?)\|\|/),
parse(capture, parse, state) {
const [match, content] = capture
return {
type: 'spoiler',
content: parse(content, state),
}
},
vue: function (node, output, state) {
return h(state.components?.spoiler || 'span', { class: 'spoiler' }, output(node.content, state))
},
html: function (node, output, state) {
return htmlTag('span', output(node.content, state), { class: 'spoiler' })
},
},
heading: {
...defaultRules.heading,
requiredFirstCharacters: [' ', '#'],
match(source, state, prev) {
if (state.allowHeading === false) {
return null
}
if (prev == null || prev === '' || prev.match(/\n$/) != null) {
return anyScopeRegex(/^ *(#{1,3})(?:\s+)((?![#]+)[^\n]+?)#*\s*(?:\n|$)/)(source, state, prev)
}
return null
},
},
list: {
...defaultRules.list,
requiredFirstCharacters: ' *-0123456789'.split(''),
match: function (source, state) {
if (state.allowList === false || state._listLevel >= 11) {
return null
}
const prevCaptureStr = state.prevCapture == null ? '' : state.prevCapture[0]
const isStartOfLineCapture = LIST_LOOKBEHIND_R.exec(prevCaptureStr)
return !isStartOfLineCapture || LIST_WHITESPACE.test(isStartOfLineCapture[0]) ? null : LIST_R.exec(source)
},
parse: function (capture, parse, state) {
const bullet = capture[2]
const ordered = bullet.length > 1
const start = ordered ? Math.min(1e9, Math.max(1, +bullet)) : undefined
// @ts-expect-error - TS2322 - Type 'RegExpMatchArray | null' is not assignable to type 'string[]'.
const items: Array<string> = capture[0].replace(LIST_BLOCK_END_R, '\n').match(LIST_ITEM_R)
// We know this will match here, because of how the regexes are
// defined
let lastItemWasAParagraph = false
const itemContent = items.map(function (item: string, i: number) {
// We need to see how far indented this item is:
const prefixCapture = LIST_ITEM_PREFIX_R.exec(item)
const space = prefixCapture ? prefixCapture[0].length : 0
// And then we construct a regex to "unindent" the subsequent
// lines of the items by that amount:
const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm')
// Before processing the item, we need a couple things
const content = item
// remove indents on trailing lines:
.replace(spaceRegex, '')
// remove the bullet:
.replace(LIST_ITEM_PREFIX_R, '')
// I'm not sur4 why this is necessary again?
// Handling "loose" lists, like:
//
// * this is wrapped in a paragraph
//
// * as is this
//
// * as is this
const isLastItem = i === items.length - 1
const containsBlocks = content.indexOf('\n\n') !== -1
// Any element in a list is a block if it contains multiple
// newlines. The last element in the list can also be a block
// if the previous item in the list was a block (this is
// because non-last items in the list can end with \n\n, but
// the last item can't, so we just "inherit" this property
// from our previous element).
const thisItemIsAParagraph = containsBlocks || (isLastItem && lastItemWasAParagraph)
lastItemWasAParagraph = thisItemIsAParagraph
// backup our state for restoration afterwards. We're going to
// want to set state._list to true, and state.inline depending
// on our list's looseness.
const oldStateInline = state.inline
const oldStateList = state._list
const oldStateListLevel = state._listLevel
state._list = true
state._listLevel = (oldStateListLevel ? oldStateListLevel : 0) + 1
// Parse inline if we're in a tight list, or block if we're in
// a loose list.
let adjustedContent
if (thisItemIsAParagraph) {
state.inline = false
adjustedContent = content.replace(LIST_ITEM_END_R, '\n\n')
} else {
state.inline = true
adjustedContent = content.replace(LIST_ITEM_END_R, '')
}
const result = parse(adjustedContent, {
...state,
allowHeading: false,
}).map((node) => ('text' === node.type && null != node.content && (node.content = node.content.replace(/\n+\s*$/, '')), node))
// Restore our state before returning
state.inline = oldStateInline
state._list = oldStateList
state._listLevel = oldStateListLevel
return result
})
return {
ordered: ordered,
start: start,
items: itemContent,
}
},
},
}
const defaultRawParse = parserFor(discordRules)
const defaultBlockParse = function (source: string, state?: State | null): Array<SingleASTNode> {
state = state || {}
state.inline = false
return defaultRawParse(source, state)
}
const defaultInlineParse = function (source: string, state?: State | null): Array<SingleASTNode> {
state = state || {}
state.inline = true
return defaultRawParse(source, state)
}
const defaultImplicitParse = function (source: string, state?: State | null): Array<SingleASTNode> {
const isBlock = BLOCK_END_R.test(source)
state = state || {}
state.inline = !isBlock
return defaultRawParse(source, state)
}
const defaultVueOutput: VueOutput = outputFor(discordRules, 'vue')
const defaultHtmlOutput: HtmlOutput = outputFor(discordRules, 'html')
const markdownToVue = function (source: string, state?: State | null): VNode {
return defaultVueOutput(defaultBlockParse(source, state), state)
}
const markdownToHtml = function (source: string, state?: State | null): string {
return defaultHtmlOutput(defaultBlockParse(source, state), state)
}
type Exports = {
readonly discordRules: Rules
readonly markdownToVue: (source: string, state?: State | null | undefined) => VNode
readonly markdownToHtml: (source: string, state?: State | null | undefined) => string
readonly defaultRawParse: (source: string, state?: State | null | undefined) => Array<SingleASTNode>
readonly defaultBlockParse: (source: string, state?: State | null | undefined) => Array<SingleASTNode>
readonly defaultInlineParse: (source: string, state?: State | null | undefined) => Array<SingleASTNode>
readonly defaultImplicitParse: (source: string, state?: State | null | undefined) => Array<SingleASTNode>
readonly defaultVueOutput: VueOutput
readonly defaultHtmlOutput: HtmlOutput
}
const SimpleMarkdown: Exports = {
discordRules: discordRules,
markdownToVue,
markdownToHtml,
defaultRawParse,
defaultBlockParse,
defaultInlineParse,
defaultImplicitParse,
defaultVueOutput,
defaultHtmlOutput,
}
export default SimpleMarkdown
export {
discordRules,
markdownToVue,
markdownToHtml,
defaultRawParse,
defaultBlockParse,
defaultInlineParse,
defaultImplicitParse,
defaultVueOutput,
defaultHtmlOutput,
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment