基于 highlightjs-line-numbers.js 插件二次开发

插件源码:

import type { HLJSApi } from "highlight.js";

interface LineNumbersMethods {
  initLineNumbersOnLoad: (options?: any) => void;
  lineNumbersBlock: (element: HTMLElement, options?: any) => void;
  lineNumbersValue: (value: string, options?: any) => string;
  copyCodeBlock: (element: HTMLElement | string) => boolean;
  setLineHighlight: (
    className: string,
    startLine?: number,
    endLine?: number
  ) => void; // 设置高亮行
  clearLineHighlights: () => void; // 清除所有高亮配置
  destroy: () => void;
}

export type ExtendedHLJS = HLJSApi & LineNumbersMethods;

/** 显示行号插件 */
export const hljsLineNumbers = (hljs: ExtendedHLJS) => {
  // 定义用于生成 HTML 和 CSS 的常量
  const TABLE_NAME = "hljs-ln", // 行号表格的 CSS 类名
    LINE_NAME = "hljs-ln-line", // 每一行的 CSS 类名
    CODE_BLOCK_NAME = "hljs-ln-code", // 代码单元格的 CSS 类名
    NUMBERS_BLOCK_NAME = "hljs-ln-numbers", // 行号单元格的 CSS 类名
    NUMBER_LINE_NAME = "hljs-ln-n", // 行号数字的 CSS 类名
    DATA_ATTR_NAME = "data-line-number", // 存储行号的 data 属性名
    BREAK_LINE_REGEXP = /\r\n|\r|\n/g; // 用于分割行的正则表达式,兼容不同操作系统的换行符

  // 存储高亮行信息
  const highlightedLines: {
    start: number;
    end: number;
    className: string;
  }[] = [];

  /**
   * 设置需要高亮的代码行
   * @param startLine - 起始行号
   * @param endLine - 结束行号(可选,默认等于起始行号)
   * @param className - 要添加的CSS类名
   */
  const setLineHighlight = (
    className: string,
    startLine?: number,
    endLine: number = startLine || 0
  ): void => {
    if (!startLine) return;
    if (startLine > endLine) {
      [startLine, endLine] = [endLine, startLine];
    }

    highlightedLines.push({ start: startLine, end: endLine, className });
  };

  /**
   * 检查给定的 DOM 元素是否是代码区域(即带有 .hljs-ln-code 类名的元素)的后代
   * @param domElt - 要检查的 DOM 元素
   * @returns 如果是后代,则返回 true,否则返回 false
   */
  const isHljsLnCodeDescendant = (domElt: any): boolean => {
    let curElt = domElt;
    while (curElt) {
      if (curElt.className && curElt.className.indexOf("hljs-ln-code") !== -1) {
        return true;
      }
      curElt = curElt.parentNode;
    }
    return false;
  };

  /**
   * 从行号插件生成的任何子元素开始,向上查找并返回包含整个行号结构的 <table> 元素
   * @param hljsLnDomElt - 行号结构内的任一 DOM 元素
   * @returns 包含行号的 <table> 元素
   */
  const getHljsLnTable = (hljsLnDomElt: any): HTMLElement => {
    let curElt = hljsLnDomElt;
    while (curElt.nodeName !== "TABLE") {
      curElt = curElt.parentNode;
    }
    return curElt;
  };

  /**
   * 修复 Microsoft Edge 浏览器复制代码时丢失换行符的问题
   * 由于 hljs-ln 将代码行包装在 <table> 元素中,Edge 复制时不会包含换行符,
   * 需要根据 DOM 结构重建真实的选中代码文本
   * @param selection 当前的 Selection 对象
   * @returns 真实选中的代码文本(包含换行符)
   */
  const edgeGetSelectedCodeLines = (selection: any): string => {
    // 获取当前选中的不含换行符的文本
    const selectionText = selection.toString();

    // 获取 selection 的起始节点(anchorNode)和结束节点(focusNode)所在的 <td> 元素
    // selection 的起始和结束节点可能是文本节点,需要向上遍历 DOM 树找到其父级 <td>
    let tdAnchor = selection.anchorNode;
    while (tdAnchor.nodeName !== "TD") {
      tdAnchor = tdAnchor.parentNode;
    }
    let tdFocus = selection.focusNode;
    while (tdFocus.nodeName !== "TD") {
      tdFocus = tdFocus.parentNode;
    }

    // 从 <td> 元素的 data-line-number 属性中提取行号
    let firstLineNumber = parseInt(tdAnchor.dataset.lineNumber);
    let lastLineNumber = parseInt(tdFocus.dataset.lineNumber);

    // 如果复制了多行
    if (firstLineNumber !== lastLineNumber) {
      let firstLineText = tdAnchor.textContent;
      let lastLineText = tdFocus.textContent;

      // 如果用户是反向选择(例如从下往上),则交换起始和结束的行号及文本内容
      if (firstLineNumber > lastLineNumber) {
        let tmp = firstLineNumber;
        firstLineNumber = lastLineNumber;
        lastLineNumber = tmp;
        tmp = firstLineText;
        firstLineText = lastLineText;
        lastLineText = tmp;
      }

      // 剔除第一行开头未被选中的部分
      while (selectionText.indexOf(firstLineText) !== 0) {
        firstLineText = firstLineText.slice(1);
      }

      // 剔除最后一行末尾未被选中的部分
      while (selectionText.lastIndexOf(lastLineText) === -1) {
        lastLineText = lastLineText.slice(0, -1);
      }

      // 重构并返回真实复制的文本
      // 1. 从第一行中实际选中的文本开始
      let selectedText = firstLineText;

      // 2. 获取整个代码块的 table 元素
      const hljsLnTable = getHljsLnTable(tdAnchor);

      // 3. 遍历中间所有被完整选中的行
      for (let i = firstLineNumber + 1; i < lastLineNumber; ++i) {
        // 构造选择器,找到对应行号的代码单元格
        const codeLineSel = format('.{0}[{1}="{2}"]', [
          CODE_BLOCK_NAME,
          DATA_ATTR_NAME,
          i
        ]);
        const codeLineElt = hljsLnTable.querySelector(codeLineSel);
        // 拼接换行符和该行的全部内容
        selectedText += "\n" + codeLineElt?.textContent;
      }

      // 4. 拼接最后一行中实际选中的文本
      selectedText += "\n" + lastLineText;
      return selectedText;
    } else {
      // 如果只复制了单行,直接返回 selection 的文本即可
      return selectionText;
    }
  };

  // 确保所有浏览器的代码复制/粘贴行为一致
  // (相关 issue: https://github.com/wcoder/highlightjs-line-numbers.js/issues/51)
  // 监听全局的 'copy' 事件
  const copyEventHandler = (e: ClipboardEvent) => {
    const selection = window.getSelection();
    if (isHljsLnCodeDescendant(selection?.anchorNode)) {
      let selectionText;
      if (window.navigator.userAgent.indexOf("Edge") !== -1) {
        selectionText = edgeGetSelectedCodeLines(selection);
      } else {
        selectionText = selection?.toString();
      }
      e.clipboardData?.setData("text/plain", selectionText!);
      e.preventDefault();
    }
  };

  window.removeEventListener("copy", copyEventHandler);
  window.addEventListener("copy", copyEventHandler);

  /**
   * 在文档的 <head> 中动态创建一个 <style> 标签,并注入插件所需的 CSS 样式
   */
  const addStyles = () => {
    const css = document.createElement("style");
    css.innerHTML = format(
      // CSS 规则:
      // 1. .hljs-ln { border-collapse: collapse; } -> 让表格边框合并
      // 2. .hljs-ln td { padding: 0; } -> 移除单元格的内边距
      // 3. .hljs-ln-n:before { content: attr(data-line-number); } -> 核心!使用 ::before 伪元素和 content 属性显示 data-line-number 的值作为行号
      ".{0}{border-collapse:collapse}" +
        ".{0} td{padding:0}" +
        ".{1}:before{content:attr({2})}",
      [TABLE_NAME, NUMBER_LINE_NAME, DATA_ATTR_NAME]
    );
    document.getElementsByTagName("head")[0].appendChild(css);
  };

  /**
   * 初始化函数,在页面加载时自动为所有代码块添加行号
   * @param options - 配置选项
   */
  const initLineNumbersOnLoad = (options: any) => {
    // 检查文档是否已经加载完成
    if (
      document.readyState === "interactive" ||
      document.readyState === "complete"
    ) {
      documentReady(options);
    } else {
      // 如果尚未加载完成,则添加一个事件监听器,在 DOMContentLoaded 事件触发时执行
      addEventListener("DOMContentLoaded", () => {
        documentReady(options);
      });
    }
  };

  /**
   * 当文档准备就绪时执行此函数
   * 它会查找页面上所有需要添加行号的代码块并进行处理
   * @param options - 配置选项
   */
  const documentReady = (options: any) => {
    try {
      // 查找所有 `<code>` 标签,它们通常带有 `hljs` 或 `nohighlight` 类
      const blocks = document.querySelectorAll("code.hljs,code.nohighlight");

      for (const i in blocks) {
        // 使用 hasOwnProperty 确保我们处理的是元素自身的属性,而不是原型链上的
        if (Object.prototype.hasOwnProperty.call(blocks, i)) {
          // 检查该代码块是否被显式禁用行号功能
          if (!isPluginDisabledForBlock(blocks[i] as HTMLElement)) {
            lineNumbersBlock(blocks[i] as HTMLElement, options);
          }
        }
      }
    } catch (e) {
      console.error("LineNumbers error: ", e);
    }
  };

  /**
   * 检查一个代码块元素是否通过添加 'nohljsln' 类来禁用了行号插件
   * @param element - 代码块元素
   * @returns 如果被禁用则返回 true
   */
  const isPluginDisabledForBlock = (element: HTMLElement): boolean => {
    return element.classList.contains("nohljsln");
  };

  /**
   * 对单个代码块元素应用行号
   * @param element - 要处理的 `<code>` 元素
   * @param options - 配置选项
   */
  const lineNumbersBlock = (element: HTMLElement, options: any) => {
    if (typeof element !== "object") return;
    // 将处理过程推入事件队列,避免在处理大型代码块时阻塞 UI 线程
    setTimeout(() => {
      element.innerHTML = lineNumbersInternal(element, options);
    }, 0);
  };

  /**
   * 对一个代码字符串应用行号并返回结果
   * @param value - 包含代码的字符串
   * @param options - 配置选项
   * @returns - 添加了行号的 HTML 字符串
   */
  const lineNumbersValue = (value: string, options: any): string => {
    if (typeof value !== "string") return "";
    // 创建一个临时的 `<code>` 元素来容纳字符串,以便复用处理逻辑
    const element = document.createElement("code");
    element.innerHTML = value;

    return lineNumbersInternal(element, options);
  };

  /**
   * 行号处理的核心内部函数
   * @param element - 包含代码的 `<code>` 元素
   * @param options - 配置选项
   * @returns - 添加了行号的 HTML 字符串
   */
  const lineNumbersInternal = (element: HTMLElement, options: any): string => {
    // 1. 合并和解析选项
    const internalOptions = mapOptions(element, options);
    // 2. 修复 highlight.js 可能产生的多行 span 问题
    duplicateMultilineNodes(element);
    // 3. 生成并返回带行号的 HTML
    return addLineNumbersBlockFor(element.innerHTML, internalOptions);
  };

  /**
   * 根据给定的 HTML 内容和选项,生成最终的行号表格 HTML
   * @param inputHtml - 从 `<code>` 元素获取的 innerHTML
   * @param options - 内部使用的配置选项
   * @returns - 完整的 `<table>` HTML 或原始 HTML
   */
  const addLineNumbersBlockFor = (
    inputHtml: string,
    options: { singleLine: boolean; startFrom: number }
  ): string => {
    const lines = getLines(inputHtml);
    // 如果最后一行是空的(通常是由于末尾的换行符导致),则移除它
    if (lines.length > 0 && lines[lines.length - 1].trim() === "") {
      lines.pop();
    }
    // 仅当代码有多于一行,或 `singleLine` 选项为 true 时,才添加行号
    if (lines.length > 1 || options.singleLine) {
      let html = "";
      // 遍历每一行代码
      for (let i = 0, l = lines.length; i < l; i++) {
        const lineNumber = i + options.startFrom;

        // 检查当前行是否需要高亮,并获取对应的类名
        const lineClassArr: string[] = [];
        // 遍历所有需要高亮的行配置
        for (const highlight of highlightedLines) {
          if (lineNumber >= highlight.start && lineNumber <= highlight.end) {
            // 对类名去重
            const classes = highlight.className.split(/\s+/).filter((i) => i);
            for (const className of classes) {
              if (!lineClassArr.includes(className)) {
                lineClassArr.push(className);
              }
            }
          }
        }

        // 额外添加的类名
        const lineClass = lineClassArr.length
          ? `class="${lineClassArr.join(" ")}"`
          : "";

        // 为每一行生成一个 <tr>
        html += format(
          "<tr {7}>" +
            // 左侧 <td>: 用于显示行号
            '<td class="{0} {1}" {3}="{5}">' +
            // 这个 div 的 :before 伪元素会显示行号
            '<div class="{2}" {3}="{5}"></div>' +
            "</td>" +
            // 右侧 <td>: 用于显示代码
            '<td class="{0} {4}" {3}="{5}">' +
            // 如果行是空的,则插入一个空格以确保行高不会坍缩
            "{6}" +
            "</td>" +
            "</tr>",
          [
            LINE_NAME,
            NUMBERS_BLOCK_NAME,
            NUMBER_LINE_NAME,
            DATA_ATTR_NAME,
            CODE_BLOCK_NAME,
            i + options.startFrom, // 计算实际行号(考虑 startFrom 选项)
            lines[i].length > 0 ? lines[i] : " ", // 代码内容
            lineClass // 额外添加的类名
          ]
        );
      }
      // 将所有行包装在一个 `<table>` 元素中
      return format('<table class="{0}" data-total-lines={2}>{1}</table>', [
        TABLE_NAME,
        html,
        lines.length
      ]);
    }
    // 如果不满足添加行号的条件,则返回原始 HTML
    return inputHtml;
  };

  /**
   * 将外部传入的选项和从元素 data 属性中读取的选项合并为内部使用的标准选项对象
   * @param element 代码块
   * @param options 外部 API 选项
   * @returns 内部 API 选项
   */
  const mapOptions = (
    element: HTMLElement,
    options: {}
  ): { singleLine: boolean; startFrom: number } => {
    options = options || {};
    return {
      singleLine: getSingleLineOption(options as { singleLine: boolean }),
      startFrom: getStartFromOption(element, options as { startFrom: number })
    };
  };

  /**
   * 获取 singleLine 选项的值
   * @param options - 选项对象
   * @returns
   */
  const getSingleLineOption = (options: { singleLine: boolean }): boolean => {
    const defaultValue = false;
    return options.singleLine ? options.singleLine : defaultValue;
  };

  /**
   * 获取起始行号
   * 优先级:data 属性 > JS 选项 > 默认值
   * @param element 代码块元素
   * @param options 选项对象
   * @returns 起始行号
   */
  const getStartFromOption = (
    element: HTMLElement,
    options: { startFrom: number }
  ): number => {
    const defaultValue = 1;
    let startFrom = defaultValue;

    // 首先检查 JS 选项
    if (isFinite(options.startFrom)) {
      startFrom = options.startFrom;
    }

    // 检查元素上的 data-ln-start-from 属性,如果存在,则覆盖 JS 选项
    // 因为局部选项(HTML 属性)的优先级更高
    const value = getAttribute(element, "data-ln-start-from");
    if (value !== null) {
      startFrom = toNumber(value, defaultValue);
    }

    return startFrom;
  };

  /**
   * 修复 highlight.js 将多行内容(如多行注释、字符串)渲染在单个 <span> 内的问题
   * 此函数递归地遍历 DOM 节点,找到并处理这些多行 <span>
   * @param element - 要处理的元素
   */
  const duplicateMultilineNodes = (element: { childNodes: any }) => {
    const nodes = element.childNodes;
    for (const node of nodes) {
      // 检查节点内容是否包含换行符
      if (getLinesCount(node.textContent) > 0) {
        // 如果此节点还有子节点,则递归处理
        if (node.childNodes.length > 0) {
          duplicateMultilineNodes(node);
        } else {
          // 如果是叶子节点(没有子节点),则调用 duplicateMultilineNode 进行处理
          duplicateMultilineNode(node.parentNode);
        }
      }
    }
  };

  /**
   * 将一个包含多行文本的节点(通常是 <span>),拆分成多个节点,
   * 每个新节点包含一行文本,并继承原始节点的 CSS 类
   * @param element - 包含多行文本的节点
   */
  const duplicateMultilineNode = (element: HTMLElement) => {
    const className = element.className;
    // 如果节点没有 hljs- 相关的类,则不处理
    if (!/hljs-/.test(className)) return;

    // 获取节点内的所有行
    const lines = getLines(element.innerHTML);
    let result = "";

    // 遍历每一行,用带有原始类的 <span> 包裹它
    for (let i = 0; i < lines.length; i++) {
      const lineText = lines[i].length > 0 ? lines[i] : " ";
      result += format('<span class="{0}">{1}</span>\n', [className, lineText]);
    }

    // 用生成的新 HTML 替换原始节点的 innerHTML
    element.innerHTML = result.trim();
  };

  /**
   * 将文本字符串按换行符分割成数组
   * @param text - 输入文本
   * @returns - 行数组
   */
  const getLines = (text: string): string[] => {
    if (text.length === 0) return [];
    return text.split(BREAK_LINE_REGEXP);
  };

  /**
   * 计算文本中的换行符数量
   * @param text - 输入文本
   * @returns - 换行符的数量
   */
  const getLinesCount = (text: string): number => {
    return (text.trim().match(BREAK_LINE_REGEXP) || []).length;
  };

  /**
   * 一个简单的字符串格式化函数,类似于 C# 的 String.Format
   * 用法: format("Hello {0}, {1}!", ["World", "Test"]) -> "Hello World, Test!"
   * @param format - 格式化模板,使用 {0}, {1} ...作为占位符
   * @param args - 替换占位符的参数数组
   */
  const format = (format: string, args: any[]): string => {
    return format.replace(/\{(\d+)\}/g, (m: any, n: string | number) => {
      return args[n as number] !== undefined ? args[n as number] : m;
    });
  };

  /**
   * 安全地获取元素的 HTML 属性
   * @param element 代码块
   * @param attrName 属性名
   * @returns 属性值,如果不存在则返回 null
   */
  const getAttribute = (
    element: HTMLElement,
    attrName: string
  ): string | null => {
    return element.hasAttribute(attrName)
      ? element.getAttribute(attrName)
      : null;
  };

  /**
   * 将字符串转换为数字,如果转换失败,则返回一个备用值
   * @param str 源字符串
   * @param fallback 备用值
   * @returns 解析后的数字或备用值
   */
  const toNumber = (str: string | null, fallback: number): number => {
    if (!str) return fallback;
    const number = Number(str);
    return isFinite(number) ? number : fallback;
  };

  /**
   * 复制代码块内容
   * @param element 代码块元素或选择器
   * @returns 是否复制成功
   */
  const copyCodeBlock = (element: HTMLElement | string): boolean => {
    try {
      // 如果传入的是选择器字符串,则获取对应元素
      const codeElement =
        typeof element === "string"
          ? (document.querySelector(element) as HTMLElement)
          : element;

      if (!codeElement) return false;

      // 创建选区并选中代码
      const range = document.createRange();
      range.selectNode(codeElement);

      // 清除当前选择并添加新选区
      const selection = window.getSelection();
      selection?.removeAllRanges();
      selection?.addRange(range);

      // 执行复制命令
      document.execCommand("copy");

      // 清除选择
      selection?.removeAllRanges();
      return true;
    } catch (e) {
      console.error("Failed to copy code block:", e);
      return false;
    }
  };

  // 检查全局环境中 hljs (highlight.js) 是否已加载
  if (hljs) {
    // 如果已加载,则将本插件的核心功能函数挂载到 hljs 对象上,以便外部调用
    hljs.initLineNumbersOnLoad = initLineNumbersOnLoad;
    hljs.lineNumbersBlock = lineNumbersBlock;
    hljs.lineNumbersValue = lineNumbersValue; // 注入插件所需的 CSS 样式
    hljs.copyCodeBlock = copyCodeBlock;
    hljs.setLineHighlight = setLineHighlight;
    hljs.clearLineHighlights = () => {
      highlightedLines.length = 0;
    };
    hljs.destroy = () => {
      window.removeEventListener("copy", copyEventHandler);
      highlightedLines.length = 0;
      // TODO: 是否还有其他需要清理的?
    };

    addStyles();
  } else {
    // 如果未检测到 highlight.js,就报错
    console.error("highlight.js not detected!", hljs);
  }
};

在vue3中使用:

<script setup lang="ts">
import "highlight.js/styles/atom-one-light.min.css";

import { useMutationObserver } from "@vueuse/core";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import plaintext from "highlight.js/lib/languages/plaintext";
import python from "highlight.js/lib/languages/python";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";

import { type ExtendedHLJS, hljsLineNumbers } from "@/utils";

// 按需引入语言
hljs.registerLanguage("plaintext", plaintext);
hljs.registerLanguage("python", python);
hljs.registerLanguage("bash", bash);

const props = defineProps<{
  data: string;
  showLineNumbers?: boolean;
  setLineHighlight?: {
    className: string;
    startLine?: number;
    endLine?: number;
  }[];
}>();

// 标记代码块是否渲染完成
const codeRendered = ref(false);

// 标记markdown显示,防止行号插件的渲染导致代码块闪烁
const showMarked = computed(() => {
  const show = !props.showLineNumbers ? true : codeRendered.value;
  return show;
});

const marked = new Marked(
  markedHighlight({
    langPrefix: "hljs language-",
    highlight(code, lang) {
      const language = hljs.getLanguage(lang) ? lang : "plaintext";
      return hljs.highlight(code, { language }).value;
    }
  })
);

// 解析markdown内容
const htmlContent = marked.parse(props.data);

// 获取代码块,后面需要添加行号
const contentRef = ref<HTMLDivElement | null>(null);

watch(
  () => contentRef.value,
  (el) => {
    if (!el || !props.showLineNumbers) return;
    // 拿到代码块之后为代码块添加行号
    // 初始化行号
    hljsLineNumbers(hljs as ExtendedHLJS);
    let code = document.querySelectorAll("pre code");
    // 为代码块指定区间的行添加样式
    if (props.setLineHighlight && Array.isArray(props.setLineHighlight)) {
      props.setLineHighlight.forEach((i) => {
        (hljs as ExtendedHLJS).setLineHighlight(
          i.className,
          i.startLine,
          i.endLine
        );
      });
    }
    // 为代码块添加行号
    code.forEach((block) => {
      (hljs as ExtendedHLJS).lineNumbersBlock(block as HTMLElement);
    });
  }
);

// 监听代码块内容变化,有hljs-ln代表行号添加完成
const observer = useMutationObserver(
  contentRef,
  (mutations) => {
    for (const mutation of mutations) {
      if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
        // 检查新增节点是否为 table 且 class 包含 hljs-ln
        mutation.addedNodes.forEach((node) => {
          if (
            node instanceof HTMLElement &&
            node.tagName.toLowerCase() === "table" &&
            node.classList.contains("hljs-ln")
          ) {
            codeRendered.value = true;
            // 获取代码块的行数 <table class="hljs-ln" data-total-lines="26">
            const totalLines = node.getAttribute("data-total-lines");
            if (totalLines) {
              const digitNum = totalLines.length; // 位数
              const width = digitNum * 17 + "px";
              // 获取table下的.hljs-ln-numbers元素,并设置行号的宽度,防止行号元素在宽页面下变宽,设置第一个就好
              const lnNumbers = node.querySelector(".hljs-ln-numbers");
              if (lnNumbers && lnNumbers instanceof HTMLElement) {
                lnNumbers.style.width = width;
              }
            }
          }
        });
      }
    }
  },
  { childList: true, subtree: true }
);

// showLineNumbers为 false 不需要监听
computed(() => {
  if (!props.showLineNumbers) {
    observer.stop();
  }
});

onUnmounted(() => {
  // 目前主要卸载其中的监听
  if (props.showLineNumbers) {
    (hljs as ExtendedHLJS).destroy?.();
  }
});

// 向外暴露一个方法,用于复制代码块,因为使用原先的复制组件复制会导致复制出被压缩的代码
const copyCodeBlock = () => {
  if (!contentRef.value) return;
  (hljs as ExtendedHLJS).copyCodeBlock(contentRef.value);
};

defineExpose({
  copyCodeBlock
});
</script>

<template>
  <div v-show="showMarked" ref="contentRef" v-html="htmlContent"></div>
</template>

<style scoped>
:deep(.hljs) {
  background: #f9fbff !important;
}

:deep(.hljs-ln) {
  width: 100%;
}

:deep(.hljs-ln-n) {
  padding-right: 16px;
  text-align: right;
  color: #999;
}
</style>