eslint-plugin-markdown-preferences-paragraph 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/CONTRIBUTING.md +53 -0
- package/DESIGN.md +238 -0
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/RELEASING.md +69 -0
- package/assets/logo.svg +11 -0
- package/package.json +78 -4
- package/src/index.js +11 -0
- package/src/rules/wrap.js +355 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026/11/08)
|
|
4
|
+
|
|
5
|
+
- [#2](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/2): Add opt-in numeric paragraph wrapping and unwrapping, leaving paragraphs untouched when width is omitted, with data-driven file-fixture, packaged ESLint, and workflow-checker CLI tests - [@dblock](https://github.com/dblock).
|
|
6
|
+
- [#3](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/3): Reflow safe paragraphs to numeric widths even when existing lines fit, preserving hard breaks, literals, containers, and canonical wrapping; reset the unpublished version to 0.1.0 - [@dblock](https://github.com/dblock).
|
|
7
|
+
- [#4](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/4): Replace Markdownlint tooling with ESLint Markdown/style checks, this plugin's single-line paragraph rule, and native CSpell-backed spelling; add actual-configuration file fixtures and retain the reviewed vocabulary - [@dblock](https://github.com/dblock).
|
|
8
|
+
- [#5](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/5): Add an original editable pale blue paragraph-wrap logo, 160-pixel README placement, artwork maintenance guidance, and packaged-asset checks - [@dblock](https://github.com/dblock).
|
|
9
|
+
- [#7](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/7): Add manually dispatched release-it automation for version and changelog preparation, exact-artifact npm trusted publishing, tags and GitHub Releases, recovery artifacts, and the next development version - [@dblock](https://github.com/dblock).
|
|
10
|
+
- [#8](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/8): Prepare installation instructions for the first 0.1.0 release, simplify usage, separate configuration, fixing, and interoperability guidance, and update the README installation pin during releases - [@dblock](https://github.com/dblock).
|
|
11
|
+
- [#10](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/10): Rename the unpublished package and repository to eslint-plugin-markdown-preferences-paragraph, use the paragraph/paragraph-wrap namespace in examples and fixtures, and correct the staged npm bootstrap sequence - [@dblock](https://github.com/dblock).
|
|
12
|
+
- [#11](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/11): Rename the exported rule to wrap for paragraph/wrap configuration, including the project's own Markdown linting, documentation, and file fixtures before the first publication - [@dblock](https://github.com/dblock).
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Read [Design](DESIGN.md) and [Automated Contributor Instructions](AGENTS.md). The rule supports numeric wrapping and unwrapping through one paragraph-wide policy.
|
|
4
|
+
|
|
5
|
+
## Setup and Validation
|
|
6
|
+
|
|
7
|
+
Use Node.js `26.13.0` from `.nvmrc` for development and npm. The package supports Node.js `^22.13.0 || >=24.0.0`, ESLint `^9.39.0 || ^10.0.0`, and `@eslint/markdown ^8.0.0`. Documentation spelling tools require Node.js 22.18 or later; the compatibility test matrix exercises the package's lower runtime minimum without running those tools there. Recheck stable releases when selecting or updating dependencies while preserving supported-runtime compatibility.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm ci
|
|
11
|
+
npm test
|
|
12
|
+
npm run lint
|
|
13
|
+
npm run test:coverage
|
|
14
|
+
npm run package:check
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Run `npm test` and `npm run lint` before every push, including updates to a PR. Run final checks before committing and reuse results for the push when tested content/configuration has not changed. Validate packaging/public-entry-point changes with the packaged CLI fixture suite: it creates an archive with `npm pack`, inspects its contents, installs that exact archive in a temporary consumer, and runs the real ESLint CLI. `npm test` already runs this suite; `npm run package:check` selects it alone, so do not immediately repeat it after a passing full test run.
|
|
18
|
+
|
|
19
|
+
`npm run lint:code` checks source and embedded JavaScript examples. `npm run lint:markdown` separately uses ESLint for Markdown structure, prose, spelling, single-line paragraphs, and title-case headings. It combines `@eslint/markdown`, `eslint-plugin-markdown-preferences`, this plugin with `width: "nowrap"`, and native ESLint documentation rules backed by public CSpell dictionary/text APIs. No Markdownlint tools are installed or invoked. `npm run lint:workflows` parses YAML and checks title case only for workflow, job, and step display names. Machine identifiers, expressions, and arbitrary `name` fields are not capitalization targets.
|
|
20
|
+
|
|
21
|
+
Keep human-readable Markdown headings and YAML display names in title case, preserving proper names and code identifiers. Keep each Markdown prose paragraph on one source line. Review `.spellcheck.txt` additions and fix typos instead of adding them to the dictionary. Intentionally invalid file fixtures are not project documentation.
|
|
22
|
+
|
|
23
|
+
Test, Lint, and Package have separate CI workflows. Tests cover minimum/current Node versions and supported ESLint majors using explicit inline `matrix.entry` records without `include`/`exclude`; a current-Node coverage job reports to Coveralls. ESLint 9 compatibility is retained for existing Markdown setups while development and lint use current ESLint 10. No Danger checks are configured for this JavaScript project.
|
|
24
|
+
|
|
25
|
+
## Tests
|
|
26
|
+
|
|
27
|
+
Run focused API fixture tests with `node --test test/plugin.test.js` or the packed CLI suite with `npm run package:check`. `node --test test/documentation.test.js` loads the actual ESLint configs against file fixtures for headings, prose wrapping, spelling and original Unicode offsets, structural errors, first headings/final newlines, fixture exclusions, and embedded JavaScript. Native Markdown/spelling fixtures require Node 22.18 or later and are skipped below that development-tool minimum; package API/CLI tests still cover Node 22.13. Tests also exercise the YAML display-name checker.
|
|
28
|
+
|
|
29
|
+
`node --test test/release.test.js` checks project-owned changelog fixtures, unsafe version inputs, metadata guards, and registry errors. One isolated integration uses real release-it and local Git with simulated npm publication to check that the validated archive is published once and the next development version is prepared; it does not retest the dependency's failure matrix. CLI/integration checks require release-it's supported runtime; the workflow uses Node 26.13.0. The `PARAGRAPH_WRAP_PACKAGE_ARCHIVE` environment variable selects an existing release tarball for the packed-consumer suite, preserving the artifact validated for publication.
|
|
30
|
+
|
|
31
|
+
Both API and CLI tests use the same [data-driven file fixtures](DESIGN.md#data-driven-file-fixtures): metadata, original Markdown, expected fixed Markdown, and stable before/after diagnostics. The CLI suite uses the actual packed plugin with the matrix-selected ESLint version, compares exact bytes and exit status, and verifies second-pass stability without mutating checked-in fixtures. Add cases through fixture files, not case-specific runner branches. Preserve CRLF/mixed endings through `.gitattributes` and the missing-final-newline fixture's bytes. Add focused helper tests only for behavior that is not clearly expressed by file fixtures.
|
|
32
|
+
|
|
33
|
+
## Pull Requests
|
|
34
|
+
|
|
35
|
+
For artwork changes, follow [Logo Maintenance](DESIGN.md#logo). Keep the editable SVG, license/provenance, descriptive alternative text, and 160-pixel README placement. Inspect full-size and light/dark README rendering; `npm test` checks that the real installed archive retains the logo. Keep generated previews outside the repository.
|
|
36
|
+
|
|
37
|
+
Keep the primary checkout on `master`; use a separate worktree and `<username>/<topic>` branch for each PR. For example, after inspecting existing branches and worktrees:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
git worktree add -b dblock/implementation ../eslint-plugin-markdown-preferences-paragraph-implementation master
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Preserve unrelated work. Prefer one coherent commit per PR and squash multiple topic-branch commits before pushing. Squash-merge PRs; do not use merge commits. Do not push development changes directly to `master`, force-push `master`, amend without approval, or merge/publish/tag without explicit authorization. If a prerequisite PR remains open, obtain the appropriate stacked-PR workflow before creating a dependent PR.
|
|
44
|
+
|
|
45
|
+
Update affected documentation and add CHANGELOG entries for features, improvements, and fixes, but not minor typos or the initial scaffold. Use `- [#123](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/pull/123): Description - [@dblock](https://github.com/dblock).` with the actual PR number and contributor. Append entries chronologically above `- Your contribution here.` in the Next section. Predict the PR number before opening when reliable, then verify it and correct mismatches without unauthorized amending or force-pushing.
|
|
46
|
+
|
|
47
|
+
Feature additions increment minor versions in the same PR: reset patch for `0.x`, preserve patch for `1.x` and later. Ask whether to bump major for breaking changes. Synchronize the package manifest and lockfile; see [Releasing](RELEASING.md).
|
|
48
|
+
|
|
49
|
+
Include the Copilot co-author trailer in AI-assisted commits and disclose AI authorship on behalf of `@dblock` in PRs/comments. Link related issues with closing references; leave them open until the PR merges. State actual validation and pending CI honestly.
|
|
50
|
+
|
|
51
|
+
The authorized manual release workflow is the sole direct-push exception: release-it publishes the validated npm archive through OIDC, atomically pushes the release commit/tag, creates a GitHub Release, and validates/pushes the next development version. Project hooks format the changelog, validate the exact archive and metadata, and verify pushed refs and registry integrity. Publication precedes the Git push, so a later failure requires recovery rather than an automatic rerun. It never runs on PR merges, has no dry-run input, and requires the `release` environment restricted to `master`. The no-App setup removes the platform PR requirement for all writers, not only Actions; force-push and branch-deletion protections remain active. Ordinary development stays PR-based. Opening or merging its setup PR does not authorize dispatching publication; follow [Releasing](RELEASING.md).
|
|
52
|
+
|
|
53
|
+
After merging, fetch with pruning, verify the merged state of the PR on GitHub, and inspect its worktree for dirty/untracked files and dependent PRs. Remove only clean, no-longer-needed topic worktrees with `git worktree remove`, then delete their local branches. If squash ancestry requires `git branch -D`, first verify that no unmerged or dependent work would be lost. Never remove `master`, active PR branches, or dirty worktrees. Prune stale worktree metadata, verify cleanup, and report blocked cleanup.
|
package/DESIGN.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
**Status:** First implementation of the agreed scope; not published yet. Research checked on November 8, 2026.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
Create `eslint-plugin-markdown-preferences-paragraph`, an independent, opt-in ESLint plugin that checks and safely fixes Markdown paragraph wrapping. Support both positive numeric widths and `"nowrap"` in one `wrap` rule, without becoming a general Markdown formatter.
|
|
8
|
+
|
|
9
|
+
[To Wrap or Not to Wrap in Markdown?](https://code.dblock.org/2021/06/07/to-wrap-or-not-to-wrap-in-markdown.html) motivates one source line per paragraph: arbitrary reflow can turn a small wording edit into changes across several lines. This is a source-format preference, not a guarantee that all Markdown renderers treat soft line breaks identically.
|
|
10
|
+
|
|
11
|
+
## Inventory
|
|
12
|
+
|
|
13
|
+
The article names four existing enforcement tools and an ESLint proposal. `MD013` is also relevant because the article explicitly disables it. Formatters are distinguished from lint rules below.
|
|
14
|
+
|
|
15
|
+
| Tool | Configuration and Default | Numeric Wrapping | Unwrapping | Implication |
|
|
16
|
+
| --- | --- | --- | --- | --- |
|
|
17
|
+
| [markdownlint-rule-single-line-paragraphs](https://github.com/dblock/markdownlint-rule-single-line-paragraphs) | Enable the custom `single-line-paragraphs` rule; no width setting. | Not supported. | Reports soft-wrapped paragraph segments and supplies coordinated joining/deletion fixes. | Strong precedent for narrow, paragraph-only enforcement, but not a combined wrap/unwrap API. |
|
|
18
|
+
| [markdownlint `MD013` / `line-length`](https://github.com/DavidAnson/markdownlint/blob/main/doc/md013.md) | `line_length: 80`; headings, code blocks, and tables included by default; `strict` and `stern` default to `false`. | Reports overlong lines; documentation recommends splitting them. No automatic paragraph reflow. | Not supported. | A length constraint is not a wrapping preference. Disable `MD013` when using unrestricted single-line paragraphs; simply setting a huge limit is not unwrapping. |
|
|
19
|
+
| [RuboCop `Prose/MaxLength`](https://github.com/dblock/rubocop-prose/blob/main/docs/cops/prose/max_length.md) | Enabled by default; `Max: false` means unwrapping, not disabling. `IgnoreURLs: true`. | A positive `Max` wraps prose, including container prefixes in the character budget. | `Max: false` joins soft line breaks. | Closest existing lint-rule precedent for combining both modes. Its [structural line-length cop](https://github.com/dblock/rubocop-prose/blob/main/docs/cops/markdown/structural_line_length.md) separates non-paragraph limits. |
|
|
20
|
+
| [Prettier](https://prettier.io/docs/options#prose-wrap) | `proseWrap: "preserve"` by default; `printWidth: 80`. | `"always"` reflows prose toward `printWidth`, which is a formatting target rather than an absolute maximum. | `"never"` puts prose blocks on one source line. | Separates wrapping policy from width. `--check` enforces all Prettier formatting, not just paragraph wrapping; `"preserve"` does not unwrap existing text. |
|
|
21
|
+
| [mdformat](https://mdformat.readthedocs.io/en/stable/users/configuration_file.html) | `wrap = "keep"` by default; also accepts `"no"` or an integer. | An integer requests wrapping. | `"no"` requests unwrapping. | Direct precedent for a width-or-keyword setting. Check/write modes also apply other formatting choices; CommonMark is the default, with extensions needed for additional dialects. |
|
|
22
|
+
| [eslint-plugin-markdown-preferences `max-len`](https://ota-meshi.github.io/eslint-plugin-markdown-preferences/rules/max-len.html) | Paragraph default `120`; accepts numeric limits or `"ignore"`, with container overrides and `ignoreUrls: true`. | Reports overlong source lines; the current rule has no fixes. | Not supported in the current rule. | Existing paragraph ownership overlaps this plugin unless users explicitly disable those limits. |
|
|
23
|
+
|
|
24
|
+
### Syntax and Exemption Differences
|
|
25
|
+
|
|
26
|
+
The markdownlint custom rule covers root, list, and blockquote paragraphs. It permits hard breaks from two or more trailing spaces or an unescaped trailing backslash, plus line-boundary `<br>`, `<br/>`, and `<br />` tags. Its README excludes headings, code blocks, tables, HTML blocks, front matter, and definitions; it does not advertise footnote-definition paragraph coverage.
|
|
27
|
+
|
|
28
|
+
`Prose/MaxLength` supports Markdown and plain text, preserves nesting, inline syntax, and hard breaks including `<br>`, and can report without correcting indented paragraphs or unbreakable overlong tokens. Its URL exemption is documented as paragraph lines containing only one URL. In contrast, ESLint's existing `max-len` exempts a line containing a recognized URL anywhere when `ignoreUrls` is enabled. These are materially different policies, not interchangeable options.
|
|
29
|
+
|
|
30
|
+
`MD013` has its own long-token exceptions: normal mode permits lines with no whitespace beyond the limit, `strict` removes that exception, and `stern` permits unbreakable lines while reporting lines that could be split. Link/image definitions and standalone link/image lines have additional exemptions. These policies should not be silently imported into a new ESLint rule.
|
|
31
|
+
|
|
32
|
+
Prettier and mdformat are useful behavioral references, not implementation dependencies. Their broader formatting scope and default preservation modes differ from a focused ESLint diagnostic and fix.
|
|
33
|
+
|
|
34
|
+
## Upstream Direction
|
|
35
|
+
|
|
36
|
+
[Issue #338](https://github.com/ota-meshi/eslint-plugin-markdown-preferences/issues/338) initially proposed `single-line-paragraphs` without options. The [maintainer response](https://github.com/ota-meshi/eslint-plugin-markdown-preferences/issues/338#issuecomment-6646164240) instead favors `paragraph-wrap` owning both maximum paragraph width and unwrapping, using the term `"nowrap"` and eventually removing paragraph checking from `max-len`.
|
|
37
|
+
|
|
38
|
+
The [latest proposal](https://github.com/ota-meshi/eslint-plugin-markdown-preferences/issues/338#issuecomment-6665919201) presents two alternatives: introduce `paragraph-wrap` and migrate paragraph ownership, or extend `max-len` without deprecation. The issue remains open; neither alternative should be described as accepted or released. [PR #332](https://github.com/ota-meshi/eslint-plugin-markdown-preferences/pull/332) remains open and unmerged and contains earlier unwrapping work.
|
|
39
|
+
|
|
40
|
+
This project follows the separate `paragraph-wrap` direction but cannot deprecate, change, or disable another package's rule. It must document coexistence honestly. Any reuse of upstream code or fixtures requires reviewing their license and retaining applicable notices; this proposal does not assume a private upstream utility is a supported API.
|
|
41
|
+
|
|
42
|
+
## Proposed Public API
|
|
43
|
+
|
|
44
|
+
Use the repository and npm package name `eslint-plugin-markdown-preferences-paragraph`, under GitHub owner `dblock`. Before this pre-release rename on November 8, 2026, the GitHub repository lookup found no repository, and npm returned `E404` for the unscoped package name; these checks suggest availability but do not reserve the names or guarantee registry acceptance.
|
|
45
|
+
|
|
46
|
+
Export one ESM plugin object with `meta` and `rules.wrap`. Register it as `paragraph` to use `paragraph/wrap`; the namespace is an ESLint configuration alias, not part of the exported rule name. There is no standalone CLI, replacement parser, fork of the upstream plugin, or automatic activation of unrelated rules.
|
|
47
|
+
|
|
48
|
+
### Options
|
|
49
|
+
|
|
50
|
+
| Option | Accepted Values | Proposed Default |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `width` | Positive integer, `"nowrap"`, or `"ignore"` | `"ignore"`; omitting `width` leaves paragraphs untouched. |
|
|
53
|
+
|
|
54
|
+
Reject zero, negative or fractional widths, unsupported keywords, unknown properties, and incorrect types through the rule schema. `"ignore"` skips enforcement; ESLint severity `"off"` disables the entire rule.
|
|
55
|
+
|
|
56
|
+
Apply the same `width` to every paragraph, including paragraphs inside lists, blockquotes, and supported footnote definitions. The first version has no container-specific overrides or policy precedence. This deliberately simplifies the latest upstream proposal while retaining paragraph coverage in all supported containers.
|
|
57
|
+
|
|
58
|
+
There is no `ignoreUrls` option or URL-line exemption. Wrap surrounding prose at safe boundaries while keeping URLs intact. If an indivisible URL exceeds the configured width, report without fixing rather than splitting it or ignoring the entire line. URLs need no exception in `"nowrap"` mode.
|
|
59
|
+
|
|
60
|
+
Do not ship an opinionated recommended preset in the first version. Enable the rule explicitly, and set `width: "nowrap"` for the user's preferred single-line prose or a positive integer for numeric wrapping. Enabling the rule without a `width`, including with an empty options object, does nothing.
|
|
61
|
+
|
|
62
|
+
### Example Configuration
|
|
63
|
+
|
|
64
|
+
The public consumer configuration is:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
import markdown from "@eslint/markdown";
|
|
68
|
+
import paragraph from "eslint-plugin-markdown-preferences-paragraph";
|
|
69
|
+
|
|
70
|
+
export default [
|
|
71
|
+
{
|
|
72
|
+
files: ["**/*.md", "**/*.markdown"],
|
|
73
|
+
plugins: {
|
|
74
|
+
markdown,
|
|
75
|
+
paragraph,
|
|
76
|
+
},
|
|
77
|
+
language: "markdown/gfm",
|
|
78
|
+
rules: {
|
|
79
|
+
"paragraph/wrap": [
|
|
80
|
+
"error",
|
|
81
|
+
{ width: "nowrap" },
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
];
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For numeric wrapping, use `{ width: 120 }`. Container-specific settings and `ignoreUrls` are not supported in the first version.
|
|
89
|
+
|
|
90
|
+
### Coexistence With Existing Length Rules
|
|
91
|
+
|
|
92
|
+
When also using `markdown-preferences/max-len`, explicitly ignore paragraph limits at the root and in every supported container. Numeric container settings must be replaced with entity-specific objects so they cannot reintroduce paragraph limits:
|
|
93
|
+
|
|
94
|
+
```js
|
|
95
|
+
const nonParagraphLimits = {
|
|
96
|
+
heading: 80,
|
|
97
|
+
table: 120,
|
|
98
|
+
html: 120,
|
|
99
|
+
code: "ignore",
|
|
100
|
+
frontmatter: "ignore",
|
|
101
|
+
math: "ignore",
|
|
102
|
+
paragraph: "ignore",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const maxLenOptions = {
|
|
106
|
+
...nonParagraphLimits,
|
|
107
|
+
list: { ...nonParagraphLimits },
|
|
108
|
+
blockquote: { ...nonParagraphLimits },
|
|
109
|
+
footnoteDefinition: { ...nonParagraphLimits },
|
|
110
|
+
ignoreUrls: true,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Add this to an existing configuration that registers markdown-preferences.
|
|
114
|
+
export const rules = {
|
|
115
|
+
"markdown-preferences/max-len": ["error", maxLenOptions],
|
|
116
|
+
};
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Adapt existing non-paragraph limits rather than replacing deliberate project policies with these example values. With unrestricted `"nowrap"`, disable markdownlint `MD013`; align any formatter with the same wrapping policy. The plugin neither reads other rules' configuration nor guarantees conflict-free behavior under incompatible settings. A standalone companion cannot provide the maintainer's preferred exclusive ownership until upstream changes are released.
|
|
120
|
+
|
|
121
|
+
## First-Version Behavior
|
|
122
|
+
|
|
123
|
+
Check actual paragraph AST nodes at the document root and inside lists, blockquotes, and footnote definitions when the selected language parses them. Support CommonMark and GFM through `@eslint/markdown`, including task lists and GFM footnotes. Compatibility with `markdown-preferences/extended-syntax` is an optional integration, not a reason to create another parser.
|
|
124
|
+
|
|
125
|
+
In `"nowrap"` mode, report each soft-wrapped segment and join safe line boundaries with one space, without imposing any maximum length. Preserve existing intraline spacing and source spelling. A segment is the part of a paragraph between intentional hard-break boundaries.
|
|
126
|
+
|
|
127
|
+
In numeric mode, greedily reflow safely editable segments at safe whitespace boundaries to the configured width, including segments whose existing lines already fit. Increasing width from `30` to `80` therefore fills wider lines; decreasing it wraps them more narrowly. Leave already canonical wrapping unchanged. Count indentation, quote/list/footnote prefixes, and inline Markdown syntax in the source width, not rendered text width. Match upstream's documented implementation convention: Unicode display width with four-column tab stops, while preserving original UTF-16 offsets for ESLint fixes.
|
|
128
|
+
|
|
129
|
+
Keep the existing `width` diagnostics for overlong lines. When all lines fit but a proven safe reflow would change the segment, report one fixable `reflow` diagnostic at its first line. Do not invent a reflow diagnostic when a safe correction cannot be established; overlong lines still report without a fix when unsafe or impossible to correct.
|
|
130
|
+
|
|
131
|
+
Do not split words, URLs, link/image syntax, inline code, or other indivisible constructs to satisfy a width. URLs do not exempt surrounding prose from wrapping. If an atomic construct cannot fit, retain a diagnostic without an unsafe or endlessly repeated fix.
|
|
132
|
+
|
|
133
|
+
Preserve two-space and backslash hard breaks, and case-insensitive `<br>`, `<br/>`, and `<br />` at either side of a source-line boundary. Do not interpret an escaped tag or a tag inside inline code as HTML. Keep hard-break spelling and boundaries unchanged and process the remaining soft-wrapped segments independently.
|
|
134
|
+
|
|
135
|
+
Leave headings, tables, code blocks, HTML blocks, parsed front matter, link definitions, and math blocks alone. This promise depends on the selected parser recognizing those constructs: document the required front-matter/math language options, and do not claim unsupported dialect syntax is automatically protected.
|
|
136
|
+
|
|
137
|
+
Remove only proven continuation indentation and container prefixes when joining. Preserve first-line markers, task checkboxes, list numbering, blockquote nesting, paragraph boundaries, blank lines, and LF/CRLF style. New numeric-wrap lines must use valid continuation prefixes, never repeated list-item markers.
|
|
138
|
+
|
|
139
|
+
Report without fixing when safety cannot be established, including multiline literals, ambiguous indentation, directive boundaries, unknown extension syntax, mixed line endings in an edited segment, or a proposed line break that would create a heading, list, blockquote, thematic break, code block, or HTML block. No whole-document serialization or unrelated whitespace cleanup.
|
|
140
|
+
|
|
141
|
+
## Integration and Implementation
|
|
142
|
+
|
|
143
|
+
The rule uses the host's paragraph AST, source ranges, ancestry, and original source text. It traverses public AST children rather than importing upstream internal helpers. One rule contains local helpers for display width, segments, proven container prefixes, whitespace boundaries, greedy numeric reflow, and safe edits.
|
|
144
|
+
|
|
145
|
+
ESLint's public rule context exposes language options but not the active language object or dialect name. The implementation therefore finds the public `@eslint/markdown` languages whose parses reproduce the original document AST, then requires each proposed edit to preserve that AST under every matching language. It normalizes only soft-break whitespace in text values and discards positions; literal values, inline syntax, hard breaks, and block structure must remain equal. It does not inspect other rules' configuration or import an internal parser.
|
|
146
|
+
|
|
147
|
+
This deliberately favors safety over fixing coverage. If no public dialect matches the original AST, diagnostics remain report-only throughout the file. Standard syntax under the optional extended language can be fixed when it matches; custom containers and other unmatched extension nodes are report-only. Multiline literals, inline comments, directive-boundary patterns, tab-indented prefixes, and ambiguous edited line endings also prevent fixes. A numeric segment containing an overlong atomic construct receives no partial reflow because the whole proposed correction must be compliant and stable.
|
|
148
|
+
|
|
149
|
+
Both source/API and packaged CLI file fixtures provide behavioral safety evidence. AST equivalence alone is not proof that every renderer treats soft line breaks identically.
|
|
150
|
+
|
|
151
|
+
Fixes must be non-overlapping and stabilize on a subsequent ESLint pass. A segment that cannot be made compliant safely remains report-only. Use deterministic diagnostics and localized replacement ranges.
|
|
152
|
+
|
|
153
|
+
### Dependencies and Layout
|
|
154
|
+
|
|
155
|
+
Use JavaScript ESM, npm with a committed `package-lock.json`, and `node --test`. The package has `src/index.js`, `src/rules/wrap.js`, `test/`, shared test support under `test-support/`, and integration file fixtures. Public exports and an explicit packaged-file list exclude development support from the archive.
|
|
156
|
+
|
|
157
|
+
Registry checks currently report ESLint `10.14.0`, `@eslint/markdown` `8.0.3`, `eslint-plugin-markdown-preferences` `0.43.0`, and `string-width` `8.3.0`; the Node distribution index reports Node `26.13.0` as the latest stable release and LTS. These are research observations, not permanent pins; recheck stable versions during scaffolding.
|
|
158
|
+
|
|
159
|
+
Use ESLint `^9.39.0 || ^10.0.0` and `@eslint/markdown ^8.0.0` as peer dependencies, with `string-width` as a direct runtime dependency. `eslint-plugin-markdown-preferences` is a development dependency for coexistence/extended-syntax integration tests; consumers need it only for those optional integrations. Supported Node versions start at `^22.13.0 || >=24.0.0`, matching the selected host's minimum on the supported Node 22 line rather than adding the older Node 20 line solely because upstream still allows it. Test the minimum supported Node version and the latest stable development runtime, plus both supported ESLint major versions. Use Node.js `26.13.0` for development; documentation spelling tools require at least Node.js 22.18.
|
|
160
|
+
|
|
161
|
+
Development linting uses ESLint throughout JavaScript and Markdown. `@eslint/markdown` checks document structure and processes embedded JavaScript; `eslint-plugin-markdown-preferences` supplies Markdown style and title-case checks; this package's public export enforces single-line prose with `width: "nowrap"`. The historical Markdownlint tools in the inventory above are research references, not installed dependencies or active checks. Markdown-source linting and code-block processor linting use separate ESLint invocations because applying the processor replaces the original Markdown input. A small tested YAML-aware checker enforces title case for workflow/job/step display names without checking machine identifiers or arbitrary `name` fields. Test, Lint, and Package workflows remain separate, with coverage and Coveralls.
|
|
162
|
+
|
|
163
|
+
Native Markdown spelling uses a local ESLint rule backed by public `cspell-lib` dictionary and text-extraction APIs, with US English, software terminology, and the existing `.spellcheck.txt` vocabulary. CSpell's ESLint plugin currently visits only JavaScript `Program` nodes, so enabling it on the Markdown AST would silently skip prose. The native rule checks text nodes rather than URLs or inline/fenced code, preserves original UTF-16 source offsets, and never rewrites spelling or mutates third-party rules/ASTs. Two small native rules retain first-level-one-heading and final-newline checks; comments and standalone logo images may precede the heading. HTML allowances are limited to `img` elements. The style configuration keeps the prior allowance for both Markdown hard-break forms and unaligned table columns.
|
|
164
|
+
|
|
165
|
+
Documentation file fixtures load the actual Markdown and embedded-code ESLint configs and verify valid/invalid headings, spelling and Unicode locations, public `wrap` fixes, structural errors, final-newline fixes, ignored test fixtures, and literal/code exclusions. These development-tool checks require Node 22.18 or later, independently of the package's lower runtime minimum.
|
|
166
|
+
|
|
167
|
+
## Release Automation
|
|
168
|
+
|
|
169
|
+
A manually dispatched GitHub Actions workflow uses development-only `release-it` for version updates, Git commits/tags/pushes, npm trusted publishing, and GitHub Release creation. Project hooks retain the existing handwritten changelog format and validate the exact artifact. Dispatch releases the checked-in stable version unless an explicit stable-version override is supplied. It does not infer versions from conventional commits or publish on merges, and it has no dry-run input.
|
|
170
|
+
|
|
171
|
+
Release-it 21.1.2 supports the release runtime but its proxy chain selects an outdated FTP client. A scoped `get-uri` override selects stable `basic-ftp` 6.2.6 to address the reported directory-listing denial of service; its public Client methods used by `get-uri` are retained. This release workflow uses HTTPS, not FTP. Recheck and remove the override when the upstream dependency includes the fix.
|
|
172
|
+
|
|
173
|
+
The hooks reject dirty checkouts, mismatched metadata, empty notes, existing registry versions/tags, unexpected file changes, and concurrent default-branch updates. They pack once to `.release/package.tgz` and run the existing real CLI consumers against that exact archive; `npm.publishPath` lets release-it publish the retained file without replacing it. `npm.skipChecks` avoids token-based authentication checks that cannot validate OIDC. The native flow publishes npm first, then commits/tags and atomically pushes Git without force. A pre-GitHub-Release hook verifies the remote commit/tag and registry integrity, because release-it can report a Git push failure without throwing after npm succeeds.
|
|
174
|
+
|
|
175
|
+
The clean-check hook replaces `git.requireCleanWorkingDir`, disabling native source-reset and local-tag rollback handlers without allowing a dirty starting checkout. Successful native npm publication prevents release-it from attempting remote-tag rollback on a failed Git push; the next-version run disables tagging altogether. Failures retain the archive, checksum, and metadata patch in Actions artifacts rather than maintaining custom state or Git bundles. Never roll back published packages or delete release tags.
|
|
176
|
+
|
|
177
|
+
After successful npm publication and GitHub Release creation, a second release-it run increments patch, opens a new Next section through its changelog hook, runs tests and lint against that next version, and commits/pushes its metadata. This run disables npm publication, GitHub Releases, and Git tags; it does not repeat the release hooks. Ordinary development remains PR-based. The explicitly approved no-App setup allows direct default-branch pushes for all repository writers while retaining force-push/deletion protections; the release environment is restricted to `master`. See [Releasing](RELEASING.md) for the one-time npm bootstrap, trusted-publisher configuration, invocation, and failure recovery.
|
|
178
|
+
|
|
179
|
+
## Acceptance Tests
|
|
180
|
+
|
|
181
|
+
| Area | Required Evidence |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| Modes and schema | Wrap/unwrap/ignore with the same width across root and nested paragraphs; invalid settings, including container-specific options and `ignoreUrls`, rejected; opt-in activation. |
|
|
184
|
+
| Containers | Ordered/unordered and task lists, nested quotes/lists, lazy continuations, loose paragraphs, and footnotes under a supporting language; markers and structure unchanged. |
|
|
185
|
+
| Breaks and inline syntax | Both Markdown hard-break forms; HTML breaks on either side of a boundary; escaped tags/backslashes; links, images, emphasis, inline code, Unicode, and literal content. |
|
|
186
|
+
| Numeric limits | Prefix-inclusive widths, tabs and wide/combining characters; prose wraps around intact URLs; overlong indivisible URLs and other constructs report without fixes; widening and narrowing reflow existing wrapping; canonical paragraphs unchanged; no partial-fix loops. |
|
|
187
|
+
| Conservative fixes | Multiline literals, directive boundaries, ambiguous indentation, unknown extensions, and syntax-changing split points produce diagnostics without unsafe edits. |
|
|
188
|
+
| Exclusions and stability | Non-paragraph blocks unchanged; configured front matter and math; LF/CRLF preservation; no fix overlap; a second fix pass makes no further changes. |
|
|
189
|
+
| Real integration | ESLint CLI check and `--fix` with CommonMark/GFM, optional extended syntax, inline disable comments, and actual `max-len` coexistence configuration. |
|
|
190
|
+
| Packaged consumer | Install the actual `npm pack` artifact in an owned temporary consumer; import public exports and run ESLint against fixtures without source-tree dependencies. |
|
|
191
|
+
|
|
192
|
+
### Data-Driven File Fixtures
|
|
193
|
+
|
|
194
|
+
Drive integration tests from checked-in fixture directories rather than Markdown strings embedded in test code. Each directory is one named case, discovered in sorted order by a shared `node --test` runner:
|
|
195
|
+
|
|
196
|
+
```text
|
|
197
|
+
test/fixtures/integration/
|
|
198
|
+
nowrap-list/
|
|
199
|
+
case.json
|
|
200
|
+
input.md
|
|
201
|
+
expected.md
|
|
202
|
+
diagnostics.json
|
|
203
|
+
numeric-url/
|
|
204
|
+
case.json
|
|
205
|
+
input.md
|
|
206
|
+
expected.md
|
|
207
|
+
diagnostics.json
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
`case.json` declares the language (`commonmark`, `gfm`, or optional `extended-syntax`), language options, rule options, and whether to enable the existing `max-len` coexistence configuration. Optional metadata selects severity, an `.md`/`.markdown` consumer filename, or expected configuration failure. The shared loader validates metadata and diagnostic fields so misspellings cannot silently skip coverage. Run each case under its declared dialect; do not indiscriminately apply GFM fixtures to CommonMark.
|
|
211
|
+
|
|
212
|
+
`input.md` is the original file; `expected.md` is the exact file after fixing. For valid and report-only cases, expected output is identical to input. Check in expected bytes rather than deriving them from the implementation. `diagnostics.json` records expected diagnostics before and after fixing using message ID, start line/column, and fix availability; optional rule ID/severity override defaults of this plugin's rule and error severity. Do not snapshot temporary absolute paths, raw fix ranges, or unrelated CLI metadata.
|
|
213
|
+
|
|
214
|
+
The CLI runner inspects and installs the actual packed plugin in an owned temporary consumer with the matrix-selected ESLint and matching host versions. It generates flat configuration from fixture metadata using public plugin exports. It reuses that installation across cases but copies each input into an isolated case directory before invoking the real ESLint CLI. It never runs `--fix` against checked-in fixtures or imports the plugin from the source checkout. A separate public-API runner exercises the same fixtures against source for focused checks and coverage. Process invocations use argument arrays and capture output and exit status.
|
|
215
|
+
|
|
216
|
+
For every case, run a check with JSON output and compare diagnostics and the expected success/failure status. Then run `--fix`, compare the resulting Markdown byte-for-byte with `expected.md`, and compare remaining diagnostics. Finally run `--fix` again and assert unchanged bytes and diagnostics. Report-only cases may still exit with lint errors; that is expected, not a runner failure. Assert that check-only invocations leave files unchanged.
|
|
217
|
+
|
|
218
|
+
Start with fixtures covering root prose, ordered/unordered/task lists, nested quotes and lists, footnotes, hard-break segments, inline markup and literals, URL-adjacent prose and overlong URLs, numeric split points that would change parsing, ignored widths, excluded blocks, inline disable comments, and `max-len` coexistence. Add invalid-option cases with expected configuration failure and unchanged input, without depending on full ESLint error wording. Exercise Unicode and tabs in file fixtures, not only helper unit tests.
|
|
219
|
+
|
|
220
|
+
Include LF and CRLF cases and a missing-final-newline case. Preserve fixture bytes with narrowly targeted `.gitattributes` entries such as `-text` for CRLF and mixed-ending fixtures so Git checkout cannot invalidate the line-ending assertions. Mixed-ending edits that cannot be proven safe should remain report-only. Keep intentionally invalid fixtures out of project documentation linting without excluding normal documentation.
|
|
221
|
+
|
|
222
|
+
Use focused helper tests only for behavior that cannot be expressed clearly through file fixtures. New end-to-end behavior should normally require adding or updating a fixture, not adding a case-specific branch to the runner. Fixture failures must identify the case and phase and show a concise diagnostic or file diff.
|
|
223
|
+
|
|
224
|
+
## Logo
|
|
225
|
+
|
|
226
|
+
[`assets/logo.svg`](assets/logo.svg) is original artwork created by GitHub Copilot for this project from the approved notepad-and-wrap-arrow concept, refined to a softer pale blue palette with a thin border. It uses the project's [MIT license](LICENSE); no third-party logos, fonts, or images were adapted.
|
|
227
|
+
|
|
228
|
+
The SVG is both the editable source and the README display asset. Its transparent 256-by-256 canvas contains a pale blue notepad, a lightly shaded folded corner, a thin blue border, and muted blue paragraph lines with a returning wrap arrow. The README places it above the title at 160 pixels wide, with descriptive alternative text and blank lines separating the logo, title, and badges.
|
|
229
|
+
|
|
230
|
+
Edit the SVG paths and colors directly; no generator, font, or raster conversion is required. Preserve the view box, accessible title/description, transparent background, and clear geometry at the README display size. Keep previews outside the package and repository. Inspect the full-size SVG and rendered README at 160 pixels on light and dark backgrounds for legibility and clipping; run `npm run lint` and `npm test`. The packed-consumer suite verifies that the exact editable logo is installed with the README.
|
|
231
|
+
|
|
232
|
+
## Scope and Review Gates
|
|
233
|
+
|
|
234
|
+
The first implementation includes numeric wrapping and `"nowrap"` together, one width policy for all paragraphs, safe fixes, documentation, and real host/package tests. Non-goals are container-specific policies, upstream deprecation work, a full Markdown formatter, semantic sentence wrapping, MDX or arbitrary renderer support, and automatic cross-rule conflict resolution.
|
|
235
|
+
|
|
236
|
+
The agreed first-version scope combines numeric wrapping and `"nowrap"`, with no checks when `width` is omitted, no container-specific options, and no URL exemptions. The scaffold PR has merged, and this implementation is the next focused PR.
|
|
237
|
+
|
|
238
|
+
Validate and open the implementation PR from its separate worktree, then pause for explicit approval. No additional merging, publication, release tags, or artwork is authorized.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dblock
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,102 @@
|
|
|
1
|
-
|
|
1
|
+
<img src="assets/logo.svg" alt="Pale blue notepad with a thin border and a paragraph-wrap arrow" width="160">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ESLint Plugin Markdown Preferences Paragraph
|
|
4
|
+
|
|
5
|
+
[](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/actions/workflows/test.yml) [](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/actions/workflows/lint.yml) [](https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/actions/workflows/package.yml) [](https://coveralls.io/github/dblock/eslint-plugin-markdown-preferences-paragraph?branch=master)
|
|
6
|
+
|
|
7
|
+
An ESLint plugin for checking and safely fixing Markdown paragraph wrapping, inspired by [eslint-plugin-markdown-preferences#338](https://github.com/ota-meshi/eslint-plugin-markdown-preferences/issues/338).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install --save-dev eslint @eslint/markdown eslint-plugin-markdown-preferences-paragraph@0.1.0
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node.js 22.13 or a supported newer version, ESLint 9.39 or 10.x, and `@eslint/markdown` 8.x.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
In `eslint.config.js`:
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import markdown from "@eslint/markdown";
|
|
23
|
+
import paragraph from "eslint-plugin-markdown-preferences-paragraph";
|
|
24
|
+
|
|
25
|
+
export default [{
|
|
26
|
+
files: ["**/*.md"],
|
|
27
|
+
plugins: { markdown, paragraph },
|
|
28
|
+
language: "markdown/gfm",
|
|
29
|
+
rules: {
|
|
30
|
+
"paragraph/wrap": ["error", { width: 80 }],
|
|
31
|
+
},
|
|
32
|
+
}];
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Check Markdown paragraphs against an 80-column width:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx eslint "**/*.md"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
The rule accepts one option, `width`:
|
|
44
|
+
|
|
45
|
+
| Value | Behavior |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| Positive integer, such as `80` | Check paragraph width and safely reflow to that width. |
|
|
48
|
+
| `"nowrap"` | Check for soft line breaks and safely join them without a width limit. |
|
|
49
|
+
| `"ignore"` | Skip paragraph checks and fixes. |
|
|
50
|
+
| Omitted (default) | Skip paragraph checks and fixes. |
|
|
51
|
+
|
|
52
|
+
One width policy applies to every paragraph, including paragraphs in lists, blockquotes, and GFM footnotes. There are no container-specific widths, URL exemptions, or recommended presets.
|
|
53
|
+
|
|
54
|
+
Numeric widths include source markup and container prefixes, Unicode display width, and four-column tab stops.
|
|
55
|
+
|
|
56
|
+
## Fixing
|
|
57
|
+
|
|
58
|
+
Apply safe paragraph fixes with ESLint:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
npx eslint "**/*.md" --fix
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Numeric widths reflow paragraphs to the configured width; `"nowrap"` joins soft line breaks. Intentional hard breaks remain separate, and words, URLs, and inline constructs are never split. Unsafe or impossible corrections report without a fix; see [Fix Safety](DESIGN.md#integration-and-implementation).
|
|
65
|
+
|
|
66
|
+
Safe paragraph segments reflow even if their existing lines already fit: changing `width` from `30` to `80` fills the wider lines when fixing. Already canonical wrapping stays untouched.
|
|
67
|
+
|
|
68
|
+
## Interoperability
|
|
69
|
+
|
|
70
|
+
### [`markdown-preferences`](https://github.com/ota-meshi/eslint-plugin-markdown-preferences)
|
|
71
|
+
|
|
72
|
+
With the plugin registered as `markdownPreferences`, configure `max-len` to skip paragraphs at the root and in containers. For example, retain an 80-column heading limit:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"markdownPreferences/max-len": ["error", {
|
|
77
|
+
"heading": 80,
|
|
78
|
+
"paragraph": "ignore",
|
|
79
|
+
"list": { "paragraph": "ignore" },
|
|
80
|
+
"blockquote": { "paragraph": "ignore" },
|
|
81
|
+
"footnoteDefinition": { "paragraph": "ignore" }
|
|
82
|
+
}]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### [markdownlint](https://github.com/DavidAnson/markdownlint)
|
|
87
|
+
|
|
88
|
+
Disable markdownlint `MD013` when enforcing unrestricted `"nowrap"`. In `.markdownlint.json`:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"MD013": false
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Contributing
|
|
97
|
+
|
|
98
|
+
Read [Contributing](CONTRIBUTING.md), [Design](DESIGN.md), [Changelog](CHANGELOG.md), [Releasing](RELEASING.md), and [Automated Contributor Instructions](AGENTS.md).
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
[MIT](LICENSE).
|
package/RELEASING.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Use **Actions > Release > Run workflow** on `master`. A dispatch requests a real release; there is no dry-run input. Opening or merging the workflow setup PR does not authorize publication. Finish the one-time setup and review the candidate before dispatching.
|
|
4
|
+
|
|
5
|
+
## One-time Setup
|
|
6
|
+
|
|
7
|
+
The package is initially unpublished. npm requires an existing package before its trusted publisher can be configured, unlike RubyGems pending publishers. The approved bootstrap uses npm staged publishing: stage a separate `0.0.1-bootstrap` candidate to establish the public `0.0.0-stage` placeholder on npm, then configure the trusted publisher and run the normal workflow for the real `0.1.0` release. This keeps changelog, tag, GitHub Release, and next-version preparation in release-it rather than creating bootstrap metadata PRs.
|
|
8
|
+
|
|
9
|
+
Use npm 11.15.0 or later for staging and trusted-publisher configuration, with a compatible Node version. Log in and complete account-level 2FA locally; never share credentials. Prepare the bootstrap tarball from an isolated copy with version `0.0.1-bootstrap`, leaving tracked package and lockfile versions at `0.1.0`, then stage it:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm stage publish .release/eslint-plugin-markdown-preferences-paragraph-0.0.1-bootstrap.tgz --access public --tag bootstrap
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Keep the bootstrap stage pending until the real `0.1.0` release succeeds. Do not stage `0.1.0`: staged and published versions share the same unique version index. Do not approve the bootstrap candidate or reject it before the real release exists; rejecting the only stage can remove the placeholder and trigger the 24-hour npm unpublish lock.
|
|
16
|
+
|
|
17
|
+
Create an npm trusted publisher at **Package > Settings > Trusted Publisher** with owner `dblock`, repository `eslint-plugin-markdown-preferences-paragraph`, workflow filename `release.yml`, and environment `release`. Explicitly allow direct `npm publish`, not only staged publication; staged approval requires a human and is not this workflow. Create the connection just before its first automated release: a new connection expires if its first successful publish does not happen within two days.
|
|
18
|
+
|
|
19
|
+
The GitHub `release` environment is configured for only `master`, without required reviewers. Writers can dispatch releases; add maintainer reviewers if approval before publication is desired. The approved no-App setup removes the default branch's PR requirement, allowing all writers to push directly, while preserving active force-push and deletion protections without bypass actors. Ordinary development must still use PRs.
|
|
20
|
+
|
|
21
|
+
After verifying the real release, reject the pending bootstrap stage with `npm stage reject <stage-id>`. Do not unpublish the public placeholder or the released package.
|
|
22
|
+
|
|
23
|
+
The workflow uses Node 26.13.0 and requires npm 11.5.1 or later. It uses the built-in `GITHUB_TOKEN` for Git and GitHub Releases, with `contents: write` and `id-token: write`. No GitHub App, stored npm token, or `NODE_AUTH_TOKEN` is needed. npm automatically supplies provenance for this public package and repository.
|
|
24
|
+
|
|
25
|
+
After verifying trusted publication, select **Require two-factor authentication and disallow tokens** in npm publishing-access settings. This does not disable OIDC. Never put registry credentials into the repository or this conversation.
|
|
26
|
+
|
|
27
|
+
## Prepare and Dispatch
|
|
28
|
+
|
|
29
|
+
Keep `package.json`, `package-lock.json`, and `## <version> (Next)` synchronized. Feature PRs choose their version according to the contributor policy; the workflow defaults to that checked-in version and automatically prepares the next patch after release. Require actual changelog entries, not just `- Your contribution here.`.
|
|
30
|
+
|
|
31
|
+
Run local validation and confirm the separate Test, Lint, and Package checks before requesting publication:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm ci
|
|
35
|
+
npm run test:coverage
|
|
36
|
+
npm run lint
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The coverage command includes real packed-package CLI consumers; do not immediately repeat `package:check`. Release-tool tests use isolated Git repositories and simulated npm responses, never actual publication.
|
|
40
|
+
|
|
41
|
+
After explicit release authorization, dispatch from `master`:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
gh workflow run release.yml --repo github.com/dblock/eslint-plugin-markdown-preferences-paragraph --ref master
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To override the checked-in version, provide a stable version without a `v` prefix:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
gh workflow run release.yml --repo github.com/dblock/eslint-plugin-markdown-preferences-paragraph --ref master -f version=0.2.0
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Overrides cannot be older than the checked-in version. Prerelease versions are not supported. Concurrent release runs are serialized, and source changes after dispatch cause failure rather than rebasing or overwriting another writer.
|
|
54
|
+
|
|
55
|
+
## Automated Sequence
|
|
56
|
+
|
|
57
|
+
Actions checks out the exact dispatched commit and runs only on `master` in the restricted release environment, using the pinned Node runtime. Hooks require a clean installation, meaningful release notes, an unpublished version, and an absent tag. Release-it updates version declarations; hooks date the Next section in UTC, remove its placeholder while preserving prior entries, and update the README npm installation pin to the release version before packing.
|
|
58
|
+
|
|
59
|
+
The hooks pack once to `.release/package.tgz`, retain a checksum and metadata patch, run lint, and pass that exact archive to the full tests through `PARAGRAPH_WRAP_PACKAGE_ARCHIVE`. The real consumer suite inspects and installs it, checks version and logo integrity, and exercises ESLint check/fix/stability fixtures. Release changes are limited to package version fields, the changelog, and README. Immediately before publication, hooks reject changed metadata/artifacts or an advanced remote `master`.
|
|
60
|
+
|
|
61
|
+
Release-it's native npm publisher publishes that retained tarball through OIDC, without repacking. Its native Git plugin then commits the metadata, creates the annotated tag, and atomically pushes the commit/tag. Before creating the GitHub Release, a hook verifies that the remote branch/tag match the release commit and that the registry version/integrity match the validated archive. Release-it attaches the tarball/checksum to the GitHub Release.
|
|
62
|
+
|
|
63
|
+
After publication and GitHub Release creation succeed, a second release-it run increments patch, creates the new Next section and placeholder through its changelog hook, runs lint and full tests for that next version, and commits/pushes its metadata. The README installation pin stays at the released version, not the unpublished development version. This run does not publish, create tags, or create another GitHub Release. No ordinary development commits are authorized to bypass PRs.
|
|
64
|
+
|
|
65
|
+
## Failure Recovery
|
|
66
|
+
|
|
67
|
+
Download the run's release artifacts before retention expires: the tarball, checksum, and release metadata patch are retained for 14 days even when later stages fail. Do not blindly rerun a failed release; its tag or npm version may already exist, and the workflow deliberately rejects duplicates. Native destructive rollback handlers are disabled; prepared metadata is not reset on failure.
|
|
68
|
+
|
|
69
|
+
Check the remote tag/commit, npm version and `dist.integrity`, GitHub Release, and current `master` before choosing recovery. npm is published before Git is pushed: if Git fails after successful publication, reconstruct the release metadata from the exact dispatched source and retained patch, reconcile any newer work without overwriting it, and finish the missing commit/tag push only with explicit authorization. If npm publication failed, investigate authentication and use the original validated tarball through an explicitly authorized workflow or interactive publication. Registry visibility can lag; verify it before treating a missing lookup as a failed publication. If only the GitHub Release or next-version preparation failed, finish those missing steps. Never overwrite registry versions, force-push `master`, delete release tags, unpublish, or reset another writer's work.
|
package/assets/logo.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256" role="img" aria-labelledby="title description">
|
|
2
|
+
<title id="title">Paragraph Wrapping</title>
|
|
3
|
+
<desc id="description">A pale blue notepad with a thin border, muted blue paragraph lines, and a returning wrap arrow.</desc>
|
|
4
|
+
<path d="M60 20h108l40 40v156a20 20 0 0 1-20 20H60a20 20 0 0 1-20-20V40a20 20 0 0 1 20-20Z" fill="#f1f7fc" stroke="#b8cedf" stroke-width="2" stroke-linejoin="round"/>
|
|
5
|
+
<path d="M168 20v40h40Z" fill="#deebf6" stroke="#b8cedf" stroke-width="2" stroke-linejoin="round"/>
|
|
6
|
+
<g fill="none" stroke="#8cafca" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
|
|
7
|
+
<path d="M72 76h70M72 106h110M72 136h60M72 166h60M72 196h110"/>
|
|
8
|
+
<path d="M174 128h8a18 18 0 0 1 0 36h-22"/>
|
|
9
|
+
<path d="m174 150-14 14 14 14"/>
|
|
10
|
+
</g>
|
|
11
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eslint-plugin-markdown-preferences-paragraph",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "An ESLint plugin for checking and safely fixing Markdown paragraph wrapping.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src/",
|
|
12
|
+
"assets/logo.svg",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"CONTRIBUTING.md",
|
|
17
|
+
"DESIGN.md",
|
|
18
|
+
"RELEASING.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": "^22.13.0 || >=24.0.0"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test",
|
|
25
|
+
"test:coverage": "c8 --reporter=text --reporter=lcov npm test",
|
|
26
|
+
"lint": "npm run lint:code && npm run lint:markdown && npm run lint:workflows",
|
|
27
|
+
"lint:code": "eslint .",
|
|
28
|
+
"lint:markdown": "eslint --config eslint.config-markdown.js \"*.md\"",
|
|
29
|
+
"lint:workflows": "node scripts/check-workflow-names.js",
|
|
30
|
+
"release": "node scripts/release.js",
|
|
31
|
+
"package:check": "node --test test/integration.test.js"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"string-width": "^8.3.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@eslint/markdown": "^8.0.0",
|
|
38
|
+
"eslint": "^9.39.0 || ^10.0.0"
|
|
39
|
+
},
|
|
40
|
+
"overrides": {
|
|
41
|
+
"get-uri": {
|
|
42
|
+
"basic-ftp": "^6.2.6"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@eslint/js": "^10.0.1",
|
|
47
|
+
"@eslint/markdown": "^8.0.3",
|
|
48
|
+
"@cspell/dict-software-terms": "^5.4.9",
|
|
49
|
+
"c8": "^12.0.0",
|
|
50
|
+
"eslint": "^10.14.0",
|
|
51
|
+
"eslint-plugin-markdown-preferences": "^0.43.0",
|
|
52
|
+
"cspell-lib": "^10.4.1",
|
|
53
|
+
"release-it": "^21.1.2",
|
|
54
|
+
"title-case": "^4.3.2",
|
|
55
|
+
"yaml": "^2.9.1"
|
|
56
|
+
},
|
|
57
|
+
"repository": {
|
|
58
|
+
"type": "git",
|
|
59
|
+
"url": "git+https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph.git"
|
|
60
|
+
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph/issues"
|
|
63
|
+
},
|
|
64
|
+
"homepage": "https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph#readme",
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"author": "dblock",
|
|
67
|
+
"keywords": [
|
|
68
|
+
"eslint",
|
|
69
|
+
"eslintplugin",
|
|
70
|
+
"markdown",
|
|
71
|
+
"paragraph",
|
|
72
|
+
"wrap"
|
|
73
|
+
],
|
|
74
|
+
"c8": {
|
|
75
|
+
"include": [
|
|
76
|
+
"src/**/*.js",
|
|
77
|
+
"scripts/**/*.js"
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import wrap from "./rules/wrap.js";
|
|
3
|
+
|
|
4
|
+
const { name, version } = JSON.parse(
|
|
5
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
meta: { name, version },
|
|
10
|
+
rules: { wrap },
|
|
11
|
+
};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import markdown from "@eslint/markdown";
|
|
2
|
+
import stringWidth from "string-width";
|
|
3
|
+
|
|
4
|
+
const containers = new Set([
|
|
5
|
+
"root", "list", "listItem", "blockquote", "footnoteDefinition",
|
|
6
|
+
]);
|
|
7
|
+
const literals = new Set([
|
|
8
|
+
"inlineCode", "inlineMath", "html", "link", "image",
|
|
9
|
+
"linkReference", "imageReference",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
function textWidth(text) {
|
|
13
|
+
return text.split("\t").reduce(
|
|
14
|
+
(width, part, index) =>
|
|
15
|
+
width + (index ? 4 - (width % 4) : 0) + stringWidth(part),
|
|
16
|
+
0,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sourceLines(text) {
|
|
21
|
+
const lines = [];
|
|
22
|
+
for (const match of text.matchAll(/([^\r\n]*)(\r\n|\r|\n|$)/gu)) {
|
|
23
|
+
if (!match[0]) continue;
|
|
24
|
+
lines.push({
|
|
25
|
+
content: match[1],
|
|
26
|
+
ending: match[2],
|
|
27
|
+
start: match.index,
|
|
28
|
+
end: match.index + match[1].length,
|
|
29
|
+
number: lines.length + 1,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return lines;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function visit(node, callback, ancestors = []) {
|
|
36
|
+
callback(node, ancestors);
|
|
37
|
+
for (const child of node.children ?? []) {
|
|
38
|
+
visit(child, callback, [...ancestors, node]);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalized(ast) {
|
|
43
|
+
return JSON.stringify(ast, (key, value) => {
|
|
44
|
+
if (key === "position") return undefined;
|
|
45
|
+
if (value?.type === "text") {
|
|
46
|
+
return {
|
|
47
|
+
...value,
|
|
48
|
+
value: value.value.replace(/[ \t]*(?:\r\n|\r|\n)[ \t]*/gu, " "),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
return value;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function verifier(sourceCode, languageOptions) {
|
|
56
|
+
let languages;
|
|
57
|
+
const original = normalized(sourceCode.ast);
|
|
58
|
+
function parse(language, text) {
|
|
59
|
+
const result = language.parse({ body: text }, { languageOptions });
|
|
60
|
+
return result.ok ? normalized(result.ast) : null;
|
|
61
|
+
}
|
|
62
|
+
return (range, replacement) => {
|
|
63
|
+
// Rule contexts do not expose the active language. Require every matching
|
|
64
|
+
// public dialect to preserve the original AST, rather than guessing one.
|
|
65
|
+
languages ??= Object.values(markdown.languages).filter(
|
|
66
|
+
(language) => parse(language, sourceCode.text) === original,
|
|
67
|
+
);
|
|
68
|
+
const candidate =
|
|
69
|
+
sourceCode.text.slice(0, range[0]) +
|
|
70
|
+
replacement +
|
|
71
|
+
sourceCode.text.slice(range[1]);
|
|
72
|
+
return languages.length > 0 &&
|
|
73
|
+
languages.every((language) => parse(language, candidate) === original);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function continuationPrefix(prefix, ancestors) {
|
|
78
|
+
if (prefix.includes("\t")) return null;
|
|
79
|
+
let remaining = prefix;
|
|
80
|
+
let result = "";
|
|
81
|
+
let quotes = 0;
|
|
82
|
+
while (remaining) {
|
|
83
|
+
const match = remaining.match(
|
|
84
|
+
/^( +|> ?|(?:[-+*]|\d{1,9}[.)]) +|\[\^[^\]\r\n]+\]: *|\[[ xX]\] +)/u,
|
|
85
|
+
);
|
|
86
|
+
if (!match) return null;
|
|
87
|
+
const token = match[0];
|
|
88
|
+
if (token.startsWith(">")) {
|
|
89
|
+
quotes++;
|
|
90
|
+
result += token;
|
|
91
|
+
} else if (/^ +$/u.test(token)) {
|
|
92
|
+
result += token;
|
|
93
|
+
} else if (token.startsWith("[^")) {
|
|
94
|
+
if (!ancestors.some((node) => node.type === "footnoteDefinition")) return null;
|
|
95
|
+
result += " ";
|
|
96
|
+
} else if (token.startsWith("[")) {
|
|
97
|
+
if (!ancestors.some((node) =>
|
|
98
|
+
node.type === "listItem" && typeof node.checked === "boolean"
|
|
99
|
+
)) return null;
|
|
100
|
+
} else {
|
|
101
|
+
if (!ancestors.some((node) => node.type === "listItem")) return null;
|
|
102
|
+
result += " ".repeat(token.length);
|
|
103
|
+
}
|
|
104
|
+
remaining = remaining.slice(token.length);
|
|
105
|
+
}
|
|
106
|
+
return quotes === ancestors.filter((node) => node.type === "blockquote").length
|
|
107
|
+
? result
|
|
108
|
+
: null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function continuationStart(line, quotes) {
|
|
112
|
+
let offset = 0;
|
|
113
|
+
for (let index = 0; index < quotes; index++) {
|
|
114
|
+
const match = line.slice(offset).match(/^[ \t]*> ?/u);
|
|
115
|
+
if (!match) break;
|
|
116
|
+
offset += match[0].length;
|
|
117
|
+
}
|
|
118
|
+
return offset + line.slice(offset).match(/^[ \t]*/u)[0].length;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function paragraphSegments(node, ancestors, lines, text) {
|
|
122
|
+
const quotes = ancestors.filter((parent) => parent.type === "blockquote").length;
|
|
123
|
+
const rows = lines
|
|
124
|
+
.slice(node.position.start.line - 1, node.position.end.line)
|
|
125
|
+
.map((line, index, selected) => {
|
|
126
|
+
const start = index === 0
|
|
127
|
+
? node.position.start.offset
|
|
128
|
+
: line.start + continuationStart(line.content, quotes);
|
|
129
|
+
const end = index === selected.length - 1
|
|
130
|
+
? node.position.end.offset
|
|
131
|
+
: line.end;
|
|
132
|
+
return {
|
|
133
|
+
...line,
|
|
134
|
+
bodyStart: start,
|
|
135
|
+
bodyEnd: end,
|
|
136
|
+
prefix: text.slice(line.start, start),
|
|
137
|
+
body: text.slice(start, end),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
const boundaries = new Set();
|
|
141
|
+
visit(node, (child) => {
|
|
142
|
+
if (child.type === "break") {
|
|
143
|
+
boundaries.add(child.position.start.line);
|
|
144
|
+
}
|
|
145
|
+
if (child.type === "html" && /^<br\s*\/?>$/iu.test(child.value)) {
|
|
146
|
+
const row = rows.find((line) => line.number === child.position.start.line);
|
|
147
|
+
if (!row) return;
|
|
148
|
+
if (/^[ \t]*$/u.test(text.slice(child.position.end.offset, row.bodyEnd))) {
|
|
149
|
+
boundaries.add(row.number);
|
|
150
|
+
}
|
|
151
|
+
if (/^[ \t]*$/u.test(text.slice(row.bodyStart, child.position.start.offset))) {
|
|
152
|
+
boundaries.add(row.number - 1);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
const segments = [];
|
|
157
|
+
let segment = [];
|
|
158
|
+
for (const row of rows) {
|
|
159
|
+
segment.push(row);
|
|
160
|
+
if (boundaries.has(row.number)) {
|
|
161
|
+
segments.push(segment);
|
|
162
|
+
segment = [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (segment.length) segments.push(segment);
|
|
166
|
+
return segments;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function joinSegment(rows, node) {
|
|
170
|
+
const textNodes = node.children.filter((child) => child.type === "text");
|
|
171
|
+
const atoms = node.children.filter((child) => child.type !== "text");
|
|
172
|
+
let text = "";
|
|
173
|
+
const gaps = [];
|
|
174
|
+
for (const [index, row] of rows.entries()) {
|
|
175
|
+
if (index > 0) {
|
|
176
|
+
const boundary = rows[index - 1].end;
|
|
177
|
+
if (!atoms.some((atom) =>
|
|
178
|
+
atom.position.start.offset <= boundary &&
|
|
179
|
+
atom.position.end.offset > boundary
|
|
180
|
+
)) {
|
|
181
|
+
gaps.push({ start: text.length, end: text.length + 1 });
|
|
182
|
+
}
|
|
183
|
+
text += " ";
|
|
184
|
+
}
|
|
185
|
+
const body = index < rows.length - 1
|
|
186
|
+
? row.body.replace(/[ \t]+$/u, "")
|
|
187
|
+
: row.body;
|
|
188
|
+
for (const match of body.matchAll(/[ \t]+/gu)) {
|
|
189
|
+
const start = row.bodyStart + match.index;
|
|
190
|
+
const end = start + match[0].length;
|
|
191
|
+
const escaped = body.slice(0, match.index).match(/\\+$/u)?.[0].length ?? 0;
|
|
192
|
+
if (escaped % 2 === 0 && textNodes.some((child) =>
|
|
193
|
+
child.position.start.offset <= start && child.position.end.offset >= end
|
|
194
|
+
)) {
|
|
195
|
+
gaps.push({ start: text.length + match.index, end: text.length + match.index + match[0].length });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
text += body;
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
text,
|
|
202
|
+
gaps: gaps.filter((gap) => gap.start > 0 && gap.end < text.length),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function wrapSegment(joined, firstPrefix, nextPrefix, suffix, width, ending) {
|
|
207
|
+
const { text, gaps } = joined;
|
|
208
|
+
const output = [];
|
|
209
|
+
let start = 0;
|
|
210
|
+
let prefix = firstPrefix;
|
|
211
|
+
let current = "";
|
|
212
|
+
let separator = "";
|
|
213
|
+
for (const gap of [...gaps, { start: text.length, end: text.length }]) {
|
|
214
|
+
const token = text.slice(start, gap.start);
|
|
215
|
+
if (current && textWidth(prefix + current + separator + token) > width) {
|
|
216
|
+
if (textWidth(prefix + current) > width) return null;
|
|
217
|
+
output.push(current);
|
|
218
|
+
prefix = nextPrefix;
|
|
219
|
+
current = token;
|
|
220
|
+
} else {
|
|
221
|
+
current += separator + token;
|
|
222
|
+
}
|
|
223
|
+
separator = text.slice(gap.start, gap.end);
|
|
224
|
+
start = gap.end;
|
|
225
|
+
}
|
|
226
|
+
if (textWidth(prefix + current + suffix) > width) return null;
|
|
227
|
+
output.push(current);
|
|
228
|
+
return output.join(ending + nextPrefix);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function lineEnding(rows, lines) {
|
|
232
|
+
const endings = new Set(rows.slice(0, -1).map((row) => row.ending));
|
|
233
|
+
if (endings.size > 1) return null;
|
|
234
|
+
if (endings.size === 1) return [...endings][0];
|
|
235
|
+
if (rows[0].ending) return rows[0].ending;
|
|
236
|
+
const fileEndings = new Set(lines.map((line) => line.ending).filter(Boolean));
|
|
237
|
+
return fileEndings.size > 1 ? null : [...fileEndings][0] ?? "\n";
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export default {
|
|
241
|
+
meta: {
|
|
242
|
+
type: "layout",
|
|
243
|
+
languages: ["markdown/*", "markdown-preferences/*"],
|
|
244
|
+
docs: {
|
|
245
|
+
description: "Wrap or unwrap Markdown paragraphs without changing their structure.",
|
|
246
|
+
url: "https://github.com/dblock/eslint-plugin-markdown-preferences-paragraph#configuration",
|
|
247
|
+
},
|
|
248
|
+
fixable: "whitespace",
|
|
249
|
+
schema: [{
|
|
250
|
+
type: "object",
|
|
251
|
+
properties: {
|
|
252
|
+
width: {
|
|
253
|
+
oneOf: [
|
|
254
|
+
{ type: "integer", minimum: 1 },
|
|
255
|
+
{ enum: ["nowrap", "ignore"] },
|
|
256
|
+
],
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
additionalProperties: false,
|
|
260
|
+
}],
|
|
261
|
+
messages: {
|
|
262
|
+
nowrap: "Keep each soft-wrapped paragraph segment on one source line.",
|
|
263
|
+
width: "Source line width {{actual}} exceeds the maximum of {{width}}.",
|
|
264
|
+
reflow: "Reflow this paragraph segment to width {{width}}.",
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
create(context) {
|
|
268
|
+
const width = context.options[0]?.width ?? "ignore";
|
|
269
|
+
const sourceCode = context.sourceCode;
|
|
270
|
+
if (width === "ignore" || sourceCode.ast.type !== "root") return {};
|
|
271
|
+
const lines = sourceLines(sourceCode.text);
|
|
272
|
+
const parents = new WeakMap();
|
|
273
|
+
visit(sourceCode.ast, (node, ancestors) => parents.set(node, ancestors));
|
|
274
|
+
const verify = verifier(sourceCode, context.languageOptions);
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
paragraph(node) {
|
|
278
|
+
const ancestors = parents.get(node);
|
|
279
|
+
const segments = paragraphSegments(node, ancestors, lines, sourceCode.text);
|
|
280
|
+
const firstLine = lines[node.position.start.line - 1];
|
|
281
|
+
const prefix = continuationPrefix(
|
|
282
|
+
sourceCode.text.slice(firstLine.start, node.position.start.offset),
|
|
283
|
+
ancestors,
|
|
284
|
+
);
|
|
285
|
+
let unsafe = prefix === null || ancestors.some((parent) => !containers.has(parent.type));
|
|
286
|
+
const unsafeRanges = [];
|
|
287
|
+
visit(node, (child) => {
|
|
288
|
+
if (
|
|
289
|
+
(literals.has(child.type) &&
|
|
290
|
+
child.position.start.line !== child.position.end.line) ||
|
|
291
|
+
(child.type === "html" && child.value.includes("<!--"))
|
|
292
|
+
) {
|
|
293
|
+
unsafeRanges.push([child.position.start.offset, child.position.end.offset]);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
if (segments.flat().some((row) => /^(?::::|<<<)(?:\s|$)/u.test(row.body))) {
|
|
297
|
+
unsafe = true;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const rows of segments) {
|
|
301
|
+
const violations = width === "nowrap"
|
|
302
|
+
? (rows.length > 1 ? [rows[0]] : [])
|
|
303
|
+
: rows.filter((row) => textWidth(row.content) > width);
|
|
304
|
+
const reflow = violations.length === 0;
|
|
305
|
+
if (width === "nowrap" && reflow) continue;
|
|
306
|
+
const range = [rows[0].bodyStart, rows.at(-1).bodyEnd];
|
|
307
|
+
const ending = lineEnding(rows, lines);
|
|
308
|
+
let replacement = null;
|
|
309
|
+
if (
|
|
310
|
+
!unsafe && ending &&
|
|
311
|
+
!rows.some((row) => row.prefix.includes("\t")) &&
|
|
312
|
+
!unsafeRanges.some(([start, end]) => start < range[1] && end > range[0])
|
|
313
|
+
) {
|
|
314
|
+
const joined = joinSegment(rows, node);
|
|
315
|
+
replacement = width === "nowrap"
|
|
316
|
+
? joined.text
|
|
317
|
+
: wrapSegment(
|
|
318
|
+
joined,
|
|
319
|
+
rows[0].prefix,
|
|
320
|
+
prefix,
|
|
321
|
+
sourceCode.text.slice(rows.at(-1).bodyEnd, rows.at(-1).end),
|
|
322
|
+
width,
|
|
323
|
+
ending,
|
|
324
|
+
);
|
|
325
|
+
if (replacement !== null && (
|
|
326
|
+
replacement === sourceCode.text.slice(range[0], range[1]) ||
|
|
327
|
+
!verify(range, replacement)
|
|
328
|
+
)) replacement = null;
|
|
329
|
+
}
|
|
330
|
+
if (reflow) {
|
|
331
|
+
if (replacement === null) continue;
|
|
332
|
+
violations.push(rows[0]);
|
|
333
|
+
}
|
|
334
|
+
for (const [index, row] of violations.entries()) {
|
|
335
|
+
context.report({
|
|
336
|
+
node,
|
|
337
|
+
loc: {
|
|
338
|
+
start: {
|
|
339
|
+
line: row.number,
|
|
340
|
+
column: width === "nowrap" ? row.bodyStart - row.start + 1 : 1,
|
|
341
|
+
},
|
|
342
|
+
end: { line: row.number, column: row.content.length + 1 },
|
|
343
|
+
},
|
|
344
|
+
messageId: width === "nowrap" ? "nowrap" : reflow ? "reflow" : "width",
|
|
345
|
+
data: { actual: String(textWidth(row.content)), width: String(width) },
|
|
346
|
+
...(index === 0 && replacement !== null ? {
|
|
347
|
+
fix: (fixer) => fixer.replaceTextRange(range, replacement),
|
|
348
|
+
} : {}),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
};
|