tackbox 0.1.70 → 0.1.72
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/README.md +38 -24
- package/bin/tackbox-mdlint.js +46 -3
- package/js/README.md +41 -0
- package/js/markdownlint-rules/declared-chars.js +155 -0
- package/js/markdownlint-rules/link-integrity.js +275 -0
- package/package.json +1 -1
- package/js/markdownlint-rules/no-non-ascii.js +0 -132
package/README.md
CHANGED
|
@@ -191,9 +191,14 @@ fetched separately and cached per version:
|
|
|
191
191
|
wheel's `engines.json`, and
|
|
192
192
|
unpacks it once into `$XDG_DATA_HOME/tackbox/engines/<version>/`
|
|
193
193
|
(default `~/.local/share/...`; override `TACKBOX_ENGINES_DIR`).
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
194
|
+
A machine-wide per-version lock makes concurrent first runs share
|
|
195
|
+
one download: one process fetches and verifies while every other
|
|
196
|
+
process waits and then reuses the committed store. Every later thin
|
|
197
|
+
version reuses that one copy, so a stream of `@latest` patch bumps
|
|
198
|
+
never re-materializes the engines. Installed engine versions coexist
|
|
199
|
+
rather than deleting one another during normal commands, so an older
|
|
200
|
+
thin client cannot force a re-download. Bumped only when an engine
|
|
201
|
+
changes.
|
|
197
202
|
|
|
198
203
|
After the first fetch tackbox runs fully offline until the engine
|
|
199
204
|
version changes. Platform wheels cover Linux x86_64/arm64 (manylinux),
|
|
@@ -323,29 +328,40 @@ every lint and writes no clean-cache markers. A `java`-format clone that
|
|
|
323
328
|
lies entirely within both files' headers (package, imports, leading
|
|
324
329
|
comments) has no extractable code and is dropped before it is reported.
|
|
325
330
|
|
|
326
|
-
### Markdown:
|
|
331
|
+
### Markdown: declared charset
|
|
327
332
|
|
|
328
|
-
The Markdown engine
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
333
|
+
The Markdown engine does not enforce Markdown or prose style. It runs the
|
|
334
|
+
four link-reference built-ins - `MD011` (reversed links), `MD042` (empty
|
|
335
|
+
links), `MD051` (in-file link fragments), `MD052` (reference links
|
|
336
|
+
defined) - plus `MD-CHARS`, which checks a file's character repertoire
|
|
337
|
+
against a declaration the file makes about itself.
|
|
332
338
|
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
The check is opt-in and declaration-driven. With no marker, the charset
|
|
340
|
+
is not checked. One HTML comment on one of the first five lines turns it
|
|
341
|
+
on and names the allowed sets:
|
|
335
342
|
|
|
336
343
|
```text
|
|
337
|
-
<!-- tackbox:
|
|
344
|
+
<!-- tackbox: chars=cyrillic,punct -->
|
|
338
345
|
```
|
|
339
346
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
emoji and other scripts included, is still flagged.
|
|
347
|
+
With a marker present, every codepoint must be in the always-allowed
|
|
348
|
+
ASCII base (U+0000-U+007F) or in one of the declared sets; anything else
|
|
349
|
+
is a finding. The sets are named by character repertoire, not language:
|
|
344
350
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
351
|
+
- `ascii` adds nothing (it declares the check with no extension);
|
|
352
|
+
- `cyrillic` adds the Cyrillic block U+0400-U+04FF;
|
|
353
|
+
- `punct` adds em/en dash, guillemets, ellipsis, curly and low quotes,
|
|
354
|
+
and NBSP.
|
|
355
|
+
|
|
356
|
+
Sets are comma-joined and unioned; a space after a comma is fine
|
|
357
|
+
(`chars=ascii, cyrillic`). Russian prose declares `chars=cyrillic,punct`;
|
|
358
|
+
a grep-friendly zone declares `chars=cyrillic`.
|
|
359
|
+
|
|
360
|
+
The marker strengthens the check, so it is not a suppression: it draws no
|
|
361
|
+
approval and does not appear in the escapes inventory. An invalid marker
|
|
362
|
+
(an unknown set, an empty list, a duplicate set, a duplicate marker, or a
|
|
363
|
+
marker past the fifth line) is itself a finding, and the content charset
|
|
364
|
+
is then not checked - a broken declaration does not pass silently.
|
|
349
365
|
|
|
350
366
|
## No configuration
|
|
351
367
|
|
|
@@ -396,8 +412,6 @@ ordinary comment token:
|
|
|
396
412
|
engine also accepts a standalone single-line `/* ... */` (see its
|
|
397
413
|
section).
|
|
398
414
|
- Python: a `#` comment.
|
|
399
|
-
- Markdown: the `tackbox: lang=` HTML comment (see the Markdown
|
|
400
|
-
engine section) is the only Markdown marker.
|
|
401
415
|
- Svelte: inside `<script>` blocks the `//` form works as in JS/TS;
|
|
402
416
|
the template adds two forms - a `//` comment inside a `{...}`
|
|
403
417
|
expression (line-adjacent, as ever) and an HTML comment
|
|
@@ -592,8 +606,8 @@ cheap command that review tooling of any harness can consume (D013). It
|
|
|
592
606
|
enumerates:
|
|
593
607
|
|
|
594
608
|
- **suppression markers** (`// no-report`, `// parse-skip`,
|
|
595
|
-
`// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok
|
|
596
|
-
|
|
609
|
+
`// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`), each
|
|
610
|
+
with its reason;
|
|
597
611
|
- **`.tackbox/reporters` declarations** - the tier-2 sinks;
|
|
598
612
|
- **notify / quiet lane choices** - the call sites of the user-lane-only
|
|
599
613
|
`notify` and the telemetry-only `quiet` verbs.
|
|
@@ -647,7 +661,7 @@ uvx tackbox@latest escapes --since origin/main --context 5
|
|
|
647
661
|
- `text` is the trimmed source line; for a marker it runs from the marker
|
|
648
662
|
keyword to end of line.
|
|
649
663
|
- `reason` (markers only) is what follows the keyword's colon, trimmed -
|
|
650
|
-
possibly empty
|
|
664
|
+
possibly empty.
|
|
651
665
|
- `context` is the surrounding source, `--context N` lines each side
|
|
652
666
|
(default 3), inclusive of the entry line itself - the window
|
|
653
667
|
`[line-N, line+N]`, clipped at file edges, each line trimmed of trailing
|
package/bin/tackbox-mdlint.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
const fs = require('fs')
|
|
3
3
|
const { lint } = require('markdownlint/promise')
|
|
4
|
-
const
|
|
4
|
+
const declaredChars = require('../js/markdownlint-rules/declared-chars')
|
|
5
|
+
const linkIntegrity = require('../js/markdownlint-rules/link-integrity')
|
|
5
6
|
|
|
6
7
|
// readFilesFrom reads a newline-separated UTF-8 list-file into its non-empty
|
|
7
8
|
// paths. Additive to positional paths - the bin is public on npm.
|
|
@@ -9,25 +10,67 @@ function readFilesFrom(listPath) {
|
|
|
9
10
|
return fs.readFileSync(listPath, 'utf8').split(/\r?\n/).filter(Boolean)
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
// Parse the link-target inventory list-file (LF, `<kind>\t<path>` per line) the
|
|
14
|
+
// tackbox CLI writes: F = linkable files, L = tracked symlinks, G = gitlink
|
|
15
|
+
// roots. Repo-relative paths, kept verbatim.
|
|
16
|
+
function readLinkTargets(listPath) {
|
|
17
|
+
const F = new Set()
|
|
18
|
+
const L = new Set()
|
|
19
|
+
const G = []
|
|
20
|
+
for (const line of readFilesFrom(listPath)) {
|
|
21
|
+
const tab = line.indexOf('\t')
|
|
22
|
+
const kind = line.slice(0, tab)
|
|
23
|
+
const p = line.slice(tab + 1)
|
|
24
|
+
if (kind === 'F') F.add(p)
|
|
25
|
+
else if (kind === 'L') L.add(p)
|
|
26
|
+
else if (kind === 'G') G.push(p)
|
|
27
|
+
}
|
|
28
|
+
return { F, L, G }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const USAGE =
|
|
32
|
+
'tackbox-mdlint: --repo-root <dir> and --link-targets-from <list-file> are ' +
|
|
33
|
+
'required (the cross-file link rule needs the whole-tree target inventory)\n'
|
|
34
|
+
|
|
12
35
|
async function run() {
|
|
13
36
|
const argv = process.argv.slice(2)
|
|
14
37
|
const machine = argv.includes('--machine')
|
|
15
38
|
const files = []
|
|
39
|
+
let repoRoot = null
|
|
40
|
+
let linkTargetsFrom = null
|
|
16
41
|
for (let i = 0; i < argv.length; i++) {
|
|
17
42
|
const a = argv[i]
|
|
18
43
|
if (a === '--machine') continue
|
|
19
44
|
// The file set rides a list-file, not positional argv (ARG_MAX safety).
|
|
20
45
|
if (a === '--files-from') { files.push(...readFilesFrom(argv[++i])); continue }
|
|
46
|
+
if (a === '--repo-root') { repoRoot = argv[++i]; continue }
|
|
47
|
+
if (a === '--link-targets-from') { linkTargetsFrom = argv[++i]; continue }
|
|
21
48
|
files.push(a)
|
|
22
49
|
}
|
|
50
|
+
if (!repoRoot || !linkTargetsFrom) {
|
|
51
|
+
process.stderr.write(USAGE)
|
|
52
|
+
process.exit(2)
|
|
53
|
+
}
|
|
23
54
|
if (files.length === 0) {
|
|
24
55
|
process.stderr.write('tackbox-mdlint: no files supplied\n')
|
|
25
56
|
process.exit(2)
|
|
26
57
|
}
|
|
58
|
+
const { F, L, G } = readLinkTargets(linkTargetsFrom)
|
|
27
59
|
const result = await lint({
|
|
28
60
|
files,
|
|
29
|
-
|
|
30
|
-
|
|
61
|
+
// Style preset off; only the link-reference built-ins plus the declared-
|
|
62
|
+
// charset and cross-file link rules run (D017/D018). noInlineConfig blocks
|
|
63
|
+
// in-file rule toggles.
|
|
64
|
+
config: {
|
|
65
|
+
default: false,
|
|
66
|
+
MD011: true,
|
|
67
|
+
MD042: true,
|
|
68
|
+
MD051: true,
|
|
69
|
+
MD052: true,
|
|
70
|
+
'declared-chars': true,
|
|
71
|
+
'link-integrity': true,
|
|
72
|
+
},
|
|
73
|
+
customRules: [declaredChars, linkIntegrity.makeRule({ repoRoot, F, L, G })],
|
|
31
74
|
noInlineConfig: true,
|
|
32
75
|
})
|
|
33
76
|
let count = 0
|
package/js/README.md
CHANGED
|
@@ -138,6 +138,47 @@ siblings still report. Recognition reads the enclosing element's
|
|
|
138
138
|
preceding `SvelteHTMLComment` sibling; `/* ... */` block comments
|
|
139
139
|
are never markers.
|
|
140
140
|
|
|
141
|
+
## Markdown
|
|
142
|
+
|
|
143
|
+
The CLI also lints `.md` files through `tackbox-mdlint`, a thin markdownlint
|
|
144
|
+
wrapper. The style preset is off; only the link-reference built-ins (MD011,
|
|
145
|
+
MD042, MD051, MD052) plus two tackbox rules run:
|
|
146
|
+
|
|
147
|
+
- `declared-chars` (MD-CHARS) - a file's character repertoire is checked only
|
|
148
|
+
when it declares one with `<!-- tackbox: chars=... -->` (D017).
|
|
149
|
+
- `link-integrity` (MD-LINK) - a relative link or image must resolve to an
|
|
150
|
+
existing in-repo target, and a `#fragment` into a target `.md` must name a
|
|
151
|
+
real heading slug or HTML anchor (D018). Cross-file only; MD051 holds a
|
|
152
|
+
file's own fragments. Fully offline: any URI scheme or absolute path is out
|
|
153
|
+
of scope.
|
|
154
|
+
|
|
155
|
+
The link rule needs the whole-tree set of valid targets, so the bin takes two
|
|
156
|
+
mandatory flags - a call missing either is a usage error (exit 2):
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
tackbox-mdlint --repo-root <dir> --link-targets-from <list-file> \
|
|
160
|
+
[--files-from <list-file>] [files...]
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- `--repo-root <dir>` - the repository root; linted files and targets are
|
|
164
|
+
normalized to it, so a call from a subdirectory resolves correctly.
|
|
165
|
+
- `--link-targets-from <list-file>` - the link-target inventory (below).
|
|
166
|
+
- `--files-from <list-file>` - the files to lint, one per line (additive to
|
|
167
|
+
positional paths; keeps the spawn under ARG_MAX).
|
|
168
|
+
|
|
169
|
+
The inventory list-file is LF-terminated, one `<kind>\t<path>` per line, paths
|
|
170
|
+
repo-relative:
|
|
171
|
+
|
|
172
|
+
- `F` - a linkable file (a source-set file, before generated/vendored
|
|
173
|
+
exclusion: existence is not linting).
|
|
174
|
+
- `L` - a tracked symlink; its target exists but is not dereferenced and its
|
|
175
|
+
fragment is not checked.
|
|
176
|
+
- `G` - a gitlink (submodule) root; a target under it is skipped.
|
|
177
|
+
|
|
178
|
+
The tackbox CLI builds this inventory from the git listing and passes it in;
|
|
179
|
+
the flags exist for that one caller and are a deliberate breaking change to the
|
|
180
|
+
public bin.
|
|
181
|
+
|
|
141
182
|
## Reporter recognition
|
|
142
183
|
|
|
143
184
|
A call counts as a reporter only when its callee resolves to one of the
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Custom markdownlint rule: a Markdown file's character repertoire is checked
|
|
2
|
+
// only when the file declares one. Semantics are declaration-driven, not a
|
|
3
|
+
// strict-ASCII default (D017):
|
|
4
|
+
//
|
|
5
|
+
// * No marker -> the charset is not checked at all.
|
|
6
|
+
// * A marker -> every codepoint must be in the always-allowed ASCII base
|
|
7
|
+
// (U+0000-U+007F, the Markdown syntax alphabet) or in one of the declared
|
|
8
|
+
// named sets. Everything else is a finding.
|
|
9
|
+
//
|
|
10
|
+
// <!-- tackbox: chars=cyrillic,punct -->
|
|
11
|
+
//
|
|
12
|
+
// Sets are named by character repertoire, not by language (a set proves nothing
|
|
13
|
+
// about the prose's language). The marker lists them comma-joined (union);
|
|
14
|
+
// tokens are trimmed, so a space after a comma is allowed. An invalid marker -
|
|
15
|
+
// an unknown set, an empty token, a duplicate set, a duplicate marker, or a
|
|
16
|
+
// marker below the fifth line - is a finding on the marker itself, and the
|
|
17
|
+
// content charset is then not checked (a broken declaration does not pass
|
|
18
|
+
// silently; there is no default to fall back to).
|
|
19
|
+
//
|
|
20
|
+
// The marker is read from micromark HTML-comment tokens, not params.lines:
|
|
21
|
+
// markdownlint masks HTML-comment interiors in `lines`, so the raw code is
|
|
22
|
+
// only visible in the parse tree.
|
|
23
|
+
|
|
24
|
+
// Named character sets: extra codepoints a set adds beyond the ASCII base. Add
|
|
25
|
+
// a set by adding one entry (its script range(s) and/or individual points).
|
|
26
|
+
const CHAR_SETS = {
|
|
27
|
+
// Declares the check with no extension; the ASCII base is always allowed.
|
|
28
|
+
ascii: { ranges: [], points: [] },
|
|
29
|
+
// Cyrillic block U+0400-U+04FF in full.
|
|
30
|
+
cyrillic: { ranges: [[0x0400, 0x04ff]], points: [] },
|
|
31
|
+
// Typographic punctuation: em/en dash, guillemets, ellipsis, curly
|
|
32
|
+
// single/double quotes (incl. low opening quotes), NBSP.
|
|
33
|
+
punct: {
|
|
34
|
+
ranges: [],
|
|
35
|
+
points: [
|
|
36
|
+
0x2014, 0x2013, 0x00ab, 0x00bb, 0x2026,
|
|
37
|
+
0x2018, 0x2019, 0x201c, 0x201d, 0x201e, 0x201a, 0x00a0,
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MARKER_MAX_LINE = 5
|
|
43
|
+
const MARKER_RE = /<!--\s*tackbox:\s*chars=([^>]*?)\s*-->/g
|
|
44
|
+
|
|
45
|
+
function collectMarkers(token, found) {
|
|
46
|
+
for (const m of token.text.matchAll(MARKER_RE)) {
|
|
47
|
+
const before = token.text.slice(0, m.index)
|
|
48
|
+
const lineOffset = (before.match(/\n/g) || []).length
|
|
49
|
+
const lastNl = before.lastIndexOf('\n')
|
|
50
|
+
found.push({
|
|
51
|
+
lineNumber: token.startLine + lineOffset,
|
|
52
|
+
list: m[1],
|
|
53
|
+
col: lineOffset === 0 ? token.startColumn + m.index : m.index - lastNl,
|
|
54
|
+
len: m[0].length,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Every marker occurrence in the file's HTML comments: {lineNumber, list, col,
|
|
60
|
+
// len}. Walks the micromark tree; htmlFlow / htmlText carry the raw comment
|
|
61
|
+
// text (their children just re-slice it, so we do not descend).
|
|
62
|
+
function findMarkers(tokens) {
|
|
63
|
+
const found = []
|
|
64
|
+
const walk = (toks) => {
|
|
65
|
+
for (const t of toks) {
|
|
66
|
+
if (t.type === 'htmlFlow' || t.type === 'htmlText') {
|
|
67
|
+
collectMarkers(t, found)
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (t.children && t.children.length) walk(t.children)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
walk(tokens)
|
|
74
|
+
return found
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validate the marker set; emit findings for a misplaced / duplicate / invalid
|
|
78
|
+
// marker. Return the allowed set list for a single valid marker (to check
|
|
79
|
+
// content against), or null (do not check content) for no marker or any invalid
|
|
80
|
+
// one.
|
|
81
|
+
function resolveMarkers(markers, onError) {
|
|
82
|
+
if (markers.length === 0) return null
|
|
83
|
+
const markerErr = (m, detail) =>
|
|
84
|
+
onError({ lineNumber: m.lineNumber, detail, range: [m.col, m.len] })
|
|
85
|
+
|
|
86
|
+
if (markers.length > 1) {
|
|
87
|
+
for (const dup of markers.slice(1)) {
|
|
88
|
+
markerErr(dup, 'duplicate tackbox chars marker (one marker per file)')
|
|
89
|
+
}
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
const m = markers[0]
|
|
93
|
+
if (m.lineNumber > MARKER_MAX_LINE) {
|
|
94
|
+
markerErr(m, `tackbox chars marker must be within the first ${MARKER_MAX_LINE} lines`)
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
const tokens = m.list.split(',').map((t) => t.trim())
|
|
98
|
+
if (tokens.some((t) => t === '')) {
|
|
99
|
+
markerErr(m, 'tackbox chars marker: empty character-set list')
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
const seen = new Set()
|
|
103
|
+
const allowed = []
|
|
104
|
+
for (const t of tokens) {
|
|
105
|
+
if (seen.has(t)) {
|
|
106
|
+
markerErr(m, `tackbox chars marker: duplicate set '${t}'`)
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
seen.add(t)
|
|
110
|
+
const set = CHAR_SETS[t]
|
|
111
|
+
if (!set) {
|
|
112
|
+
markerErr(m, `tackbox chars marker: unknown character set '${t}'`)
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
allowed.push(set)
|
|
116
|
+
}
|
|
117
|
+
return allowed
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isAllowed(code, allowed) {
|
|
121
|
+
if (code <= 0x7f) return true
|
|
122
|
+
for (const set of allowed) {
|
|
123
|
+
for (const [lo, hi] of set.ranges) {
|
|
124
|
+
if (code >= lo && code <= hi) return true
|
|
125
|
+
}
|
|
126
|
+
if (set.points.includes(code)) return true
|
|
127
|
+
}
|
|
128
|
+
return false
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
names: ['MD-CHARS', 'declared-chars'],
|
|
133
|
+
description: 'Character outside declared repertoire',
|
|
134
|
+
tags: ['charset'],
|
|
135
|
+
parser: 'micromark',
|
|
136
|
+
function: function rule(params, onError) {
|
|
137
|
+
const allowed = resolveMarkers(findMarkers(params.parsers.micromark.tokens), onError)
|
|
138
|
+
if (!allowed) return
|
|
139
|
+
params.lines.forEach((line, idx) => {
|
|
140
|
+
let col = 0
|
|
141
|
+
for (const ch of line) {
|
|
142
|
+
const code = ch.codePointAt(0)
|
|
143
|
+
if (!isAllowed(code, allowed)) {
|
|
144
|
+
onError({
|
|
145
|
+
lineNumber: idx + 1,
|
|
146
|
+
detail:
|
|
147
|
+
'U+' + code.toString(16).toUpperCase() + ' (' + ch + ') is not in the declared character set',
|
|
148
|
+
range: [col + 1, ch.length],
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
col += ch.length
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Custom markdownlint rule: every relative Markdown link / image target must
|
|
2
|
+
// exist and stay inside the repo, and a #fragment into a target .md must name a
|
|
3
|
+
// real anchor (D018). Cross-file only - MD051 (enabled alongside) already holds
|
|
4
|
+
// same-file fragments.
|
|
5
|
+
//
|
|
6
|
+
// Target existence is decided against a whole-tree link-target inventory the
|
|
7
|
+
// tackbox CLI builds from the raw git listing, NOT fs.exists: a link to a
|
|
8
|
+
// gitignored file is broken in a clean clone, so it is a finding. The inventory
|
|
9
|
+
// is a factory input, so the rule is constructed per run with the repo root and
|
|
10
|
+
// the parsed inventory baked in.
|
|
11
|
+
//
|
|
12
|
+
// Anchor semantics follow the MD051 / GitHub contract. markdownlint does not
|
|
13
|
+
// export its heading-fragment helper, so the GitHub slugger is ported here and
|
|
14
|
+
// pinned by fixtures. A nested markdownlint lint is not an option: its per-file
|
|
15
|
+
// token cache is module-level and reset per file, so parsing a target inside a
|
|
16
|
+
// rule would corrupt the linted file's own results.
|
|
17
|
+
|
|
18
|
+
const fs = require('fs')
|
|
19
|
+
const path = require('path')
|
|
20
|
+
|
|
21
|
+
// RFC3986 scheme: an external URL (http, mailto, tel, data, ftp, ...) is out of
|
|
22
|
+
// scope - the rule is fully offline, no network anywhere.
|
|
23
|
+
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
|
|
24
|
+
|
|
25
|
+
// GitHub heading slugger, ported verbatim from markdownlint's md051
|
|
26
|
+
// convertHeadingToHTMLFragment minus the encodeURIComponent wrapper: anchors are
|
|
27
|
+
// compared decoded, so unicode headings (legal since the charset flip) match a
|
|
28
|
+
// literal or a percent-encoded link fragment alike.
|
|
29
|
+
function slug(text) {
|
|
30
|
+
return text
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^\p{Letter}\p{Mark}\p{Number}\p{Connector_Punctuation}\- ]/gu, '')
|
|
33
|
+
.replace(/ /gu, '-')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Reduce a raw heading line to its plain inline text before slugging: images
|
|
37
|
+
// drop entirely (GitHub excludes their alt), links keep their visible text, raw
|
|
38
|
+
// HTML / autolinks drop, word-boundary underscore emphasis is removed (intraword
|
|
39
|
+
// `_` stays literal per CommonMark), and backslash escapes become the char. The
|
|
40
|
+
// remaining markup punctuation (`*`, backticks, brackets) is stripped by slug's
|
|
41
|
+
// own punctuation filter, so code spans and asterisk emphasis need no pass here.
|
|
42
|
+
function headingInlineText(raw) {
|
|
43
|
+
return raw
|
|
44
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
|
45
|
+
.replace(/!\[[^\]]*\](\[[^\]]*\])?/g, '')
|
|
46
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
47
|
+
.replace(/\[([^\]]*)\]\[[^\]]*\]/g, '$1')
|
|
48
|
+
.replace(/\[([^\]]*)\]/g, '$1')
|
|
49
|
+
.replace(/<[^>]*>/g, '')
|
|
50
|
+
.replace(/(^|[^\p{L}\p{N}])_{1,3}(?=\S)/gu, '$1')
|
|
51
|
+
.replace(/(?<=\S)_{1,3}($|[^\p{L}\p{N}])/gu, '$1')
|
|
52
|
+
.replace(/\\(.)/g, '$1')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const ID_RE = /<[^>]*?\bid\s*=\s*["']([^"']+)["']/gi
|
|
56
|
+
const NAME_RE = /<a\b[^>]*?\bname\s*=\s*["']([^"']+)["']/gi
|
|
57
|
+
|
|
58
|
+
function collectHtmlAnchors(line, anchors) {
|
|
59
|
+
let m
|
|
60
|
+
while ((m = ID_RE.exec(line)) !== null) anchors.add(m[1])
|
|
61
|
+
while ((m = NAME_RE.exec(line)) !== null) anchors.add(m[1])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addHeadingAnchor(rawText, anchors, counts) {
|
|
65
|
+
const s = slug(headingInlineText(rawText))
|
|
66
|
+
if (s === '') return
|
|
67
|
+
const c = counts.get(s) || 0
|
|
68
|
+
// A duplicate heading slug gets the -1 / -2 ... suffix GitHub assigns.
|
|
69
|
+
if (c > 0) anchors.add(s + '-' + c)
|
|
70
|
+
anchors.add(s)
|
|
71
|
+
counts.set(s, c + 1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Anchor set of a target .md's text: heading slugs (with GitHub duplicate
|
|
75
|
+
// suffixes), HTML id= / <a name=> anchors, and #top (always valid). Text-based
|
|
76
|
+
// so no markdownlint reentrancy; generous extraction (extra anchors) only ever
|
|
77
|
+
// misses a broken link, never invents one.
|
|
78
|
+
function computeAnchors(text) {
|
|
79
|
+
const anchors = new Set(['top'])
|
|
80
|
+
const counts = new Map()
|
|
81
|
+
const lines = text.split(/\r?\n/)
|
|
82
|
+
let fence = null
|
|
83
|
+
for (let i = 0; i < lines.length; i++) {
|
|
84
|
+
const line = lines[i]
|
|
85
|
+
const open = line.match(/^ {0,3}(`{3,}|~{3,})/)
|
|
86
|
+
if (fence === null && open) {
|
|
87
|
+
fence = open[1][0]
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
if (fence !== null) {
|
|
91
|
+
if (new RegExp('^ {0,3}' + fence + '{3,}[ \\t]*$').test(line)) fence = null
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const atx = line.match(/^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*$/)
|
|
95
|
+
if (atx) {
|
|
96
|
+
addHeadingAnchor((atx[2] || '').replace(/[ \t]+#+[ \t]*$/, ''), anchors, counts)
|
|
97
|
+
collectHtmlAnchors(line, anchors)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const next = i + 1 < lines.length ? lines[i + 1] : ''
|
|
101
|
+
if (
|
|
102
|
+
line.trim() !== '' &&
|
|
103
|
+
!/^ {0,3}#/.test(line) &&
|
|
104
|
+
/^ {0,3}(=+|-+)[ \t]*$/.test(next)
|
|
105
|
+
) {
|
|
106
|
+
addHeadingAnchor(line.trim(), anchors, counts)
|
|
107
|
+
}
|
|
108
|
+
collectHtmlAnchors(line, anchors)
|
|
109
|
+
}
|
|
110
|
+
return anchors
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Percent-decode without throwing (unlike decodeURIComponent): each maximal run
|
|
114
|
+
// of %XX escapes is decoded as UTF-8 bytes, invalid bytes become U+FFFD, and a
|
|
115
|
+
// stray % stays literal. A malformed link must never crash the lint.
|
|
116
|
+
function percentDecode(s) {
|
|
117
|
+
return s.replace(/(?:%[0-9A-Fa-f]{2})+/g, (seq) => {
|
|
118
|
+
const bytes = new Uint8Array(seq.length / 3)
|
|
119
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
120
|
+
bytes[i] = parseInt(seq.slice(i * 3 + 1, i * 3 + 3), 16)
|
|
121
|
+
}
|
|
122
|
+
return new TextDecoder('utf-8').decode(bytes)
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Split a raw destination into { pathPart, fragment }, or null to skip it. The
|
|
127
|
+
// fragment is everything after the first '#'; the query before it is dropped.
|
|
128
|
+
// External schemes, absolute paths, empty and same-file (#...) destinations are
|
|
129
|
+
// skipped - the latter is MD051's job.
|
|
130
|
+
function splitDest(dest) {
|
|
131
|
+
if (dest === '' || SCHEME_RE.test(dest) || dest.startsWith('/') || dest.startsWith('#')) {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
const hash = dest.indexOf('#')
|
|
135
|
+
let before = hash >= 0 ? dest.slice(0, hash) : dest
|
|
136
|
+
const fragment = hash >= 0 ? dest.slice(hash + 1) : null
|
|
137
|
+
const q = before.indexOf('?')
|
|
138
|
+
if (q >= 0) before = before.slice(0, q)
|
|
139
|
+
if (before === '') return null
|
|
140
|
+
return { pathPart: percentDecode(before), fragment: fragment === null ? null : percentDecode(fragment) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The rule's own destination string(s), never a nested link/image's: prune the
|
|
144
|
+
// walk at nested link/image boundaries so `[](target)` reports both
|
|
145
|
+
// img and target, each against its own token.
|
|
146
|
+
function ownLinkData(linkToken) {
|
|
147
|
+
const data = { dest: null, ref: null, label: null }
|
|
148
|
+
const rec = (toks) => {
|
|
149
|
+
for (const t of toks) {
|
|
150
|
+
if (t !== linkToken && (t.type === 'link' || t.type === 'image')) continue
|
|
151
|
+
if (t.type === 'resourceDestinationString' && data.dest === null) data.dest = t.text
|
|
152
|
+
if (t.type === 'referenceString' && data.ref === null) data.ref = t.text
|
|
153
|
+
if (t.type === 'labelText' && data.label === null) data.label = t.text
|
|
154
|
+
if (t.children && t.children.length) rec(t.children)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
rec([linkToken])
|
|
158
|
+
return data
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normLabel(s) {
|
|
162
|
+
return s.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Every link / image in the file as { dest, line, endLine, startColumn,
|
|
166
|
+
// endColumn }: inline destinations plus reference / collapsed / shortcut ones
|
|
167
|
+
// resolved through the file's link definitions.
|
|
168
|
+
function collectLinks(tokens) {
|
|
169
|
+
const definitions = new Map()
|
|
170
|
+
const links = []
|
|
171
|
+
const walk = (toks) => {
|
|
172
|
+
for (const t of toks) {
|
|
173
|
+
if (t.type === 'definition') {
|
|
174
|
+
let label = null
|
|
175
|
+
let dest = null
|
|
176
|
+
const rec = (xs) => {
|
|
177
|
+
for (const x of xs) {
|
|
178
|
+
if (x.type === 'definitionLabelString' && label === null) label = x.text
|
|
179
|
+
if (x.type === 'definitionDestinationString' && dest === null) dest = x.text
|
|
180
|
+
if (x.children && x.children.length) rec(x.children)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
rec(t.children || [])
|
|
184
|
+
if (label !== null && dest !== null) definitions.set(normLabel(label), dest)
|
|
185
|
+
}
|
|
186
|
+
if (t.type === 'link' || t.type === 'image') {
|
|
187
|
+
links.push({ token: t, data: ownLinkData(t) })
|
|
188
|
+
}
|
|
189
|
+
if (t.children && t.children.length) walk(t.children)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
walk(tokens)
|
|
193
|
+
const out = []
|
|
194
|
+
for (const { token, data } of links) {
|
|
195
|
+
let dest = data.dest
|
|
196
|
+
if (dest === null) {
|
|
197
|
+
const key = data.ref && data.ref !== '' ? data.ref : data.label
|
|
198
|
+
if (key === null) continue
|
|
199
|
+
dest = definitions.get(normLabel(key))
|
|
200
|
+
if (dest === undefined || dest === null) continue
|
|
201
|
+
}
|
|
202
|
+
out.push({
|
|
203
|
+
dest,
|
|
204
|
+
line: token.startLine,
|
|
205
|
+
endLine: token.endLine,
|
|
206
|
+
startColumn: token.startColumn,
|
|
207
|
+
endColumn: token.endColumn,
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
return out
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Build the rule bound to `repoRoot` (absolute) and the parsed inventory. F =
|
|
214
|
+
// linkable files, L = tracked symlinks (exist, not dereferenced), G = gitlink
|
|
215
|
+
// roots (targets under them are skipped). dirs holds every ancestor prefix of an
|
|
216
|
+
// F/L entry, so a directory link resolves in O(1).
|
|
217
|
+
function makeRule({ repoRoot, F, L, G }) {
|
|
218
|
+
// realpath both the root and each linted file: a symlinked temp root (macOS
|
|
219
|
+
// /tmp -> /private/tmp) would otherwise make path.relative diverge, since a
|
|
220
|
+
// spawned node resolves process.cwd() through the symlink.
|
|
221
|
+
const absRoot = fs.realpathSync(path.resolve(repoRoot))
|
|
222
|
+
const dirs = new Set()
|
|
223
|
+
for (const p of [...F, ...L]) {
|
|
224
|
+
const parts = p.split('/')
|
|
225
|
+
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join('/'))
|
|
226
|
+
}
|
|
227
|
+
const anchorCache = new Map()
|
|
228
|
+
const anchorsOf = (rel) => {
|
|
229
|
+
if (!anchorCache.has(rel)) {
|
|
230
|
+
anchorCache.set(rel, computeAnchors(fs.readFileSync(path.resolve(absRoot, rel), 'utf8')))
|
|
231
|
+
}
|
|
232
|
+
return anchorCache.get(rel)
|
|
233
|
+
}
|
|
234
|
+
const underGitlink = (rel) => G.some((g) => rel === g || rel.startsWith(g + '/'))
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
names: ['MD-LINK', 'link-integrity'],
|
|
238
|
+
description: 'Broken relative link target',
|
|
239
|
+
tags: ['links'],
|
|
240
|
+
parser: 'micromark',
|
|
241
|
+
function: function rule(params, onError) {
|
|
242
|
+
const abs = fs.realpathSync(path.resolve(process.cwd(), params.name))
|
|
243
|
+
const relDir = path.relative(absRoot, abs).split(path.sep).join('/').replace(/[^/]*$/, '')
|
|
244
|
+
for (const link of collectLinks(params.parsers.micromark.tokens)) {
|
|
245
|
+
const split = splitDest(link.dest)
|
|
246
|
+
if (split === null) continue
|
|
247
|
+
const rel = path.posix.normalize(path.posix.join(relDir, split.pathPart)).replace(/\/$/, '')
|
|
248
|
+
const report = (detail) => {
|
|
249
|
+
const single = link.line === link.endLine
|
|
250
|
+
onError({
|
|
251
|
+
lineNumber: link.line,
|
|
252
|
+
detail,
|
|
253
|
+
range: single ? [link.startColumn, link.endColumn - link.startColumn] : undefined,
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
if (rel === '..' || rel.startsWith('../')) {
|
|
257
|
+
report('link target escapes the repository root: ' + split.pathPart)
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
if (underGitlink(rel)) continue
|
|
261
|
+
if (F.has(rel)) {
|
|
262
|
+
if (split.fragment && rel.endsWith('.md') && !anchorsOf(rel).has(split.fragment)) {
|
|
263
|
+
report('link fragment not found in target: ' + rel + '#' + split.fragment)
|
|
264
|
+
}
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
if (L.has(rel)) continue
|
|
268
|
+
if (dirs.has(rel)) continue
|
|
269
|
+
report('link target does not exist: ' + rel)
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { makeRule, computeAnchors, slug, headingInlineText, percentDecode, splitDest }
|
package/package.json
CHANGED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
// Custom markdownlint rule: flag any character outside the printable
|
|
2
|
-
// ASCII range (codepoints > 0x7F). Keeps docs strictly ASCII so
|
|
3
|
-
// em-dashes, curly quotes, Cyrillic, box-drawing chars, emoji, etc.
|
|
4
|
-
// cannot leak into prose, tables, or code fences.
|
|
5
|
-
//
|
|
6
|
-
// One escape hatch: a language marker in an HTML comment within the first
|
|
7
|
-
// 5 lines widens the alphabet for that one file to a declared language's
|
|
8
|
-
// script (plus a little typographic punctuation). It never disables the
|
|
9
|
-
// rule - every other non-ASCII character (emoji, zero-width, other
|
|
10
|
-
// scripts) is still flagged, and a misplaced / duplicate / malformed
|
|
11
|
-
// marker is a finding that leaves the file strict-ASCII.
|
|
12
|
-
//
|
|
13
|
-
// <!-- tackbox lang ru personal experimental repo -->
|
|
14
|
-
//
|
|
15
|
-
// The marker is read from micromark HTML-comment tokens, not params.lines:
|
|
16
|
-
// markdownlint masks HTML-comment interiors in `lines`, so the raw code is
|
|
17
|
-
// only visible in the parse tree.
|
|
18
|
-
|
|
19
|
-
// code -> extra codepoints allowed when a valid marker declares it. Add a
|
|
20
|
-
// language by adding one entry: its script range(s) plus the typographic
|
|
21
|
-
// punctuation its prose uses.
|
|
22
|
-
const LANG_SCRIPTS = {
|
|
23
|
-
ru: {
|
|
24
|
-
// Cyrillic (U+0400-U+04FF).
|
|
25
|
-
ranges: [[0x0400, 0x04ff]],
|
|
26
|
-
// Typographic punctuation common in Russian prose: em/en dash,
|
|
27
|
-
// guillemets, ellipsis, curly single/double quotes (incl. low
|
|
28
|
-
// opening quotes), NBSP.
|
|
29
|
-
punct: [
|
|
30
|
-
0x2014, 0x2013, 0x00ab, 0x00bb, 0x2026,
|
|
31
|
-
0x2018, 0x2019, 0x201c, 0x201d, 0x201e, 0x201a, 0x00a0,
|
|
32
|
-
],
|
|
33
|
-
},
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const MARKER_MAX_LINE = 5
|
|
37
|
-
const MARKER_RE = /<!--\s*tackbox:\s*lang=([^\s>]*)[^>]*-->/g
|
|
38
|
-
|
|
39
|
-
function collectMarkers(token, found) {
|
|
40
|
-
for (const m of token.text.matchAll(MARKER_RE)) {
|
|
41
|
-
const before = token.text.slice(0, m.index)
|
|
42
|
-
const lineOffset = (before.match(/\n/g) || []).length
|
|
43
|
-
const lastNl = before.lastIndexOf('\n')
|
|
44
|
-
found.push({
|
|
45
|
-
lineNumber: token.startLine + lineOffset,
|
|
46
|
-
code: m[1],
|
|
47
|
-
col: lineOffset === 0 ? token.startColumn + m.index : m.index - lastNl,
|
|
48
|
-
len: m[0].length,
|
|
49
|
-
})
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Every marker occurrence in the file's HTML comments: {lineNumber, code,
|
|
54
|
-
// col, len}. Walks the micromark tree; htmlFlow / htmlText carry the raw
|
|
55
|
-
// comment text (their children just re-slice it, so we do not descend).
|
|
56
|
-
function findMarkers(tokens) {
|
|
57
|
-
const found = []
|
|
58
|
-
const walk = (toks) => {
|
|
59
|
-
for (const t of toks) {
|
|
60
|
-
if (t.type === 'htmlFlow' || t.type === 'htmlText') {
|
|
61
|
-
collectMarkers(t, found)
|
|
62
|
-
continue
|
|
63
|
-
}
|
|
64
|
-
if (t.children && t.children.length) walk(t.children)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
walk(tokens)
|
|
68
|
-
return found
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Validate the marker set; emit findings for a misplaced / duplicate /
|
|
72
|
-
// malformed / unknown marker. Return the allow-config for a single valid
|
|
73
|
-
// marker, or null (strict ASCII-only) for no marker or any invalid one.
|
|
74
|
-
function resolveMarkers(markers, onError) {
|
|
75
|
-
if (markers.length === 0) return null
|
|
76
|
-
const markerErr = (m, detail) =>
|
|
77
|
-
onError({ lineNumber: m.lineNumber, detail, range: [m.col, m.len] })
|
|
78
|
-
|
|
79
|
-
if (markers.length > 1) {
|
|
80
|
-
for (const dup of markers.slice(1)) {
|
|
81
|
-
markerErr(dup, 'duplicate tackbox lang marker (one marker per file)')
|
|
82
|
-
}
|
|
83
|
-
return null
|
|
84
|
-
}
|
|
85
|
-
const m = markers[0]
|
|
86
|
-
if (m.lineNumber > MARKER_MAX_LINE) {
|
|
87
|
-
markerErr(m, `tackbox lang marker must be within the first ${MARKER_MAX_LINE} lines`)
|
|
88
|
-
return null
|
|
89
|
-
}
|
|
90
|
-
if (m.code === '') {
|
|
91
|
-
markerErr(m, 'tackbox lang marker is missing a language code')
|
|
92
|
-
return null
|
|
93
|
-
}
|
|
94
|
-
const cfg = LANG_SCRIPTS[m.code]
|
|
95
|
-
if (!cfg) {
|
|
96
|
-
markerErr(m, `tackbox lang marker: unsupported language code '${m.code}'`)
|
|
97
|
-
return null
|
|
98
|
-
}
|
|
99
|
-
return cfg
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function isWidened(code, allow) {
|
|
103
|
-
if (!allow) return false
|
|
104
|
-
for (const [lo, hi] of allow.ranges) {
|
|
105
|
-
if (code >= lo && code <= hi) return true
|
|
106
|
-
}
|
|
107
|
-
return allow.punct.includes(code)
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
module.exports = {
|
|
111
|
-
names: ['MD-ASCII', 'no-non-ascii'],
|
|
112
|
-
description: 'Non-ASCII character',
|
|
113
|
-
tags: ['ascii'],
|
|
114
|
-
parser: 'micromark',
|
|
115
|
-
function: function rule(params, onError) {
|
|
116
|
-
const allow = resolveMarkers(findMarkers(params.parsers.micromark.tokens), onError)
|
|
117
|
-
params.lines.forEach((line, idx) => {
|
|
118
|
-
let col = 0
|
|
119
|
-
for (const ch of line) {
|
|
120
|
-
const code = ch.codePointAt(0)
|
|
121
|
-
if (code > 0x7f && !isWidened(code, allow)) {
|
|
122
|
-
onError({
|
|
123
|
-
lineNumber: idx + 1,
|
|
124
|
-
detail: 'Non-ASCII character U+' + code.toString(16).toUpperCase() + ' (' + ch + ')',
|
|
125
|
-
range: [col + 1, ch.length],
|
|
126
|
-
})
|
|
127
|
-
}
|
|
128
|
-
col += ch.length
|
|
129
|
-
}
|
|
130
|
-
})
|
|
131
|
-
},
|
|
132
|
-
}
|