feat(markdown): support CJK and emoji (#3026)

* refactor: extract `splitText`

* fix: respcet CJK width in table

* test: add failing test

* fix: support emoji

* test: add failing test

* feat: support CJK character

* feat: enable adding whitespace between non-CJK/CJK-character by default

* fix: do not print softline at node that is sensitive to its identifier

* fix: treat full-width whitespace as CJK punctuation

* disallow leading/trailing full-width whitespace

* feat: remove `--split-cjk-text` option and enable it by default

* refactor: simplify regex and remove unnecessary `g` flag
master
Ika 2017-10-14 23:57:31 -05:00 committed by GitHub
parent c3b965145b
commit c27cc7ff45
19 changed files with 271 additions and 23 deletions

View File

@ -18,9 +18,11 @@
"babylon": "7.0.0-beta.23",
"camelcase": "4.1.0",
"chalk": "2.1.0",
"cjk-regex": "1.0.1",
"cosmiconfig": "3.1.0",
"dashify": "0.2.2",
"diff": "3.2.0",
"emoji-regex": "6.5.1",
"escape-string-regexp": "1.0.5",
"esutils": "2.0.2",
"flow-parser": "0.51.0",

View File

@ -1,7 +1,6 @@
"use strict";
const stringWidth = require("string-width");
const util = require("./util");
const docBuilders = require("./doc-builders");
const concat = docBuilders.concat;
const fill = docBuilders.fill;
@ -68,7 +67,7 @@ function fits(next, restCommands, width, mustBeFlat) {
const doc = x[2];
if (typeof doc === "string") {
width -= stringWidth(doc);
width -= util.getStringWidth(doc);
} else {
switch (doc.type) {
case "concat":
@ -155,7 +154,7 @@ function printDocToString(doc, options) {
if (typeof doc === "string") {
out.push(doc);
pos += stringWidth(doc);
pos += util.getStringWidth(doc);
} else {
switch (doc.type) {
case "cursor":

View File

@ -3,6 +3,7 @@
const remarkFrontmatter = require("remark-frontmatter");
const remarkParse = require("remark-parse");
const unified = require("unified");
const util = require("./util");
/**
* based on [MDAST](https://github.com/syntax-tree/mdast) with following modifications:
@ -145,15 +146,7 @@ function splitText() {
return {
type: "sentence",
position: node.position,
children: value
.split(/(\s+)/g)
.map(
(text, index) =>
index % 2 === 0
? { type: "word", value: text }
: { type: "whitespace", value: " " }
)
.filter(node => node.value !== "")
children: util.splitText(value)
};
});
}

View File

@ -6,6 +6,7 @@ const concat = docBuilders.concat;
const join = docBuilders.join;
const line = docBuilders.line;
const hardline = docBuilders.hardline;
const softline = docBuilders.softline;
const fill = docBuilders.fill;
const align = docBuilders.align;
const docPrinter = require("./doc-printer");
@ -42,11 +43,17 @@ function genericPrint(path, options, print) {
if (shouldRemainTheSameContent(path)) {
return concat(
options.originalText
.slice(node.position.start.offset, node.position.end.offset)
.split(/(\s+)/g)
.map((text, index) => (index % 2 === 0 ? text : line))
.filter(doc => doc !== "")
util
.splitText(
options.originalText.slice(
node.position.start.offset,
node.position.end.offset
)
)
.map(
node =>
node.type === "word" ? node.value : node.value === "" ? "" : line
)
);
}
@ -68,7 +75,9 @@ function genericPrint(path, options, print) {
.replace(/(^|[^\\])\*/g, "$1\\*") // escape all unescaped `*` and `_`
.replace(/\b(^|[^\\])_\b/g, "$1\\_"); // `1_2_3` is not considered emphasis
case "whitespace":
return getAncestorNode(path, SINGLE_LINE_NODE_TYPES) ? " " : line;
return getAncestorNode(path, SINGLE_LINE_NODE_TYPES)
? node.value === "" ? "" : " "
: node.value === "" ? softline : line;
case "emphasis": {
const parentNode = path.getParentNode();
const index = parentNode.children.indexOf(node);
@ -334,7 +343,7 @@ function printTable(path, options, print) {
const columnMaxWidths = contents.reduce(
(currentWidths, rowContents) =>
currentWidths.map((width, columnIndex) =>
Math.max(width, rowContents[columnIndex].length)
Math.max(width, util.getStringWidth(rowContents[columnIndex]))
),
contents[0].map(() => 3) // minimum width = 3 (---, :--, :-:, --:)
);
@ -388,15 +397,15 @@ function printTable(path, options, print) {
}
function alignLeft(text, width) {
return concat([text, " ".repeat(width - text.length)]);
return concat([text, " ".repeat(width - util.getStringWidth(text))]);
}
function alignRight(text, width) {
return concat([" ".repeat(width - text.length), text]);
return concat([" ".repeat(width - util.getStringWidth(text)), text]);
}
function alignCenter(text, width) {
const spaces = width - text.length;
const spaces = width - util.getStringWidth(text);
const left = Math.floor(spaces / 2);
const right = spaces - left;
return concat([" ".repeat(left), text, " ".repeat(right)]);

View File

@ -1,7 +1,13 @@
"use strict";
const stringWidth = require("string-width");
const emojiRegex = require("emoji-regex")();
const escapeStringRegexp = require("escape-string-regexp");
const getCjkRegex = require("cjk-regex");
const cjkRegex = getCjkRegex();
const cjkPunctuationRegex = getCjkRegex.punctuations();
function isExportDeclaration(node) {
if (node) {
switch (node.type) {
@ -636,7 +642,104 @@ function mapDoc(doc, callback) {
return callback(doc);
}
/**
* split text into whitespaces and words
* @param {string} text
* @return {Array<{ type: "whitespace", value: " " | "" } | { type: "word", value: string }>}
*/
function splitText(text) {
const KIND_NON_CJK = "non-cjk";
const KIND_CJK_CHARACTER = "cjk-character";
const KIND_CJK_PUNCTUATION = "cjk-punctuation";
const nodes = [];
text
.replace(
new RegExp(`(${cjkRegex.source})\n(${cjkRegex.source})`, "g"),
"$1$2"
)
// `\s` but exclude full-width whitspace (`\u3000`)
.split(/([^\S\u3000]+)/)
.forEach((token, index, tokens) => {
// whitespace
if (index % 2 === 1) {
nodes.push({ type: "whitespace", value: " " });
return;
}
// word separated by whitespace
if ((index === 0 || index === tokens.length - 1) && token === "") {
return;
}
token
.split(new RegExp(`(${cjkRegex.source})`))
.forEach((innerToken, innerIndex, innerTokens) => {
if (
(innerIndex === 0 || innerIndex === innerTokens.length - 1) &&
innerToken === ""
) {
return;
}
// non-CJK word
if (innerIndex % 2 === 0) {
if (innerToken !== "") {
appendNode({
type: "word",
value: innerToken,
kind: KIND_NON_CJK
});
}
return;
}
// CJK character
const kind = cjkPunctuationRegex.test(innerToken)
? KIND_CJK_PUNCTUATION
: KIND_CJK_CHARACTER;
appendNode({ type: "word", value: innerToken, kind });
});
});
return nodes;
function appendNode(node) {
const lastNode = nodes[nodes.length - 1];
if (lastNode && lastNode.type === "word") {
if (isBetween(KIND_NON_CJK, KIND_CJK_CHARACTER)) {
nodes.push({ type: "whitespace", value: " " });
} else if (
!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 getStringWidth(text) {
// emojis are considered 2-char width for consistency
// see https://github.com/sindresorhus/string-width/issues/11
// for the reason why not implemented in `string-width`
return stringWidth(text.replace(emojiRegex, " "));
}
module.exports = {
getStringWidth,
splitText,
mapDoc,
getMaxContinuousCount,
getPrecedence,

View File

@ -1,5 +1,12 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`cjk.md 1`] = `
[這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落][]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落][]
`;
exports[`collapsed.md 1`] = `
[hello][]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -0,0 +1 @@
[這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落][]

View File

@ -1,5 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`cjk.md 1`] = `
這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落
這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph
全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長
很長的段落
這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph
全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白
全  形 空白全  形 空白全  形 空白
`;
exports[`inline-nodes.md 1`] = `
It removes all original styling[*](#styling-footnote) and ensures that all outputted code conforms to a consistent style. (See this [blog post](http://jlongster.com/A-Prettier-Formatter))
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -0,0 +1,5 @@
這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落
這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph
全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白

View File

@ -0,0 +1,58 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`cjk.md 1`] = `
這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落
全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白
空白全形空白全形空白全形空白 空白全形空白全形空白全形空白 空白全形空白全形空白全形空白 空白全形空白全形空白全形空白
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長
很長的段落
全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白
全  形 空白全  形 空白
空白全形空白全形空白全形空白 空白全形空白全形空白全形空白 空白全形空白全形空白
全形空白 空白全形空白全形空白全形空白
`;
exports[`link.md 1`] = `
[這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落][]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
[這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落][]
`;
exports[`mixed.md 1`] = `
這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph
`;
exports[`space.md 1`] = `
這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段
Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著
中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個
English 混合著中文的一段 Paragraph
`;

View File

@ -0,0 +1,5 @@
這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落
全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白全  形 空白
空白全形空白全形空白全形空白 空白全形空白全形空白全形空白 空白全形空白全形空白全形空白 空白全形空白全形空白全形空白

View File

@ -0,0 +1 @@
run_spec(__dirname, { parser: "markdown" });

View File

@ -0,0 +1 @@
[這是一段很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長很長的段落][]

View File

@ -0,0 +1 @@
這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph這是一個English混合著中文的一段Paragraph

View File

@ -0,0 +1 @@
這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph這是一個 English 混合著中文的一段 Paragraph

View File

@ -11,6 +11,28 @@ exports[`align.md 1`] = `
`;
exports[`cjk.md 1`] = `
| abc | def | ghi |
| --- | --- | --- |
| 第一欄 | 第二欄 | 第三欄 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| abc | def | ghi |
| ------ | ------ | ------ |
| 第一欄 | 第二欄 | 第三欄 |
`;
exports[`emoji.md 1`] = `
| abc | def | ghi |
| --- | --- | --- |
| 👍👍👍 | 👍👍👍 | 👍👍👍 |
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| abc | def | ghi |
| ------ | ------ | ------ |
| 👍👍👍 | 👍👍👍 | 👍👍👍 |
`;
exports[`escape.md 1`] = `
| a | b | c |
|:--|:-:|--:|

View File

@ -0,0 +1,3 @@
| abc | def | ghi |
| --- | --- | --- |
| 第一欄 | 第二欄 | 第三欄 |

View File

@ -0,0 +1,3 @@
| abc | def | ghi |
| --- | --- | --- |
| 👍👍👍 | 👍👍👍 | 👍👍👍 |

View File

@ -1060,6 +1060,10 @@ circular-json@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/circular-json/-/circular-json-0.3.1.tgz#be8b36aefccde8b3ca7aa2d6afc07a37242c0d2d"
cjk-regex@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/cjk-regex/-/cjk-regex-1.0.1.tgz#9bee3087e5e4e943549ace766b5d95a9b700dd41"
cli-cursor@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
@ -1395,6 +1399,10 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.0"
emoji-regex@6.5.1:
version "6.5.1"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-6.5.1.tgz#9baea929b155565c11ea41c6626eaa65cef992c2"
emojis-list@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389"