import MarkdownIt from 'markdown-it' 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 | MarkdownIt.PluginWithParams export interface NcMarkdownParserConstructorType { openLinkOnClick?: boolean enableMention?: boolean users?: (Partial | Partial)[] currentUser?: Partial | Partial | 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 = /(? { const checked = state.toLowerCase() === 'x' ? '[x]' : '[ ]' return `${spaces}- ${checked}` }) // } // Replace single tilde strikethrough syntax with tags return markdownText.replace(strikeThroughRegex, '$1') } /** * 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(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 if (ncIsString(content)) { return 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, ): 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 = {}, ): 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 } }