Skip to content

Commit f2da73e

Browse files
committed
fix(no-tables): sentence-split generated lists
Apply shared one-sentence-per-line formatting during table-to-list fixes when the coordination option is enabled, preserving list-body indentation for top-level and nested entries. Document and configure the option, and cover fixture, JavaScript, Python functional, and disabled behavior. Replace the sandbox-incompatible optional-dependency subprocess test with an isolated in-process load so the full rule suite runs reliably.
1 parent 5cdfa2e commit f2da73e

10 files changed

Lines changed: 121 additions & 33 deletions

File tree

‎.markdownlint.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,11 @@ document-length:
9393
# no-tables: disallow GFM tables; when convert-to is "list", suggest converting each table to a list.
9494
# Options (all optional):
9595
# convert-to: string (default "none"); "none" = short message only, "list" = show suggested list format in detail
96+
# one-sentence-per-line: bool (default false); split sentences in generated list items with list-body indentation
9697
# excludePathPatterns: list of globs; skip this rule for matching file paths
9798
no-tables:
9899
convert-to: list
100+
one-sentence-per-line: true
99101
# # excludePathPatterns: ["**/README.md"]
100102

101103
# no-h1-content: under first h1 allow only TOC (blank lines, list-of-links, badges, HTML comments including multi-line).

‎markdownlint-rules/.markdownlint.clean.yml‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,13 +352,17 @@ default: true
352352
# -----------------------------------------------------------------------------
353353
# no-tables (custom)
354354
# -----------------------------------------------------------------------------
355-
# To override: Uncomment the block; set convert-to and/or excludePathPatterns. Disallow GFM tables; when convert-to is "list", suggest converting each table to a list.
355+
# To override: Uncomment the block; set convert-to, one-sentence-per-line,
356+
# and/or excludePathPatterns. Disallow GFM tables; when convert-to is "list",
357+
# suggest converting each table to a list.
356358
# Options (all optional):
357359
# convert-to: string (default "none"); "none" = short message only, "list" = show suggested list format in detail
360+
# one-sentence-per-line: bool (default false); split sentences in generated list items with list-body indentation
358361
# excludePathPatterns: list of globs; skip this rule for matching file paths
359362
# ---
360363
# no-tables:
361364
# # convert-to: none
365+
# # one-sentence-per-line: false
362366
# # excludePathPatterns:
363367
# # - "**/*.md"
364368

‎markdownlint-rules/README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ The rule degrades gracefully when those files are absent.
228228

229229
### `no-tables`
230230

231-
**File:** `no-tables.js`
231+
**Files:** `no-tables.js`, plus `one-sentence-per-line.js` and `one-sentence-per-line-sentence.js` for sentence-aware list conversion.
232232

233233
**Description:** Disallow GFM tables.
234234
When `convert-to` is `"list"`, the rule suggests converting each table to a list (first column as **Header:**, remaining columns as indented `- header: cell`).
@@ -239,12 +239,16 @@ When `convert-to` is `"none"` (default), only a short message is reported and th
239239
```yaml
240240
no-tables:
241241
convert-to: none # optional; "none" (default) or "list"
242+
one-sentence-per-line: false # optional; split sentences in generated lists
242243
# excludePathPatterns:
243244
# - "**/README.md"
244245
```
245246

246247
- **`convert-to`** (string, default `"none"`): `"none"` = report violation with short message only (no suggested list). `"list"` = include suggested list format in the error detail.
247248
Only `"list"` and `"none"` are valid; invalid values are treated as `"none"`.
249+
- **`one-sentence-per-line`** (boolean, default `false`): When `true`, sentence-split the generated list text using the same boundary detection as the `one-sentence-per-line` rule.
250+
Continuation lines align with each list item's body: two spaces for top-level items and four spaces for the generated nested items.
251+
Enable this option when the `one-sentence-per-line` rule is enabled because markdownlint isolates each custom rule's configuration.
248252
- **`excludePathPatterns`** (list of strings, default none): Glob patterns for file paths where this rule is skipped.
249253

250254
**Behavior:** Reports every GFM table (outside fenced code blocks).
@@ -253,6 +257,7 @@ When `convert-to` is `"list"`, one error per table line (so `--fix` can replace
253257
Suppress per table: put `<!-- no-tables allow -->` on the line before the table's first line, or at the end of that line.
254258

255259
**Fixable:** When `convert-to` is `"list"`, the rule reports `fixInfo` so `--fix` converts each table to the list format.
260+
With `one-sentence-per-line: true`, the generated list is sentence-split during the same fix pass.
256261

257262
### `no-h1-content`
258263

‎markdownlint-rules/no-tables.js‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use strict";
22

33
const { isRuleSuppressedByComment, pathMatchesAny } = require("./utils.js");
4+
const { formatLineAsOneSentencePerLine } = require("./one-sentence-per-line.js");
45

56
/** Each cell of a GFM separator row contains only spaces, dashes, and colons. */
67
const RE_SEPARATOR_CELL = /^[\s\-:]*$/;
@@ -105,9 +106,10 @@ function findTables(lines) {
105106
* Build list-format suggestion from parsed table (first column bold header, rest indented " - header: cell").
106107
* @param {string[]} headerRow
107108
* @param {string[][]} bodyRows
109+
* @param {boolean} applyOneSentencePerLine
108110
* @returns {string}
109111
*/
110-
function tableToListSuggestion(headerRow, bodyRows) {
112+
function tableToListSuggestion(headerRow, bodyRows, applyOneSentencePerLine) {
111113
if (headerRow.length === 0) return "";
112114
const h0 = headerRow[0];
113115
const restHeaders = headerRow.slice(1);
@@ -122,7 +124,10 @@ function tableToListSuggestion(headerRow, bodyRows) {
122124
lines.push(` - ${label}: ${cell}`);
123125
}
124126
}
125-
return lines.join("\n");
127+
const formattedLines = applyOneSentencePerLine
128+
? lines.map((line) => formatLineAsOneSentencePerLine(line))
129+
: lines;
130+
return formattedLines.join("\n");
126131
}
127132

128133
const SHORT_MESSAGE = "Tables are not allowed.";
@@ -158,11 +163,16 @@ function reportTableError(opts) {
158163
* @param {object} opts
159164
* @param {{ startLine: number, endLine: number, headerRow: string[], bodyRows: string[][] }} opts.table
160165
* @param {string[]} opts.lines
166+
* @param {boolean} opts.applyOneSentencePerLine
161167
* @param {function(object): void} opts.onError
162168
*/
163169
function reportTableWithFix(opts) {
164-
const { table, lines, onError } = opts;
165-
const suggestedList = tableToListSuggestion(table.headerRow, table.bodyRows);
170+
const { table, lines, applyOneSentencePerLine, onError } = opts;
171+
const suggestedList = tableToListSuggestion(
172+
table.headerRow,
173+
table.bodyRows,
174+
applyOneSentencePerLine,
175+
);
166176
const firstLineIndex = table.startLine - 1;
167177
const firstLine = lines[firstLineIndex];
168178
/* c8 ignore next 1 -- defensive: firstLine missing (sparse array) */
@@ -193,30 +203,35 @@ function reportTableWithFix(opts) {
193203
}
194204

195205
/**
196-
* Normalize rule config: convert-to ("list" or "none") and excludePathPatterns.
206+
* Normalize conversion, sentence-splitting, and path-exclusion configuration.
197207
* @param {object} ruleConfig
198-
* @returns {{ convertTo: "list"|"none", excludePathPatterns: string[]|undefined }}
208+
* @returns {{ convertTo: "list"|"none", applyOneSentencePerLine: boolean, excludePathPatterns: string[]|undefined }}
199209
*/
200210
function getConfig(ruleConfig) {
201211
const convertTo =
202212
typeof ruleConfig["convert-to"] === "string" && ruleConfig["convert-to"].toLowerCase() === "list"
203213
? "list"
204214
: "none";
205-
return { convertTo, excludePathPatterns: ruleConfig.excludePathPatterns };
215+
return {
216+
convertTo,
217+
applyOneSentencePerLine: ruleConfig["one-sentence-per-line"] === true,
218+
excludePathPatterns: ruleConfig.excludePathPatterns,
219+
};
206220
}
207221

208222
/**
209223
* Report all table violations for the given tables.
210224
* When convertTo is "list", reports one error per table line with fixInfo so --fix converts table to list.
211225
* @param {string[]} lines
212226
* @param {{ startLine: number, endLine: number, headerRow: string[], bodyRows: string[][] }[]} tables
213-
* @param {"list"|"none"} convertTo
227+
* @param {{ convertTo: "list"|"none", applyOneSentencePerLine: boolean }} fixConfig
214228
* @param {function(object): void} onError
215229
*/
216-
function reportTables(lines, tables, convertTo, onError) {
230+
function reportTables(lines, tables, fixConfig, onError) {
231+
const { convertTo, applyOneSentencePerLine } = fixConfig;
217232
for (const table of tables) {
218233
if (convertTo === "list") {
219-
reportTableWithFix({ table, lines, onError });
234+
reportTableWithFix({ table, lines, applyOneSentencePerLine, onError });
220235
} else {
221236
const suggestedList = "";
222237
reportTableError({
@@ -242,12 +257,12 @@ function ruleFunction(params, onError) {
242257
const filePath = params.name || "";
243258
/* c8 ignore next 1 -- config fallback when params.config undefined */
244259
const ruleConfig = params.config?.["no-tables"] ?? params.config ?? {};
245-
const { convertTo, excludePathPatterns } = getConfig(ruleConfig);
260+
const { convertTo, applyOneSentencePerLine, excludePathPatterns } = getConfig(ruleConfig);
246261
if (Array.isArray(excludePathPatterns) && excludePathPatterns.length > 0 && pathMatchesAny(filePath, excludePathPatterns)) {
247262
return;
248263
}
249264
const tables = findTables(lines);
250-
reportTables(lines, tables, convertTo, onError);
265+
reportTables(lines, tables, { convertTo, applyOneSentencePerLine }, onError);
251266
}
252267

253268
module.exports = {

‎markdownlint-rules/one-sentence-per-line.js‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,26 @@ function buildFixInfo(line, listInfo, boundaryIndices, continuationOpts) {
138138
};
139139
}
140140

141+
/**
142+
* Format one prose or list line so every detected sentence occupies its own
143+
* physical line. This is shared with rules that generate Markdown content.
144+
*
145+
* @param {string} line - Full Markdown line
146+
* @param {{ abbreviations?: Set<string> }} [opts] - Sentence-boundary options
147+
* @returns {string} Original or sentence-split line
148+
*/
149+
function formatLineAsOneSentencePerLine(line, opts) {
150+
const listInfo = getListInfo(line);
151+
const boundaryIndices = getAllSentenceBoundaries(listInfo.content, opts);
152+
if (boundaryIndices.length === 0) return line;
153+
const fixInfo = buildFixInfo(line, listInfo, boundaryIndices, {
154+
continuationIndent: 4,
155+
hasExplicitContinuation: false,
156+
});
157+
const editIndex = fixInfo.editColumn - 1;
158+
return line.slice(0, editIndex) + fixInfo.insertText;
159+
}
160+
141161
/** Detect whole-content emphasis wrapper (e.g. **...**, *...*, _..._, ~~...~~). */
142162
function getFullLineEmphasisWrapper(content) {
143163
const TOKENS = ["**", "__", "~~", "*", "_"];
@@ -426,4 +446,5 @@ module.exports = {
426446
getFirstSentenceBoundary,
427447
getAllSentenceBoundaries,
428448
getLineEndingState,
449+
formatLineAsOneSentencePerLine,
429450
};

‎md_test_files/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Each item: **filename** - custom rule(s) that fail; sub-bullet - what the fixtur
5959
- **negative_no_h1_content.md** - no-h1-content
6060
- Prose under the first h1 (only TOC-style content allowed there).
6161
- **negative_no_tables.md** - no-tables
62-
- GFM table reported; with default config (convert-to none) short message only; suppress via `<!-- no-tables allow -->`.
62+
- GFM table reported; list conversion includes sentence-per-line content for top-level and nested list indentation; suppress via `<!-- no-tables allow -->`.
6363
- **negative_one_sentence_per_line.md** - one-sentence-per-line
6464
- Prose and list lines with multiple sentences (paragraph, bullet, numbered, nested); line with abbreviation (e.g.) not reported; ellipsis then new sentence (First... Then) reported.
6565
- **positive_general.md** has a line with ellipsis in the middle of one sentence (not reported).

‎md_test_files/negative_no_tables.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
| Heading 1 | Heading 2 | Heading 3 |
99
| --- | --- | --- |
10-
| h1c1 content | h2c1 content | h3c1 content |
10+
| First h1c1 sentence. Second h1c1 sentence. | First h2c1 sentence. Second h2c1 sentence. | h3c1 content |
1111
| h1c2 content | h2c2 content | h3c2 content |
1212
| h1c3 content | h2c3 content | h3c3 content |
1313
| h1c4 content | h2c4 content | h3c4 content |

‎test-scripts/test_fix_no_tables.py‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,28 @@ def test_fix_converts_table_to_list(self) -> None:
4949
5050
| heading 1 | heading 2 | heading 3 |
5151
| --- | --- | --- |
52-
| h1c1 content | h2c1 content | h3c1 content |
52+
| Top first. Top second. | Nested first. Nested second. | h3c1 content |
5353
| h1c2 content | h2c2 content | h3c2 content |
5454
"""
5555
content_after = """# Doc
5656
5757
## Table
5858
59-
- **heading 1:** h1c1 content
60-
- heading 2: h2c1 content
59+
- **heading 1:** Top first.
60+
Top second.
61+
- heading 2: Nested first.
62+
Nested second.
6163
- heading 3: h3c1 content
6264
- **heading 1:** h1c2 content
6365
- heading 2: h2c2 content
6466
- heading 3: h3c2 content
6567
"""
66-
overrides = {"no-tables": {"convert-to": "list"}}
68+
overrides = {
69+
"no-tables": {
70+
"convert-to": "list",
71+
"one-sentence-per-line": True,
72+
}
73+
}
6774
with tempfile.TemporaryDirectory(prefix="fix_no_tables_") as tmp:
6875
path = Path(tmp) / "test.md"
6976
path.write_text(content_before, encoding="utf-8")

‎test/markdownlint-rules/no-heading-like-lines.test.js‎

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const { describe, it } = require("node:test");
1010
const assert = require("node:assert");
1111
const fs = require("fs");
1212
const path = require("path");
13-
const { spawnSync } = require("child_process");
1413
const rule = require("../../markdownlint-rules/no-heading-like-lines.js");
1514
const { runRule } = require("./run-rule.js");
1615

@@ -377,19 +376,16 @@ describe("no-heading-like-lines", () => {
377376
path.join(RULES_DIR, "no-heading-like-lines.js"),
378377
path.join(tmpDir, "no-heading-like-lines.js")
379378
);
380-
const script = `
381-
const rule = require("./no-heading-like-lines.js");
382-
const errors = [];
383-
rule.function({ lines: ["**Hi:**", "content"], config: { "no-heading-like-lines": { convertToHeading: false } } }, (e) => errors.push(e));
384-
console.log(JSON.stringify(errors.length > 0 ? errors[0].fixInfo : null));
385-
`;
386-
const result = spawnSync(
387-
process.execPath,
388-
["-e", script],
389-
{ cwd: tmpDir, encoding: "utf8", maxBuffer: 10 * 1024 }
379+
const standaloneRule = require(path.join(tmpDir, "no-heading-like-lines.js"));
380+
const errors = [];
381+
standaloneRule.function(
382+
{
383+
lines: ["**Hi:**", "content"],
384+
config: { "no-heading-like-lines": { convertToHeading: false } },
385+
},
386+
(error) => errors.push(error),
390387
);
391-
assert.strictEqual(result.status, 0, result.stderr || result.error);
392-
const fixInfo = JSON.parse(result.stdout.trim());
388+
const fixInfo = errors[0]?.fixInfo;
393389
assert.ok(fixInfo, "should report one error with fixInfo");
394390
assert.strictEqual(fixInfo.insertText, "Hi", "stripEmphasis and trailing colon when optional deps missing");
395391
} finally {

‎test/markdownlint-rules/no-tables.test.js‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,44 @@ describe("no-tables", () => {
5454
}
5555
});
5656

57+
it("sentence-splits generated list content with proper nesting indentation when enabled", () => {
58+
const lines = [
59+
"| heading 1 | heading 2 |",
60+
"| --- | --- |",
61+
"| First top-level sentence. Second top-level sentence. | First nested sentence. Second nested sentence. |",
62+
];
63+
const config = {
64+
"no-tables": {
65+
"convert-to": "list",
66+
"one-sentence-per-line": true,
67+
},
68+
};
69+
const errors = runRule(rule, lines, config);
70+
assert.strictEqual(
71+
errors[0].fixInfo.insertText,
72+
[
73+
"- **heading 1:** First top-level sentence.",
74+
" Second top-level sentence.",
75+
" - heading 2: First nested sentence.",
76+
" Second nested sentence.",
77+
].join("\n"),
78+
);
79+
});
80+
81+
it("leaves generated list sentences on one line when sentence splitting is disabled", () => {
82+
const lines = [
83+
"| heading 1 | heading 2 |",
84+
"| --- | --- |",
85+
"| First sentence. Second sentence. | Nested first. Nested second. |",
86+
];
87+
const config = { "no-tables": { "convert-to": "list" } };
88+
const errors = runRule(rule, lines, config);
89+
assert.strictEqual(
90+
errors[0].fixInfo.insertText,
91+
"- **heading 1:** First sentence. Second sentence.\n - heading 2: Nested first. Nested second.",
92+
);
93+
});
94+
5795
it("does not report table inside fenced code block", () => {
5896
const lines = [
5997
"## Code",

0 commit comments

Comments
 (0)