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  }