function splitText(text) {
  /** @type {Array<TextNode>} */
  const nodes = [];

  const tokens = text.split(/([\t\n ]+)/u);
  for (const [index, token] of tokens.entries()) {
    // whitespace
    if (index % 2 === 1) {
      nodes.push({
        type: "whitespace",
        value: /\n/u.test(token) ? "\n" : " ",
      });
      continue;
    }

    // word separated by whitespace

    if ((index === 0 || index === tokens.length - 1) && token === "") {
      continue;
    }

    const innerTokens = token.split(new RegExp(`(${CJK_REGEXP.source})`, "u"));
    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,
            isCJ: false,
            hasLeadingPunctuation: PUNCTUATION_REGEXP.test(innerToken[0]),
            hasTrailingPunctuation: PUNCTUATION_REGEXP.test(innerToken.at(-1)),
          });
        }
        continue;
      }

      // CJK character

      // punctuation for CJ(K)
      // Korean doesn't use them in horizontal writing usually
      if (PUNCTUATION_REGEXP.test(innerToken)) {
        appendNode({
          type: "word",
          value: innerToken,
          kind: KIND_CJK_PUNCTUATION,
          isCJ: true,
          hasLeadingPunctuation: true,
          hasTrailingPunctuation: true,
        });
        continue;
      }

      // Korean uses space to divide words, but Chinese & Japanese do not
      // This is why Korean should be treated like non-CJK
      if (K_REGEXP.test(innerToken)) {
        appendNode({
          type: "word",
          value: innerToken,
          kind: KIND_K_LETTER,
          isCJ: false,
          hasLeadingPunctuation: false,
          hasTrailingPunctuation: false,
        });
        continue;
      }

      appendNode({
        type: "word",
        value: innerToken,
        kind: KIND_CJ_LETTER,
        isCJ: true,
        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.ok(
        !(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/u.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)
      );
    }
  }
}
split text into whitespaces and words
@param {string} text