tackbox 0.1.69 → 0.1.71
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 +135 -37
- 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
|
@@ -323,29 +323,40 @@ every lint and writes no clean-cache markers. A `java`-format clone that
|
|
|
323
323
|
lies entirely within both files' headers (package, imports, leading
|
|
324
324
|
comments) has no extractable code and is dropped before it is reported.
|
|
325
325
|
|
|
326
|
-
### Markdown:
|
|
326
|
+
### Markdown: declared charset
|
|
327
327
|
|
|
328
|
-
The Markdown engine
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
The Markdown engine does not enforce Markdown or prose style. It runs the
|
|
329
|
+
four link-reference built-ins - `MD011` (reversed links), `MD042` (empty
|
|
330
|
+
links), `MD051` (in-file link fragments), `MD052` (reference links
|
|
331
|
+
defined) - plus `MD-CHARS`, which checks a file's character repertoire
|
|
332
|
+
against a declaration the file makes about itself.
|
|
332
333
|
|
|
333
|
-
|
|
334
|
-
|
|
334
|
+
The check is opt-in and declaration-driven. With no marker, the charset
|
|
335
|
+
is not checked. One HTML comment on one of the first five lines turns it
|
|
336
|
+
on and names the allowed sets:
|
|
335
337
|
|
|
336
338
|
```text
|
|
337
|
-
<!-- tackbox:
|
|
339
|
+
<!-- tackbox: chars=cyrillic,punct -->
|
|
338
340
|
```
|
|
339
341
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
emoji and other scripts included, is still flagged.
|
|
342
|
+
With a marker present, every codepoint must be in the always-allowed
|
|
343
|
+
ASCII base (U+0000-U+007F) or in one of the declared sets; anything else
|
|
344
|
+
is a finding. The sets are named by character repertoire, not language:
|
|
344
345
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
346
|
+
- `ascii` adds nothing (it declares the check with no extension);
|
|
347
|
+
- `cyrillic` adds the Cyrillic block U+0400-U+04FF;
|
|
348
|
+
- `punct` adds em/en dash, guillemets, ellipsis, curly and low quotes,
|
|
349
|
+
and NBSP.
|
|
350
|
+
|
|
351
|
+
Sets are comma-joined and unioned; a space after a comma is fine
|
|
352
|
+
(`chars=ascii, cyrillic`). Russian prose declares `chars=cyrillic,punct`;
|
|
353
|
+
a grep-friendly zone declares `chars=cyrillic`.
|
|
354
|
+
|
|
355
|
+
The marker strengthens the check, so it is not a suppression: it draws no
|
|
356
|
+
approval and does not appear in the escapes inventory. An invalid marker
|
|
357
|
+
(an unknown set, an empty list, a duplicate set, a duplicate marker, or a
|
|
358
|
+
marker past the fifth line) is itself a finding, and the content charset
|
|
359
|
+
is then not checked - a broken declaration does not pass silently.
|
|
349
360
|
|
|
350
361
|
## No configuration
|
|
351
362
|
|
|
@@ -396,8 +407,6 @@ ordinary comment token:
|
|
|
396
407
|
engine also accepts a standalone single-line `/* ... */` (see its
|
|
397
408
|
section).
|
|
398
409
|
- Python: a `#` comment.
|
|
399
|
-
- Markdown: the `tackbox: lang=` HTML comment (see the Markdown
|
|
400
|
-
engine section) is the only Markdown marker.
|
|
401
410
|
- Svelte: inside `<script>` blocks the `//` form works as in JS/TS;
|
|
402
411
|
the template adds two forms - a `//` comment inside a `{...}`
|
|
403
412
|
expression (line-adjacent, as ever) and an HTML comment
|
|
@@ -457,6 +466,74 @@ event, `dev.py check`, and CI reports until the entry lands or the
|
|
|
457
466
|
marker is reverted. Removing a manifest line is free; a marker whose
|
|
458
467
|
text, scope, or count changes needs its entry updated the same way.
|
|
459
468
|
|
|
469
|
+
## Generated and vendored code
|
|
470
|
+
|
|
471
|
+
Committed code that carries a generated or vendored git attribute is
|
|
472
|
+
excluded from the whole lint - findings there are not fixable in the
|
|
473
|
+
file (they belong in the generator), and a suppression marker cannot
|
|
474
|
+
survive regeneration. tackbox honors exactly three attributes,
|
|
475
|
+
`linguist-generated`, `gitlab-generated`, and `linguist-vendored`,
|
|
476
|
+
read from `.gitattributes` the same way the host (GitHub, GitLab)
|
|
477
|
+
reads them - no exclude surface of tackbox's own. The best fix stays
|
|
478
|
+
organizational: generated code should normally not be committed at
|
|
479
|
+
all; this serves the forced residue.
|
|
480
|
+
|
|
481
|
+
A file is excluded when `git check-attr` reports one of the three as
|
|
482
|
+
set. Semantics by example:
|
|
483
|
+
|
|
484
|
+
```text
|
|
485
|
+
gen/** linguist-generated
|
|
486
|
+
vendor/** linguist-vendored
|
|
487
|
+
gen/keep.go linguist-generated=false
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`gen/**` excludes everything under `gen/`; `vendor/**` the same for
|
|
491
|
+
vendored. Note `dir/**`, not `dir/` - gitattributes patterns, unlike
|
|
492
|
+
gitignore, do not match a trailing-slash directory form. `=false`
|
|
493
|
+
re-includes a single file inside an excluded tree (`gen/keep.go` above
|
|
494
|
+
is linted normally); `-attr` and `!attr` also leave a file in. Only
|
|
495
|
+
`set` / `=true` excludes.
|
|
496
|
+
|
|
497
|
+
The exclusion covers everything: per-file engines, the erclint Go
|
|
498
|
+
package run (a mixed package's excluded file is compiled but its
|
|
499
|
+
findings drop; a compile break still fails loudly), duplication, the
|
|
500
|
+
CodeClimate report, and the marker inventory - an excluded file's
|
|
501
|
+
markers are dead, so a manifest entry addressing one orphans. A lint
|
|
502
|
+
run whose scope touches excluded files prints one summary line:
|
|
503
|
+
|
|
504
|
+
```text
|
|
505
|
+
excluded by attributes: 12 files in scope (tackbox escapes lists all)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
It counts unique excluded files in the current scope (absent at zero),
|
|
509
|
+
so scoped runs are not wallpapered with a global constant;
|
|
510
|
+
`tackbox escapes` lists the full population as `attribute-excluded`
|
|
511
|
+
entries.
|
|
512
|
+
|
|
513
|
+
Because the excluded population is where the lint, the marker
|
|
514
|
+
inventory, and host diff review are all blind, the agent hook makes
|
|
515
|
+
the two ways into it loud:
|
|
516
|
+
|
|
517
|
+
- adding a positive exclusion line (a bare `<attr>` or `<attr>=true`)
|
|
518
|
+
to any `.gitattributes` draws a PreToolUse ask, one joint ask per
|
|
519
|
+
edit listing every added line; removals, `=false`, `-attr`, and
|
|
520
|
+
non-exclusion lines are free;
|
|
521
|
+
- editing (or creating) a file that is effective-excluded draws an
|
|
522
|
+
ask naming the attributes.
|
|
523
|
+
|
|
524
|
+
Generators run through Bash and are unaffected - the boundary is that
|
|
525
|
+
the change stays in the commit/PR diff, and hosts collapse
|
|
526
|
+
excluded-file diffs, so reviewers must expand them.
|
|
527
|
+
|
|
528
|
+
`tackbox doctor` adds an informational `attributes` section (never a
|
|
529
|
+
check, no exit-code effect) naming local conditions that can make a
|
|
530
|
+
run diverge from a clean CI clone - an `info/attributes` or an
|
|
531
|
+
untracked/index-hidden `.gitattributes` carrier mentioning the
|
|
532
|
+
attributes, or a neutralized attribute source override. `tackbox
|
|
533
|
+
escapes --since <rev>` resolves the baseline's attributes as of the
|
|
534
|
+
rev and so needs git >= 2.40 (older git is a named infra error on the
|
|
535
|
+
`--since` path only; the plain listing needs no version bump).
|
|
536
|
+
|
|
460
537
|
## Runtime reporting helpers
|
|
461
538
|
|
|
462
539
|
Direct reporting helpers ship per language; their shared runtime behavior -
|
|
@@ -483,9 +560,11 @@ Claude Code hook event on stdin and dispatches by `hook_event_name`:
|
|
|
483
560
|
nothing, and the block repeats on every event until the tree is
|
|
484
561
|
consistent. The authoritative gate stays pre-commit / CI.
|
|
485
562
|
- **PreToolUse** asks for approval before a new `.tackbox/approvals`
|
|
486
|
-
line or a new `.tackbox/reporters` line lands
|
|
487
|
-
|
|
488
|
-
|
|
563
|
+
line or a new `.tackbox/reporters` line lands, before a positive
|
|
564
|
+
exclusion line is added to any `.gitattributes`, and before an edit
|
|
565
|
+
to an attribute-excluded file (see "Generated and vendored code");
|
|
566
|
+
removing a line is free. Editing markers in code draws no Pre ask -
|
|
567
|
+
the consistency check owns them.
|
|
489
568
|
|
|
490
569
|
Only markers in files an engine would lint participate in the check
|
|
491
570
|
(D012): a marker in a Go `testdata/` path or a non-lintable fixture
|
|
@@ -522,8 +601,8 @@ cheap command that review tooling of any harness can consume (D013). It
|
|
|
522
601
|
enumerates:
|
|
523
602
|
|
|
524
603
|
- **suppression markers** (`// no-report`, `// parse-skip`,
|
|
525
|
-
`// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok
|
|
526
|
-
|
|
604
|
+
`// nil-return`, `// long-comment`, `// test-skip`, `// dup-ok`), each
|
|
605
|
+
with its reason;
|
|
527
606
|
- **`.tackbox/reporters` declarations** - the tier-2 sinks;
|
|
528
607
|
- **notify / quiet lane choices** - the call sites of the user-lane-only
|
|
529
608
|
`notify` and the telemetry-only `quiet` verbs.
|
|
@@ -543,9 +622,11 @@ uvx tackbox@latest escapes --since origin/main --context 5
|
|
|
543
622
|
|
|
544
623
|
```json
|
|
545
624
|
{
|
|
546
|
-
"version":
|
|
625
|
+
"version": 2,
|
|
547
626
|
"since": null,
|
|
548
627
|
"entries": [
|
|
628
|
+
{"kind": "attribute-excluded", "file": "gen/api.pb.go",
|
|
629
|
+
"attribute": "linguist-generated"},
|
|
549
630
|
{"kind": "marker", "file": "a/b.py", "line": 12,
|
|
550
631
|
"text": "no-report: central boundary already captures it",
|
|
551
632
|
"reason": "central boundary already captures it",
|
|
@@ -559,29 +640,39 @@ uvx tackbox@latest escapes --since origin/main --context 5
|
|
|
559
640
|
{"kind": "quiet-site", "file": "go/x.go", "line": 9,
|
|
560
641
|
"text": "report.Quiet(ctx, ...)", "context": ["..."]}
|
|
561
642
|
],
|
|
562
|
-
"counts": {"marker": 1, "reporter-decl": 1, "notify-site": 1,
|
|
643
|
+
"counts": {"marker": 1, "reporter-decl": 1, "notify-site": 1,
|
|
644
|
+
"quiet-site": 1, "attribute-excluded": 1}
|
|
563
645
|
}
|
|
564
646
|
```
|
|
565
647
|
|
|
566
|
-
- `version` is the schema version (`
|
|
567
|
-
kinds, even at zero, so consumers see a stable shape.
|
|
648
|
+
- `version` is the schema version (`2`); `counts` always carries all five
|
|
649
|
+
kinds, even at zero, so consumers see a stable shape. Every count is an
|
|
650
|
+
entry count except `attribute-excluded`, which counts unique files.
|
|
568
651
|
- `since` echoes the `--since` rev, or `null`.
|
|
652
|
+
- `attribute-excluded` entries carry only `kind` / `file` / `attribute` (no
|
|
653
|
+
line or text): the whole file is the bypass, one entry per set attribute
|
|
654
|
+
of the three (`linguist-generated`, `gitlab-generated`,
|
|
655
|
+
`linguist-vendored`). See "Generated and vendored code".
|
|
569
656
|
- `text` is the trimmed source line; for a marker it runs from the marker
|
|
570
657
|
keyword to end of line.
|
|
571
658
|
- `reason` (markers only) is what follows the keyword's colon, trimmed -
|
|
572
|
-
possibly empty
|
|
659
|
+
possibly empty.
|
|
573
660
|
- `context` is the surrounding source, `--context N` lines each side
|
|
574
661
|
(default 3), inclusive of the entry line itself - the window
|
|
575
662
|
`[line-N, line+N]`, clipped at file edges, each line trimmed of trailing
|
|
576
663
|
whitespace. It is plain source; the entry line is not marked.
|
|
577
|
-
- `entries` are sorted by `(file,
|
|
664
|
+
- `entries` are sorted by `(file, kind, kind-subkey)` - the subkey is
|
|
665
|
+
`(line, text)` for the line-bearing kinds and `(attribute,)` for
|
|
666
|
+
`attribute-excluded`.
|
|
578
667
|
|
|
579
668
|
### Scope and detection
|
|
580
669
|
|
|
581
670
|
The scan covers the same lintable source set the linter would scan (the
|
|
582
671
|
D012 predicate: extension match plus each engine's path filter, so a Go
|
|
583
|
-
`testdata/` file is out), plus the root
|
|
584
|
-
non-empty line is one declaration - the file has
|
|
672
|
+
`testdata/` file is out) minus the attribute-excluded files, plus the root
|
|
673
|
+
`.tackbox/reporters` (every non-empty line is one declaration - the file has
|
|
674
|
+
no comment syntax). An attribute-excluded file's own markers are dead, so it
|
|
675
|
+
surfaces only as its `attribute-excluded` entries.
|
|
585
676
|
notify / quiet call sites are detected **textually per language**
|
|
586
677
|
(`report_quiet` / `notify` in Python, `reportQuiet` / `notify` in the JS
|
|
587
678
|
family, `.Quiet(` / `.Notify(` in Go, `.quiet(` / `.notify(` in Java),
|
|
@@ -592,12 +683,19 @@ this is observability, not a lint.
|
|
|
592
683
|
### `--since <rev>`
|
|
593
684
|
|
|
594
685
|
`--since <rev>` prints only entries **new against `<rev>`**, compared by
|
|
595
|
-
content identity `(kind, file, text)
|
|
596
|
-
the
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
686
|
+
content identity (`(kind, file, text)`, or `(kind, file, attribute)` for
|
|
687
|
+
attribute-excluded) - the same extraction run against the tree at `<rev>`
|
|
688
|
+
(via `git ls-tree` + `git show`) subtracted, count aware, from the current
|
|
689
|
+
tree's entries. The baseline is attribute-aware: it resolves the attributes
|
|
690
|
+
as of `<rev>` (via the seam's `git check-attr --source`), so an attribute
|
|
691
|
+
added since the rev reports its newly-excluded files, a removed one
|
|
692
|
+
re-activates its markers as new (never a silent subtraction), and an
|
|
693
|
+
unchanged one adds no noise. Because `--source` needs git >= 2.40, an older
|
|
694
|
+
git is a named infra error on the `--since` path only (the plain listing
|
|
695
|
+
needs no version bump). It over-reports on moved code (a new file path is a
|
|
696
|
+
new identity) but never silently drops an entry - the conservative direction
|
|
697
|
+
for a review aid. A bad rev is the other infra error: one stderr line,
|
|
698
|
+
exit 1.
|
|
601
699
|
|
|
602
700
|
## Layout
|
|
603
701
|
|
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
|
-
}
|