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,227 @@
|
|
|
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
|
+
* Callouts — the GFM alert grammar, `> [!NOTE]`.
|
|
5
|
+
*
|
|
6
|
+
* A callout is an ordinary blockquote whose first line is a type marker. That
|
|
7
|
+
* is the whole trick: every reader that does not know about callouts still
|
|
8
|
+
* sees quoted prose, and the bytes stay a blockquote on disk. There is no new
|
|
9
|
+
* fence, no new frontmatter key, nothing an agent has to learn.
|
|
10
|
+
*
|
|
11
|
+
* > [!WARNING] Ship blocker
|
|
12
|
+
* > The migration has to run before the deploy.
|
|
13
|
+
*
|
|
14
|
+
* Fifteen types. The first five are GitHub's alert set — note, tip, important,
|
|
15
|
+
* warning, caution — and the other ten are the Obsidian set every vault and
|
|
16
|
+
* every other markdown tool already writes, so a document pasted in from one of
|
|
17
|
+
* them keeps its tone instead of falling back to a plain quote. The marker is
|
|
18
|
+
* written uppercase and read case-insensitively, because people type `[!note]`
|
|
19
|
+
* and pasted GitHub markdown says `[!NOTE]` — both are the same callout, and
|
|
20
|
+
* the first save canonicalizes.
|
|
21
|
+
*
|
|
22
|
+
* Two things beyond the type sit on the marker line, and both are round-tripped
|
|
23
|
+
* rather than normalised away:
|
|
24
|
+
*
|
|
25
|
+
* - an ALIAS. `[!WARN]`, `[!SUMMARY]`, `[!ERROR]` name a type by another
|
|
26
|
+
* word. They resolve to the canonical type for rendering, and the word the
|
|
27
|
+
* author actually typed is carried on `authoredAs` so the bytes come back
|
|
28
|
+
* unchanged. Normalising the spelling would rewrite a line the author did
|
|
29
|
+
* not touch, which is churn in a file two agents and a person share.
|
|
30
|
+
* - a FOLD marker. Obsidian's `[!NOTE]+` and `[!NOTE]-` say the callout is
|
|
31
|
+
* collapsible and whether it starts open. It is one character of grammar
|
|
32
|
+
* and it is the whole difference between a callout and a details block.
|
|
33
|
+
*
|
|
34
|
+
* Pure functions over lines: no DOM, no ProseMirror, no React. The reader
|
|
35
|
+
* (`src/lib/utils/markdown.ts`), the Tiptap node
|
|
36
|
+
* (`src/components/editor/extensions/callout.ts`), and the CLI all read the
|
|
37
|
+
* grammar from here so they cannot disagree about what a callout is.
|
|
38
|
+
*/
|
|
39
|
+
export const CALLOUT_TYPES = [
|
|
40
|
+
// GitHub's five.
|
|
41
|
+
"note",
|
|
42
|
+
"tip",
|
|
43
|
+
"important",
|
|
44
|
+
"warning",
|
|
45
|
+
"caution",
|
|
46
|
+
// Obsidian's ten.
|
|
47
|
+
"abstract",
|
|
48
|
+
"info",
|
|
49
|
+
"todo",
|
|
50
|
+
"success",
|
|
51
|
+
"question",
|
|
52
|
+
"failure",
|
|
53
|
+
"danger",
|
|
54
|
+
"bug",
|
|
55
|
+
"example",
|
|
56
|
+
"quote",
|
|
57
|
+
];
|
|
58
|
+
/**
|
|
59
|
+
* The other words people write for a type that already exists. Ported from
|
|
60
|
+
* open-knowledge's `callout-transformer.ts:41-71`, which took them from
|
|
61
|
+
* Obsidian, so a vault's markdown lands here meaning what it meant there.
|
|
62
|
+
*
|
|
63
|
+
* An alias is a spelling, not a type: it never widens `CalloutType`, and the
|
|
64
|
+
* authored word survives on `Callout.authoredAs` rather than in the enum.
|
|
65
|
+
*/
|
|
66
|
+
export const CALLOUT_ALIASES = {
|
|
67
|
+
summary: "abstract",
|
|
68
|
+
tldr: "abstract",
|
|
69
|
+
check: "success",
|
|
70
|
+
done: "success",
|
|
71
|
+
help: "question",
|
|
72
|
+
faq: "question",
|
|
73
|
+
fail: "failure",
|
|
74
|
+
missing: "failure",
|
|
75
|
+
error: "danger",
|
|
76
|
+
cite: "quote",
|
|
77
|
+
idea: "tip",
|
|
78
|
+
hint: "tip",
|
|
79
|
+
warn: "warning",
|
|
80
|
+
attention: "warning",
|
|
81
|
+
};
|
|
82
|
+
const MARKER = /^\[!([A-Za-z]+)\]([+-])?[ \t]*(.*)$/;
|
|
83
|
+
/**
|
|
84
|
+
* The marker as written: uppercase, so `[!NOTE]` is what lands on disk — unless
|
|
85
|
+
* the author spelled the type another way, in which case their word goes back.
|
|
86
|
+
*
|
|
87
|
+
* `authoredAs` is checked, not trusted. It is an attribute by the time it gets
|
|
88
|
+
* here (the editor carries it on the node), and an attribute a paste or a
|
|
89
|
+
* command could have set to anything; a marker that no longer resolves to this
|
|
90
|
+
* callout's type would come back as a plain quote with a stray bracket. So a
|
|
91
|
+
* spelling that does not resolve to `type` is discarded and the canonical word
|
|
92
|
+
* is written instead — the tone is preserved and only the churn is paid.
|
|
93
|
+
*/
|
|
94
|
+
export function calloutMarker(type, options = {}) {
|
|
95
|
+
const authored = options.authoredAs?.trim();
|
|
96
|
+
const token = authored && resolveCalloutType(authored) === type
|
|
97
|
+
? authored
|
|
98
|
+
: type.toUpperCase();
|
|
99
|
+
return `[!${token}]${options.fold ?? ""}`;
|
|
100
|
+
}
|
|
101
|
+
/** True for the fifteen canonical type names, in any case. Aliases are not types. */
|
|
102
|
+
export function isCalloutType(value) {
|
|
103
|
+
return CALLOUT_TYPES.includes(value.toLowerCase());
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The token on a marker line — canonical or alias — resolved to the type that
|
|
107
|
+
* renders it, or null when it names nothing. Every caller that has to decide
|
|
108
|
+
* "is this a callout?" asks this rather than `isCalloutType`, because an alias
|
|
109
|
+
* IS a callout and only differs in how it is spelled.
|
|
110
|
+
*/
|
|
111
|
+
export function resolveCalloutType(token) {
|
|
112
|
+
const lower = token.toLowerCase();
|
|
113
|
+
if (isCalloutType(lower))
|
|
114
|
+
return lower;
|
|
115
|
+
return CALLOUT_ALIASES[lower] ?? null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Drop one level of `>` quoting. A single space after the marker is part of
|
|
119
|
+
* the marker, not the content — `> indented` keeps one space.
|
|
120
|
+
*/
|
|
121
|
+
export function stripQuoteMarkers(lines) {
|
|
122
|
+
return lines.map((line) => line.replace(/^[ \t]{0,3}>[ ]?/, ""));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Read a callout out of the lines of a blockquote.
|
|
126
|
+
*
|
|
127
|
+
* Accepts either raw source lines (`> [!NOTE]`) or lines whose quote markers a
|
|
128
|
+
* caller has already stripped (`[!NOTE]`) — the reader strips as it scans, the
|
|
129
|
+
* editor does not. The two are told apart by looking at the block as a whole:
|
|
130
|
+
* if every non-blank line is quoted, one level comes off. That way a callout
|
|
131
|
+
* whose body contains a nested quote survives either way round.
|
|
132
|
+
*
|
|
133
|
+
* Returns null for anything that is not a callout, which is the signal to
|
|
134
|
+
* render an ordinary blockquote.
|
|
135
|
+
*/
|
|
136
|
+
export function parseCallout(blockquoteLines) {
|
|
137
|
+
return readCallout(blockquoteLines)?.callout ?? null;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Index, within the same lines, of the callout body's first line — or -1 when
|
|
141
|
+
* the block is not a callout. A caller that re-parses `body` as markdown needs
|
|
142
|
+
* it to map a node back to the document line it came from; the reader joins
|
|
143
|
+
* its checkboxes to the document's line scan that way. Kept off `Callout`
|
|
144
|
+
* itself because the type is a value the editor and CLI construct by hand.
|
|
145
|
+
*/
|
|
146
|
+
export function calloutBodyLine(blockquoteLines) {
|
|
147
|
+
return readCallout(blockquoteLines)?.bodyLine ?? -1;
|
|
148
|
+
}
|
|
149
|
+
function readCallout(blockquoteLines) {
|
|
150
|
+
const nonBlank = blockquoteLines.filter((line) => line.trim() !== "");
|
|
151
|
+
if (nonBlank.length === 0)
|
|
152
|
+
return null;
|
|
153
|
+
const quoted = nonBlank.every((line) => /^[ \t]{0,3}>/.test(line));
|
|
154
|
+
const lines = quoted ? stripQuoteMarkers(blockquoteLines) : [...blockquoteLines];
|
|
155
|
+
// The emptiness check above ran on the RAW lines, and a line can be
|
|
156
|
+
// non-blank before its `>` marker comes off and blank after — a bare `>` is
|
|
157
|
+
// the whole of CommonMark's empty blockquote. Without this the findIndex
|
|
158
|
+
// returns -1 and the deref below throws, taking down the render of any post
|
|
159
|
+
// that contains one.
|
|
160
|
+
const first = lines.findIndex((line) => line.trim() !== "");
|
|
161
|
+
if (first === -1)
|
|
162
|
+
return null;
|
|
163
|
+
const match = MARKER.exec(lines[first].trim());
|
|
164
|
+
if (!match)
|
|
165
|
+
return null;
|
|
166
|
+
const keyword = match[1];
|
|
167
|
+
const type = resolveCalloutType(keyword);
|
|
168
|
+
if (!type)
|
|
169
|
+
return null;
|
|
170
|
+
// The author's own spelling, kept only when it is not the canonical word.
|
|
171
|
+
// Same test open-knowledge makes (`callout-transformer.ts:223-224`): a
|
|
172
|
+
// lowercase `[!note]` is the canonical type spelled small, so it still
|
|
173
|
+
// canonicalizes on save and the existing law does not move.
|
|
174
|
+
const authoredAs = keyword.toLowerCase() === type ? undefined : keyword;
|
|
175
|
+
const fold = match[2] === "+" || match[2] === "-" ? match[2] : undefined;
|
|
176
|
+
const title = match[3].trim();
|
|
177
|
+
// Same trim as `trimBlankEdges`, unrolled so the leading blanks it drops can
|
|
178
|
+
// be added to the body's line index rather than silently lost.
|
|
179
|
+
const rest = lines.slice(first + 1).map((line) => line.replace(/\s+$/, ""));
|
|
180
|
+
let start = 0;
|
|
181
|
+
while (start < rest.length && rest[start] === "")
|
|
182
|
+
start++;
|
|
183
|
+
let end = rest.length;
|
|
184
|
+
while (end > start && rest[end - 1] === "")
|
|
185
|
+
end--;
|
|
186
|
+
const body = rest.slice(start, end).join("\n");
|
|
187
|
+
return {
|
|
188
|
+
callout: {
|
|
189
|
+
type,
|
|
190
|
+
...(title ? { title } : {}),
|
|
191
|
+
body,
|
|
192
|
+
...(authoredAs ? { authoredAs } : {}),
|
|
193
|
+
...(fold ? { fold } : {}),
|
|
194
|
+
},
|
|
195
|
+
bodyLine: first + 1 + start,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Write a callout back out. The inverse of `parseCallout` on the canonical
|
|
200
|
+
* form, and the only place the serialized shape is spelled: one blockquote,
|
|
201
|
+
* marker line first, body quoted line by line. A blank body line is a bare
|
|
202
|
+
* `>` — no trailing space, so the bytes survive editors that strip them.
|
|
203
|
+
*
|
|
204
|
+
* The marker line is rebuilt from the three fields that spell it — type,
|
|
205
|
+
* authored word, fold — rather than kept as a string, so a callout the editor
|
|
206
|
+
* constructed by hand and one parsed off disk are written by the same code.
|
|
207
|
+
*/
|
|
208
|
+
export function calloutToMarkdown(callout) {
|
|
209
|
+
const title = callout.title?.trim();
|
|
210
|
+
const marker = calloutMarker(callout.type, {
|
|
211
|
+
authoredAs: callout.authoredAs,
|
|
212
|
+
fold: callout.fold,
|
|
213
|
+
});
|
|
214
|
+
const head = title ? `> ${marker} ${title}` : `> ${marker}`;
|
|
215
|
+
const body = trimBlankEdges(callout.body.split("\n"));
|
|
216
|
+
if (body.length === 0)
|
|
217
|
+
return head;
|
|
218
|
+
return [head, ...body.map((line) => (line === "" ? ">" : `> ${line}`))].join("\n");
|
|
219
|
+
}
|
|
220
|
+
function trimBlankEdges(lines) {
|
|
221
|
+
const out = lines.map((line) => line.replace(/\s+$/, ""));
|
|
222
|
+
while (out.length > 0 && out[0] === "")
|
|
223
|
+
out.shift();
|
|
224
|
+
while (out.length > 0 && out[out.length - 1] === "")
|
|
225
|
+
out.pop();
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
@@ -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
|
// Pure markdown <-> kanban card serialization for the agent FS API (/v1/fs).
|
|
2
4
|
//
|
|
3
5
|
// Mirrors postMarkdown.ts: frontmatter/YAML, mentions, slugs, dates, and the
|
|
@@ -68,9 +70,17 @@ export function cardToMarkdown(card, board, column, commentsCount) {
|
|
|
68
70
|
["kind", card.kind], // undefined for ordinary tasks → skipped
|
|
69
71
|
["status", card.status],
|
|
70
72
|
["resolution", card.resolution], // set once a question is decided
|
|
73
|
+
["scope", card.scope], // "out" for a question ruled out of scope
|
|
71
74
|
["priority", card.priority ?? "none"],
|
|
72
75
|
["assignees", card.assignees ?? []],
|
|
73
76
|
["labels", card.labels ?? []],
|
|
77
|
+
// Emitted only when edges exist — key absent means "no blockers".
|
|
78
|
+
[
|
|
79
|
+
"blocked-by",
|
|
80
|
+
card.blockedBy && card.blockedBy.length > 0
|
|
81
|
+
? card.blockedBy.map(String)
|
|
82
|
+
: undefined,
|
|
83
|
+
],
|
|
74
84
|
["due", toDateOnly(card.dueAt)],
|
|
75
85
|
["created", toISO(card._creationTime)],
|
|
76
86
|
["lastActivityAt", toISO(card.lastActivityAt)],
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare const CHECKBOX_RE: RegExp;
|
|
2
|
+
/** One checkbox, as both the scan and the reader see it. */
|
|
3
|
+
export interface ChecklistItem {
|
|
4
|
+
/** 0-based line index in the body. The reader's join key. */
|
|
5
|
+
line: number;
|
|
6
|
+
/** 0-based position in document order — the index `toggleChecklistItem` takes. */
|
|
7
|
+
index: number;
|
|
8
|
+
checked: boolean;
|
|
9
|
+
/** The item's text, markup intact. */
|
|
10
|
+
text: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ChecklistProgress {
|
|
13
|
+
done: number;
|
|
14
|
+
total: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Every checkbox in a body, in document order.
|
|
18
|
+
*
|
|
19
|
+
* Non-rendering bytes are skipped: a `- [ ]` inside a ``` block, an inline
|
|
20
|
+
* code span or an HTML comment is a sample, not a task, and counting it is how
|
|
21
|
+
* a click on the first real checkbox ends up rewriting a line inside someone's
|
|
22
|
+
* code. That judgement comes from `lineGeometry`'s shared mask — the same one
|
|
23
|
+
* lint and the block parsers read — so nothing here decides on its own what
|
|
24
|
+
* renders. A fence inside a blockquote or a callout counts too, since a quoted
|
|
25
|
+
* `- [ ]` is otherwise a task.
|
|
26
|
+
*
|
|
27
|
+
* Indented code is skipped the only way a line scan can tell: four spaces of
|
|
28
|
+
* indent with no enclosing list item above it is a code block, whereas the
|
|
29
|
+
* same indent under a bullet is a nested list. Bodies carry no frontmatter —
|
|
30
|
+
* callers hand us a body, same as the reader — so the fence scan starts at 0.
|
|
31
|
+
*/
|
|
32
|
+
export declare function scanChecklist(body: string | undefined | null): ChecklistItem[];
|
|
33
|
+
export declare function checklistProgress(body: string | undefined | null): ChecklistProgress;
|
|
34
|
+
export declare function toggleChecklistItem(body: string, index: number): string;
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
// Checklist items are plain markdown checkboxes — `- [ ]` / `- [x]` — in an
|
|
4
|
+
// entity body. There is no separate sub-task entity: the body IS the list, so
|
|
5
|
+
// progress and toggling are string operations over it.
|
|
6
|
+
//
|
|
7
|
+
// ONE scan answers all three questions — which lines are checkboxes, how many
|
|
8
|
+
// are done, and which line index N addresses. The reader (src/lib/utils/
|
|
9
|
+
// markdown.ts) walks the mdast tree instead, and joins back to this scan BY
|
|
10
|
+
// LINE, so a rendered checkbox and the line a toggle rewrites are the same
|
|
11
|
+
// line by construction rather than by two counters agreeing.
|
|
12
|
+
//
|
|
13
|
+
// Pure: no React, no Convex, no Node. The web app re-exports this from
|
|
14
|
+
// src/lib/subtasks.ts; the mobile preprocessor keeps its own copy because
|
|
15
|
+
// packages/mobile installs outside the pnpm workspace.
|
|
16
|
+
import { lineText, maskNonRenderingLines } from "./lineGeometry.js";
|
|
17
|
+
/** The blockquote markers opening a line — `>` runs, callout syntax included. */
|
|
18
|
+
const QUOTE_PREFIX_RE = /^(?:[ \t]{0,3}>[ \t]?)*/;
|
|
19
|
+
/**
|
|
20
|
+
* Non-rendering lines that only a blockquote-relative scan can see.
|
|
21
|
+
*
|
|
22
|
+
* The mask reads the raw lines, where `> ```` ``` ```` is not a fence open at
|
|
23
|
+
* all, so a sample checkbox inside a quoted or callout fence would count as a
|
|
24
|
+
* task. Quote markers therefore come off before the scan — but only within a
|
|
25
|
+
* contiguous run of lines at the SAME quote depth, because a blockquote is its
|
|
26
|
+
* own container: an unclosed fence inside one ends with the quote and must not
|
|
27
|
+
* swallow the real checkboxes that follow it at top level.
|
|
28
|
+
*
|
|
29
|
+
* It is the whole mask that runs on the stripped lines, not just the fence
|
|
30
|
+
* half, and it cuts both ways: a `<code>` left open inside a blockquote hides
|
|
31
|
+
* the checkbox below it even where the raw scan sees the line plainly, and a
|
|
32
|
+
* ``` inside a quoted comment is comment text rather than a fence, so the task
|
|
33
|
+
* after the `-->` survives. Both are pinned in checklist.test.ts.
|
|
34
|
+
*
|
|
35
|
+
* Depth 0 is left to the caller's raw mask, which already reads those lines
|
|
36
|
+
* with their `>`-looking content intact.
|
|
37
|
+
*/
|
|
38
|
+
function quotedNonRenderingMask(lines) {
|
|
39
|
+
const mask = new Array(lines.length).fill(false);
|
|
40
|
+
const depths = lines.map((_, i) => (lineText(lines, i).match(QUOTE_PREFIX_RE)[0].match(/>/g) ?? []).length);
|
|
41
|
+
for (let start = 0; start < lines.length; start++) {
|
|
42
|
+
const depth = depths[start];
|
|
43
|
+
if (depth === 0)
|
|
44
|
+
continue;
|
|
45
|
+
let end = start;
|
|
46
|
+
while (end + 1 < lines.length && depths[end + 1] === depth)
|
|
47
|
+
end++;
|
|
48
|
+
const stripped = [];
|
|
49
|
+
for (let i = start; i <= end; i++) {
|
|
50
|
+
stripped.push(lineText(lines, i).replace(QUOTE_PREFIX_RE, ""));
|
|
51
|
+
}
|
|
52
|
+
const quoted = maskNonRenderingLines(stripped);
|
|
53
|
+
for (let i = 0; i < quoted.length; i++) {
|
|
54
|
+
if (quoted[i].trim() === "")
|
|
55
|
+
mask[start + i] = true;
|
|
56
|
+
}
|
|
57
|
+
start = end;
|
|
58
|
+
}
|
|
59
|
+
return mask;
|
|
60
|
+
}
|
|
61
|
+
// A checkbox line: optional blockquote markers, then a bullet (`-`/`*`/`+`) or
|
|
62
|
+
// an ordered marker (`1.`/`1)`), then the state box. Every one of these forms
|
|
63
|
+
// is a task item to GFM, so every one of them is addressable here — a reader
|
|
64
|
+
// that renders `1. [ ] ship` interactive and a toggler whose regex cannot see
|
|
65
|
+
// it is the divergence this grammar exists to close.
|
|
66
|
+
//
|
|
67
|
+
// Captures the prefix, the state character, the gap, and the item text, so a
|
|
68
|
+
// toggle rebuilds the line byte for byte apart from the box it flipped.
|
|
69
|
+
export const CHECKBOX_RE = /^((?:[ \t]{0,3}>[ \t]?)*[ \t]*(?:[-*+]|\d{1,9}[.)])[ \t]+)\[([ xX])\]([ \t]+)(.*)$/;
|
|
70
|
+
// The same opening, without the box — any list item at all. Establishes the
|
|
71
|
+
// list context that tells an indented nested checkbox from indented code.
|
|
72
|
+
const BULLET_RE = /^(?:[ \t]{0,3}>[ \t]?)*([ \t]*)(?:[-*+]|\d{1,9}[.)])[ \t]+/;
|
|
73
|
+
/** Indentation of a line's content, blockquote markers not counted. */
|
|
74
|
+
const INDENT_RE = /^(?:[ \t]{0,3}>[ \t]?)*([ \t]*)/;
|
|
75
|
+
/**
|
|
76
|
+
* Every checkbox in a body, in document order.
|
|
77
|
+
*
|
|
78
|
+
* Non-rendering bytes are skipped: a `- [ ]` inside a ``` block, an inline
|
|
79
|
+
* code span or an HTML comment is a sample, not a task, and counting it is how
|
|
80
|
+
* a click on the first real checkbox ends up rewriting a line inside someone's
|
|
81
|
+
* code. That judgement comes from `lineGeometry`'s shared mask — the same one
|
|
82
|
+
* lint and the block parsers read — so nothing here decides on its own what
|
|
83
|
+
* renders. A fence inside a blockquote or a callout counts too, since a quoted
|
|
84
|
+
* `- [ ]` is otherwise a task.
|
|
85
|
+
*
|
|
86
|
+
* Indented code is skipped the only way a line scan can tell: four spaces of
|
|
87
|
+
* indent with no enclosing list item above it is a code block, whereas the
|
|
88
|
+
* same indent under a bullet is a nested list. Bodies carry no frontmatter —
|
|
89
|
+
* callers hand us a body, same as the reader — so the fence scan starts at 0.
|
|
90
|
+
*/
|
|
91
|
+
export function scanChecklist(body) {
|
|
92
|
+
if (!body)
|
|
93
|
+
return [];
|
|
94
|
+
const lines = body.split("\n");
|
|
95
|
+
const masked = maskNonRenderingLines(lines);
|
|
96
|
+
const quoted = quotedNonRenderingMask(lines);
|
|
97
|
+
const out = [];
|
|
98
|
+
// Indent of the innermost list item still open above the cursor, or null
|
|
99
|
+
// when we are at top level. A non-blank unindented line that is not itself a
|
|
100
|
+
// list item closes the list.
|
|
101
|
+
let openIndent = null;
|
|
102
|
+
for (let i = 0; i < lines.length; i++) {
|
|
103
|
+
if (quoted[i])
|
|
104
|
+
continue;
|
|
105
|
+
// Structure is read off the MASKED line, so a fenced, commented-out or
|
|
106
|
+
// code-spanned checkbox is not a list item and not a task; the item's own
|
|
107
|
+
// text still comes off the raw line, markup intact.
|
|
108
|
+
const line = lineText(masked, i);
|
|
109
|
+
if (line.trim() === "")
|
|
110
|
+
continue;
|
|
111
|
+
const bullet = BULLET_RE.exec(line);
|
|
112
|
+
if (!bullet) {
|
|
113
|
+
if (INDENT_RE.exec(line)[1].length === 0)
|
|
114
|
+
openIndent = null;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const indent = bullet[1].length;
|
|
118
|
+
if (indent >= 4 && (openIndent === null || openIndent >= indent))
|
|
119
|
+
continue;
|
|
120
|
+
openIndent = indent;
|
|
121
|
+
const raw = lineText(lines, i);
|
|
122
|
+
const box = CHECKBOX_RE.exec(raw);
|
|
123
|
+
// The box has to be one of the bytes that render, and it has to be the
|
|
124
|
+
// box the grammar found. Both halves are reachable on one line: with a
|
|
125
|
+
// comment carried down from above, `- [ ] a --> - [ ] b` renders a
|
|
126
|
+
// checkbox, but not the one CHECKBOX_RE anchored at the head of the line,
|
|
127
|
+
// and that is the one a toggle would rewrite. No addressable box, no item.
|
|
128
|
+
if (!box || line[box[1].length] !== "[")
|
|
129
|
+
continue;
|
|
130
|
+
out.push({
|
|
131
|
+
line: i,
|
|
132
|
+
index: out.length,
|
|
133
|
+
checked: box[2] !== " ",
|
|
134
|
+
text: box[4],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
// Count checkbox items in a body. total === 0 means "no checklist".
|
|
140
|
+
export function checklistProgress(body) {
|
|
141
|
+
const items = scanChecklist(body);
|
|
142
|
+
return { done: items.filter((item) => item.checked).length, total: items.length };
|
|
143
|
+
}
|
|
144
|
+
// Toggle the Nth checkbox (0-based, in document order) and return the new body.
|
|
145
|
+
// Returns the original body unchanged if the index is out of range.
|
|
146
|
+
export function toggleChecklistItem(body, index) {
|
|
147
|
+
const item = scanChecklist(body)[index];
|
|
148
|
+
if (!item)
|
|
149
|
+
return body;
|
|
150
|
+
const lines = body.split("\n");
|
|
151
|
+
const raw = lines[item.line];
|
|
152
|
+
const eol = raw.endsWith("\r") ? "\r" : "";
|
|
153
|
+
const m = CHECKBOX_RE.exec(eol ? raw.slice(0, -1) : raw);
|
|
154
|
+
if (!m)
|
|
155
|
+
return body;
|
|
156
|
+
lines[item.line] = `${m[1]}[${item.checked ? " " : "x"}]${m[3]}${m[4]}${eol}`;
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|