sfora-cli 0.8.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 +24 -11
- 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 +4 -0
- package/dist/format/cardMarkdown.js +12 -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/local/workspace.d.ts +12 -0
- package/dist/local/workspace.js +100 -7
- package/dist/mcp-server.js +11 -5
- package/package.json +7 -6
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
// `> [!WARN]` — a callout marker whose type does not exist.
|
|
4
|
+
//
|
|
5
|
+
// This one is worth a mark precisely because nothing visibly breaks: an
|
|
6
|
+
// unrecognized marker falls back to an ordinary blockquote, so the author sees
|
|
7
|
+
// quoted prose and assumes the callout "did not save". The mark says what
|
|
8
|
+
// actually happened.
|
|
9
|
+
//
|
|
10
|
+
// Line-based on purpose. Callouts are a line grammar (see callout.ts), and
|
|
11
|
+
// reading them off the mdast tree would only re-derive the same first line of
|
|
12
|
+
// the same blockquote — with a chance of disagreeing with the reader, which is
|
|
13
|
+
// the one thing a linter must never do.
|
|
14
|
+
import { CALLOUT_TYPES, isCalloutType } from "../../callout.js";
|
|
15
|
+
import { isSkippableLine, lineText } from "../lintSource.js";
|
|
16
|
+
const RULE_ID = "sfora/malformed-callout";
|
|
17
|
+
const QUOTED_MARKER = /^ {0,3}>[ \t]?[ \t]*(\[!([A-Za-z]+)\])/;
|
|
18
|
+
const QUOTE_LINE = /^ {0,3}>/;
|
|
19
|
+
/**
|
|
20
|
+
* What people write when they mean one of ours. A word maps to a single type
|
|
21
|
+
* or to nothing: `info` reads as both "note" and "tip" depending on who is
|
|
22
|
+
* writing, and a fix that guesses wrong silently changes the document's tone.
|
|
23
|
+
* Ambiguous words still get the diagnostic — just no one-click answer.
|
|
24
|
+
*/
|
|
25
|
+
const ALIASES = {
|
|
26
|
+
warn: ["warning"],
|
|
27
|
+
warnings: ["warning"],
|
|
28
|
+
error: ["caution"],
|
|
29
|
+
danger: ["caution"],
|
|
30
|
+
attention: ["important"],
|
|
31
|
+
info: ["note", "tip"],
|
|
32
|
+
hint: ["note", "tip"],
|
|
33
|
+
};
|
|
34
|
+
export const malformedCalloutRule = {
|
|
35
|
+
id: RULE_ID,
|
|
36
|
+
run(ctx) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (let i = 0; i < ctx.lines.length; i++) {
|
|
39
|
+
if (isSkippableLine(ctx, i))
|
|
40
|
+
continue;
|
|
41
|
+
const line = lineText(ctx.lines, i);
|
|
42
|
+
const match = QUOTED_MARKER.exec(line);
|
|
43
|
+
if (!match)
|
|
44
|
+
continue;
|
|
45
|
+
// A marker only counts on the FIRST line of a blockquote; anywhere else
|
|
46
|
+
// it is prose that happens to contain brackets.
|
|
47
|
+
if (i > 0 && QUOTE_LINE.test(lineText(ctx.lines, i - 1)))
|
|
48
|
+
continue;
|
|
49
|
+
const keyword = match[2];
|
|
50
|
+
if (isCalloutType(keyword))
|
|
51
|
+
continue;
|
|
52
|
+
const markerStart = ctx.lineStart(i) + line.indexOf(match[1]);
|
|
53
|
+
const candidates = ALIASES[keyword.toLowerCase()] ?? [];
|
|
54
|
+
const fixes = candidates.length === 1
|
|
55
|
+
? [
|
|
56
|
+
{
|
|
57
|
+
label: `Change to [!${candidates[0].toUpperCase()}]`,
|
|
58
|
+
edits: [
|
|
59
|
+
{
|
|
60
|
+
from: markerStart,
|
|
61
|
+
to: markerStart + match[1].length,
|
|
62
|
+
insert: `[!${candidates[0].toUpperCase()}]`,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
]
|
|
67
|
+
: [];
|
|
68
|
+
out.push({
|
|
69
|
+
from: markerStart,
|
|
70
|
+
to: markerStart + match[1].length,
|
|
71
|
+
severity: "warning",
|
|
72
|
+
ruleId: RULE_ID,
|
|
73
|
+
message: `\`${keyword}\` isn't a callout type, so this renders as a plain quote. Try ${CALLOUT_TYPES.join(", ")}.`,
|
|
74
|
+
...(fixes.length > 0 ? { fixes } : {}),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
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. Info, not warning: the line still reads fine, it
|
|
8
|
+
// just is not counted.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately narrow. Only bullet lines, only brackets holding nothing but a
|
|
11
|
+
// space or an `x`, and only when there is text after them — `- []` on its own
|
|
12
|
+
// is someone who has typed two characters of a checkbox and is still going.
|
|
13
|
+
import { CHECKBOX_RE } from "../../checklist.js";
|
|
14
|
+
import { codeSpanMask, isSkippableLine, lineText } from "../lintSource.js";
|
|
15
|
+
const RULE_ID = "sfora/malformed-checklist";
|
|
16
|
+
const NEAR_MISS = /^(\s*[-*]\s+)\[([ \t]*[xX]?[ \t]*)\](.*)$/;
|
|
17
|
+
export const malformedChecklistRule = {
|
|
18
|
+
id: RULE_ID,
|
|
19
|
+
run(ctx) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let i = 0; i < ctx.lines.length; i++) {
|
|
22
|
+
if (isSkippableLine(ctx, i))
|
|
23
|
+
continue;
|
|
24
|
+
const line = lineText(ctx.lines, i);
|
|
25
|
+
if (CHECKBOX_RE.test(line))
|
|
26
|
+
continue;
|
|
27
|
+
const match = NEAR_MISS.exec(line);
|
|
28
|
+
if (!match)
|
|
29
|
+
continue;
|
|
30
|
+
const bullet = match[1];
|
|
31
|
+
const inner = match[2];
|
|
32
|
+
const rest = match[3];
|
|
33
|
+
if (rest.trim() === "")
|
|
34
|
+
continue;
|
|
35
|
+
// `- [x](https://…)` and `- [x][ref]` are links whose text happens to be
|
|
36
|
+
// one character, not checkboxes.
|
|
37
|
+
if (rest.startsWith("(") || rest.startsWith("["))
|
|
38
|
+
continue;
|
|
39
|
+
if (codeSpanMask(line)[bullet.length])
|
|
40
|
+
continue;
|
|
41
|
+
const checked = /[xX]/.test(inner);
|
|
42
|
+
const canonical = `${bullet}[${checked ? "x" : " "}] ${rest.trim()}`;
|
|
43
|
+
const from = ctx.lineStart(i);
|
|
44
|
+
out.push({
|
|
45
|
+
from,
|
|
46
|
+
to: from + line.length,
|
|
47
|
+
severity: "info",
|
|
48
|
+
ruleId: RULE_ID,
|
|
49
|
+
message: "This isn't a task checkbox, so it won't be counted. Tasks are exactly `- [ ] ` or `- [x] `.",
|
|
50
|
+
fixes: [
|
|
51
|
+
{
|
|
52
|
+
label: "Fix the checkbox",
|
|
53
|
+
edits: [{ from, to: from + line.length, insert: canonical }],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
severity: "warning",
|
|
31
|
+
ruleId: RULE_ID,
|
|
32
|
+
// No fix: where the block was meant to end is the author's
|
|
33
|
+
// knowledge, and guessing turns their first paragraph into metadata.
|
|
34
|
+
message: "This `---` block is never closed, so the whole file is read as body text.",
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
const out = [];
|
|
39
|
+
const seen = new Map();
|
|
40
|
+
for (let i = block.open + 1; i < block.close; i++) {
|
|
41
|
+
const line = lineText(ctx.lines, i);
|
|
42
|
+
const text = line.trim();
|
|
43
|
+
if (text === "" || text.startsWith("#"))
|
|
44
|
+
continue;
|
|
45
|
+
const key = KEY.exec(text)?.[1]?.trim();
|
|
46
|
+
if (!key) {
|
|
47
|
+
const from = ctx.lineStart(i);
|
|
48
|
+
out.push({
|
|
49
|
+
from,
|
|
50
|
+
to: from + line.length,
|
|
51
|
+
severity: "info",
|
|
52
|
+
ruleId: RULE_ID,
|
|
53
|
+
message: "Frontmatter is `key: value` lines — this one has no `:`, so it won't be read.",
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Only top-level keys can collide; an indented line belongs to the
|
|
58
|
+
// value above it.
|
|
59
|
+
if (line.startsWith(" ") || line.startsWith("\t"))
|
|
60
|
+
continue;
|
|
61
|
+
const first = seen.get(key);
|
|
62
|
+
if (first === undefined) {
|
|
63
|
+
seen.set(key, i);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const from = ctx.lineStart(i);
|
|
67
|
+
out.push({
|
|
68
|
+
from,
|
|
69
|
+
to: from + line.length,
|
|
70
|
+
severity: "warning",
|
|
71
|
+
ruleId: RULE_ID,
|
|
72
|
+
message: `\`${key}\` is set twice — this line wins, so line ${first + 1} is dead.`,
|
|
73
|
+
fixes: [
|
|
74
|
+
{
|
|
75
|
+
// The DEAD line is the one that goes. Removing the winning line
|
|
76
|
+
// instead would quietly change what the document says, which is
|
|
77
|
+
// not something a lint fix is allowed to do. The newline goes with
|
|
78
|
+
// it so the block does not grow a blank row.
|
|
79
|
+
label: `Remove the unused \`${key}\` on line ${first + 1}`,
|
|
80
|
+
edits: [
|
|
81
|
+
{
|
|
82
|
+
from: ctx.lineStart(first),
|
|
83
|
+
to: ctx.lineStart(first + 1),
|
|
84
|
+
insert: "",
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -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,80 @@
|
|
|
1
|
+
import type { Root } from "mdast";
|
|
2
|
+
/**
|
|
3
|
+
* Nothing in sfora-law is an "error". A rule that is not sure stays silent,
|
|
4
|
+
* and a rule that is sure still only says "this will not render the way you
|
|
5
|
+
* think" — the bytes on disk are always the user's to keep.
|
|
6
|
+
*/
|
|
7
|
+
export type LintSeverity = "error" | "warning" | "info";
|
|
8
|
+
/** A replacement over the PRE-fix document. Offsets are absolute. */
|
|
9
|
+
export interface LintEdit {
|
|
10
|
+
from: number;
|
|
11
|
+
to: number;
|
|
12
|
+
insert: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* One quick fix. Its edits compose atomically — they are all applied against
|
|
16
|
+
* the same document state, so they must not overlap.
|
|
17
|
+
*/
|
|
18
|
+
export interface LintFix {
|
|
19
|
+
label: string;
|
|
20
|
+
edits: LintEdit[];
|
|
21
|
+
}
|
|
22
|
+
export interface SforaDiagnostic {
|
|
23
|
+
from: number;
|
|
24
|
+
to: number;
|
|
25
|
+
severity: LintSeverity;
|
|
26
|
+
/** Namespaced, e.g. `sfora/broken-wiki-link`. Stable — the UI keys off it. */
|
|
27
|
+
ruleId: string;
|
|
28
|
+
message: string;
|
|
29
|
+
fixes?: LintFix[];
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What a link resolver tells us about a target. Structurally the same shape as
|
|
33
|
+
* the editor's `WikiLinkResolution`, restated here so the core does not import
|
|
34
|
+
* from the app.
|
|
35
|
+
*/
|
|
36
|
+
export interface LintLinkResolution {
|
|
37
|
+
exists: boolean;
|
|
38
|
+
title?: string | null;
|
|
39
|
+
}
|
|
40
|
+
export interface LintContext {
|
|
41
|
+
source: string;
|
|
42
|
+
/** `source` split on newlines. A trailing `\r` is left on the line. */
|
|
43
|
+
lines: string[];
|
|
44
|
+
/** Absolute offset of the first character of line `i`. */
|
|
45
|
+
lineStart: (i: number) => number;
|
|
46
|
+
/**
|
|
47
|
+
* True for a fenced code block's delimiters and everything between them.
|
|
48
|
+
* Rules never fire inside a fence: the bytes there are not markdown.
|
|
49
|
+
*/
|
|
50
|
+
inFence: (lineIndex: number) => boolean;
|
|
51
|
+
/** True for the frontmatter fences and everything between them. */
|
|
52
|
+
inFrontmatter: (lineIndex: number) => boolean;
|
|
53
|
+
/** Line extents of the frontmatter block, or null when there is none. */
|
|
54
|
+
frontmatter: {
|
|
55
|
+
open: number;
|
|
56
|
+
close: number;
|
|
57
|
+
} | null;
|
|
58
|
+
/** Present only when a parser was injected AND it succeeded. */
|
|
59
|
+
ast?: Root;
|
|
60
|
+
/**
|
|
61
|
+
* SYNCHRONOUS cache lookup over the target as written, prefix included
|
|
62
|
+
* (`c:42`, not `42`). `undefined` means "unknown, still resolving" and is
|
|
63
|
+
* the signal to stay silent — only an explicit `{ exists: false }` is a
|
|
64
|
+
* broken link.
|
|
65
|
+
*/
|
|
66
|
+
resolveLink?: (target: string) => LintLinkResolution | undefined;
|
|
67
|
+
}
|
|
68
|
+
export interface LintRule {
|
|
69
|
+
id: string;
|
|
70
|
+
/** When true the rule is skipped unless `ctx.ast` is present. */
|
|
71
|
+
needsAst?: boolean;
|
|
72
|
+
run(ctx: LintContext): SforaDiagnostic[];
|
|
73
|
+
}
|
|
74
|
+
export interface LintSourceOptions {
|
|
75
|
+
/** Defaults to the full registry, `SFORA_LINT_RULES`. */
|
|
76
|
+
rules?: readonly LintRule[];
|
|
77
|
+
/** Injected mdast parser — `parseMarkdownAst(source).root`. */
|
|
78
|
+
parseAst?: (source: string) => Root;
|
|
79
|
+
resolveLink?: LintContext["resolveLink"];
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
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 contracts sfora-law rules are written against.
|
|
4
|
+
//
|
|
5
|
+
// A rule is a pure function from a document to spans-with-messages. It gets a
|
|
6
|
+
// prepared view of the source — lines, line starts, where the code fences and
|
|
7
|
+
// the frontmatter block are — so no rule re-derives that geometry and no two
|
|
8
|
+
// rules disagree about whether a line is inside a fence.
|
|
9
|
+
//
|
|
10
|
+
// Two things are deliberately absent. There is no CodeMirror here: diagnostics
|
|
11
|
+
// carry absolute offsets, and the editor adapter
|
|
12
|
+
// (src/components/notes/cm-lint.ts) translates. And there is no parser here:
|
|
13
|
+
// the mdast tree is INJECTED by the caller (`parseAst` in LintSourceOptions),
|
|
14
|
+
// so the lint core stays dependency-free and ships in the CLI tarball with the
|
|
15
|
+
// rest of the engine. `import type { Root }` is erased at compile time.
|
|
16
|
+
export {};
|
|
@@ -1,3 +1,5 @@
|
|
|
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).
|
|
1
3
|
// Date <-> frontmatter helpers. Timestamps are ms-epoch internally; on the wire
|
|
2
4
|
// they're ISO-8601 (or date-only for coarse fields like a card due date).
|
|
3
5
|
export function toISO(ms) {
|
|
@@ -1,3 +1,5 @@
|
|
|
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).
|
|
1
3
|
// The Jekyll-style document shape shared by every entity: an optional YAML
|
|
2
4
|
// frontmatter fence, an H1 title (first non-empty line), then the body.
|
|
3
5
|
import { parseYaml } from "./yaml.js";
|
|
@@ -1,3 +1,5 @@
|
|
|
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).
|
|
1
3
|
// Shared markdown core for the agent filesystem API (/v1/fs). One battle-tested
|
|
2
4
|
// implementation of frontmatter/YAML, mention rendering, slugs, dates, and the
|
|
3
5
|
// document (frontmatter + H1 + body) shape — consumed by postMarkdown,
|
|
@@ -1,3 +1,5 @@
|
|
|
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).
|
|
1
3
|
// Canonical mention syntax shared by messages / posts / cards / notes:
|
|
2
4
|
// @[Display Name](memberId)
|
|
3
5
|
// On the wire we render that to a human-readable @Name and keep the display
|
|
@@ -1,3 +1,5 @@
|
|
|
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).
|
|
1
3
|
// Slug + filename helpers shared by the markdown serializers.
|
|
2
4
|
// kebab-case a title: lowercase, strip accents, collapse non-alphanumerics to
|
|
3
5
|
// single hyphens, trim. Empty titles fall back to "untitled". Dedupe of
|
|
@@ -1,3 +1,5 @@
|
|
|
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).
|
|
1
3
|
// Tiny YAML for markdown frontmatter: scalars + flat string arrays only.
|
|
2
4
|
// Shared by the post / card / note serializers (see ./index). No Convex imports
|
|
3
5
|
// — pure string transforms, safe to import from httpActions, internal Convex
|