Files
test-repo/src/language-markdown/utils.js
T

278 lines
6.8 KiB
JavaScript

import assert from "node:assert";
import { locStart, locEnd } from "./loc.js";
import {
cjkPattern,
kRegex,
punctuationPattern,
} from "./constants.evaluate.js";
const INLINE_NODE_TYPES = new Set([
"liquidNode",
"inlineCode",
"emphasis",
"esComment",
"strong",
"delete",
"wikiLink",
"link",
"linkReference",
"image",
"imageReference",
"footnote",
"footnoteReference",
"sentence",
"whitespace",
"word",
"break",
"inlineMath",
]);
const INLINE_NODE_WRAPPER_TYPES = new Set([
...INLINE_NODE_TYPES,
"tableCell",
"paragraph",
"heading",
]);
const punctuationRegex = new RegExp(punctuationPattern);
const KIND_NON_CJK = "non-cjk";
const KIND_CJ_LETTER = "cj-letter";
const KIND_K_LETTER = "k-letter";
const KIND_CJK_PUNCTUATION = "cjk-punctuation";
/**
* @typedef {" " | "\n" | ""} WhitespaceValue
* @typedef { KIND_NON_CJK | KIND_CJ_LETTER | KIND_K_LETTER | KIND_CJK_PUNCTUATION } WordKind
* @typedef {{
* type: "whitespace",
* value: WhitespaceValue,
* kind?: never
* }} WhitespaceNode
* @typedef {{
* type: "word",
* value: string,
* kind: WordKind,
* hasLeadingPunctuation: boolean,
* hasTrailingPunctuation: boolean,
* }} WordNode
* Node for a single CJK character or a sequence of non-CJK characters
* @typedef {WhitespaceNode | WordNode} TextNode
*/
/**
* split text into whitespaces and words
* @param {string} text
*/
function splitText(text) {
/** @type {Array<TextNode>} */
const nodes = [];
const tokens = text.split(/([\t\n ]+)/);
for (const [index, token] of tokens.entries()) {
// whitespace
if (index % 2 === 1) {
nodes.push({
type: "whitespace",
value: /\n/.test(token) ? "\n" : " ",
});
continue;
}
// word separated by whitespace
if ((index === 0 || index === tokens.length - 1) && token === "") {
continue;
}
const innerTokens = token.split(new RegExp(`(${cjkPattern})`));
for (const [innerIndex, innerToken] of innerTokens.entries()) {
if (
(innerIndex === 0 || innerIndex === innerTokens.length - 1) &&
innerToken === ""
) {
continue;
}
// non-CJK word
if (innerIndex % 2 === 0) {
if (innerToken !== "") {
appendNode({
type: "word",
value: innerToken,
kind: KIND_NON_CJK,
hasLeadingPunctuation: punctuationRegex.test(innerToken[0]),
hasTrailingPunctuation: punctuationRegex.test(innerToken.at(-1)),
});
}
continue;
}
// CJK character
appendNode(
punctuationRegex.test(innerToken)
? {
type: "word",
value: innerToken,
kind: KIND_CJK_PUNCTUATION,
hasLeadingPunctuation: true,
hasTrailingPunctuation: true,
}
: {
type: "word",
value: innerToken,
// Korean uses space to divide words, but Chinese & Japanese do not
kind: kRegex.test(innerToken) ? KIND_K_LETTER : KIND_CJ_LETTER,
hasLeadingPunctuation: false,
hasTrailingPunctuation: false,
}
);
}
}
// Check for `canBeConvertedToSpace` in ./print-whitespace.js etc.
if (process.env.NODE_ENV !== "production") {
for (let i = 1; i < nodes.length; i++) {
assert(
!(nodes[i - 1].type === "whitespace" && nodes[i].type === "whitespace"),
"splitText should not create consecutive whitespace nodes"
);
}
}
return nodes;
function appendNode(node) {
const lastNode = nodes.at(-1);
if (
lastNode?.type === "word" &&
!isBetween(KIND_NON_CJK, KIND_CJK_PUNCTUATION) &&
// disallow leading/trailing full-width whitespace
![lastNode.value, node.value].some((value) => /\u3000/.test(value))
) {
nodes.push({ type: "whitespace", value: "" });
}
nodes.push(node);
function isBetween(kind1, kind2) {
return (
(lastNode.kind === kind1 && node.kind === kind2) ||
(lastNode.kind === kind2 && node.kind === kind1)
);
}
}
}
function getOrderedListItemInfo(orderListItem, originalText) {
const [, numberText, marker, leadingSpaces] = originalText
.slice(
orderListItem.position.start.offset,
orderListItem.position.end.offset
)
.match(/^\s*(\d+)(\.|\))(\s*)/);
return { numberText, marker, leadingSpaces };
}
function hasGitDiffFriendlyOrderedList(node, options) {
if (!node.ordered) {
return false;
}
if (node.children.length < 2) {
return false;
}
const firstNumber = Number(
getOrderedListItemInfo(node.children[0], options.originalText).numberText
);
const secondNumber = Number(
getOrderedListItemInfo(node.children[1], options.originalText).numberText
);
if (firstNumber === 0 && node.children.length > 2) {
const thirdNumber = Number(
getOrderedListItemInfo(node.children[2], options.originalText).numberText
);
return secondNumber === 1 && thirdNumber === 1;
}
return secondNumber === 1;
}
// The final new line should not include in value
// https://github.com/remarkjs/remark/issues/512
function getFencedCodeBlockValue(node, originalText) {
const { value } = node;
if (
node.position.end.offset === originalText.length &&
value.endsWith("\n") &&
// Code block has no end mark
originalText.endsWith("\n")
) {
return value.slice(0, -1);
}
return value;
}
function mapAst(ast, handler) {
return (function preorder(node, index, parentStack) {
const newNode = { ...handler(node, index, parentStack) };
if (newNode.children) {
newNode.children = newNode.children.map((child, index) =>
preorder(child, index, [newNode, ...parentStack])
);
}
return newNode;
})(ast, null, []);
}
function isAutolink(node) {
if (node?.type !== "link" || node.children.length !== 1) {
return false;
}
const [child] = node.children;
return locStart(node) === locStart(child) && locEnd(node) === locEnd(child);
}
function getAncestorCounter(path, typeOrTypes) {
const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes];
let counter = -1;
let ancestorNode;
while ((ancestorNode = path.getParentNode(++counter))) {
if (types.includes(ancestorNode.type)) {
return counter;
}
}
return -1;
}
function getAncestorNode(path, typeOrTypes) {
const counter = getAncestorCounter(path, typeOrTypes);
return counter === -1 ? null : path.getParentNode(counter);
}
export {
mapAst,
splitText,
punctuationPattern,
getFencedCodeBlockValue,
getOrderedListItemInfo,
hasGitDiffFriendlyOrderedList,
INLINE_NODE_TYPES,
INLINE_NODE_WRAPPER_TYPES,
isAutolink,
KIND_NON_CJK,
KIND_CJ_LETTER,
KIND_K_LETTER,
KIND_CJK_PUNCTUATION,
getAncestorCounter,
getAncestorNode,
};