Files
nocodb/packages/nc-gui/helpers/tiptap/functionality/markdown/markdown.ts
2026-03-10 11:30:59 +00:00

259 lines
8.7 KiB
TypeScript

import MarkdownIt from 'markdown-it'
import DOMPurify from 'isomorphic-dompurify'
import mdTaskList from 'markdown-it-task-lists'
import type { UserType } from 'nocodb-sdk'
import { mdImageAsText, mdLinkRuleSetupExt } from '.'
import { parseUserMention } from '~/helpers/tiptap-markdown/extensions'
declare module 'markdown-it-task-lists' {
import type { PluginWithOptions } from 'markdown-it'
const taskLists: PluginWithOptions
export default taskLists
}
export type NcMarkdownExtension = MarkdownIt.PluginSimple | MarkdownIt.PluginWithOptions<any> | MarkdownIt.PluginWithParams
export interface NcMarkdownParserConstructorType {
openLinkOnClick?: boolean
enableMention?: boolean
users?: (Partial<UserType> | Partial<User>)[]
currentUser?: Partial<UserType> | Partial<User> | null
html?: boolean
linkify?: boolean
breaks?: boolean
extensions?: NcMarkdownExtension[]
maxBlockTokens?: number // Add this to limit block tokens
}
// Precompiled regex patterns
const taskRegex = /^(?!.*- )(\s*)\[( |x|X)\]/gm // Matches unchecked and checked tasks
const strikeThroughRegex = /(?<!~)~(?!~)(.*?)(?<!~)~(?!~)/g // Matches strikethrough syntax
export class NcMarkdownParser {
private static instance: NcMarkdownParser | null = null
private md: MarkdownIt
private openLinkOnClick = true
private maxBlockTokens?: number
private constructor({
openLinkOnClick = true,
enableMention = false,
users = [],
currentUser = null,
html = true,
linkify = false,
breaks = false,
extensions = [],
maxBlockTokens,
}: NcMarkdownParserConstructorType = {}) {
this.openLinkOnClick = openLinkOnClick
this.maxBlockTokens = maxBlockTokens
this.md = this.withPatchedRenderer(new MarkdownIt({ html, linkify, breaks }))
// Use the task list plugin with options
this.md.use(this.taskListExt)
this.md.use(this.setupLinkRules, { openLinkOnClick: this.openLinkOnClick })
if (enableMention) {
this.md.use(this.mentionExt, { users, currentUser })
}
/**
* Todo: Remove this once we enable proper image support in the rich text editor.
* Also, replace its usage in other places such as:
* 1. packages/nc-gui/helpers/tiptap-markdown/parse/MarkdownParser.ts
* 2. packages/nc-gui/helpers/tiptap-markdown/extensions/nodes/image.ts
*/
this.md.use(mdImageAsText)
// Apply custom extensions passed during instantiation
this.applyCustomExtensions(extensions)
}
/**
* Gets the singleton instance of NcMarkdownParser.
* If no instance exists, it creates and returns a new instance.
* @param options - The options to initialize the parser.
* @returns The singleton instance of the parser.
*/
public static getInstance(options: NcMarkdownParserConstructorType = {}): NcMarkdownParser {
if (!NcMarkdownParser.instance) {
NcMarkdownParser.instance = new NcMarkdownParser(options)
} else {
// Reconfigure the instance based on new options
NcMarkdownParser.instance.updateConfiguration(options)
}
return NcMarkdownParser.instance
}
private updateConfiguration(options: NcMarkdownParserConstructorType) {
// Update properties that may change
this.maxBlockTokens = options.maxBlockTokens
if (!ncIsUndefined(options.openLinkOnClick) && this.openLinkOnClick !== options.openLinkOnClick) {
this.openLinkOnClick = options.openLinkOnClick || false
this.md.use(this.setupLinkRules, { openLinkOnClick: this.openLinkOnClick })
}
}
public static preprocessMarkdown(markdownText: string, _isEditor = false): string {
if (!ncIsString(markdownText)) return markdownText ?? ''
// Only process task lists if not in editor mode
// if (!isEditor) {
// Replace [ ] and [x] with GitHub-style checklist syntax only if not already prefixed with "- "
markdownText = markdownText.replace(taskRegex, (_match, spaces, state) => {
const checked = state.toLowerCase() === 'x' ? '[x]' : '[ ]'
return `${spaces}- ${checked}`
})
// }
// Replace single tilde strikethrough syntax with <s> tags
return markdownText.replace(strikeThroughRegex, '<s>$1</s>')
}
/**
* Parses the content and optionally accepts options to initialize a new NcMarkdownParser instance.
* @param content - The markdown content to parse.
* @param options - Optional options to initialize the parser instance dynamically.
*/
public static parse<T extends string>(content: T, options: NcMarkdownParserConstructorType = {}, useSingleton = false): string {
if (!ncIsString(content)) return content
let parser: NcMarkdownParser
if (useSingleton) {
// Use the singleton instance
parser = NcMarkdownParser.getInstance(options)
} else {
// Create a new instance for each parse call
parser = new NcMarkdownParser(options)
}
// If content is a string, parse it and sanitize to prevent XSS
if (ncIsString(content)) {
return DOMPurify.sanitize(parser.md.render(NcMarkdownParser.preprocessMarkdown(content)))
}
return content
}
public taskListExt(md: MarkdownIt): void {
md.use(mdTaskList, { label: true, enabled: false })
}
public mentionExt(
md: MarkdownIt,
{ users, currentUser }: Pick<NcMarkdownParserConstructorType, 'users' | 'currentUser'>,
): void {
md.use(parseUserMention(users || [], currentUser, true))
}
/**
* Configures link rules (whether links should open in a new tab)
* @param openLinkOnClick
*/
private setupLinkRules(
md: MarkdownIt,
{ openLinkOnClick = true }: Pick<NcMarkdownParserConstructorType, 'openLinkOnClick'> = {},
): void {
md.use(mdLinkRuleSetupExt, { openLinkOnClick })
}
/**
* Dynamically apply custom extensions passed via constructor to the markdown-it instance.
* @param extensions - Array of markdown-it plugins (extensions).
*/
private applyCustomExtensions(extensions: NcMarkdownExtension[]): void {
extensions.forEach((extension) => {
this.md.use(extension)
})
}
private patchedRender(
tokens: MarkdownIt.Token[], // Array of tokens to render
options: MarkdownIt.Options,
env: any,
): string {
const maxBlockTokens = this.maxBlockTokens
let result = ''
const rules = this.md.renderer.rules
let blockCount = 0
const blockStack: string[] = [] // Stack to manage block-level tokens
let inBlockMode = false // Flag to handle when blocks exceed maxBlockTokens
for (let i = 0, len = tokens.length; i < len; i++) {
const { type, block, nesting } = tokens[i] as MarkdownIt.Token
// Count block-level tokens
if (maxBlockTokens && block) {
if (nesting === 1) {
// Push opening tag to the stack
blockStack.push(type)
blockCount++
} else if (nesting === -1 && blockStack.length > 0) {
// Match closing tag with stack
blockStack.pop()
}
// If block count exceeds the limit, we enter block mode
if (blockCount > maxBlockTokens) {
inBlockMode = true
}
// Only break if blockStack is empty and maxBlock limit is exceeded
if (inBlockMode && blockStack.length === 0) {
break // Stop processing further tokens once max block limit is reached and stack is empty
}
}
if (type === 'inline') {
result += this.md.renderer.renderInline(tokens[i].children, options, env)
} else if (typeof rules[type] !== 'undefined') {
result += rules[type](tokens, i, options, env, this.md.renderer)
} else {
result += this.md.renderer.renderToken(tokens, i, options)
}
// Exit block mode if the stack is empty
if (inBlockMode && blockStack.length === 0) {
inBlockMode = false
}
}
return result
}
private withPatchedRenderer(md: MarkdownIt): MarkdownIt {
// Apply withoutNewLine adjustments
const withoutNewLine =
(renderer: any) =>
(...args: any[]) => {
const rendered = renderer(...args)
if (rendered === '\n') {
return rendered // keep soft breaks
}
if (rendered[rendered.length - 1] === '\n') {
return rendered.slice(0, -1)
}
return rendered
}
md.renderer.rules.hardbreak = withoutNewLine(md.renderer.rules.hardbreak)
md.renderer.rules.softbreak = withoutNewLine(md.renderer.rules.softbreak)
md.renderer.rules.fence = withoutNewLine(md.renderer.rules.fence)
md.renderer.rules.code_block = withoutNewLine(md.renderer.rules.code_block)
md.renderer.renderToken = withoutNewLine(md.renderer.renderToken.bind(md.renderer))
// Replace the render method with the patched version
md.renderer.render = (tokens, options, env) => this.patchedRender(tokens, options, env)
return md
}
}