@webpro/remark-wrap-prose 1.0.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/LICENSE +15 -0
- package/README.md +59 -0
- package/editorconfig.js +123 -0
- package/index.d.ts +14 -0
- package/index.js +108 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License (ISC)
|
|
2
|
+
|
|
3
|
+
Copyright 2026 Lars Kappert
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose
|
|
6
|
+
with or without fee is hereby granted, provided that the above copyright notice
|
|
7
|
+
and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
11
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
|
|
13
|
+
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
|
|
14
|
+
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
|
15
|
+
THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# remark-wrap-prose
|
|
2
|
+
|
|
3
|
+
Wrap prose to a maximum line width when serializing markdown.
|
|
4
|
+
|
|
5
|
+
Only paragraphs are reflowed. Indentation from lists and block quotes is handled
|
|
6
|
+
by the default serializer, so wrapping stays within the limit at every nesting
|
|
7
|
+
level. Tables, code, and other non-prose blocks are left untouched. Wide CJK
|
|
8
|
+
characters and emoji count as two columns. Words wider than the limit are never
|
|
9
|
+
broken mid-word.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pnpm add @webpro/remark-wrap-prose
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Use
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
import { remark } from "remark";
|
|
21
|
+
import remarkWrapProse from "@webpro/remark-wrap-prose";
|
|
22
|
+
|
|
23
|
+
const file = await remark().use(remarkWrapProse, { width: 80 }).process(input);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Example
|
|
27
|
+
|
|
28
|
+
### Input
|
|
29
|
+
|
|
30
|
+
```markdown
|
|
31
|
+
Custom elements are registered under a tag name. When such a class isn't referenced elsewhere, it would be reported as unused. Instead, the registration is recognized.
|
|
32
|
+
|
|
33
|
+
- A list item with enough words in it to require wrapping across more than one single line at the configured width.
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Output
|
|
37
|
+
|
|
38
|
+
```markdown
|
|
39
|
+
Custom elements are registered under a tag name. When such a class isn't
|
|
40
|
+
referenced elsewhere, it would be reported as unused. Instead, the registration
|
|
41
|
+
is recognized.
|
|
42
|
+
|
|
43
|
+
- A list item with enough words in it to require wrapping across more than one
|
|
44
|
+
single line at the configured width.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Width
|
|
48
|
+
|
|
49
|
+
The wrap width is resolved in order:
|
|
50
|
+
|
|
51
|
+
1. The `width` option.
|
|
52
|
+
2. `max_line_length` from the nearest [`.editorconfig`][editorconfig].
|
|
53
|
+
3. `80`.
|
|
54
|
+
|
|
55
|
+
Prettier maps `max_line_length` to `printWidth`, so a single `.editorconfig`
|
|
56
|
+
entry drives both this plugin's prose wrapping and Prettier's code/table
|
|
57
|
+
wrapping.
|
|
58
|
+
|
|
59
|
+
[editorconfig]: https://editorconfig.org
|
package/editorconfig.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read `max_line_length` that applies to a markdown file in `dir`, from the
|
|
6
|
+
* nearest `.editorconfig` files (walking up to a `root = true` file). A focused
|
|
7
|
+
* reader: it only needs this one property and the glob features that occur in
|
|
8
|
+
* practice (`*`, `**`, `?`, `[set]`, `{a,b}`). Prettier maps the same key to
|
|
9
|
+
* `printWidth`, so one setting drives both prose and code wrapping.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} [dir]
|
|
12
|
+
* @returns {number | undefined}
|
|
13
|
+
*/
|
|
14
|
+
export function editorConfigWidth(dir = process.cwd()) {
|
|
15
|
+
try {
|
|
16
|
+
const configs = [];
|
|
17
|
+
for (let current = dir; ; ) {
|
|
18
|
+
let text;
|
|
19
|
+
try {
|
|
20
|
+
text = readFileSync(join(current, '.editorconfig'), 'utf8');
|
|
21
|
+
} catch {}
|
|
22
|
+
if (text !== undefined) {
|
|
23
|
+
const config = parseEditorConfig(text);
|
|
24
|
+
configs.push(config);
|
|
25
|
+
if (config.root) break;
|
|
26
|
+
}
|
|
27
|
+
const parent = dirname(current);
|
|
28
|
+
if (parent === current) break;
|
|
29
|
+
current = parent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Nearest config wins, so apply the farthest first and let nearer override.
|
|
33
|
+
let value;
|
|
34
|
+
for (const config of configs.reverse()) {
|
|
35
|
+
for (const [pattern, max] of config.sections) {
|
|
36
|
+
if (max !== undefined && pattern.test('x.md')) value = max;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const width = Number(value);
|
|
41
|
+
return Number.isInteger(width) && width > 0 ? width : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @returns {{ root: boolean, sections: Array<[RegExp, string | undefined]> }}
|
|
50
|
+
*/
|
|
51
|
+
function parseEditorConfig(text) {
|
|
52
|
+
let root = false;
|
|
53
|
+
/** @type {Array<[RegExp, Record<string, string>]>} */
|
|
54
|
+
const sections = [];
|
|
55
|
+
let props;
|
|
56
|
+
|
|
57
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
58
|
+
const line = raw.trim();
|
|
59
|
+
if (line === '' || line.startsWith('#') || line.startsWith(';')) continue;
|
|
60
|
+
|
|
61
|
+
if (line.startsWith('[') && line.endsWith(']')) {
|
|
62
|
+
props = {};
|
|
63
|
+
sections.push([globToRegExp(line.slice(1, -1)), props]);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const eq = line.indexOf('=');
|
|
68
|
+
if (eq === -1) continue;
|
|
69
|
+
const key = line.slice(0, eq).trim().toLowerCase();
|
|
70
|
+
const val = line.slice(eq + 1).trim();
|
|
71
|
+
if (props) props[key] = val;
|
|
72
|
+
else if (key === 'root') root = val.toLowerCase() === 'true';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
root,
|
|
77
|
+
sections: sections.map(([re, p]) => [re, p.max_line_length])
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Compile an EditorConfig section glob to an anchored RegExp.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} glob
|
|
85
|
+
* @returns {RegExp}
|
|
86
|
+
*/
|
|
87
|
+
function globToRegExp(glob) {
|
|
88
|
+
let re = '';
|
|
89
|
+
for (let i = 0; i < glob.length; i++) {
|
|
90
|
+
const c = glob[i];
|
|
91
|
+
if (c === '*') {
|
|
92
|
+
if (glob[i + 1] === '*') {
|
|
93
|
+
re += '.*';
|
|
94
|
+
i++;
|
|
95
|
+
} else {
|
|
96
|
+
re += '[^/]*';
|
|
97
|
+
}
|
|
98
|
+
} else if (c === '?') {
|
|
99
|
+
re += '[^/]';
|
|
100
|
+
} else if (c === '{') {
|
|
101
|
+
const close = glob.indexOf('}', i);
|
|
102
|
+
if (close === -1) {
|
|
103
|
+
re += '\\{';
|
|
104
|
+
} else {
|
|
105
|
+
const alts = glob.slice(i + 1, close).split(',');
|
|
106
|
+
re += '(?:' + alts.map(a => globToRegExp(a).source.slice(1, -1)).join('|') + ')';
|
|
107
|
+
i = close;
|
|
108
|
+
}
|
|
109
|
+
} else if (c === '[') {
|
|
110
|
+
const close = glob.indexOf(']', i + 1);
|
|
111
|
+
if (close === -1) {
|
|
112
|
+
re += '\\[';
|
|
113
|
+
} else {
|
|
114
|
+
const set = glob.slice(i + 1, close);
|
|
115
|
+
re += '[' + (set[0] === '!' ? '^' + set.slice(1) : set) + ']';
|
|
116
|
+
i = close;
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
re += c.replace(/[.+^${}()|\\]/g, '\\$&');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return new RegExp('^' + re + '$');
|
|
123
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface Options {
|
|
2
|
+
/**
|
|
3
|
+
* Maximum display width (columns) per line. Overrides `.editorconfig`.
|
|
4
|
+
*
|
|
5
|
+
* Resolved in order: this option, then `max_line_length` from
|
|
6
|
+
* `.editorconfig`, then `80`.
|
|
7
|
+
*/
|
|
8
|
+
width?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Wrap prose to a maximum line width when serializing back to markdown.
|
|
13
|
+
*/
|
|
14
|
+
export default function remarkWrapProse(options?: Options): undefined;
|
package/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fastStringTruncatedWidth from 'fast-string-truncated-width';
|
|
2
|
+
import { editorConfigWidth } from './editorconfig.js';
|
|
3
|
+
|
|
4
|
+
const DEFAULT_WIDTH = 80;
|
|
5
|
+
|
|
6
|
+
const widthOptions = {
|
|
7
|
+
tabWidth: 1,
|
|
8
|
+
emojiWidth: 2,
|
|
9
|
+
regularWidth: 1,
|
|
10
|
+
wideWidth: 2
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Display width of a string, counting wide CJK characters and emoji as 2.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} value
|
|
17
|
+
* @returns {number}
|
|
18
|
+
*/
|
|
19
|
+
function displayWidth(value) {
|
|
20
|
+
return fastStringTruncatedWidth(value, { limit: Infinity }, widthOptions)
|
|
21
|
+
.width;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ASCII soft-wrap whitespace (space, tab, newline, form feed, vertical tab).
|
|
25
|
+
// Unicode spaces (no-break U+00A0, narrow no-break U+202F, …) are intentional
|
|
26
|
+
// content and are left untouched, so they survive reflow.
|
|
27
|
+
const SOFT_WHITESPACE = /[ \t\n\r\f\v]+/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Greedily wrap words to a maximum display width, collapsing only ASCII
|
|
31
|
+
* soft-wrap whitespace. Words wider than the limit are kept intact (never
|
|
32
|
+
* broken mid-word).
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @param {number} width
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function fill(text, width) {
|
|
39
|
+
/** @type {Array<string>} */
|
|
40
|
+
const lines = [];
|
|
41
|
+
let line = '';
|
|
42
|
+
let lineWidth = 0;
|
|
43
|
+
|
|
44
|
+
for (const word of text.split(SOFT_WHITESPACE)) {
|
|
45
|
+
if (!word) continue;
|
|
46
|
+
const wordWidth = displayWidth(word);
|
|
47
|
+
if (line === '') {
|
|
48
|
+
line = word;
|
|
49
|
+
lineWidth = wordWidth;
|
|
50
|
+
} else if (lineWidth + 1 + wordWidth <= width) {
|
|
51
|
+
line += ' ' + word;
|
|
52
|
+
lineWidth += 1 + wordWidth;
|
|
53
|
+
} else {
|
|
54
|
+
lines.push(line);
|
|
55
|
+
line = word;
|
|
56
|
+
lineWidth = wordWidth;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (line) lines.push(line);
|
|
61
|
+
return lines.join('\n');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Wrap prose to a maximum line width when serializing back to markdown.
|
|
66
|
+
*
|
|
67
|
+
* Only paragraphs are reflowed. Indentation from lists and block quotes is
|
|
68
|
+
* handled by the default serializer, so wrapping stays correct (and within
|
|
69
|
+
* width) at every nesting level. A leading GitHub alert marker (`[!NOTE]`,
|
|
70
|
+
* serialized from a `gfmAlert` node) is kept on its own line.
|
|
71
|
+
*
|
|
72
|
+
* Width is resolved in order: the `width` option, then `.editorconfig`'s
|
|
73
|
+
* `max_line_length`, then `80`.
|
|
74
|
+
*
|
|
75
|
+
* @this {import('unified').Processor}
|
|
76
|
+
* @param {object} [options]
|
|
77
|
+
* @param {number} [options.width]
|
|
78
|
+
* Maximum display width (columns) per line. Overrides `.editorconfig`.
|
|
79
|
+
* Defaults to `max_line_length` from `.editorconfig`, otherwise `80`.
|
|
80
|
+
*/
|
|
81
|
+
export default function remarkWrapProse(options = {}) {
|
|
82
|
+
const width = options.width ?? editorConfigWidth() ?? DEFAULT_WIDTH;
|
|
83
|
+
|
|
84
|
+
const data = this.data();
|
|
85
|
+
data.toMarkdownExtensions = data.toMarkdownExtensions || [];
|
|
86
|
+
data.toMarkdownExtensions.push({
|
|
87
|
+
handlers: {
|
|
88
|
+
paragraph(node, _parent, state, info) {
|
|
89
|
+
const exit = state.enter('paragraph');
|
|
90
|
+
const subexit = state.enter('phrasing');
|
|
91
|
+
const value = state.containerPhrasing(node, info);
|
|
92
|
+
subexit();
|
|
93
|
+
exit();
|
|
94
|
+
|
|
95
|
+
const available = Math.max(1, width - (info.lineShift || 0));
|
|
96
|
+
|
|
97
|
+
const first = node.children[0];
|
|
98
|
+
if (first?.type === 'gfmAlert') {
|
|
99
|
+
const marker = value.slice(0, first.value.length);
|
|
100
|
+
const body = fill(value.slice(first.value.length), available);
|
|
101
|
+
return body ? marker + '\n' + body : marker;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return fill(value, available);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@webpro/remark-wrap-prose",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Remark plugin to wrap prose to a maximum line width",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"unified",
|
|
7
|
+
"remark",
|
|
8
|
+
"remark-plugin",
|
|
9
|
+
"plugin",
|
|
10
|
+
"markdown",
|
|
11
|
+
"prose",
|
|
12
|
+
"wrap",
|
|
13
|
+
"fill",
|
|
14
|
+
"reflow",
|
|
15
|
+
"width"
|
|
16
|
+
],
|
|
17
|
+
"homepage": "https://github.com/webpro/remark-wrap-prose",
|
|
18
|
+
"bugs": "https://github.com/webpro/remark-wrap-prose/issues",
|
|
19
|
+
"repository": "github:webpro/remark-wrap-prose",
|
|
20
|
+
"license": "ISC",
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "Lars Kappert",
|
|
23
|
+
"email": "lars@webpro.nl"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"packageManager": "pnpm@11.5.0",
|
|
30
|
+
"main": "index.js",
|
|
31
|
+
"types": "index.d.ts",
|
|
32
|
+
"files": [
|
|
33
|
+
"index.js",
|
|
34
|
+
"editorconfig.js",
|
|
35
|
+
"index.d.ts"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"fast-string-truncated-width": "^3.0.3"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.10.4",
|
|
45
|
+
"remark": "^15.0.1",
|
|
46
|
+
"remark-directive": "^4.0.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"test": "node --test"
|
|
50
|
+
}
|
|
51
|
+
}
|