Files
test-repo/src/language-markdown/print/whitespace.js
T
Tatsunori Uchinoandfisker Cheung 5927216227
Internal ESLint Plugin Test / Test (push) Canceled after 0s
Lint / Lint (push) Canceled after 0s
Build / Build (push) Canceled after 0s
Prod / Lint (push) Canceled after 0s
Prod / Node.js 14 on MacOS (push) Canceled after 0s
Prod / Node.js 14 on Linux (push) Canceled after 0s
Prod / Node.js 14 on Windows (push) Canceled after 0s
Prod / Node.js 16 on Linux (push) Canceled after 0s
Prod / Node.js 18 on Linux (push) Canceled after 0s
Prod / Node.js 20 on Linux (push) Canceled after 0s
Prod / Node.js 22 on Linux (push) Canceled after 0s
Prod / Node.js 24 on Linux (push) Canceled after 0s
Prod / Node.js 26 on MacOS (push) Canceled after 0s
Prod / Node.js 26 on Windows (push) Canceled after 0s
Prod / [Full Test] Node.js 26 on Linux (push) Canceled after 0s
Prod / Browser(firefox) on Linux (push) Canceled after 0s
Prod / Browser(firefox) on Windows (push) Canceled after 0s
Prod / [Full Test] Browser(chrome) on Linux (push) Canceled after 0s
Prod / CLI (push) Canceled after 0s
Prod / Preview release (push) Canceled after 0s
Release Script Test / Test (push) Canceled after 0s
Dev / Node.js 22 on MacOS (push) Canceled after 0s
Dev / Node.js 22 on Linux (push) Canceled after 0s
Dev / Node.js 22 on Windows (push) Canceled after 0s
Dev / Node.js 26 on MacOS (push) Canceled after 0s
Dev / Node.js 26 on Windows (push) Canceled after 0s
Dev / [Full Test] Node.js 26 on Linux (push) Canceled after 0s
Prod / Build (push) Canceled after 0s
fix(markdown): preserve newline around Chinese/Japanese characters not around Korean letters in all proseWrap settings (#20143)
Co-authored-by: fisker Cheung <lionkay@gmail.com>
2026-10-04 11:20:28 +08:00

359 lines
11 KiB
JavaScript

import { hardline, line, softline } from "../../document/index.js";
import {
isNewLine,
isSetextHeading,
KIND_CJ_LETTER,
KIND_CJK_PUNCTUATION,
KIND_K_LETTER,
KIND_NON_CJK,
} from "../utilities.js";
/**
* @import {WordNode, WhitespaceValue, WordKind} from "../utilities.js"
* @import AstPath from "../../common/ast-path.js"
* @typedef {"always" | "never" | "preserve"} ProseWrap
* @typedef {{ next?: WordNode | null, previous?: WordNode | null }}
* AdjacentNodes Nodes adjacent to a `whitespace` node. Are always of type
* `word`.
*/
const SINGLE_LINE_NODE_TYPES = new Set(["tableCell", "link", "wikiLink"]);
/**
* A line break between a character from this set and CJ can be converted to a
* space. Includes only ASCII punctuation marks for now.
*/
const lineBreakBetweenTheseAndCJConvertsToSpace = new Set(
"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~",
);
/**
* Determine the preferred style of spacing between Chinese or Japanese and non-CJK
* characters in the parent `sentence` node.
*
* @param {AstPath} path
* @returns {boolean} `true` if Space tends to be inserted between CJ and
* non-CJK, `false` otherwise.
*/
function isInSentenceWithCJSpaces({ parent: sentenceNode }) {
if (sentenceNode.usesCJSpaces === undefined) {
const stats = { " ": 0, "": 0 };
const { children } = sentenceNode;
for (let i = 1; i < children.length - 1; ++i) {
const node = children[i];
if (
node.type === "whitespace" &&
(node.value === " " || node.value === "")
) {
const previousKind = children[i - 1].kind;
const nextKind = children[i + 1].kind;
if (
(previousKind === KIND_CJ_LETTER && nextKind === KIND_NON_CJK) ||
(previousKind === KIND_NON_CJK && nextKind === KIND_CJ_LETTER)
) {
++stats[node.value];
}
}
}
// Inject a property to cache the result.
sentenceNode.usesCJSpaces = stats[" "] > stats[""];
}
return sentenceNode.usesCJSpaces;
}
/**
* Check whether the given `"\n"` node can be converted to a space.
*
* For example, if you would like to squash English text
*
* "You might want\nto use Prettier."
*
* into a single line, you would replace `"\n"` with `" "`:
*
* "You might want to use Prettier."
*
* However, Chinese and Japanese don't use U+0020 Space to divide words, so line
* breaks shouldn't be replaced with spaces for those languages.
*
* PRs are welcome to support line breaking rules for other languages.
*
* @param {AstPath} path
* @param {boolean} isLink
* @returns {boolean}
*/
function lineBreakCanBeConvertedToSpace(path, isLink) {
if (isLink) {
return true;
}
/** @type {AdjacentNodes} */
const { previous, next } = path;
// e.g. " \nletter"
if (!previous || !next) {
return true;
}
const previousKind = previous.kind;
const nextKind = next.kind;
if (
// "\n" between non-CJK or Korean characters always can be converted to a
// space. Korean Hangul simulates Latin words. See
// https://github.com/prettier/prettier/issues/6516
(isNonCJKOrKoreanLetter(previousKind) &&
isNonCJKOrKoreanLetter(nextKind)) ||
// Han & Hangul: same way preferred
(previousKind === KIND_K_LETTER && nextKind === KIND_CJ_LETTER) ||
(nextKind === KIND_K_LETTER && previousKind === KIND_CJ_LETTER)
) {
return true;
}
// Do not convert \n to a space:
if (
// around CJK punctuation
previousKind === KIND_CJK_PUNCTUATION ||
nextKind === KIND_CJK_PUNCTUATION ||
// between CJ
(previousKind === KIND_CJ_LETTER && nextKind === KIND_CJ_LETTER)
) {
return false;
}
// The rest of this function deals only with line breaks between CJ and
// non-CJK characters.
// Convert a line break between CJ and certain non-letter characters (e.g.
// ASCII punctuation) to a space.
//
// E.g. :::\n句子句子句子\n::: → ::: 句子句子句子 :::
//
// Note: line breaks like "(\n句子句子\n)" or "句子\n." are suppressed in
// `isBreakable(...)`.
if (
lineBreakBetweenTheseAndCJConvertsToSpace.has(next.value[0]) ||
lineBreakBetweenTheseAndCJConvertsToSpace.has(previous.value.at(-1))
) {
return true;
}
// Converting a line break between CJ and non-ASCII punctuation to a space is
// undesired in many cases. PRs are welcome to fine-tune this logic.
//
// Examples where \n must not be converted to a space:
//
// 1. "〜" (U+301C, belongs to Pd) in
//
// "ア〜\nエの中から1つ選べ。"
//
// 2. "…" (U+2026, belongs to Po) in
//
// "これはひどい……\nなんと汚いコミットログなんだ……"
if (previous.hasTrailingPunctuation || next.hasLeadingPunctuation) {
return false;
}
// If the sentence uses the style with spaces between CJ and non-CJK, "\n" can
// be converted to a space.
return isInSentenceWithCJSpaces(path);
}
/**
* @param {WordKind | undefined} kind
* @returns {boolean} `true` if `kind` is Korean letter or non-CJK
*/
function isNonCJKOrKoreanLetter(kind) {
return kind === KIND_NON_CJK || kind === KIND_K_LETTER;
}
/**
* Check whether whitespace can be printed as a line break.
*
* @param {AstPath} path
* @param {WhitespaceValue} value
* @param {ProseWrap} proseWrap
* @param {boolean} isLink
* @returns {boolean}
*/
function isBreakable(path, value, proseWrap, isLink, options) {
if (
proseWrap !== "always" ||
path.hasAncestor(
(node) =>
SINGLE_LINE_NODE_TYPES.has(node.type) ||
(node.type === "heading" &&
(options.parser === "mdx" || !isSetextHeading(node))),
)
) {
return false;
}
if (isLink) {
return value !== "";
}
/** @type {AdjacentNodes} */
const { previous, next } = path;
// [1]: We will make a breaking change to the rule to convert spaces between
// a Chinese or Japanese character and another character in the future.
// Such a space must have been always interchangeable with a line break.
// https://wpt.fyi/results/css/css-text/line-breaking?label=master&label=experimental&aligned&q=segment-break-transformation-rules-
// [2]: we should not break lines even between Chinese/Japanese characters because Chrome & Safari replaces "\n" between such characters with " " now.
// [3]: Hangul (Korean) must simulate Latin words; see https://github.com/prettier/prettier/issues/6516
// [printable][""][Hangul] & vice versa => Don't break
// [printable][\n][Hangul] will be interchangeable to [printable][" "][Hangul] in the future
// (will be compatible with Firefox's behavior)
if (!previous || !next) {
// empty side is Latin ASCII symbol (e.g. *, [, ], or `)
// value is " " or "\n" (not "")
// [1] & [2]? No, it's the only exception because " " & "\n" have been always interchangeable only here
return true;
}
if (value === "") {
// [1] & [2] & [3]
// At least either of previous or next is non-Latin (=CJK)
return false;
}
if (
// See the same product terms as the following in lineBreakCanBeConvertedToSpace
// The behavior is consistent between browsers and Prettier in that line breaks between Korean and Chinese/Japanese letters are equivalent to spaces.
// Currently, [CJK punctuation][\n][Hangul] is interchangeable to [CJK punctuation][""][Hangul],
// but this is not compatible with Firefox's behavior.
// Will be changed to [CJK punctuation][" "][Hangul] in the future
(previous.kind === KIND_K_LETTER && next.kind === KIND_CJ_LETTER) ||
(next.kind === KIND_K_LETTER && previous.kind === KIND_CJ_LETTER)
) {
return true;
}
// [1] & [2]
if (previous.isCJ || next.isCJ) {
return false;
}
return true;
}
/**
* @param {AstPath} path
* @param {WhitespaceValue} value
* @param {ProseWrap} proseWrap
* @param {boolean} isLink Special mode of (un)wrapping that preserves the
* @param {any} options Special mode of (un)wrapping that preserves the
* normalized form of link labels. https://spec.commonmark.org/0.30/#matches
*/
function printWhitespace(path, value, proseWrap, isLink, options) {
if (
(proseWrap === "preserve" || isLineBreakAmbiguous(path)) &&
value === "\n"
) {
return hardline;
}
const canBeSpace =
value === " " ||
(value === "\n" && lineBreakCanBeConvertedToSpace(path, isLink));
if (isBreakable(path, value, proseWrap, isLink, options)) {
return canBeSpace ? line : softline;
}
return canBeSpace ? " " : "";
}
function printWhitespaceNode(path, options) {
return printWhitespace(
path,
path.node.value,
shouldPreventBreak(path, options) ? "never" : options.proseWrap,
false,
options,
);
}
/**
* Determines if a line break around a CJ character can be handled differently by browsers.
*
* If `true`, since Prettier is just a formatter, which should not change the semantics of the code, it should leave such an ambiguous line break as-is
* and let the browser decide how to interpret it regardless of `proseWrap`.
*
* Once all browsers behave consistently in the future, this function will no longer be needed.
*
* @param {AstPath} path
* @returns {boolean} `true` if a line break around a CJ character are removed by some browsers but converted to a space by others.
*/
function isLineBreakAmbiguous(path) {
const { previous, next } = path;
// All browsers treat a line break between a CJ letter and a non-CJ character as
// equivalent to a space. However, Prettier v3 intentionally decided that, in
// some cases, such a line break is equivalent to removing it without
// converting it to a space, so in v3 we leave it as-is instead of converting it
// to a space or removing it.
// See also: https://github.com/prettier/prettier/pull/16805
return (
(Boolean(previous?.isCJ) || Boolean(next?.isCJ)) &&
// A line break between a CJ letter and a Korean letter has already been interchangeable with a space since Prettier v3.0.0.
// A line break between a CJK punctuation character and a Korean letter is handled differently depending on the browser and the content language.
!(
(previous.kind === KIND_K_LETTER && next.kind === KIND_CJ_LETTER) ||
(next.kind === KIND_K_LETTER && previous.kind === KIND_CJ_LETTER)
)
);
}
function shouldPreventBreak(path, options) {
const { proseWrap } = options;
if (proseWrap === "never") {
return true;
}
if (path.isLast) {
return false;
}
if (
// leading char that may cause different syntax
/^>|^(?:[*+-]|#{1,6}|\d+[).])$/.test(path.next.value) &&
// Avoid https://github.com/prettier/prettier/issues/18861
!hasFakeWhitespaceAfterNextToken(path) &&
// Next fake setext h2 `-` is going to be escaped, so no need to join adjacent words
!(proseWrap === "preserve" && isNextTokenFakeSetextH2Line(path))
) {
return true;
}
return false;
}
/**
* @param {AstPath} path
* @returns {boolean}
*/
function hasFakeWhitespaceAfterNextToken(path) {
const afterNext = path.siblings[path.index + 2];
return afterNext?.type === "whitespace" && afterNext.value === "";
}
/**
* @param {AstPath} path
* @returns {boolean}
*/
function isNextTokenFakeSetextH2Line(path) {
if (!isNewLine(path.node) || path.next.value !== "-") {
return false;
}
const afterNext = path.siblings[path.index + 2];
return !afterNext || isNewLine(afterNext);
}
export { printWhitespace, printWhitespaceNode };