sfora-cli 0.9.0 → 0.11.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/README.md +147 -6
- package/dist/SforaFs.js +278 -10
- package/dist/api-client.d.ts +290 -5
- package/dist/api-client.js +307 -22
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/cli.js +323 -29
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +81 -0
- package/dist/format/blocks/dropClosure.js +196 -0
- package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
- package/dist/format/blocks/markdown-block-catalog.js +162 -0
- package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
- package/dist/format/blocks/markdown-block-ids.mjs +25 -0
- package/dist/format/blocks/parsers.d.ts +105 -0
- package/dist/format/blocks/parsers.js +442 -0
- package/dist/format/blocks/structured-block-schema.d.ts +8 -0
- package/dist/format/blocks/structured-block-schema.js +30 -0
- package/dist/format/callout.d.ts +128 -0
- package/dist/format/callout.js +227 -0
- package/dist/format/cardMarkdown.d.ts +2 -0
- package/dist/format/cardMarkdown.js +10 -0
- package/dist/format/checklist.d.ts +34 -0
- package/dist/format/checklist.js +158 -0
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +19 -4
- package/dist/format/index.js +28 -4
- package/dist/format/lineGeometry.d.ts +100 -0
- package/dist/format/lineGeometry.js +424 -0
- package/dist/format/lint/appliesTo.d.ts +92 -0
- package/dist/format/lint/appliesTo.js +369 -0
- package/dist/format/lint/config.d.ts +106 -0
- package/dist/format/lint/config.js +205 -0
- package/dist/format/lint/fixAll.d.ts +62 -0
- package/dist/format/lint/fixAll.js +107 -0
- package/dist/format/lint/frontmatterSchema.d.ts +181 -0
- package/dist/format/lint/frontmatterSchema.js +660 -0
- package/dist/format/lint/index.d.ts +49 -0
- package/dist/format/lint/index.js +51 -0
- package/dist/format/lint/lintSource.d.ts +56 -0
- package/dist/format/lint/lintSource.js +188 -0
- package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/broken-wiki-link.js +45 -0
- package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
- package/dist/format/lint/rules/frontmatter-schema.js +92 -0
- package/dist/format/lint/rules/index.d.ts +11 -0
- package/dist/format/lint/rules/index.js +32 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +88 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +65 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
- package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
- package/dist/format/lint/rules/malformed-structured-block.js +134 -0
- package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
- package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
- package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
- package/dist/format/lint/rules/orphan-reference.js +87 -0
- package/dist/format/lint/severity.d.ts +15 -0
- package/dist/format/lint/severity.js +50 -0
- package/dist/format/lint/textEdits.d.ts +86 -0
- package/dist/format/lint/textEdits.js +162 -0
- package/dist/format/lint/types.d.ts +116 -0
- package/dist/format/lint/types.js +16 -0
- package/dist/format/markdown/dates.js +2 -0
- package/dist/format/markdown/document.js +2 -0
- package/dist/format/markdown/index.js +2 -0
- package/dist/format/markdown/mentions.js +2 -0
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +65 -0
- package/dist/format/markdown/yaml.js +2 -0
- package/dist/format/noteMarkdown.js +2 -0
- package/dist/format/parseWithFallback.d.ts +13 -0
- package/dist/format/parseWithFallback.js +98 -0
- package/dist/format/plaintext.d.ts +5 -0
- package/dist/format/plaintext.js +51 -0
- package/dist/format/postMarkdown.js +3 -1
- package/dist/format/sheetCellSpans.d.ts +95 -0
- package/dist/format/sheetCellSpans.js +223 -0
- package/dist/format/sheetSelection.d.ts +136 -0
- package/dist/format/sheetSelection.js +282 -0
- package/dist/format/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +78 -0
- package/dist/format/wikiLinks.js +266 -0
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/mcp-server.js +5 -2
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +132 -0
- package/dist/render.js +208 -0
- package/dist/shell-commands.d.ts +34 -0
- package/dist/shell-commands.js +108 -0
- package/dist/watch.d.ts +79 -0
- package/dist/watch.js +113 -0
- package/dist/web-url.d.ts +39 -0
- package/dist/web-url.js +63 -0
- package/package.json +7 -6
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// Checkboxes that miss `CHECKBOX_RE` by one character.
|
|
4
|
+
//
|
|
5
|
+
// `- [] ship it`, `- [ x] ship it`, `- [x]ship it` — all three look like tasks
|
|
6
|
+
// to a human and count as none to `checklistProgress`, so the card says 0/3
|
|
7
|
+
// and nobody can see why.
|
|
8
|
+
//
|
|
9
|
+
// A HINT, the quietest of the four levels, and the only rule that sits there.
|
|
10
|
+
// It is the level for "the render is exactly right and a derived number is
|
|
11
|
+
// not": nothing on the page is missing or wrong — the line reads as the author
|
|
12
|
+
// wrote it — and the only casualty is a progress count computed somewhere
|
|
13
|
+
// else. Everything louder is reserved for bytes that do not reach the reader.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately narrow. Only bullet lines, only brackets holding nothing but a
|
|
16
|
+
// space or an `x`, and only when there is text after them — `- []` on its own
|
|
17
|
+
// is someone who has typed two characters of a checkbox and is still going.
|
|
18
|
+
import { CHECKBOX_RE } from "../../checklist.js";
|
|
19
|
+
import { codeSpanMask, isSkippableLine, lineText } from "../lintSource.js";
|
|
20
|
+
const RULE_ID = "sfora/malformed-checklist";
|
|
21
|
+
const NEAR_MISS = /^(\s*[-*]\s+)\[([ \t]*[xX]?[ \t]*)\](.*)$/;
|
|
22
|
+
export const malformedChecklistRule = {
|
|
23
|
+
id: RULE_ID,
|
|
24
|
+
run(ctx) {
|
|
25
|
+
const out = [];
|
|
26
|
+
for (let i = 0; i < ctx.lines.length; i++) {
|
|
27
|
+
if (isSkippableLine(ctx, i))
|
|
28
|
+
continue;
|
|
29
|
+
const line = lineText(ctx.lines, i);
|
|
30
|
+
if (CHECKBOX_RE.test(line))
|
|
31
|
+
continue;
|
|
32
|
+
const match = NEAR_MISS.exec(line);
|
|
33
|
+
if (!match)
|
|
34
|
+
continue;
|
|
35
|
+
const bullet = match[1];
|
|
36
|
+
const inner = match[2];
|
|
37
|
+
const rest = match[3];
|
|
38
|
+
if (rest.trim() === "")
|
|
39
|
+
continue;
|
|
40
|
+
// `- [x](https://…)` and `- [x][ref]` are links whose text happens to be
|
|
41
|
+
// one character, not checkboxes.
|
|
42
|
+
if (rest.startsWith("(") || rest.startsWith("["))
|
|
43
|
+
continue;
|
|
44
|
+
if (codeSpanMask(line)[bullet.length])
|
|
45
|
+
continue;
|
|
46
|
+
const checked = /[xX]/.test(inner);
|
|
47
|
+
const canonical = `${bullet}[${checked ? "x" : " "}] ${rest.trim()}`;
|
|
48
|
+
const from = ctx.lineStart(i);
|
|
49
|
+
out.push({
|
|
50
|
+
from,
|
|
51
|
+
to: from + line.length,
|
|
52
|
+
severity: "hint",
|
|
53
|
+
ruleId: RULE_ID,
|
|
54
|
+
message: "This isn't a task checkbox, so it won't be counted. Tasks are exactly `- [ ] ` or `- [x] `.",
|
|
55
|
+
fixes: [
|
|
56
|
+
{
|
|
57
|
+
label: "Fix the checkbox",
|
|
58
|
+
edits: [{ from, to: from + line.length, insert: canonical }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// The `---` block at the top of a document.
|
|
4
|
+
//
|
|
5
|
+
// Structure only — is the fence closed, is every line something the reader
|
|
6
|
+
// will actually read. What the VALUES mean (a `status:` that is not a real
|
|
7
|
+
// status, a missing required key) is schema validation, and it belongs to the
|
|
8
|
+
// frontmatter schema, not here. The registry is the seam where that rule lands
|
|
9
|
+
// when it exists.
|
|
10
|
+
//
|
|
11
|
+
// The bar for each mark is "sfora's own parser will drop this". `parseYaml`
|
|
12
|
+
// (markdown/yaml.ts) keeps `key: value` and skips everything else, so a line
|
|
13
|
+
// without a colon is not a style opinion — it is a line the document does not
|
|
14
|
+
// have.
|
|
15
|
+
import { lineText } from "../lintSource.js";
|
|
16
|
+
const RULE_ID = "sfora/malformed-frontmatter";
|
|
17
|
+
const KEY = /^([^:#]+):/;
|
|
18
|
+
export const malformedFrontmatterRule = {
|
|
19
|
+
id: RULE_ID,
|
|
20
|
+
run(ctx) {
|
|
21
|
+
const block = ctx.frontmatter;
|
|
22
|
+
if (!block)
|
|
23
|
+
return [];
|
|
24
|
+
const openStart = ctx.lineStart(0);
|
|
25
|
+
if (block.close === -1) {
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
from: openStart,
|
|
29
|
+
to: openStart + lineText(ctx.lines, 0).length,
|
|
30
|
+
// The one error in sfora-law. Every other rule costs the author a
|
|
31
|
+
// sentence, a link or a count; this one costs the document its
|
|
32
|
+
// entire identity — no title, no status, no id, and a body that
|
|
33
|
+
// starts with three dashes. Nothing downstream of the mark means
|
|
34
|
+
// what it says.
|
|
35
|
+
severity: "error",
|
|
36
|
+
ruleId: RULE_ID,
|
|
37
|
+
// No fix: where the block was meant to end is the author's
|
|
38
|
+
// knowledge, and guessing turns their first paragraph into metadata.
|
|
39
|
+
message: "This `---` block is never closed, so the whole file is read as body text.",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
const out = [];
|
|
44
|
+
const seen = new Map();
|
|
45
|
+
for (let i = block.open + 1; i < block.close; i++) {
|
|
46
|
+
const line = lineText(ctx.lines, i);
|
|
47
|
+
const text = line.trim();
|
|
48
|
+
if (text === "" || text.startsWith("#"))
|
|
49
|
+
continue;
|
|
50
|
+
const key = KEY.exec(text)?.[1]?.trim();
|
|
51
|
+
if (!key) {
|
|
52
|
+
const from = ctx.lineStart(i);
|
|
53
|
+
out.push({
|
|
54
|
+
from,
|
|
55
|
+
to: from + line.length,
|
|
56
|
+
severity: "info",
|
|
57
|
+
ruleId: RULE_ID,
|
|
58
|
+
message: "Frontmatter is `key: value` lines — this one has no `:`, so it won't be read.",
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
// Only top-level keys can collide; an indented line belongs to the
|
|
63
|
+
// value above it.
|
|
64
|
+
if (line.startsWith(" ") || line.startsWith("\t"))
|
|
65
|
+
continue;
|
|
66
|
+
const first = seen.get(key);
|
|
67
|
+
if (first === undefined) {
|
|
68
|
+
seen.set(key, i);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const from = ctx.lineStart(i);
|
|
72
|
+
out.push({
|
|
73
|
+
from,
|
|
74
|
+
to: from + line.length,
|
|
75
|
+
severity: "warning",
|
|
76
|
+
ruleId: RULE_ID,
|
|
77
|
+
message: `\`${key}\` is set twice — this line wins, so line ${first + 1} is dead.`,
|
|
78
|
+
fixes: [
|
|
79
|
+
{
|
|
80
|
+
// The DEAD line is the one that goes. Removing the winning line
|
|
81
|
+
// instead would quietly change what the document says, which is
|
|
82
|
+
// not something a lint fix is allowed to do. The newline goes with
|
|
83
|
+
// it so the block does not grow a blank row.
|
|
84
|
+
label: `Remove the unused \`${key}\` on line ${first + 1}`,
|
|
85
|
+
edits: [
|
|
86
|
+
{
|
|
87
|
+
from: ctx.lineStart(first),
|
|
88
|
+
to: ctx.lineStart(first + 1),
|
|
89
|
+
insert: "",
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
},
|
|
98
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// ```status / ```board / ```chat / ```sheet blocks that will not render.
|
|
4
|
+
//
|
|
5
|
+
// The parsers in blocks/parsers.ts return null for anything that does not look
|
|
6
|
+
// like their block, and the renderer's answer to null is "draw a plain code
|
|
7
|
+
// fence". That contract is deliberate — a malformed block stays visible and
|
|
8
|
+
// copyable — but it is also silent, so an author who mistyped a sheet just
|
|
9
|
+
// sees their table as code and has to guess why.
|
|
10
|
+
//
|
|
11
|
+
// Two levels. The block as a whole failing is a warning on the opening fence.
|
|
12
|
+
// A block that parses but drops individual lines is an info per dropped line:
|
|
13
|
+
// those are the expensive ones, because the block renders and the missing row
|
|
14
|
+
// is only noticed later.
|
|
15
|
+
//
|
|
16
|
+
// The dropped lines are the PARSERS' own accounting, not a second reading of
|
|
17
|
+
// their grammars — `parseStructuredBlock` reports every line it consumed and
|
|
18
|
+
// every one it did not, and `blocks/dropClosure.ts` says which of those an
|
|
19
|
+
// author should hear about. A rule that re-derived "would this line render"
|
|
20
|
+
// from a copy of the grammar is exactly how the two drift, and it had already
|
|
21
|
+
// happened: a second `state:` line renders as a state-change marker, and this
|
|
22
|
+
// rule used to tell the author it would not appear.
|
|
23
|
+
//
|
|
24
|
+
// AST-preferred: when a tree was injected, fences come from its `code` nodes,
|
|
25
|
+
// which know the language and the extent without re-deriving either. The
|
|
26
|
+
// fallback scan produces the same regions for the same document, and the
|
|
27
|
+
// checks downstream are shared, so both paths say the same thing.
|
|
28
|
+
import { isReportableDrop, isWholeLineDrop, } from "../../blocks/dropClosure.js";
|
|
29
|
+
import { parseStructuredBlock } from "../../blocks/parsers.js";
|
|
30
|
+
import { isStructuredMarkdownLanguage, } from "../../blocks/structured-block-schema.js";
|
|
31
|
+
import { fencedRegions, lineText } from "../lintSource.js";
|
|
32
|
+
const RULE_ID = "sfora/malformed-structured-block";
|
|
33
|
+
export const malformedStructuredBlockRule = {
|
|
34
|
+
id: RULE_ID,
|
|
35
|
+
run(ctx) {
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const region of structuredFences(ctx)) {
|
|
38
|
+
const lang = region.lang;
|
|
39
|
+
const parsed = parseStructuredBlock(lang, contentOf(ctx, region));
|
|
40
|
+
if (parsed.data === null) {
|
|
41
|
+
const line = lineText(ctx.lines, region.open);
|
|
42
|
+
out.push({
|
|
43
|
+
from: ctx.lineStart(region.open),
|
|
44
|
+
to: ctx.lineStart(region.open) + line.length,
|
|
45
|
+
severity: "warning",
|
|
46
|
+
ruleId: RULE_ID,
|
|
47
|
+
message: `This \`${lang}\` block can't be read, so it renders as plain code. Check the block's format.`,
|
|
48
|
+
});
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
for (const drop of parsed.drops) {
|
|
52
|
+
if (!isWholeLineDrop(drop.reason) || !isReportableDrop(drop.reason)) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const i = region.contentStart + drop.line;
|
|
56
|
+
const raw = lineText(ctx.lines, i);
|
|
57
|
+
out.push({
|
|
58
|
+
from: ctx.lineStart(i),
|
|
59
|
+
to: ctx.lineStart(i) + raw.length,
|
|
60
|
+
severity: "info",
|
|
61
|
+
ruleId: RULE_ID,
|
|
62
|
+
message: "This line won't appear in the rendered block.",
|
|
63
|
+
...fixFor(ctx, lang, i, raw, drop.text),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* The one-shape repair for a dropped line, when there is one. Only offer it
|
|
72
|
+
* where it demonstrably works — re-running the parser over the candidate is
|
|
73
|
+
* cheaper than reasoning about the grammar, and it makes the fix its own
|
|
74
|
+
* proof. A sheet has no such shape: where the pipes go is the author's call.
|
|
75
|
+
*/
|
|
76
|
+
function fixFor(ctx, lang, line, raw, text) {
|
|
77
|
+
if (lang === "sheet")
|
|
78
|
+
return {};
|
|
79
|
+
const prefix = lang === "board" ? "- " : lang === "map" ? "- [ ] " : "- @";
|
|
80
|
+
const candidate = `${prefix}${text}`;
|
|
81
|
+
const retried = parseStructuredBlock(lang, candidate);
|
|
82
|
+
if (retried.data === null || retried.consumed.length === 0)
|
|
83
|
+
return {};
|
|
84
|
+
return {
|
|
85
|
+
fixes: [
|
|
86
|
+
{
|
|
87
|
+
label: `Prefix with "${prefix}"`,
|
|
88
|
+
edits: [
|
|
89
|
+
{
|
|
90
|
+
from: ctx.lineStart(line) + raw.indexOf(text),
|
|
91
|
+
to: ctx.lineStart(line) + raw.indexOf(text) + text.length,
|
|
92
|
+
insert: candidate,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** The fence's own text, dedented by the opening delimiter's indent. */
|
|
100
|
+
function contentOf(ctx, region) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
for (let i = region.contentStart; i < region.contentEnd; i++) {
|
|
103
|
+
lines.push(lineText(ctx.lines, i).slice(region.indent));
|
|
104
|
+
}
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
function structuredFences(ctx) {
|
|
108
|
+
const scanned = fenceScan(ctx).filter((region) => isStructuredMarkdownLanguage(region.lang));
|
|
109
|
+
if (!ctx.ast)
|
|
110
|
+
return scanned;
|
|
111
|
+
// With a tree, keep only the fences micromark also read as code blocks —
|
|
112
|
+
// the tree is the authority on what is a block and what is prose that looks
|
|
113
|
+
// like one (inside a list item's lazy continuation, say).
|
|
114
|
+
const codeLines = new Set();
|
|
115
|
+
collectCodeStarts(ctx.ast, codeLines);
|
|
116
|
+
return scanned.filter((region) => codeLines.has(region.open));
|
|
117
|
+
}
|
|
118
|
+
function collectCodeStarts(node, out) {
|
|
119
|
+
if (node.type === "code" && node.position) {
|
|
120
|
+
out.add(node.position.start.line - 1);
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(node.children)) {
|
|
123
|
+
for (const child of node.children)
|
|
124
|
+
collectCodeStarts(child, out);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function fenceScan(ctx) {
|
|
128
|
+
const from = ctx.frontmatter === null
|
|
129
|
+
? 0
|
|
130
|
+
: ctx.frontmatter.close === -1
|
|
131
|
+
? ctx.lines.length
|
|
132
|
+
: ctx.frontmatter.close + 1;
|
|
133
|
+
return fencedRegions(ctx.lines, from);
|
|
134
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// Wiki-link tokens that cannot mean anything, no matter what exists.
|
|
4
|
+
//
|
|
5
|
+
// Only unambiguous breaks: a token with no target at all, or a known prefix
|
|
6
|
+
// with nothing after it. Two near misses are deliberately NOT flagged — an
|
|
7
|
+
// unknown prefix (`[[x:1]]` is a perfectly good bare post id that happens to
|
|
8
|
+
// contain a colon) and an unclosed `[[` (that is a person halfway through
|
|
9
|
+
// typing a link, and a squiggle under the caret is a rude way to help).
|
|
10
|
+
import { scanWikiTokens } from "../lintSource.js";
|
|
11
|
+
import { WIKI_LINK_PREFIXES } from "../../wikiLinks.js";
|
|
12
|
+
const RULE_ID = "sfora/malformed-wiki-link";
|
|
13
|
+
export const malformedWikiLinkRule = {
|
|
14
|
+
id: RULE_ID,
|
|
15
|
+
run(ctx) {
|
|
16
|
+
const out = [];
|
|
17
|
+
for (const token of scanWikiTokens(ctx)) {
|
|
18
|
+
const prefix = WIKI_LINK_PREFIXES.find(([p]) => token.target === p)?.[0];
|
|
19
|
+
const message = token.target === ""
|
|
20
|
+
? "This link has no target, so it renders as literal `[[…]]`."
|
|
21
|
+
: prefix
|
|
22
|
+
? `This link is just the \`${prefix}\` prefix — there is no id after it.`
|
|
23
|
+
: null;
|
|
24
|
+
if (message === null)
|
|
25
|
+
continue;
|
|
26
|
+
out.push({
|
|
27
|
+
from: token.from,
|
|
28
|
+
to: token.to,
|
|
29
|
+
severity: "warning",
|
|
30
|
+
ruleId: RULE_ID,
|
|
31
|
+
message,
|
|
32
|
+
fixes: [removeToken(token.from, token.to, token.hasLabel ? token.label : "")],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
function removeToken(from, to, label) {
|
|
39
|
+
return {
|
|
40
|
+
label: label ? `Remove link, keep "${label}"` : "Remove link",
|
|
41
|
+
edits: [{ from, to, insert: label }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// `[text][ref]` with no `[ref]: …` definition anywhere in the document.
|
|
4
|
+
//
|
|
5
|
+
// A reference link whose definition is missing renders as literal brackets —
|
|
6
|
+
// the one markdown failure mode that looks like a typo rather than a feature,
|
|
7
|
+
// which is why it survives so many rounds of proofreading.
|
|
8
|
+
//
|
|
9
|
+
// Raw scan, not mdast: a tree has no node for an undefined reference. Remark
|
|
10
|
+
// resolves what it can and leaves the rest as text, so the thing we are
|
|
11
|
+
// looking for is precisely the thing the tree does not contain.
|
|
12
|
+
//
|
|
13
|
+
// Two neighbours stay silent. Shortcut references (`[ref]` alone) are never
|
|
14
|
+
// flagged — `[sic]`, `[1]` and `[TODO]` are ordinary prose, and no amount of
|
|
15
|
+
// context tells them apart from a link someone forgot to define. Unused
|
|
16
|
+
// definitions are not flagged either: a stale definition costs nothing and
|
|
17
|
+
// deleting one is not a fix a linter should suggest.
|
|
18
|
+
import { codeSpanMask, isEscaped, isSkippableLine, lineText, } from "../lintSource.js";
|
|
19
|
+
const RULE_ID = "sfora/orphan-reference";
|
|
20
|
+
const DEFINITION = /^ {0,3}\[([^\]]+)\]:\s*\S/;
|
|
21
|
+
const USAGE = /\[([^[\]]*)\]\[([^[\]]*)\]/g;
|
|
22
|
+
/** Reference labels are matched case-insensitively, with runs of whitespace
|
|
23
|
+
* collapsed — CommonMark's rules, not ours. */
|
|
24
|
+
function fold(label) {
|
|
25
|
+
return label.trim().replace(/\s+/g, " ").toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
export const orphanReferenceRule = {
|
|
28
|
+
id: RULE_ID,
|
|
29
|
+
run(ctx) {
|
|
30
|
+
const defined = definitions(ctx);
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < ctx.lines.length; i++) {
|
|
33
|
+
if (isSkippableLine(ctx, i))
|
|
34
|
+
continue;
|
|
35
|
+
const line = lineText(ctx.lines, i);
|
|
36
|
+
if (DEFINITION.test(line))
|
|
37
|
+
continue;
|
|
38
|
+
const mask = codeSpanMask(line);
|
|
39
|
+
USAGE.lastIndex = 0;
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = USAGE.exec(line)) !== null) {
|
|
42
|
+
if (mask[m.index] || isEscaped(line, m.index))
|
|
43
|
+
continue;
|
|
44
|
+
const text = m[1];
|
|
45
|
+
// `[text][]` is the collapsed form: the text IS the label.
|
|
46
|
+
const label = m[2].trim() === "" ? text : m[2];
|
|
47
|
+
if (label.trim() === "" || defined.has(fold(label)))
|
|
48
|
+
continue;
|
|
49
|
+
// `![alt][ref]` is the image form — swallow the `!` too, or removing
|
|
50
|
+
// the brackets leaves a stray bang in the sentence.
|
|
51
|
+
const bang = m.index > 0 && line[m.index - 1] === "!" ? 1 : 0;
|
|
52
|
+
const from = ctx.lineStart(i) + m.index - bang;
|
|
53
|
+
out.push({
|
|
54
|
+
from,
|
|
55
|
+
to: ctx.lineStart(i) + m.index + m[0].length,
|
|
56
|
+
severity: "warning",
|
|
57
|
+
ruleId: RULE_ID,
|
|
58
|
+
message: `No \`[${label.trim()}]:\` definition in this document, so these brackets render as-is.`,
|
|
59
|
+
fixes: [
|
|
60
|
+
{
|
|
61
|
+
label: "Convert to plain text",
|
|
62
|
+
edits: [
|
|
63
|
+
{
|
|
64
|
+
from,
|
|
65
|
+
to: ctx.lineStart(i) + m.index + m[0].length,
|
|
66
|
+
insert: text,
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
function definitions(ctx) {
|
|
78
|
+
const out = new Set();
|
|
79
|
+
for (let i = 0; i < ctx.lines.length; i++) {
|
|
80
|
+
if (isSkippableLine(ctx, i))
|
|
81
|
+
continue;
|
|
82
|
+
const match = DEFINITION.exec(lineText(ctx.lines, i));
|
|
83
|
+
if (match)
|
|
84
|
+
out.add(fold(match[1]));
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Most severe first. The order IS the rank. */
|
|
2
|
+
export declare const LINT_SEVERITIES: readonly ["error", "warning", "info", "hint"];
|
|
3
|
+
/**
|
|
4
|
+
* Nothing in sfora-law is an alarm. Even `error` only says "sfora will read
|
|
5
|
+
* this differently than you will" — the bytes on disk are always the user's to
|
|
6
|
+
* keep, and no rule here ever refuses a document.
|
|
7
|
+
*/
|
|
8
|
+
export type LintSeverity = (typeof LINT_SEVERITIES)[number];
|
|
9
|
+
/** 0 for `error`, 3 for `hint`. Lower is more severe. */
|
|
10
|
+
export declare function severityRank(severity: LintSeverity): number;
|
|
11
|
+
/** Negative when `a` is more severe than `b`. Sorts most-severe-first. */
|
|
12
|
+
export declare function compareSeverity(a: LintSeverity, b: LintSeverity): number;
|
|
13
|
+
/** True when `severity` is at least as severe as `floor`. */
|
|
14
|
+
export declare function atLeastAsSevere(severity: LintSeverity, floor: LintSeverity): boolean;
|
|
15
|
+
export declare function isLintSeverity(value: unknown): value is LintSeverity;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// The four levels a sfora-law diagnostic can speak at, and what each one means.
|
|
4
|
+
//
|
|
5
|
+
// One level is not enough. Every rule in this directory answers the same
|
|
6
|
+
// question — "will this byte sequence render as the author obviously meant it
|
|
7
|
+
// to?" — but the ANSWERS differ in kind, and flattening them made the gutter
|
|
8
|
+
// lie in both directions: an unclosed frontmatter fence, which costs a document
|
|
9
|
+
// every field it claims to have, wore the same amber as a checkbox that renders
|
|
10
|
+
// perfectly and is merely uncounted.
|
|
11
|
+
//
|
|
12
|
+
// The ladder is ordered by WHAT IS LOST, not by how loud the rule feels:
|
|
13
|
+
//
|
|
14
|
+
// error the document as a whole stops meaning what it says. Everything
|
|
15
|
+
// downstream of the mark is read as something else entirely.
|
|
16
|
+
// warning something the author wrote will not render as they meant it. The
|
|
17
|
+
// bytes are there; the reading is wrong.
|
|
18
|
+
// info something the author wrote is silently dropped. The render is not
|
|
19
|
+
// wrong, it is missing a piece, and nothing on screen says so.
|
|
20
|
+
// hint the render is exactly right. Only a derived number — a progress
|
|
21
|
+
// count, an index — comes out different than the author expects.
|
|
22
|
+
//
|
|
23
|
+
// Every level has at least one rule standing on it (asserted in
|
|
24
|
+
// `__tests__/lint.test.ts`), because a severity nothing emits is a severity
|
|
25
|
+
// nobody has thought about.
|
|
26
|
+
//
|
|
27
|
+
// The names match LSP's `DiagnosticSeverity` and CodeMirror's `Severity`, in
|
|
28
|
+
// that order, so the editor adapter is a lookup rather than a translation.
|
|
29
|
+
/** Most severe first. The order IS the rank. */
|
|
30
|
+
export const LINT_SEVERITIES = ["error", "warning", "info", "hint"];
|
|
31
|
+
/** 0 for `error`, 3 for `hint`. Lower is more severe. */
|
|
32
|
+
export function severityRank(severity) {
|
|
33
|
+
const index = LINT_SEVERITIES.indexOf(severity);
|
|
34
|
+
// An unknown string is treated as the quietest thing we have rather than as
|
|
35
|
+
// a crash: a diagnostic that arrived from an older copy of the package must
|
|
36
|
+
// still sort somewhere.
|
|
37
|
+
return index === -1 ? LINT_SEVERITIES.length : index;
|
|
38
|
+
}
|
|
39
|
+
/** Negative when `a` is more severe than `b`. Sorts most-severe-first. */
|
|
40
|
+
export function compareSeverity(a, b) {
|
|
41
|
+
return severityRank(a) - severityRank(b);
|
|
42
|
+
}
|
|
43
|
+
/** True when `severity` is at least as severe as `floor`. */
|
|
44
|
+
export function atLeastAsSevere(severity, floor) {
|
|
45
|
+
return severityRank(severity) <= severityRank(floor);
|
|
46
|
+
}
|
|
47
|
+
export function isLintSeverity(value) {
|
|
48
|
+
return (typeof value === "string" &&
|
|
49
|
+
LINT_SEVERITIES.includes(value));
|
|
50
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { LintEdit, LintFix } from "./types.js";
|
|
2
|
+
/** Why a list of edits cannot be applied as one atomic change. */
|
|
3
|
+
export type LintEditRejection = {
|
|
4
|
+
reason: "inverted";
|
|
5
|
+
edit: LintEdit;
|
|
6
|
+
} | {
|
|
7
|
+
reason: "out-of-range";
|
|
8
|
+
edit: LintEdit;
|
|
9
|
+
} | {
|
|
10
|
+
reason: "overlap";
|
|
11
|
+
edit: LintEdit;
|
|
12
|
+
conflictsWith: LintEdit;
|
|
13
|
+
};
|
|
14
|
+
export type LintEditResult = {
|
|
15
|
+
ok: true;
|
|
16
|
+
text: string;
|
|
17
|
+
} | {
|
|
18
|
+
ok: false;
|
|
19
|
+
rejection: LintEditRejection;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* True when two edits cannot both be applied to the same document state.
|
|
23
|
+
*
|
|
24
|
+
* Proper overlap is the obvious case. The second clause is the one that bites:
|
|
25
|
+
* two pure INSERTIONS at the same offset do not overlap by any interval test,
|
|
26
|
+
* yet the text they produce depends entirely on which goes first, and a fix
|
|
27
|
+
* whose output depends on sort stability is not a fix. Adjacency
|
|
28
|
+
* (`a.to === b.from`) is fine and stays allowed — that is two rules repairing
|
|
29
|
+
* neighbouring spans, which is exactly what fix-all is for.
|
|
30
|
+
*/
|
|
31
|
+
export declare function editsConflict(a: LintEdit, b: LintEdit): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* The application order. Descending by start, then by end, so an edit that
|
|
34
|
+
* starts later lands first and leaves every earlier offset untouched.
|
|
35
|
+
*
|
|
36
|
+
* This function is the reason the offsets in a fix mean anything. Sorting it
|
|
37
|
+
* the other way still "works" for a single-edit fix — which is most of them,
|
|
38
|
+
* which is how a bug here would hide — and quietly corrupts every multi-edit
|
|
39
|
+
* one.
|
|
40
|
+
*/
|
|
41
|
+
export declare function sortEditsForApply(edits: readonly LintEdit[]): LintEdit[];
|
|
42
|
+
/**
|
|
43
|
+
* Can these edits compose over `source`? Returns the first reason they cannot,
|
|
44
|
+
* or null. Checked against the source the edits were DERIVED from — an edit
|
|
45
|
+
* checked against a document that has moved on is meaningless.
|
|
46
|
+
*/
|
|
47
|
+
export declare function checkEdits(source: string, edits: readonly LintEdit[]): LintEditRejection | null;
|
|
48
|
+
/**
|
|
49
|
+
* Apply `edits` to `source` as one atomic change, or say why not.
|
|
50
|
+
*
|
|
51
|
+
* Never throws and never half-applies: on any rejection the caller gets the
|
|
52
|
+
* reason and the original text is untouched. Lint is a background nicety in a
|
|
53
|
+
* text editor; it is never allowed to be the reason a document is damaged.
|
|
54
|
+
*/
|
|
55
|
+
export declare function tryApplyEdits(source: string, edits: readonly LintEdit[]): LintEditResult;
|
|
56
|
+
/**
|
|
57
|
+
* `tryApplyEdits` for callers that have already validated, or that would
|
|
58
|
+
* rather lose the fix than reason about a rejection. Returns `source`
|
|
59
|
+
* unchanged when the edits do not compose.
|
|
60
|
+
*/
|
|
61
|
+
export declare function applyEdits(source: string, edits: readonly LintEdit[]): string;
|
|
62
|
+
/**
|
|
63
|
+
* True when every edit still describes the bytes it was computed against.
|
|
64
|
+
*
|
|
65
|
+
* The staleness question, factored out of the editor adapter. A fix's offsets
|
|
66
|
+
* point into the source a rule read; applying them to a document that has been
|
|
67
|
+
* typed into since is not a repair, it is corruption at a plausible-looking
|
|
68
|
+
* offset. Both the CodeMirror action and the batch path ask this before they
|
|
69
|
+
* commit.
|
|
70
|
+
*/
|
|
71
|
+
export declare function editsMatch(source: string, edits: readonly LintEdit[], expected: string): boolean;
|
|
72
|
+
/** LSP's `Position`: zero-based line, zero-based UTF-16 offset within it. */
|
|
73
|
+
export interface LintPosition {
|
|
74
|
+
line: number;
|
|
75
|
+
character: number;
|
|
76
|
+
}
|
|
77
|
+
/** LSP's `TextEdit`, for anything that talks over a wire instead of a buffer. */
|
|
78
|
+
export interface LintTextEdit {
|
|
79
|
+
range: {
|
|
80
|
+
start: LintPosition;
|
|
81
|
+
end: LintPosition;
|
|
82
|
+
};
|
|
83
|
+
newText: string;
|
|
84
|
+
}
|
|
85
|
+
/** One fix's edits in LSP wire form, in the order LSP wants them (any). */
|
|
86
|
+
export declare function fixToTextEdits(source: string, fix: LintFix): LintTextEdit[];
|