diff --git a/.github/dictionary.txt b/.github/dictionary.txt index d2cd70e8..8cd8589c 100644 --- a/.github/dictionary.txt +++ b/.github/dictionary.txt @@ -21,6 +21,7 @@ changelog Changelog changelogs Changelogs +CJK CLI coc-markdownlint CodeQL @@ -67,6 +68,7 @@ mdl MDN minified MkDocs +monospaced MSBuild namespace Neovim diff --git a/doc-build/md060.md b/doc-build/md060.md index 4adc754e..cf6912c9 100644 --- a/doc-build/md060.md +++ b/doc-build/md060.md @@ -1,9 +1,9 @@ This rule is triggered when the column separators of a [GitHub Flavored Markdown table][gfm-table-060] are used inconsistently. -This rule recognizes three table column styles based on popular use: +This rule recognizes three table column styles based on popular use. -Style `aligned` looks the most like a table: +Style `aligned` ensures table delimiters are vertically aligned: ```markdown | Character | Meaning | @@ -12,7 +12,16 @@ Style `aligned` looks the most like a table: | N | No | ``` -Style `compact` uses a single space to pad cell content: +The `aligned` style ignores cell content, so the following is also valid: + +```markdown +| Character | Meaning | +|-----------|---------| +| Y | Yes | +| N | No | +``` + +Style `compact` avoids extra padding with a single space around cell content: ```markdown | Character | Meaning | @@ -21,7 +30,7 @@ Style `compact` uses a single space to pad cell content: | N | No | ``` -Style `tight` uses no padding for cell content: +Style `tight` uses no padding at all for cell content: ```markdown |Character|Meaning| @@ -31,16 +40,18 @@ Style `tight` uses no padding for cell content: ``` When this rule's `style` parameter is set to `aligned`, `compact`, or `tight`, -every table must match the corresponding pattern and errors will be reported for -any violations. By default, or when the `any` style is used, each table is -analyzed to see if it satisfies any supported style. If so, no errors are -reported. If not, errors are be reported for whichever style would produce the -*fewest* errors (i.e., whichever style is the closest match). +every table must match the corresponding pattern and any violations will be +reported. By default, or when the `any` style is used, each table is analyzed to +see if it satisfies any supported style. If so, no violations are reported. If +not, violations are be reported for whichever style would produce the *fewest* +issues (i.e., whichever style is the closest match). -Note: Pipe alignment for the `aligned` style is based on character count, so -wide characters and multi-character encodings can produce unexpected results. -The following table is correctly aligned based on character count, though some -editors render the emoji wider: +Note: Delimiter placement for the `aligned` style is based on visual appearance +and not character count. Because editors typically render [emoji][emoji] and +[CJK characters][cjk-characters] at *twice* the width of +[Latin characters][latin-script], this rule takes that into account for tables +using the `aligned` style. The following table is correctly formatted and will +appear aligned in most editors and monospaced fonts: @@ -48,12 +59,15 @@ editors render the emoji wider: ```markdown | Response | Emoji | | -------- | ----- | -| Yes | ✅ | -| No | ❎ | +| Yes | ✅ | +| No | ❎ | ``` Rationale: Consistent formatting makes it easier to understand a document. +[cjk-characters]: https://en.wikipedia.org/wiki/CJK_characters +[emoji]: https://en.wikipedia.org/wiki/Emoji [gfm-table-060]: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables +[latin-script]: https://en.wikipedia.org/wiki/Latin_script diff --git a/doc/Rules.md b/doc/Rules.md index e938844a..042b7dbe 100644 --- a/doc/Rules.md +++ b/doc/Rules.md @@ -2700,9 +2700,9 @@ Parameters: This rule is triggered when the column separators of a [GitHub Flavored Markdown table][gfm-table-060] are used inconsistently. -This rule recognizes three table column styles based on popular use: +This rule recognizes three table column styles based on popular use. -Style `aligned` looks the most like a table: +Style `aligned` ensures table delimiters are vertically aligned: ```markdown | Character | Meaning | @@ -2711,7 +2711,16 @@ Style `aligned` looks the most like a table: | N | No | ``` -Style `compact` uses a single space to pad cell content: +The `aligned` style ignores cell content, so the following is also valid: + +```markdown +| Character | Meaning | +|-----------|---------| +| Y | Yes | +| N | No | +``` + +Style `compact` avoids extra padding with a single space around cell content: ```markdown | Character | Meaning | @@ -2720,7 +2729,7 @@ Style `compact` uses a single space to pad cell content: | N | No | ``` -Style `tight` uses no padding for cell content: +Style `tight` uses no padding at all for cell content: ```markdown |Character|Meaning| @@ -2730,16 +2739,18 @@ Style `tight` uses no padding for cell content: ``` When this rule's `style` parameter is set to `aligned`, `compact`, or `tight`, -every table must match the corresponding pattern and errors will be reported for -any violations. By default, or when the `any` style is used, each table is -analyzed to see if it satisfies any supported style. If so, no errors are -reported. If not, errors are be reported for whichever style would produce the -*fewest* errors (i.e., whichever style is the closest match). +every table must match the corresponding pattern and any violations will be +reported. By default, or when the `any` style is used, each table is analyzed to +see if it satisfies any supported style. If so, no violations are reported. If +not, violations are be reported for whichever style would produce the *fewest* +issues (i.e., whichever style is the closest match). -Note: Pipe alignment for the `aligned` style is based on character count, so -wide characters and multi-character encodings can produce unexpected results. -The following table is correctly aligned based on character count, though some -editors render the emoji wider: +Note: Delimiter placement for the `aligned` style is based on visual appearance +and not character count. Because editors typically render [emoji][emoji] and +[CJK characters][cjk-characters] at *twice* the width of +[Latin characters][latin-script], this rule takes that into account for tables +using the `aligned` style. The following table is correctly formatted and will +appear aligned in most editors and monospaced fonts: @@ -2747,15 +2758,18 @@ editors render the emoji wider: ```markdown | Response | Emoji | | -------- | ----- | -| Yes | ✅ | -| No | ❎ | +| Yes | ✅ | +| No | ❎ | ``` Rationale: Consistent formatting makes it easier to understand a document. +[cjk-characters]: https://en.wikipedia.org/wiki/CJK_characters +[emoji]: https://en.wikipedia.org/wiki/Emoji [gfm-table-060]: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables +[latin-script]: https://en.wikipedia.org/wiki/Latin_script @@ -59,12 +70,15 @@ editors render the emoji wider: ```markdown | Response | Emoji | | -------- | ----- | -| Yes | ✅ | -| No | ❎ | +| Yes | ✅ | +| No | ❎ | ``` Rationale: Consistent formatting makes it easier to understand a document. +[cjk-characters]: https://en.wikipedia.org/wiki/CJK_characters +[emoji]: https://en.wikipedia.org/wiki/Emoji [gfm-table-060]: https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables +[latin-script]: https://en.wikipedia.org/wiki/Latin_script diff --git a/lib/md060.mjs b/lib/md060.mjs index f6b95785..3675b847 100644 --- a/lib/md060.mjs +++ b/lib/md060.mjs @@ -2,10 +2,37 @@ import { filterByTypes } from "../helpers/micromark-helpers.cjs"; import { filterByTypesCached } from "./cache.mjs"; +import stringWidth from "string-width"; /** @typedef {import("micromark-extension-gfm-table")} */ +/** @typedef {import("markdownlint").MicromarkToken} MicromarkToken */ /** @typedef {import("markdownlint").RuleOnErrorInfo} RuleOnErrorInfo */ +/** + * @typedef Column + * @property {number} actual Actual column (1-based). + * @property {number} effective Effective column (1-based). + */ + +/** + * Gets a list of table cell divider columns. + * + * @param {readonly string[]} lines File/string lines. + * @param {MicromarkToken} row Micromark row token. + * @returns {Column[]} Divider columns. + */ +function getTableDividerColumns(lines, row) { + return filterByTypes( + row.children, + [ "tableCellDivider" ] + ).map( + (divider) => ({ + "actual": divider.startColumn, + "effective": stringWidth(lines[row.startLine - 1].slice(0, divider.startColumn - 1)) + }) + ); +} + /** * Adds a RuleOnErrorInfo object to a list of RuleOnErrorInfo objects. * @@ -44,13 +71,13 @@ export default { /** @type {RuleOnErrorInfo[]} */ const errorsIfAligned = []; if (styleAlignedAllowed) { - const headingDividerColumns = filterByTypes(headingRow.children, [ "tableCellDivider" ]).map((divider) => divider.startColumn); + const headingDividerColumns = getTableDividerColumns(params.lines, headingRow); for (const row of rows.slice(1)) { - const remainingHeadingDividerColumns = new Set(headingDividerColumns); - const rowDividerColumns = filterByTypes(row.children, [ "tableCellDivider" ]).map((divider) => divider.startColumn); + const remainingHeadingDividerColumns = new Set(headingDividerColumns.map((column) => column.effective)); + const rowDividerColumns = getTableDividerColumns(params.lines, row); for (const dividerColumn of rowDividerColumns) { - if ((remainingHeadingDividerColumns.size > 0) && !remainingHeadingDividerColumns.delete(dividerColumn)) { - addError(errorsIfAligned, row.startLine, dividerColumn, "Table pipe does not align with heading for style \"aligned\""); + if ((remainingHeadingDividerColumns.size > 0) && !remainingHeadingDividerColumns.delete(dividerColumn.effective)) { + addError(errorsIfAligned, row.startLine, dividerColumn.actual, "Table pipe does not align with heading for style \"aligned\""); } } } diff --git a/package.json b/package.json index ad7865cd..3ebe581b 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,8 @@ "micromark-extension-gfm-footnote": "2.1.0", "micromark-extension-gfm-table": "2.1.1", "micromark-extension-math": "3.1.0", - "micromark-util-types": "2.0.2" + "micromark-util-types": "2.0.2", + "string-width": "8.1.0" }, "devDependencies": { "@eslint/js": "9.39.1", diff --git a/test/markdownlint-test.mjs b/test/markdownlint-test.mjs index f53bf9a0..bc2825dc 100644 --- a/test/markdownlint-test.mjs +++ b/test/markdownlint-test.mjs @@ -1101,7 +1101,7 @@ test("readme", async(t) => { }); test("validateJsonUsingConfigSchemaStrict", async(t) => { - t.plan(219); + t.plan(220); // @ts-ignore const ajv = new Ajv(ajvOptions); const validateSchemaStrict = ajv.compile(configSchemaStrict); diff --git a/test/snapshots/markdownlint-test-repos-small.mjs.md b/test/snapshots/markdownlint-test-repos-small.mjs.md index 35aa3086..c6faaaae 100644 --- a/test/snapshots/markdownlint-test-repos-small.mjs.md +++ b/test/snapshots/markdownlint-test-repos-small.mjs.md @@ -556,8 +556,6 @@ Generated by [AVA](https://avajs.dev). test-repos/v8-v8-dev/src/features/promise-combinators.md:23 error MD058/blanks-around-tables Tables should be surrounded by blank lines [Context: "| name ..."]␊ test-repos/v8-v8-dev/src/features/promise-combinators.md:28 error MD058/blanks-around-tables Tables should be surrounded by blank lines [Context: "| [\`Promise.any\`](#promise.any..."]␊ test-repos/v8-v8-dev/src/features/promise-combinators.md:25:172 error MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"]␊ - test-repos/v8-v8-dev/src/features/promise-combinators.md:26:162 error MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"]␊ - test-repos/v8-v8-dev/src/features/promise-combinators.md:27:162 error MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"]␊ test-repos/v8-v8-dev/src/features/promise-combinators.md:28:172 error MD060/table-column-style Table column style [Table pipe does not align with heading for style "aligned"]␊ test-repos/v8-v8-dev/src/features/promise-finally.md:82:1 error MD033/no-inline-html Inline HTML [Element: feature-support]␊ test-repos/v8-v8-dev/src/features/regexp-match-indices.md:134:1 error MD033/no-inline-html Inline HTML [Element: feature-support]␊ diff --git a/test/snapshots/markdownlint-test-repos-small.mjs.snap b/test/snapshots/markdownlint-test-repos-small.mjs.snap index d0db4873..67268e50 100644 Binary files a/test/snapshots/markdownlint-test-repos-small.mjs.snap and b/test/snapshots/markdownlint-test-repos-small.mjs.snap differ diff --git a/test/snapshots/markdownlint-test-scenarios.mjs.md b/test/snapshots/markdownlint-test-scenarios.mjs.md index da599a58..454a836b 100644 --- a/test/snapshots/markdownlint-test-scenarios.mjs.md +++ b/test/snapshots/markdownlint-test-scenarios.mjs.md @@ -67803,7 +67803,7 @@ Generated by [AVA](https://avajs.dev). errorContext: null, errorDetail: 'Table pipe does not align with heading for style "aligned"', errorRange: [ - 19, + 20, 1, ], fixInfo: null, @@ -67857,9 +67857,9 @@ Generated by [AVA](https://avajs.dev). ␊ | Response | Emoji |␊ | -------- | ----- |␊ - | Yes | ✅ |␊ - | No | ❎ |␊ - | Oops | ❌ |␊ + | Yes | ✅ |␊ + | No | ❎ |␊ + | Oops | ❌ |␊ ␊ {MD060:-2}␊ ␊ @@ -72533,6 +72533,72 @@ Generated by [AVA](https://avajs.dev). `, } +## table-column-style-wide-characters.md + +> Snapshot 1 + + { + errors: [], + fixed: `# Table Column Style - Wide Characters␊ + ␊ + ## Emoji␊ + ␊ + | AB | CD |␊ + | -- | -- |␊ + | EF | GH |␊ + | ✅ | KL |␊ + | MN | ✅ |␊ + | ✅ | ✅ |␊ + ␊ + | ✅ | CD |␊ + | -- | -- |␊ + | EF | GH |␊ + | ✅ | KL |␊ + | MN | ✅ |␊ + | ✅ | ✅ |␊ + ␊ + | AB | ✅ |␊ + | -- | -- |␊ + | EF | GH |␊ + | ✅ | KL |␊ + | MN | ✅ |␊ + | ✅ | ✅ |␊ + ␊ + | ✅ | ✅ |␊ + | -- | -- |␊ + | EF | GH |␊ + | ✅ | KL |␊ + | MN | ✅ |␊ + | ✅ | ✅ |␊ + ␊ + ## Hello World␊ + ␊ + | Language | Translation |␊ + |---------------------|----------------|␊ + | Emoji | 👋🌎 |␊ + | Portuguese (Brazil) | Olá mundo |␊ + | Turkish | Merhaba dünya |␊ + | Chinese (Mandarin) | 你好,世界 |␊ + | Japanese | こんにちは世界 |␊ + | Korean | 안녕 세상 |␊ + ␊ + ## ANSI Escape Codes␊ + ␊ + | Style | Escape codes |␊ + | --------- | ------------------------------------------------------- |␊ + | Bold | \\u001B[1m大胆な\\u001B[22m |␊ + | Underline | \\u001B[4mUnderline\\u001B[0m |␊ + | Link | \\u001B]8;;https://example.com\\u0007Link\\u001B]8;;\\u0007 |␊ + ␊ + ␊ + `, + } + ## table-content-with-issues.md > Snapshot 1 diff --git a/test/snapshots/markdownlint-test-scenarios.mjs.snap b/test/snapshots/markdownlint-test-scenarios.mjs.snap index 38d5fd73..f1350fa3 100644 Binary files a/test/snapshots/markdownlint-test-scenarios.mjs.snap and b/test/snapshots/markdownlint-test-scenarios.mjs.snap differ diff --git a/test/table-column-style-emoji.md b/test/table-column-style-emoji.md index 58120159..7fc0ffc5 100644 --- a/test/table-column-style-emoji.md +++ b/test/table-column-style-emoji.md @@ -4,9 +4,9 @@ | Response | Emoji | | -------- | ----- | -| Yes | ✅ | -| No | ❎ | -| Oops | ❌ | +| Yes | ✅ | +| No | ❎ | +| Oops | ❌ | {MD060:-2} diff --git a/test/table-column-style-wide-characters.md b/test/table-column-style-wide-characters.md new file mode 100644 index 00000000..abb30238 --- /dev/null +++ b/test/table-column-style-wide-characters.md @@ -0,0 +1,57 @@ +# Table Column Style - Wide Characters + +## Emoji + +| AB | CD | +| -- | -- | +| EF | GH | +| ✅ | KL | +| MN | ✅ | +| ✅ | ✅ | + +| ✅ | CD | +| -- | -- | +| EF | GH | +| ✅ | KL | +| MN | ✅ | +| ✅ | ✅ | + +| AB | ✅ | +| -- | -- | +| EF | GH | +| ✅ | KL | +| MN | ✅ | +| ✅ | ✅ | + +| ✅ | ✅ | +| -- | -- | +| EF | GH | +| ✅ | KL | +| MN | ✅ | +| ✅ | ✅ | + +## Hello World + +| Language | Translation | +|---------------------|----------------| +| Emoji | 👋🌎 | +| Portuguese (Brazil) | Olá mundo | +| Turkish | Merhaba dünya | +| Chinese (Mandarin) | 你好,世界 | +| Japanese | こんにちは世界 | +| Korean | 안녕 세상 | + +## ANSI Escape Codes + +| Style | Escape codes | +| --------- | ------------------------------------------------------- | +| Bold | \u001B[1m大胆な\u001B[22m | +| Underline | \u001B[4mUnderline\u001B[0m | +| Link | \u001B]8;;https://example.com\u0007Link\u001B]8;;\u0007 | + +