go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/tools/markdown/plugins/special_line.ts (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 /** 16 * @fileoverview This file contains a MarkdownIt plugin to support applying 17 * special rules to lines with the specified prefix. 18 * 19 * It performs the following steps. 20 * 1. During inline processing of a block, add hidden tokens to mark the rule 21 * to apply to each line. 22 * 2. After the inline processing of the block, transform the text tokens in the 23 * special lines with the provided transform function. 24 */ 25 26 import MarkdownIt from 'markdown-it'; 27 import StateCore from 'markdown-it/lib/rules_core/state_core'; 28 import StateInline from 'markdown-it/lib/rules_inline/state_inline'; 29 import Token from 'markdown-it/lib/token'; 30 31 export type TransformFn = (token: Token) => Token[]; 32 33 interface Rule { 34 rePrefix: RegExp; 35 transformFn: TransformFn; 36 } 37 38 class SpecialLineRulesProcessor { 39 private rules: Rule[] = []; 40 41 addRule(rule: Rule) { 42 this.rules.push(rule); 43 } 44 45 /** 46 * Add special line open tokens. 47 */ 48 tokenize = (s: StateInline) => { 49 const hardBreakOnly = !s.md.options.breaks; 50 const lastTokenType = s.tokens[s.tokens.length - 1]?.type; 51 // Check if it's a new line. 52 if ( 53 lastTokenType === undefined || 54 lastTokenType === 'hardbreak' || 55 (!hardBreakOnly && lastTokenType === 'softbreak') 56 ) { 57 const content = s.src.slice(s.pos); 58 59 for ( 60 let activeRuleIndex = 0; 61 activeRuleIndex < this.rules.length; 62 ++activeRuleIndex 63 ) { 64 const rule = this.rules[activeRuleIndex]; 65 const prefixMatch = rule.rePrefix.exec(content); 66 if (prefixMatch && prefixMatch.index === 0) { 67 const prefix = prefixMatch![0]; 68 let token = s.push('text', '', 0); 69 token.content = prefix; 70 71 token = s.push(`special_line_${activeRuleIndex}`, '', 0); 72 token.hidden = true; 73 74 // markdown-it@13.0.2 requires all plugins to advance the cursor. 75 // Add a character to src and advance the pos by 1 to avoid this 76 // issue. 77 if (prefix.length === 0) { 78 s.src = s.src.slice(0, s.pos) + '@' + content; 79 s.pos += 1; 80 } 81 82 s.pos += prefix.length; 83 return true; 84 } 85 } 86 } 87 88 return false; 89 }; 90 91 /** 92 * Apply the transform rules. 93 */ 94 postProcess = (s: StateCore) => { 95 const hardBreakOnly = !s.md.options.breaks; 96 97 for (const blockToken of s.tokens) { 98 if (blockToken.type !== 'inline') { 99 continue; 100 } 101 102 const newChildren: Token[] = []; 103 let activeRule: Rule | null = null; 104 let ruleLevel = 0; 105 106 for (const srcToken of blockToken.children!) { 107 // Apply transformFn to text tokens in the same level in the special 108 // line. 109 if ( 110 activeRule?.transformFn && 111 srcToken.type === 'text' && 112 srcToken.level === ruleLevel 113 ) { 114 newChildren.push(...activeRule.transformFn(srcToken)); 115 continue; 116 } 117 118 // When encountering a new special line, update activeRule and 119 // ruleLevel. 120 const match = /^special_line_(\d+)$/.exec(srcToken.type); 121 if (match) { 122 const ruleIndex = Number(match[1]); 123 activeRule = this.rules[ruleIndex]!; 124 ruleLevel = srcToken.level; 125 126 // Omit the special line token. 127 continue; 128 } 129 130 // Other tokens remain the same. 131 newChildren.push(srcToken); 132 133 // Reset activeRule and ruleLevel when starting a new line. 134 if ( 135 srcToken.type === 'hardbreak' || 136 (!hardBreakOnly && srcToken.type === 'softbreak') 137 ) { 138 activeRule = null; 139 ruleLevel = 0; 140 continue; 141 } 142 } 143 144 blockToken.children = newChildren; 145 } 146 147 return true; 148 }; 149 } 150 151 // Track the processor of each MarkdownIt instance. 152 const processorMap = new Map<MarkdownIt, SpecialLineRulesProcessor>(); 153 154 /** 155 * Apply special rules to text tokens that 156 * 1. in lines satisfy the special prefix rule, and 157 * 2. not enclosed by other tags. 158 */ 159 export function specialLine( 160 md: MarkdownIt, 161 rePrefix: RegExp, 162 transformFn: TransformFn, 163 ) { 164 let processor = processorMap.get(md); 165 166 if (!processor) { 167 processor = new SpecialLineRulesProcessor(); 168 processorMap.set(md, processor); 169 170 // Use a common processor for all the special rules for better performance. 171 md.inline.ruler.before('text', 'special_line', processor.tokenize); 172 md.core.ruler.push('special_line', processor.postProcess); 173 } 174 processor.addRule({ rePrefix, transformFn }); 175 }