sfora-cli 0.9.0 → 0.10.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 +8 -6
- package/dist/SforaFs.js +270 -4
- package/dist/api-client.d.ts +47 -1
- package/dist/api-client.js +60 -3
- package/dist/cli.js +6 -3
- package/dist/format/__tests__/byteStable.d.ts +5 -0
- package/dist/format/__tests__/byteStable.js +64 -0
- package/dist/format/blocks/dropClosure.d.ts +72 -0
- package/dist/format/blocks/dropClosure.js +186 -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 +66 -0
- package/dist/format/callout.js +130 -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 +151 -0
- package/dist/format/index.d.ts +18 -4
- package/dist/format/index.js +24 -4
- package/dist/format/lineGeometry.d.ts +70 -0
- package/dist/format/lineGeometry.js +324 -0
- package/dist/format/lint/index.d.ts +20 -0
- package/dist/format/lint/index.js +22 -0
- package/dist/format/lint/lintSource.d.ts +36 -0
- package/dist/format/lint/lintSource.js +154 -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/index.d.ts +10 -0
- package/dist/format/lint/rules/index.js +26 -0
- package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
- package/dist/format/lint/rules/malformed-callout.js +79 -0
- package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
- package/dist/format/lint/rules/malformed-checklist.js +60 -0
- package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
- package/dist/format/lint/rules/malformed-frontmatter.js +93 -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/types.d.ts +80 -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.js +2 -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 +41 -0
- package/dist/format/postMarkdown.js +3 -1
- package/dist/format/taskUploadFilename.d.ts +6 -0
- package/dist/format/taskUploadFilename.js +13 -0
- package/dist/format/wayfinder.d.ts +50 -0
- package/dist/format/wayfinder.js +203 -0
- package/dist/format/wikiLinks.d.ts +19 -0
- package/dist/format/wikiLinks.js +80 -0
- package/dist/mcp-server.js +5 -2
- package/package.json +7 -6
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
// Line geometry: where the fences and the frontmatter are, and which bytes
|
|
4
|
+
// never reach a reader at all.
|
|
5
|
+
//
|
|
6
|
+
// Lint built this first and is still its heaviest user, but it is not lint's
|
|
7
|
+
// to own — a checkbox-looking line inside a fence is code to the reader, to
|
|
8
|
+
// `checklistProgress`, and to every other tool that walks lines instead of the
|
|
9
|
+
// tree. Those callers must not carry a second, subtly different idea of where
|
|
10
|
+
// a fence starts, so the scan lives here and `lint/lintSource` re-exports it.
|
|
11
|
+
//
|
|
12
|
+
// The second half of the file is the NON-RENDERING MASK: one same-length
|
|
13
|
+
// string in which every byte that does not render — fenced code, inline code
|
|
14
|
+
// spans, HTML comments, raw `<pre>`/`<code>` — has been replaced by a space.
|
|
15
|
+
// Substitution, never deletion, so offset N in the mask is offset N in the
|
|
16
|
+
// source by construction and a line scanner and the parsed tree can never
|
|
17
|
+
// disagree about which bytes are markdown. Every scanner in the package reads
|
|
18
|
+
// it; there is no second copy.
|
|
19
|
+
//
|
|
20
|
+
// Pure line arithmetic: no parser, no dependencies. It ships in the CLI.
|
|
21
|
+
// The optional BOM only ever occurs on the first line, and allowing it here is
|
|
22
|
+
// not cosmetic: without it a file that opens with a fence has no fence at all,
|
|
23
|
+
// and every rule that skips fenced code would walk straight into one.
|
|
24
|
+
const FENCE_OPEN = /^?( {0,3})(`{3,}|~{3,})(.*)$/;
|
|
25
|
+
/** The line without its trailing `\r`, so `$`-anchored rules work on CRLF. */
|
|
26
|
+
export function lineText(lines, i) {
|
|
27
|
+
const raw = lines[i] ?? "";
|
|
28
|
+
return raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Frontmatter extent, or null. Only a document that OPENS with `---` has
|
|
32
|
+
* frontmatter — a `---` further down is a thematic break, and treating it as a
|
|
33
|
+
* fence would make every horizontal rule the start of a lint dead zone.
|
|
34
|
+
*/
|
|
35
|
+
export function frontmatterExtent(lines) {
|
|
36
|
+
if (lines.length === 0)
|
|
37
|
+
return null;
|
|
38
|
+
const first = lineText(lines, 0).replace(/^/, "");
|
|
39
|
+
if (first !== "---")
|
|
40
|
+
return null;
|
|
41
|
+
for (let i = 1; i < lines.length; i++) {
|
|
42
|
+
const text = lineText(lines, i).trimEnd();
|
|
43
|
+
if (text === "---" || text === "...")
|
|
44
|
+
return { open: 0, close: i };
|
|
45
|
+
}
|
|
46
|
+
return { open: 0, close: -1 }; // opened, never closed
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Every fenced code block in the document, in order. `from` skips the
|
|
50
|
+
* frontmatter block, whose contents are not markdown either.
|
|
51
|
+
*/
|
|
52
|
+
export function fencedRegions(lines, from = 0) {
|
|
53
|
+
const regions = [];
|
|
54
|
+
for (let i = from; i < lines.length; i++) {
|
|
55
|
+
const match = FENCE_OPEN.exec(lineText(lines, i));
|
|
56
|
+
if (!match)
|
|
57
|
+
continue;
|
|
58
|
+
const delimiter = match[2];
|
|
59
|
+
const info = match[3];
|
|
60
|
+
// A backtick fence's info string may not contain a backtick — that rules
|
|
61
|
+
// out inline code like ```` ```a``` ```` opening a block.
|
|
62
|
+
if (delimiter.startsWith("`") && info.includes("`"))
|
|
63
|
+
continue;
|
|
64
|
+
const char = delimiter[0];
|
|
65
|
+
const closer = new RegExp(`^ {0,3}[${char}]{${delimiter.length},}[ \\t]*$`);
|
|
66
|
+
let close = lines.length - 1;
|
|
67
|
+
let closed = false;
|
|
68
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
69
|
+
if (closer.test(lineText(lines, j))) {
|
|
70
|
+
close = j;
|
|
71
|
+
closed = true;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
regions.push({
|
|
76
|
+
open: i,
|
|
77
|
+
close,
|
|
78
|
+
closed,
|
|
79
|
+
lang: info.trim().split(/\s+/)[0]?.toLowerCase() ?? "",
|
|
80
|
+
indent: match[1].length,
|
|
81
|
+
contentStart: i + 1,
|
|
82
|
+
contentEnd: closed ? close : lines.length,
|
|
83
|
+
});
|
|
84
|
+
i = close;
|
|
85
|
+
}
|
|
86
|
+
return regions;
|
|
87
|
+
}
|
|
88
|
+
/** True for every line covered by a fenced block, delimiters included. */
|
|
89
|
+
export function fenceMask(lines, from = 0) {
|
|
90
|
+
const mask = new Array(lines.length).fill(false);
|
|
91
|
+
for (const region of fencedRegions(lines, from)) {
|
|
92
|
+
for (let i = region.open; i <= region.close; i++)
|
|
93
|
+
mask[i] = true;
|
|
94
|
+
}
|
|
95
|
+
return mask;
|
|
96
|
+
}
|
|
97
|
+
// ── The non-rendering mask ──────────────────────────────────────────────────
|
|
98
|
+
/**
|
|
99
|
+
* One past the closing backtick run of the code span opening at `start`, or
|
|
100
|
+
* null when the run never closes.
|
|
101
|
+
*
|
|
102
|
+
* CommonMark wants the closing run to be EXACTLY as long as the opening one,
|
|
103
|
+
* which is why this is a scan and not an `indexOf`: in `` `a``b` `` the double
|
|
104
|
+
* run does not close the single, and a search for "one or more backticks"
|
|
105
|
+
* ends the span three characters early. An unterminated run opens nothing —
|
|
106
|
+
* a stray backtick must not swallow the rest of the line.
|
|
107
|
+
*/
|
|
108
|
+
function codeSpanEnd(text, start) {
|
|
109
|
+
let open = 0;
|
|
110
|
+
while (text[start + open] === "`")
|
|
111
|
+
open++;
|
|
112
|
+
if (open === 0)
|
|
113
|
+
return null;
|
|
114
|
+
let i = start + open;
|
|
115
|
+
while (i < text.length) {
|
|
116
|
+
if (text[i] !== "`") {
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
let run = 0;
|
|
121
|
+
while (text[i + run] === "`")
|
|
122
|
+
run++;
|
|
123
|
+
if (run === open)
|
|
124
|
+
return i + run;
|
|
125
|
+
i += run;
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* True where a line is inside an inline code span, delimiters included.
|
|
131
|
+
*
|
|
132
|
+
* Rules and parsers mask their matches against this so a token quoted as
|
|
133
|
+
* `` `[[c:1]]` `` — the way documentation talks ABOUT the grammar — is never
|
|
134
|
+
* read as using it, and so a pipe inside `` `a|b` `` is content rather than a
|
|
135
|
+
* cell boundary. Line-local by design: a code span cannot cross a blank line,
|
|
136
|
+
* and the callers hold one line at a time.
|
|
137
|
+
*/
|
|
138
|
+
export function codeSpanMask(line) {
|
|
139
|
+
const mask = new Array(line.length).fill(false);
|
|
140
|
+
let i = 0;
|
|
141
|
+
while (i < line.length) {
|
|
142
|
+
if (line[i] !== "`") {
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const end = codeSpanEnd(line, i);
|
|
147
|
+
if (end === null) {
|
|
148
|
+
while (line[i] === "`")
|
|
149
|
+
i++;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (let k = i; k < end; k++)
|
|
153
|
+
mask[k] = true;
|
|
154
|
+
i = end;
|
|
155
|
+
}
|
|
156
|
+
return mask;
|
|
157
|
+
}
|
|
158
|
+
/** The raw-text tag opening at `start`, or null. */
|
|
159
|
+
function rawTextTagAt(line, start) {
|
|
160
|
+
if (line[start] !== "<")
|
|
161
|
+
return null;
|
|
162
|
+
const lower = line.slice(start, start + 6).toLowerCase();
|
|
163
|
+
for (const tag of ["pre", "code"]) {
|
|
164
|
+
if (!lower.startsWith(`<${tag}`))
|
|
165
|
+
continue;
|
|
166
|
+
// `<pre>`, `<pre/>`, `<pre class=…>` — but not `<precise`.
|
|
167
|
+
const boundary = line[start + tag.length + 1];
|
|
168
|
+
if (boundary === ">" || boundary === "/" || boundary === " " || boundary === "\t") {
|
|
169
|
+
return tag;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
/** One past the `>` closing the tag that opens at `start`, or null. */
|
|
175
|
+
function htmlTagEnd(line, start) {
|
|
176
|
+
let quote = null;
|
|
177
|
+
for (let i = start; i < line.length; i++) {
|
|
178
|
+
const char = line[i];
|
|
179
|
+
if (quote !== null) {
|
|
180
|
+
if (char === quote)
|
|
181
|
+
quote = null;
|
|
182
|
+
}
|
|
183
|
+
else if (char === '"' || char === "'") {
|
|
184
|
+
quote = char;
|
|
185
|
+
}
|
|
186
|
+
else if (char === ">") {
|
|
187
|
+
return i + 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
/** One past `</pre>` / `</code>` at or after `start`, or null. */
|
|
193
|
+
function rawTextCloseEnd(line, start, tag) {
|
|
194
|
+
const match = new RegExp(`</${tag}\\s*>`, "i").exec(line.slice(start));
|
|
195
|
+
return match === null ? null : start + match.index + match[0].length;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Blank one line's non-rendering spans, carrying `state` forward.
|
|
199
|
+
*
|
|
200
|
+
* Order matters: a backtick inside a comment is not a code span, and a `<!--`
|
|
201
|
+
* inside a code span is not a comment, so whichever construct opens first owns
|
|
202
|
+
* everything up to its own close. Left open at the end of the line, a comment
|
|
203
|
+
* or a raw tag keeps consuming into the lines below — which is what an
|
|
204
|
+
* unterminated `<!--` does to a reader too.
|
|
205
|
+
*/
|
|
206
|
+
function maskLine(raw, state) {
|
|
207
|
+
const chars = raw.split("");
|
|
208
|
+
const blank = (from, to) => {
|
|
209
|
+
for (let i = from; i < to; i++)
|
|
210
|
+
chars[i] = " ";
|
|
211
|
+
};
|
|
212
|
+
let i = 0;
|
|
213
|
+
while (i < raw.length) {
|
|
214
|
+
if (state.comment) {
|
|
215
|
+
const close = raw.indexOf("-->", i);
|
|
216
|
+
blank(i, close < 0 ? raw.length : close + 3);
|
|
217
|
+
if (close < 0)
|
|
218
|
+
break;
|
|
219
|
+
state.comment = false;
|
|
220
|
+
i = close + 3;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (state.rawTag !== null) {
|
|
224
|
+
const end = rawTextCloseEnd(raw, i, state.rawTag);
|
|
225
|
+
blank(i, end ?? raw.length);
|
|
226
|
+
if (end === null)
|
|
227
|
+
break;
|
|
228
|
+
state.rawTag = null;
|
|
229
|
+
i = end;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const char = raw[i];
|
|
233
|
+
if (char === "`") {
|
|
234
|
+
const end = codeSpanEnd(raw, i);
|
|
235
|
+
if (end === null) {
|
|
236
|
+
while (raw[i] === "`")
|
|
237
|
+
i++;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
blank(i, end);
|
|
241
|
+
i = end;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (char === "<") {
|
|
245
|
+
if (raw.startsWith("<!--", i)) {
|
|
246
|
+
// CommonMark 0.31 admits the two degenerate spellings `<!-->` and
|
|
247
|
+
// `<!--->` as complete comments. Without them the `-->` search runs
|
|
248
|
+
// off the end, the comment is read as unterminated, and everything
|
|
249
|
+
// after it in the document is masked on the strength of five bytes.
|
|
250
|
+
const short = raw.startsWith("<!--->", i)
|
|
251
|
+
? 6
|
|
252
|
+
: raw.startsWith("<!-->", i)
|
|
253
|
+
? 5
|
|
254
|
+
: 0;
|
|
255
|
+
if (short > 0) {
|
|
256
|
+
blank(i, i + short);
|
|
257
|
+
i += short;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const close = raw.indexOf("-->", i + 4);
|
|
261
|
+
blank(i, close < 0 ? raw.length : close + 3);
|
|
262
|
+
state.comment = close < 0;
|
|
263
|
+
if (state.comment)
|
|
264
|
+
break;
|
|
265
|
+
i = close + 3;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const tag = rawTextTagAt(raw, i);
|
|
269
|
+
if (tag !== null) {
|
|
270
|
+
const openEnd = htmlTagEnd(raw, i);
|
|
271
|
+
// An opening tag that runs past the end of the line: nothing after it
|
|
272
|
+
// on this line can be read with any confidence, so stop here.
|
|
273
|
+
if (openEnd === null)
|
|
274
|
+
break;
|
|
275
|
+
const closeEnd = rawTextCloseEnd(raw, openEnd, tag);
|
|
276
|
+
blank(i, closeEnd ?? raw.length);
|
|
277
|
+
state.rawTag = closeEnd === null ? tag : null;
|
|
278
|
+
if (closeEnd === null)
|
|
279
|
+
break;
|
|
280
|
+
i = closeEnd;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
return chars.join("");
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* The non-rendering mask, line by line. Each returned line has exactly the
|
|
290
|
+
* length of the line it masks, so `masked[i][k]` and `lines[i][k]` are the
|
|
291
|
+
* same byte position.
|
|
292
|
+
*
|
|
293
|
+
* `from` skips a leading frontmatter block for the FENCE scan only, mirroring
|
|
294
|
+
* {@link fenceMask} — whether the frontmatter itself renders is the caller's
|
|
295
|
+
* question, and lint already answers it with `inFrontmatter`.
|
|
296
|
+
*/
|
|
297
|
+
export function maskNonRenderingLines(lines, from = 0) {
|
|
298
|
+
const fenced = fenceMask(lines, from);
|
|
299
|
+
const state = { comment: false, rawTag: null };
|
|
300
|
+
return lines.map((raw, i) => fenced[i] ? " ".repeat(raw.length) : maskLine(raw, state));
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The whole source, masked. `masked.length === source.length` always: the two
|
|
304
|
+
* strings are the same document, one of them with the non-markdown blanked
|
|
305
|
+
* out, and every offset holds across both.
|
|
306
|
+
*/
|
|
307
|
+
export function maskNonRenderingContexts(source) {
|
|
308
|
+
return maskNonRenderingLines(source.split("\n")).join("\n");
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* True when the source span `[start, end)` holds bytes but no rendering ones —
|
|
312
|
+
* the predicate a consumer asks before trusting anything it found by scanning.
|
|
313
|
+
*
|
|
314
|
+
* A span that was blank to begin with is not "non-rendering", it is empty, and
|
|
315
|
+
* answering true for it would demote every piece of whitespace in the
|
|
316
|
+
* document.
|
|
317
|
+
*/
|
|
318
|
+
export function isNonRenderingRange(source, masked, start, end) {
|
|
319
|
+
if (end <= start)
|
|
320
|
+
return false;
|
|
321
|
+
if (source.slice(start, end).trim() === "")
|
|
322
|
+
return false;
|
|
323
|
+
return masked.slice(start, end).trim() === "";
|
|
324
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sfora-law: the rules a sfora markdown document is held to, and the quick
|
|
3
|
+
* fixes that satisfy them.
|
|
4
|
+
*
|
|
5
|
+
* Not a style checker. Every rule answers one question — "will this byte
|
|
6
|
+
* sequence render as the author obviously meant it to?" — and every rule that
|
|
7
|
+
* cannot answer with certainty says nothing. There is no line length, no
|
|
8
|
+
* heading order, no trailing whitespace. Markdown is the user's file; lint is
|
|
9
|
+
* only allowed to point at places where sfora will read it differently than
|
|
10
|
+
* they will.
|
|
11
|
+
*
|
|
12
|
+
* The core is pure: no CodeMirror, no unified. Diagnostics carry absolute
|
|
13
|
+
* offsets and the editor adapter (src/components/notes/cm-lint.ts) translates
|
|
14
|
+
* them; an mdast tree, when one is wanted, is injected. That is what lets the
|
|
15
|
+
* same rules run in the CLI before an agent PUTs a file as run in the editor
|
|
16
|
+
* while a human types it.
|
|
17
|
+
*/
|
|
18
|
+
export * from "./types.js";
|
|
19
|
+
export * from "./lintSource.js";
|
|
20
|
+
export * from "./rules/index.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
/**
|
|
4
|
+
* sfora-law: the rules a sfora markdown document is held to, and the quick
|
|
5
|
+
* fixes that satisfy them.
|
|
6
|
+
*
|
|
7
|
+
* Not a style checker. Every rule answers one question — "will this byte
|
|
8
|
+
* sequence render as the author obviously meant it to?" — and every rule that
|
|
9
|
+
* cannot answer with certainty says nothing. There is no line length, no
|
|
10
|
+
* heading order, no trailing whitespace. Markdown is the user's file; lint is
|
|
11
|
+
* only allowed to point at places where sfora will read it differently than
|
|
12
|
+
* they will.
|
|
13
|
+
*
|
|
14
|
+
* The core is pure: no CodeMirror, no unified. Diagnostics carry absolute
|
|
15
|
+
* offsets and the editor adapter (src/components/notes/cm-lint.ts) translates
|
|
16
|
+
* them; an mdast tree, when one is wanted, is injected. That is what lets the
|
|
17
|
+
* same rules run in the CLI before an agent PUTs a file as run in the editor
|
|
18
|
+
* while a human types it.
|
|
19
|
+
*/
|
|
20
|
+
export * from "./types.js";
|
|
21
|
+
export * from "./lintSource.js";
|
|
22
|
+
export * from "./rules/index.js";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type WikiLink } from "../wikiLinks.js";
|
|
2
|
+
import type { LintContext, LintFix, LintSourceOptions, SforaDiagnostic } from "./types.js";
|
|
3
|
+
export { codeSpanMask, fenceMask, fencedRegions, frontmatterExtent, isNonRenderingRange, lineText, maskNonRenderingContexts, maskNonRenderingLines, type FenceRegion, } from "../lineGeometry.js";
|
|
4
|
+
/** Lines a prose rule must not look at: fences and the frontmatter block. */
|
|
5
|
+
export declare function isSkippableLine(ctx: LintContext, i: number): boolean;
|
|
6
|
+
/** True when a `[` at `index` is escaped as `\[`. */
|
|
7
|
+
export declare function isEscaped(line: string, index: number): boolean;
|
|
8
|
+
export interface ScannedWikiToken extends WikiLink {
|
|
9
|
+
lineIndex: number;
|
|
10
|
+
/** Absolute span of the whole `[[…]]` token. */
|
|
11
|
+
from: number;
|
|
12
|
+
to: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Every `[[…]]` token outside fences, frontmatter and inline code.
|
|
16
|
+
*
|
|
17
|
+
* The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
|
|
18
|
+
* needs a non-empty body and happily spans newlines. Lint needs the opposite
|
|
19
|
+
* on both counts — it has to see `[[]]` to complain about it, and a `[[` left
|
|
20
|
+
* open at the end of a line is someone mid-typing, not a link. So the span
|
|
21
|
+
* matcher is local and one line wide; the meaning of what is inside the
|
|
22
|
+
* brackets still comes from `parseWikiToken`, which owns the prefix table.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scanWikiTokens(ctx: LintContext): ScannedWikiToken[];
|
|
25
|
+
export declare function buildLintContext(source: string, opts?: LintSourceOptions): LintContext;
|
|
26
|
+
/**
|
|
27
|
+
* Run the rules over a document. Diagnostics come back in document order; a
|
|
28
|
+
* rule that throws contributes none and does not take the others down with it.
|
|
29
|
+
*/
|
|
30
|
+
export declare function lintSource(source: string, opts?: LintSourceOptions): SforaDiagnostic[];
|
|
31
|
+
/**
|
|
32
|
+
* Apply a fix to the source it was computed against. Edits are applied last
|
|
33
|
+
* one first so earlier offsets stay valid — the same reason the editor
|
|
34
|
+
* dispatches them as one transaction.
|
|
35
|
+
*/
|
|
36
|
+
export declare function applyLintFix(source: string, fix: LintFix): string;
|
|
@@ -0,0 +1,154 @@
|
|
|
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 sfora-law runner, plus the document geometry every rule shares.
|
|
4
|
+
//
|
|
5
|
+
// One pass builds a LintContext — line starts, the fence map, the frontmatter
|
|
6
|
+
// extent — and each rule reads it. Nothing here throws: a parser that blows up
|
|
7
|
+
// costs the AST, a rule that blows up costs that rule's diagnostics, and the
|
|
8
|
+
// caller still gets a list. Lint is a background nicety in a text editor; it
|
|
9
|
+
// is never allowed to be the reason a document will not open.
|
|
10
|
+
import { codeSpanMask, fenceMask, frontmatterExtent, lineText, } from "../lineGeometry.js";
|
|
11
|
+
import { MAX_PARSE_INPUT_BYTES } from "../parseWithFallback.js";
|
|
12
|
+
import { parseWikiToken } from "../wikiLinks.js";
|
|
13
|
+
import { SFORA_LINT_RULES } from "./rules/index.js";
|
|
14
|
+
// The fence/frontmatter geometry moved to ../lineGeometry once a second
|
|
15
|
+
// caller (checklist.ts) needed it, and `codeSpanMask` followed it there once a
|
|
16
|
+
// third (blocks/parsers.ts) had hand-copied it. Re-exported so the rules — and
|
|
17
|
+
// everything importing through lint/index, the Convex shim included — keep
|
|
18
|
+
// their existing import path.
|
|
19
|
+
export { codeSpanMask, fenceMask, fencedRegions, frontmatterExtent, isNonRenderingRange, lineText, maskNonRenderingContexts, maskNonRenderingLines, } from "../lineGeometry.js";
|
|
20
|
+
/** Lines a prose rule must not look at: fences and the frontmatter block. */
|
|
21
|
+
export function isSkippableLine(ctx, i) {
|
|
22
|
+
return ctx.inFence(i) || ctx.inFrontmatter(i);
|
|
23
|
+
}
|
|
24
|
+
/** True when a `[` at `index` is escaped as `\[`. */
|
|
25
|
+
export function isEscaped(line, index) {
|
|
26
|
+
let slashes = 0;
|
|
27
|
+
for (let i = index - 1; i >= 0 && line[i] === "\\"; i--)
|
|
28
|
+
slashes++;
|
|
29
|
+
return slashes % 2 === 1;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Every `[[…]]` token outside fences, frontmatter and inline code.
|
|
33
|
+
*
|
|
34
|
+
* The engine's `wikiLinkPattern()` is the grammar of a WELL-FORMED token: it
|
|
35
|
+
* needs a non-empty body and happily spans newlines. Lint needs the opposite
|
|
36
|
+
* on both counts — it has to see `[[]]` to complain about it, and a `[[` left
|
|
37
|
+
* open at the end of a line is someone mid-typing, not a link. So the span
|
|
38
|
+
* matcher is local and one line wide; the meaning of what is inside the
|
|
39
|
+
* brackets still comes from `parseWikiToken`, which owns the prefix table.
|
|
40
|
+
*/
|
|
41
|
+
export function scanWikiTokens(ctx) {
|
|
42
|
+
const out = [];
|
|
43
|
+
for (let i = 0; i < ctx.lines.length; i++) {
|
|
44
|
+
if (isSkippableLine(ctx, i))
|
|
45
|
+
continue;
|
|
46
|
+
const line = lineText(ctx.lines, i);
|
|
47
|
+
const mask = codeSpanMask(line);
|
|
48
|
+
const re = /\[\[([^[\]\n]*)\]\]/g;
|
|
49
|
+
let m;
|
|
50
|
+
while ((m = re.exec(line)) !== null) {
|
|
51
|
+
if (mask[m.index] || isEscaped(line, m.index))
|
|
52
|
+
continue;
|
|
53
|
+
const from = ctx.lineStart(i) + m.index;
|
|
54
|
+
out.push({
|
|
55
|
+
...parseWikiToken(m[1]),
|
|
56
|
+
lineIndex: i,
|
|
57
|
+
from,
|
|
58
|
+
to: from + m[0].length,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
export function buildLintContext(source, opts = {}) {
|
|
65
|
+
const lines = source.split("\n");
|
|
66
|
+
const starts = new Array(lines.length);
|
|
67
|
+
let offset = 0;
|
|
68
|
+
for (let i = 0; i < lines.length; i++) {
|
|
69
|
+
starts[i] = offset;
|
|
70
|
+
offset += lines[i].length + 1;
|
|
71
|
+
}
|
|
72
|
+
const frontmatter = frontmatterExtent(lines);
|
|
73
|
+
const fenceScanFrom = frontmatter === null
|
|
74
|
+
? 0
|
|
75
|
+
: frontmatter.close === -1
|
|
76
|
+
? lines.length
|
|
77
|
+
: frontmatter.close + 1;
|
|
78
|
+
const fenced = fenceMask(lines, fenceScanFrom);
|
|
79
|
+
const fmEnd = frontmatter === null
|
|
80
|
+
? -1
|
|
81
|
+
: frontmatter.close === -1
|
|
82
|
+
? lines.length - 1
|
|
83
|
+
: frontmatter.close;
|
|
84
|
+
return {
|
|
85
|
+
source,
|
|
86
|
+
lines,
|
|
87
|
+
lineStart: (i) => starts[i] ?? source.length,
|
|
88
|
+
inFence: (i) => fenced[i] === true,
|
|
89
|
+
inFrontmatter: (i) => frontmatter !== null && i >= 0 && i <= fmEnd,
|
|
90
|
+
frontmatter,
|
|
91
|
+
ast: parseAstSafely(source, opts.parseAst),
|
|
92
|
+
resolveLink: opts.resolveLink,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// A throwing parser costs the AST and nothing else. The size cap is the same
|
|
96
|
+
// one parseWithFallback uses: past it we are looking at a paste accident, and
|
|
97
|
+
// no lint mark is worth parsing four megabytes on a keystroke.
|
|
98
|
+
//
|
|
99
|
+
// A leading BOM costs it too. `parseMarkdownAst` strips one before parsing, so
|
|
100
|
+
// every offset in the tree it returns is one short of the source the rules
|
|
101
|
+
// hold — and a rule that mixes the two paints its squiggle one character off.
|
|
102
|
+
// A document that opens with a BOM is rare enough, and the AST optional
|
|
103
|
+
// enough, that declining is the honest answer.
|
|
104
|
+
function parseAstSafely(source, parseAst) {
|
|
105
|
+
if (!parseAst || source.length > MAX_PARSE_INPUT_BYTES)
|
|
106
|
+
return undefined;
|
|
107
|
+
if (source.charCodeAt(0) === 0xfeff)
|
|
108
|
+
return undefined;
|
|
109
|
+
try {
|
|
110
|
+
return parseAst(source);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Run the rules over a document. Diagnostics come back in document order; a
|
|
118
|
+
* rule that throws contributes none and does not take the others down with it.
|
|
119
|
+
*/
|
|
120
|
+
export function lintSource(source, opts = {}) {
|
|
121
|
+
const ctx = buildLintContext(source, opts);
|
|
122
|
+
const rules = opts.rules ?? SFORA_LINT_RULES;
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const rule of rules) {
|
|
125
|
+
if (rule.needsAst && !ctx.ast)
|
|
126
|
+
continue;
|
|
127
|
+
try {
|
|
128
|
+
out.push(...rule.run(ctx));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// A broken rule is a missing mark, never a broken editor.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out.sort((a, b) => a.from !== b.from
|
|
135
|
+
? a.from - b.from
|
|
136
|
+
: a.to !== b.to
|
|
137
|
+
? a.to - b.to
|
|
138
|
+
: a.ruleId.localeCompare(b.ruleId));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Apply a fix to the source it was computed against. Edits are applied last
|
|
142
|
+
* one first so earlier offsets stay valid — the same reason the editor
|
|
143
|
+
* dispatches them as one transaction.
|
|
144
|
+
*/
|
|
145
|
+
export function applyLintFix(source, fix) {
|
|
146
|
+
const edits = [...fix.edits].sort((a, b) => b.from - a.from);
|
|
147
|
+
let out = source;
|
|
148
|
+
for (const edit of edits) {
|
|
149
|
+
if (edit.from < 0 || edit.to > out.length || edit.from > edit.to)
|
|
150
|
+
continue;
|
|
151
|
+
out = out.slice(0, edit.from) + edit.insert + out.slice(edit.to);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
// `[[c:42|Fix login]]` pointing at something that is not there.
|
|
4
|
+
//
|
|
5
|
+
// The whole rule turns on one asymmetry: a resolver that says `{exists: false}`
|
|
6
|
+
// KNOWS the target is gone, while a resolver that returns `undefined` has not
|
|
7
|
+
// looked yet (or does not recognize the prefix). Only the first is a
|
|
8
|
+
// diagnostic. That is what keeps a freshly opened document from painting every
|
|
9
|
+
// link dead for the half second before resolution lands.
|
|
10
|
+
import { isSkippableLine, scanWikiTokens } from "../lintSource.js";
|
|
11
|
+
export const brokenWikiLinkRule = {
|
|
12
|
+
id: "sfora/broken-wiki-link",
|
|
13
|
+
run(ctx) {
|
|
14
|
+
const resolve = ctx.resolveLink;
|
|
15
|
+
if (!resolve)
|
|
16
|
+
return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const token of scanWikiTokens(ctx)) {
|
|
19
|
+
// Empty and prefix-only targets are the malformed rule's business.
|
|
20
|
+
if (token.target === "" || token.id === "")
|
|
21
|
+
continue;
|
|
22
|
+
if (isSkippableLine(ctx, token.lineIndex))
|
|
23
|
+
continue;
|
|
24
|
+
if (resolve(token.target)?.exists !== false)
|
|
25
|
+
continue;
|
|
26
|
+
out.push({
|
|
27
|
+
from: token.from,
|
|
28
|
+
to: token.to,
|
|
29
|
+
severity: "warning",
|
|
30
|
+
ruleId: "sfora/broken-wiki-link",
|
|
31
|
+
message: `Nothing here links to \`${token.target}\` — it was deleted, or you don't have access.`,
|
|
32
|
+
// No "create the target" fix: this rule cannot know what the missing
|
|
33
|
+
// thing was meant to be, and inventing one would be a worse guess than
|
|
34
|
+
// leaving the sentence readable.
|
|
35
|
+
fixes: [
|
|
36
|
+
{
|
|
37
|
+
label: `Remove link, keep "${token.label}"`,
|
|
38
|
+
edits: [{ from: token.from, to: token.to, insert: token.label }],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { brokenWikiLinkRule } from "./broken-wiki-link.js";
|
|
2
|
+
import { malformedCalloutRule } from "./malformed-callout.js";
|
|
3
|
+
import { malformedChecklistRule } from "./malformed-checklist.js";
|
|
4
|
+
import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
|
|
5
|
+
import { malformedStructuredBlockRule } from "./malformed-structured-block.js";
|
|
6
|
+
import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
|
|
7
|
+
import { orphanReferenceRule } from "./orphan-reference.js";
|
|
8
|
+
import type { LintRule } from "../types.js";
|
|
9
|
+
export declare const SFORA_LINT_RULES: readonly LintRule[];
|
|
10
|
+
export { brokenWikiLinkRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
|
|
@@ -0,0 +1,26 @@
|
|
|
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 rule registry — sfora-law in the order its marks are read.
|
|
4
|
+
//
|
|
5
|
+
// Kept beside the rules rather than in lint/index.ts so the runner can import
|
|
6
|
+
// it without the barrel importing the runner: one direction of dependency, no
|
|
7
|
+
// module cycle, no half-initialized array at load time.
|
|
8
|
+
//
|
|
9
|
+
// Adding a rule is adding a file and a line here. Nothing else knows the list.
|
|
10
|
+
import { brokenWikiLinkRule } from "./broken-wiki-link.js";
|
|
11
|
+
import { malformedCalloutRule } from "./malformed-callout.js";
|
|
12
|
+
import { malformedChecklistRule } from "./malformed-checklist.js";
|
|
13
|
+
import { malformedFrontmatterRule } from "./malformed-frontmatter.js";
|
|
14
|
+
import { malformedStructuredBlockRule } from "./malformed-structured-block.js";
|
|
15
|
+
import { malformedWikiLinkRule } from "./malformed-wiki-link.js";
|
|
16
|
+
import { orphanReferenceRule } from "./orphan-reference.js";
|
|
17
|
+
export const SFORA_LINT_RULES = [
|
|
18
|
+
brokenWikiLinkRule,
|
|
19
|
+
malformedWikiLinkRule,
|
|
20
|
+
malformedCalloutRule,
|
|
21
|
+
malformedStructuredBlockRule,
|
|
22
|
+
orphanReferenceRule,
|
|
23
|
+
malformedChecklistRule,
|
|
24
|
+
malformedFrontmatterRule,
|
|
25
|
+
];
|
|
26
|
+
export { brokenWikiLinkRule, malformedCalloutRule, malformedChecklistRule, malformedFrontmatterRule, malformedStructuredBlockRule, malformedWikiLinkRule, orphanReferenceRule, };
|