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,282 @@
|
|
|
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
|
+
* Keyboard-first selection for the sheet block.
|
|
5
|
+
*
|
|
6
|
+
* The shape is BlockSuite's data-view model, kept because it earns its keep: a
|
|
7
|
+
* selection is a plain serializable VALUE — a focus coordinate plus a rung —
|
|
8
|
+
* not a DOM range, not a set of element references. That is what lets the same
|
|
9
|
+
* selection survive a re-render, a re-parse of the block source, a round trip
|
|
10
|
+
* through JSON, and (later) a collaborative cursor. Reference:
|
|
11
|
+
* context/AFFiNE/blocksuite/affine/data-view/src/view-presets/table/selection.ts
|
|
12
|
+
* and .../table/pc/controller/hotkeys.ts.
|
|
13
|
+
*
|
|
14
|
+
* Three of their behaviours are deliberately NOT copied.
|
|
15
|
+
*
|
|
16
|
+
* 1. The inescapable Escape loop. In their hotkeys, Escape on a cell promotes
|
|
17
|
+
* to row selection and Escape on a row selection converts the rows straight
|
|
18
|
+
* back into an area selection — the two rungs trade places forever and the
|
|
19
|
+
* table never lets go. Here Escape walks a strictly decreasing ladder
|
|
20
|
+
* (editing → cell → row → none) and then reports `ignored` so the key
|
|
21
|
+
* belongs to whatever encloses the block. `selectionRung` is the proof
|
|
22
|
+
* obligation: every Escape lowers it, so the walk terminates.
|
|
23
|
+
*
|
|
24
|
+
* 2. Tab dead-ending. Theirs wraps to the next row and stops moving at the last
|
|
25
|
+
* cell while still swallowing the key, so a keyboard user is trapped in the
|
|
26
|
+
* grid with no way forward. Here Tab past the last cell RELEASES — the
|
|
27
|
+
* caller lets the browser move focus on — and the selection value is left
|
|
28
|
+
* intact so tabbing back in lands where you left.
|
|
29
|
+
*
|
|
30
|
+
* 3. Silent undefined. Their cell lookups go through a DOM query that returns
|
|
31
|
+
* `undefined` and the handler simply does nothing, so a coordinate that has
|
|
32
|
+
* drifted out of range is indistinguishable from a key that did nothing.
|
|
33
|
+
* This reducer is total: coordinates are clamped against the shape on every
|
|
34
|
+
* transition, and every call returns a named outcome.
|
|
35
|
+
*/
|
|
36
|
+
export const SHEET_SELECTION_NONE = { kind: "none" };
|
|
37
|
+
/**
|
|
38
|
+
* The ladder rung. Escape lowers it by exactly one, which is the whole reason
|
|
39
|
+
* the Escape walk terminates rather than ping-ponging.
|
|
40
|
+
*/
|
|
41
|
+
export function selectionRung(selection) {
|
|
42
|
+
if (selection.kind === "none")
|
|
43
|
+
return 0;
|
|
44
|
+
if (selection.kind === "row")
|
|
45
|
+
return 1;
|
|
46
|
+
return selection.isEditing ? 3 : 2;
|
|
47
|
+
}
|
|
48
|
+
function clamp(value, max) {
|
|
49
|
+
if (!Number.isFinite(value))
|
|
50
|
+
return 0;
|
|
51
|
+
if (value < 0)
|
|
52
|
+
return 0;
|
|
53
|
+
if (value > max)
|
|
54
|
+
return max;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function inShape(shape) {
|
|
58
|
+
return shape.rows > 0 && shape.columns > 0;
|
|
59
|
+
}
|
|
60
|
+
/** Every coordinate that leaves this module has been pulled back in range. */
|
|
61
|
+
function normalize(selection, shape) {
|
|
62
|
+
if (!inShape(shape))
|
|
63
|
+
return SHEET_SELECTION_NONE;
|
|
64
|
+
if (selection.kind === "row") {
|
|
65
|
+
return { kind: "row", rowIndex: clamp(selection.rowIndex, shape.rows - 1) };
|
|
66
|
+
}
|
|
67
|
+
if (selection.kind === "cell") {
|
|
68
|
+
return {
|
|
69
|
+
kind: "cell",
|
|
70
|
+
rowIndex: clamp(selection.rowIndex, shape.rows - 1),
|
|
71
|
+
columnIndex: clamp(selection.columnIndex, shape.columns - 1),
|
|
72
|
+
isEditing: selection.isEditing,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return selection;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Re-seat a selection against a new shape. The block source can change under
|
|
79
|
+
* the selection — an agent writes a row, a paste shortens one — and the value
|
|
80
|
+
* has to stay meaningful without being thrown away.
|
|
81
|
+
*/
|
|
82
|
+
export function reseatSelection(selection, shape) {
|
|
83
|
+
return normalize(selection, shape);
|
|
84
|
+
}
|
|
85
|
+
function stay(selection, outcome = "handled") {
|
|
86
|
+
return { selection, outcome, write: null, draft: null };
|
|
87
|
+
}
|
|
88
|
+
/** Linear cell index, so Tab is one arithmetic step and its edges are obvious. */
|
|
89
|
+
function linearIndex(rowIndex, columnIndex, shape) {
|
|
90
|
+
return rowIndex * shape.columns + columnIndex;
|
|
91
|
+
}
|
|
92
|
+
export function sheetSelectionReducer(selection, action, context) {
|
|
93
|
+
const shape = context.shape;
|
|
94
|
+
if (!inShape(shape))
|
|
95
|
+
return stay(SHEET_SELECTION_NONE, "ignored");
|
|
96
|
+
const current = normalize(selection, shape);
|
|
97
|
+
switch (action.type) {
|
|
98
|
+
case "focusCell": {
|
|
99
|
+
const next = normalize({
|
|
100
|
+
kind: "cell",
|
|
101
|
+
rowIndex: action.rowIndex,
|
|
102
|
+
columnIndex: action.columnIndex,
|
|
103
|
+
isEditing: action.edit === true,
|
|
104
|
+
}, shape);
|
|
105
|
+
const draft = next.kind === "cell" && next.isEditing ? context.read(next.rowIndex, next.columnIndex) : null;
|
|
106
|
+
return { selection: next, outcome: "handled", write: null, draft };
|
|
107
|
+
}
|
|
108
|
+
case "release":
|
|
109
|
+
return stay(SHEET_SELECTION_NONE);
|
|
110
|
+
case "move": {
|
|
111
|
+
if (current.kind === "none") {
|
|
112
|
+
return {
|
|
113
|
+
selection: { kind: "cell", rowIndex: 0, columnIndex: 0, isEditing: false },
|
|
114
|
+
outcome: "handled",
|
|
115
|
+
write: null,
|
|
116
|
+
draft: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (current.kind === "row") {
|
|
120
|
+
// A row is a whole-row rung: up and down walk rows, right steps back
|
|
121
|
+
// into the row's first cell. Left has nothing lower to go to.
|
|
122
|
+
if (action.direction === "up" || action.direction === "down") {
|
|
123
|
+
const delta = action.direction === "up" ? -1 : 1;
|
|
124
|
+
return stay(normalize({ kind: "row", rowIndex: current.rowIndex + delta }, shape));
|
|
125
|
+
}
|
|
126
|
+
if (action.direction === "right") {
|
|
127
|
+
return stay(normalize({ kind: "cell", rowIndex: current.rowIndex, columnIndex: 0, isEditing: false }, shape));
|
|
128
|
+
}
|
|
129
|
+
return stay(current);
|
|
130
|
+
}
|
|
131
|
+
// Arrows inside an open editor belong to the caret, not to the grid.
|
|
132
|
+
if (current.isEditing)
|
|
133
|
+
return stay(current, "ignored");
|
|
134
|
+
const rowDelta = action.direction === "up" ? -1 : action.direction === "down" ? 1 : 0;
|
|
135
|
+
const columnDelta = action.direction === "left" ? -1 : action.direction === "right" ? 1 : 0;
|
|
136
|
+
// Arrows clamp at the edges rather than wrapping: an arrow key names a
|
|
137
|
+
// direction on the grid, and wrapping would make it name a different one.
|
|
138
|
+
return stay(normalize({
|
|
139
|
+
kind: "cell",
|
|
140
|
+
rowIndex: current.rowIndex + rowDelta,
|
|
141
|
+
columnIndex: current.columnIndex + columnDelta,
|
|
142
|
+
isEditing: false,
|
|
143
|
+
}, shape));
|
|
144
|
+
}
|
|
145
|
+
case "step": {
|
|
146
|
+
if (current.kind === "none") {
|
|
147
|
+
const first = action.direction === "forward"
|
|
148
|
+
? { rowIndex: 0, columnIndex: 0 }
|
|
149
|
+
: { rowIndex: shape.rows - 1, columnIndex: shape.columns - 1 };
|
|
150
|
+
return stay({ kind: "cell", ...first, isEditing: false });
|
|
151
|
+
}
|
|
152
|
+
if (current.kind === "row") {
|
|
153
|
+
return stay(normalize({ kind: "cell", rowIndex: current.rowIndex, columnIndex: 0, isEditing: false }, shape));
|
|
154
|
+
}
|
|
155
|
+
const total = shape.rows * shape.columns;
|
|
156
|
+
const index = linearIndex(current.rowIndex, current.columnIndex, shape);
|
|
157
|
+
const nextIndex = action.direction === "forward" ? index + 1 : index - 1;
|
|
158
|
+
if (nextIndex < 0 || nextIndex >= total) {
|
|
159
|
+
// The grid is finished with the key. The coordinate is KEPT — tabbing
|
|
160
|
+
// back in returns to the cell you left, which is the whole reason not
|
|
161
|
+
// to dead-end here.
|
|
162
|
+
return stay({ kind: "cell", rowIndex: current.rowIndex, columnIndex: current.columnIndex, isEditing: false }, action.direction === "forward" ? "released-forward" : "released-back");
|
|
163
|
+
}
|
|
164
|
+
return stay({
|
|
165
|
+
kind: "cell",
|
|
166
|
+
rowIndex: Math.floor(nextIndex / shape.columns),
|
|
167
|
+
columnIndex: nextIndex % shape.columns,
|
|
168
|
+
isEditing: false,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
case "enter": {
|
|
172
|
+
if (current.kind === "none") {
|
|
173
|
+
return stay({ kind: "cell", rowIndex: 0, columnIndex: 0, isEditing: false });
|
|
174
|
+
}
|
|
175
|
+
if (current.kind === "row") {
|
|
176
|
+
return stay(normalize({ kind: "cell", rowIndex: current.rowIndex, columnIndex: 0, isEditing: false }, shape));
|
|
177
|
+
}
|
|
178
|
+
if (current.isEditing) {
|
|
179
|
+
// Committing is the caller's job — it holds the draft. Enter only says
|
|
180
|
+
// where to land afterwards.
|
|
181
|
+
return stay(current, "handled");
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
selection: { ...current, isEditing: true },
|
|
185
|
+
outcome: "handled",
|
|
186
|
+
write: null,
|
|
187
|
+
draft: context.read(current.rowIndex, current.columnIndex),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
case "escape": {
|
|
191
|
+
if (current.kind === "none")
|
|
192
|
+
return stay(current, "ignored");
|
|
193
|
+
if (current.kind === "row")
|
|
194
|
+
return stay(SHEET_SELECTION_NONE);
|
|
195
|
+
if (current.isEditing) {
|
|
196
|
+
// Escape discards. No write leaves this branch, which is what makes
|
|
197
|
+
// "Escape returns to cell selection" safe to press on a typo.
|
|
198
|
+
return stay({ ...current, isEditing: false });
|
|
199
|
+
}
|
|
200
|
+
return stay({ kind: "row", rowIndex: current.rowIndex });
|
|
201
|
+
}
|
|
202
|
+
case "typeChar": {
|
|
203
|
+
if (current.kind !== "cell" || current.isEditing)
|
|
204
|
+
return stay(current, "ignored");
|
|
205
|
+
if (action.char.length !== 1)
|
|
206
|
+
return stay(current, "ignored");
|
|
207
|
+
// Typing REPLACES: the draft starts as the character, not as the cell.
|
|
208
|
+
return {
|
|
209
|
+
selection: { ...current, isEditing: true },
|
|
210
|
+
outcome: "handled",
|
|
211
|
+
write: null,
|
|
212
|
+
draft: action.char,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
case "clear": {
|
|
216
|
+
if (current.kind !== "cell" || current.isEditing)
|
|
217
|
+
return stay(current, "ignored");
|
|
218
|
+
return {
|
|
219
|
+
selection: current,
|
|
220
|
+
outcome: "handled",
|
|
221
|
+
write: { rowIndex: current.rowIndex, columnIndex: current.columnIndex, value: "" },
|
|
222
|
+
draft: null,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
case "commit": {
|
|
226
|
+
if (current.kind !== "cell")
|
|
227
|
+
return stay(current, "ignored");
|
|
228
|
+
const write = {
|
|
229
|
+
rowIndex: current.rowIndex,
|
|
230
|
+
columnIndex: current.columnIndex,
|
|
231
|
+
value: action.value,
|
|
232
|
+
};
|
|
233
|
+
if (action.then === "stay") {
|
|
234
|
+
return { selection: { ...current, isEditing: false }, outcome: "handled", write, draft: null };
|
|
235
|
+
}
|
|
236
|
+
if (action.then === "down") {
|
|
237
|
+
const next = normalize({ kind: "cell", rowIndex: current.rowIndex + 1, columnIndex: current.columnIndex, isEditing: false }, shape);
|
|
238
|
+
return { selection: next, outcome: "handled", write, draft: null };
|
|
239
|
+
}
|
|
240
|
+
const stepped = sheetSelectionReducer({ ...current, isEditing: false }, { type: "step", direction: action.then === "forward" ? "forward" : "back" }, context);
|
|
241
|
+
return { selection: stepped.selection, outcome: stepped.outcome, write, draft: null };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* A keyboard event, read as an action. Split from the reducer so the key map
|
|
247
|
+
* is a table a test can walk without a DOM.
|
|
248
|
+
*
|
|
249
|
+
* Returns null for keys the block has no opinion about, which is the same
|
|
250
|
+
* statement as `ignored` — nothing here silently swallows a key.
|
|
251
|
+
*/
|
|
252
|
+
export function sheetKeyAction(event) {
|
|
253
|
+
const modified = event.metaKey === true || event.ctrlKey === true || event.altKey === true;
|
|
254
|
+
switch (event.key) {
|
|
255
|
+
case "ArrowUp":
|
|
256
|
+
return modified ? null : { type: "move", direction: "up" };
|
|
257
|
+
case "ArrowDown":
|
|
258
|
+
return modified ? null : { type: "move", direction: "down" };
|
|
259
|
+
case "ArrowLeft":
|
|
260
|
+
return modified ? null : { type: "move", direction: "left" };
|
|
261
|
+
case "ArrowRight":
|
|
262
|
+
return modified ? null : { type: "move", direction: "right" };
|
|
263
|
+
case "Tab":
|
|
264
|
+
return modified ? null : { type: "step", direction: event.shiftKey ? "back" : "forward" };
|
|
265
|
+
case "Enter":
|
|
266
|
+
return modified ? null : { type: "enter" };
|
|
267
|
+
case "Escape":
|
|
268
|
+
return { type: "escape" };
|
|
269
|
+
case "Backspace":
|
|
270
|
+
case "Delete":
|
|
271
|
+
return modified ? null : { type: "clear" };
|
|
272
|
+
default:
|
|
273
|
+
if (!modified && event.key.length === 1)
|
|
274
|
+
return { type: "typeChar", char: event.key };
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Where Enter should land after committing an edit — down a row, matching
|
|
280
|
+
* every spreadsheet. Kept as a named function so the hook and its test agree.
|
|
281
|
+
*/
|
|
282
|
+
export const ENTER_COMMIT_LANDING = "down";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `task` verb creates work. Canonical board filenames begin with a card
|
|
3
|
+
* number, but carrying that prefix into a cloud PUT would target that existing
|
|
4
|
+
* card. Strip it here; explicit updates belong to the filesystem API.
|
|
5
|
+
*/
|
|
6
|
+
export declare function taskUploadFilename(filename: string): string;
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
+
* The `task` verb creates work. Canonical board filenames begin with a card
|
|
5
|
+
* number, but carrying that prefix into a cloud PUT would target that existing
|
|
6
|
+
* card. Strip it here; explicit updates belong to the filesystem API.
|
|
7
|
+
*/
|
|
8
|
+
export function taskUploadFilename(filename) {
|
|
9
|
+
const stem = filename
|
|
10
|
+
.replace(/\.md$/i, "")
|
|
11
|
+
.replace(/^\d+(?:[-_. ]+|$)/, "");
|
|
12
|
+
return `${stem || "task"}.md`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Document statistics for editor footers.
|
|
3
|
+
*
|
|
4
|
+
* Word counting splits with `Intl.Segmenter` where it exists: whitespace
|
|
5
|
+
* splitting reports one word for an entire Chinese or Japanese sentence, which
|
|
6
|
+
* is the bug open-knowledge's `selection-stats.ts` fixes the same way. The
|
|
7
|
+
* fallback keeps that property by counting CJK ideographs individually.
|
|
8
|
+
*
|
|
9
|
+
* Token count is a chars/4 heuristic, not a tokenizer — always render it with a
|
|
10
|
+
* leading `~` so it never reads as exact.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Words in a chunk of Markdown. Punctuation-only segments (`#`, `*`, `|`) are
|
|
14
|
+
* not word-like, so Markdown syntax drops out without a stripping pass.
|
|
15
|
+
*/
|
|
16
|
+
export declare function countWords(text: string): number;
|
|
17
|
+
/** Rough token count: ~4 characters per token, the usual English-prose ratio. */
|
|
18
|
+
export declare function estimateTokens(text: string): number;
|
|
19
|
+
export type DocumentStats = {
|
|
20
|
+
words: number;
|
|
21
|
+
tokens: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function documentStats(text: string): DocumentStats;
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
* Document statistics for editor footers.
|
|
5
|
+
*
|
|
6
|
+
* Word counting splits with `Intl.Segmenter` where it exists: whitespace
|
|
7
|
+
* splitting reports one word for an entire Chinese or Japanese sentence, which
|
|
8
|
+
* is the bug open-knowledge's `selection-stats.ts` fixes the same way. The
|
|
9
|
+
* fallback keeps that property by counting CJK ideographs individually.
|
|
10
|
+
*
|
|
11
|
+
* Token count is a chars/4 heuristic, not a tokenizer — always render it with a
|
|
12
|
+
* leading `~` so it never reads as exact.
|
|
13
|
+
*/
|
|
14
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
15
|
+
const CJK_GLOBAL = new RegExp(CJK.source, "gu");
|
|
16
|
+
const LATIN_WORD = /[\p{L}\p{N}][\p{L}\p{N}'’_-]*/gu;
|
|
17
|
+
const CHARS_PER_TOKEN = 4;
|
|
18
|
+
let segmenter;
|
|
19
|
+
function wordSegmenter() {
|
|
20
|
+
if (segmenter !== undefined)
|
|
21
|
+
return segmenter;
|
|
22
|
+
const ctor = Intl
|
|
23
|
+
.Segmenter;
|
|
24
|
+
segmenter = ctor ? new ctor(undefined, { granularity: "word" }) : null;
|
|
25
|
+
return segmenter;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Words in a chunk of Markdown. Punctuation-only segments (`#`, `*`, `|`) are
|
|
29
|
+
* not word-like, so Markdown syntax drops out without a stripping pass.
|
|
30
|
+
*/
|
|
31
|
+
export function countWords(text) {
|
|
32
|
+
if (text.trim().length === 0)
|
|
33
|
+
return 0;
|
|
34
|
+
const prose = withoutUrls(text);
|
|
35
|
+
const seg = wordSegmenter();
|
|
36
|
+
if (seg) {
|
|
37
|
+
let words = 0;
|
|
38
|
+
let previousWasWord = false;
|
|
39
|
+
let pendingJoiner = false;
|
|
40
|
+
for (const part of seg.segment(prose)) {
|
|
41
|
+
if (part.isWordLike) {
|
|
42
|
+
// UAX #29 breaks on a hyphen, so "well-known" arrives as two segments.
|
|
43
|
+
// Rejoin it: people read a hyphenated compound as one word.
|
|
44
|
+
if (!(pendingJoiner && previousWasWord))
|
|
45
|
+
words++;
|
|
46
|
+
previousWasWord = true;
|
|
47
|
+
pendingJoiner = false;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
pendingJoiner = part.segment === "-" || part.segment === "_";
|
|
51
|
+
if (!pendingJoiner)
|
|
52
|
+
previousWasWord = false;
|
|
53
|
+
}
|
|
54
|
+
return words;
|
|
55
|
+
}
|
|
56
|
+
const cjk = prose.match(CJK_GLOBAL)?.length ?? 0;
|
|
57
|
+
let latin = 0;
|
|
58
|
+
for (const match of prose.matchAll(LATIN_WORD)) {
|
|
59
|
+
if (!CJK.test(match[0]))
|
|
60
|
+
latin++;
|
|
61
|
+
}
|
|
62
|
+
return latin + cjk;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Link destinations are addresses, not prose — a document of links should not
|
|
66
|
+
* report a word count inflated by its own URLs.
|
|
67
|
+
*/
|
|
68
|
+
function withoutUrls(text) {
|
|
69
|
+
return text.replace(/\]\([^)\n]*\)/g, "]").replace(/<?\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, " ");
|
|
70
|
+
}
|
|
71
|
+
/** Rough token count: ~4 characters per token, the usual English-prose ratio. */
|
|
72
|
+
export function estimateTokens(text) {
|
|
73
|
+
const trimmed = text.trim();
|
|
74
|
+
if (trimmed.length === 0)
|
|
75
|
+
return 0;
|
|
76
|
+
return Math.max(1, Math.round(trimmed.length / CHARS_PER_TOKEN));
|
|
77
|
+
}
|
|
78
|
+
export function documentStats(text) {
|
|
79
|
+
return { words: countWords(text), tokens: estimateTokens(text) };
|
|
80
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const WAYFINDER_TICKET_TYPES: readonly ["grilling", "prototype", "research", "task"];
|
|
2
|
+
export type WayfinderTicketType = (typeof WAYFINDER_TICKET_TYPES)[number];
|
|
3
|
+
export type WayfinderTicketState = "open" | "claimed" | "decided" | "out-of-scope";
|
|
4
|
+
export interface WayfinderTicket {
|
|
5
|
+
name: string;
|
|
6
|
+
type: WayfinderTicketType;
|
|
7
|
+
state: WayfinderTicketState;
|
|
8
|
+
/** Names of the tickets blocking this one. */
|
|
9
|
+
blockedBy: string[];
|
|
10
|
+
/**
|
|
11
|
+
* When set, the picture classes this node from it and skips frontierOf's
|
|
12
|
+
* name-resolution. A caller with server-side blocker buckets (the Plan) is
|
|
13
|
+
* the truth about blockedness: a DELETED blocker card is cleared there,
|
|
14
|
+
* while a dangling NAME in an authored map stays unresolved — without the
|
|
15
|
+
* override, a question whose blocker was deleted would render ghost-blocked
|
|
16
|
+
* in the very view whose data source already ruled it takeable.
|
|
17
|
+
*/
|
|
18
|
+
onFrontier?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface WayfinderMap {
|
|
21
|
+
/** What reaching the end of the map looks like. Drawn as the terminal node. */
|
|
22
|
+
destination?: string;
|
|
23
|
+
tickets: WayfinderTicket[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Parse a ticket-list section (the lines between headings). Non-matching
|
|
27
|
+
* lines are ignored — the list can sit inside a larger map document. Returns
|
|
28
|
+
* an empty list rather than null: a map with no tickets yet is a real state
|
|
29
|
+
* (freshly charted, everything still in the fog), not a parse failure.
|
|
30
|
+
*/
|
|
31
|
+
export declare function parseWayfinderTickets(section: string): WayfinderTicket[];
|
|
32
|
+
/**
|
|
33
|
+
* A ticket is on the FRONTIER when it is open and everything blocking it is
|
|
34
|
+
* closed (decided or out of scope). Blockers named but absent from the map
|
|
35
|
+
* count as unresolved — a dangling name is a map error the picture should
|
|
36
|
+
* make visible, not hide.
|
|
37
|
+
*/
|
|
38
|
+
export declare function frontierOf(tickets: WayfinderTicket[]): WayfinderTicket[];
|
|
39
|
+
export declare function wayfinderNodeId(index: number): string;
|
|
40
|
+
/**
|
|
41
|
+
* Draw a map as mermaid flowchart source the engine renders natively (the
|
|
42
|
+
* renderer honors classDef, so states carry color in both themes via the
|
|
43
|
+
* palette the diagram theme derives from --fg/--accent).
|
|
44
|
+
*
|
|
45
|
+
* Reading the picture: solid arrows are blocking edges pointing at what they
|
|
46
|
+
* unblock; the frontier is bold; decided tickets are quiet; out-of-scope
|
|
47
|
+
* tickets sit dashed at the edge; the destination, when named, is the
|
|
48
|
+
* terminal node every unblocked leaf feeds.
|
|
49
|
+
*/
|
|
50
|
+
export declare function wayfinderMapToMermaid(map: WayfinderMap): string;
|
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
// Wayfinder maps — the shared vocabulary for charting a big effort as a map
|
|
4
|
+
// of decision tickets, and the one place its textual form and its picture are
|
|
5
|
+
// defined.
|
|
6
|
+
//
|
|
7
|
+
// The concepts come from mattpocock/skills' wayfinder (studied in
|
|
8
|
+
// docs/research/wayfinder-sfora-map.md; repo vendored in context/): a MAP with
|
|
9
|
+
// a destination, decision TICKETS of four types, BLOCKING edges between them,
|
|
10
|
+
// and states — open on the frontier, claimed, decided, ruled out of scope,
|
|
11
|
+
// with unspecifiable work waiting in the fog. sfora's Plan already carries the
|
|
12
|
+
// same lifecycle (fuzzy / up-for-grabs / claimed / decided), so this module
|
|
13
|
+
// speaks both dialects: wayfinder's names on the wire, the Plan's states
|
|
14
|
+
// mappable one-to-one.
|
|
15
|
+
//
|
|
16
|
+
// Two exports matter:
|
|
17
|
+
// parseWayfinderTickets — read tickets from the map's markdown ticket list
|
|
18
|
+
// wayfinderMapToMermaid — draw the map as a flowchart the engine renders
|
|
19
|
+
//
|
|
20
|
+
// Pure string transforms — no React, no Convex, no Node.
|
|
21
|
+
//
|
|
22
|
+
// ── The ticket-list line grammar ───────────────────────────────────────────
|
|
23
|
+
//
|
|
24
|
+
// - [ ] Pick the auth provider (grilling)
|
|
25
|
+
// - [~] Prototype the login screen (prototype) <- Pick the auth provider
|
|
26
|
+
// - [x] Name the destination
|
|
27
|
+
// - [-] Native mobile app
|
|
28
|
+
//
|
|
29
|
+
// State rides in the bracket: `[ ]` open, `[~]` claimed, `[x]` decided,
|
|
30
|
+
// `[-]` out of scope. The type rides in a trailing parenthesis (grilling when
|
|
31
|
+
// absent — wayfinder's default). `<- A, B` names the tickets blocking this
|
|
32
|
+
// one, by title. Everything is referred to BY NAME, per wayfinder's rule that
|
|
33
|
+
// a wall of ids is illegible.
|
|
34
|
+
export const WAYFINDER_TICKET_TYPES = [
|
|
35
|
+
"grilling",
|
|
36
|
+
"prototype",
|
|
37
|
+
"research",
|
|
38
|
+
"task",
|
|
39
|
+
];
|
|
40
|
+
const STATE_BY_MARK = {
|
|
41
|
+
" ": "open",
|
|
42
|
+
"~": "claimed",
|
|
43
|
+
x: "decided",
|
|
44
|
+
X: "decided",
|
|
45
|
+
"-": "out-of-scope",
|
|
46
|
+
};
|
|
47
|
+
const TICKET_LINE = /^[-*]\s+\[([ ~xX-])\]\s+(.+)$/;
|
|
48
|
+
/**
|
|
49
|
+
* Parse a ticket-list section (the lines between headings). Non-matching
|
|
50
|
+
* lines are ignored — the list can sit inside a larger map document. Returns
|
|
51
|
+
* an empty list rather than null: a map with no tickets yet is a real state
|
|
52
|
+
* (freshly charted, everything still in the fog), not a parse failure.
|
|
53
|
+
*/
|
|
54
|
+
export function parseWayfinderTickets(section) {
|
|
55
|
+
const tickets = [];
|
|
56
|
+
for (const raw of section.split(/\r?\n/)) {
|
|
57
|
+
const match = TICKET_LINE.exec(raw.trim());
|
|
58
|
+
if (!match)
|
|
59
|
+
continue;
|
|
60
|
+
let rest = match[2].trim();
|
|
61
|
+
let blockedBy = [];
|
|
62
|
+
const arrow = rest.indexOf("<-");
|
|
63
|
+
if (arrow >= 0) {
|
|
64
|
+
blockedBy = rest
|
|
65
|
+
.slice(arrow + 2)
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((name) => name.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
rest = rest.slice(0, arrow).trim();
|
|
70
|
+
}
|
|
71
|
+
let type = "grilling";
|
|
72
|
+
const typed = /^(.*)\((grilling|prototype|research|task)\)$/.exec(rest);
|
|
73
|
+
if (typed) {
|
|
74
|
+
type = typed[2];
|
|
75
|
+
rest = typed[1].trim();
|
|
76
|
+
}
|
|
77
|
+
if (!rest)
|
|
78
|
+
continue;
|
|
79
|
+
tickets.push({
|
|
80
|
+
name: rest,
|
|
81
|
+
type,
|
|
82
|
+
state: STATE_BY_MARK[match[1]] ?? "open",
|
|
83
|
+
blockedBy,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return tickets;
|
|
87
|
+
}
|
|
88
|
+
// A blocker clears the way once it is CLOSED — decided or ruled out of scope.
|
|
89
|
+
// Wayfinder's rule is that any terminal ticket unblocks; ruling a blocker out
|
|
90
|
+
// of scope answers the question as surely as deciding it does.
|
|
91
|
+
const TERMINAL_STATES = new Set([
|
|
92
|
+
"decided",
|
|
93
|
+
"out-of-scope",
|
|
94
|
+
]);
|
|
95
|
+
/**
|
|
96
|
+
* A ticket is on the FRONTIER when it is open and everything blocking it is
|
|
97
|
+
* closed (decided or out of scope). Blockers named but absent from the map
|
|
98
|
+
* count as unresolved — a dangling name is a map error the picture should
|
|
99
|
+
* make visible, not hide.
|
|
100
|
+
*/
|
|
101
|
+
export function frontierOf(tickets) {
|
|
102
|
+
const byName = new Map(tickets.map((ticket) => [ticket.name, ticket]));
|
|
103
|
+
return tickets.filter((ticket) => {
|
|
104
|
+
if (ticket.state !== "open")
|
|
105
|
+
return false;
|
|
106
|
+
return ticket.blockedBy.every((name) => {
|
|
107
|
+
const blocker = byName.get(name);
|
|
108
|
+
return blocker !== undefined && TERMINAL_STATES.has(blocker.state);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Node ids must be word-safe for the flowchart grammar; the label carries the
|
|
113
|
+
// real name. Stable across renders: derived from position, not content.
|
|
114
|
+
// Exported so a consumer can join a rendered node's data-id back to the
|
|
115
|
+
// ticket at the same index without re-implementing the scheme.
|
|
116
|
+
export function wayfinderNodeId(index) {
|
|
117
|
+
return `t${index}`;
|
|
118
|
+
}
|
|
119
|
+
const TYPE_GLYPH = {
|
|
120
|
+
grilling: "", // the default carries no marker — most tickets are grilling
|
|
121
|
+
prototype: " ◇",
|
|
122
|
+
research: " ※",
|
|
123
|
+
task: " ⚙",
|
|
124
|
+
};
|
|
125
|
+
function escapeLabel(name) {
|
|
126
|
+
// Brackets and quotes would close the node's label early.
|
|
127
|
+
return name.replace(/["[\]]/g, " ").replace(/\s+/g, " ").trim();
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Draw a map as mermaid flowchart source the engine renders natively (the
|
|
131
|
+
* renderer honors classDef, so states carry color in both themes via the
|
|
132
|
+
* palette the diagram theme derives from --fg/--accent).
|
|
133
|
+
*
|
|
134
|
+
* Reading the picture: solid arrows are blocking edges pointing at what they
|
|
135
|
+
* unblock; the frontier is bold; decided tickets are quiet; out-of-scope
|
|
136
|
+
* tickets sit dashed at the edge; the destination, when named, is the
|
|
137
|
+
* terminal node every unblocked leaf feeds.
|
|
138
|
+
*/
|
|
139
|
+
export function wayfinderMapToMermaid(map) {
|
|
140
|
+
const { tickets } = map;
|
|
141
|
+
const byName = new Map(tickets.map((ticket, index) => [ticket.name, index]));
|
|
142
|
+
const frontier = new Set(frontierOf(tickets).map((ticket) => ticket.name));
|
|
143
|
+
const lines = ["flowchart TD"];
|
|
144
|
+
for (const [index, ticket] of tickets.entries()) {
|
|
145
|
+
const label = escapeLabel(ticket.name) + TYPE_GLYPH[ticket.type];
|
|
146
|
+
// Shape says state at a glance even without color: decided closes into a
|
|
147
|
+
// stadium, out-of-scope stays a plain box, open work is a sharp rect.
|
|
148
|
+
const node = ticket.state === "decided"
|
|
149
|
+
? `${wayfinderNodeId(index)}(["${label} ✓"])`
|
|
150
|
+
: `${wayfinderNodeId(index)}["${label}"]`;
|
|
151
|
+
lines.push(` ${node}`);
|
|
152
|
+
}
|
|
153
|
+
for (const [index, ticket] of tickets.entries()) {
|
|
154
|
+
for (const blocker of ticket.blockedBy) {
|
|
155
|
+
const from = byName.get(blocker);
|
|
156
|
+
if (from === undefined) {
|
|
157
|
+
// A dangling blocker name gets its own node so the error is VISIBLE.
|
|
158
|
+
const ghost = `missing${lines.length}`;
|
|
159
|
+
lines.push(` ${ghost}["${escapeLabel(blocker)} ?"]`);
|
|
160
|
+
lines.push(` ${ghost} -.-> ${wayfinderNodeId(index)}`);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
lines.push(` ${wayfinderNodeId(from)} --> ${wayfinderNodeId(index)}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (map.destination) {
|
|
167
|
+
const dest = `dest(["${escapeLabel(map.destination)}"])`;
|
|
168
|
+
lines.push(` ${dest}`);
|
|
169
|
+
// Every ticket nothing depends on feeds the destination — the map's
|
|
170
|
+
// remaining route at a glance.
|
|
171
|
+
const blockedNames = new Set(tickets.flatMap((ticket) => ticket.blockedBy));
|
|
172
|
+
for (const [index, ticket] of tickets.entries()) {
|
|
173
|
+
if (ticket.state === "out-of-scope")
|
|
174
|
+
continue;
|
|
175
|
+
if (!blockedNames.has(ticket.name)) {
|
|
176
|
+
lines.push(` ${wayfinderNodeId(index)} --> dest`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// States as classes; the renderer resolves classDef props over its theme.
|
|
181
|
+
// Each state gets a DISTINCT stroke so the four read apart on screen — the
|
|
182
|
+
// same tones PlanHill assigns the same buckets, so hill and map speak one
|
|
183
|
+
// palette. Frontier and claimed differ by color, not only weight; decided
|
|
184
|
+
// is quiet but named; out of scope sits dashed.
|
|
185
|
+
lines.push(" classDef claimed stroke:var(--color-accent-amber),stroke-width:2px");
|
|
186
|
+
lines.push(" classDef frontier stroke:var(--color-brand-blue),stroke-width:2px");
|
|
187
|
+
lines.push(" classDef outofscope stroke:var(--color-text-tertiary),stroke-dasharray:4 3");
|
|
188
|
+
lines.push(" classDef decided stroke:var(--color-success)");
|
|
189
|
+
const byState = (predicate) => tickets.flatMap((ticket, index) => (predicate(ticket) ? [wayfinderNodeId(index)] : []));
|
|
190
|
+
const claimed = byState((t) => t.state === "claimed");
|
|
191
|
+
const front = byState((t) => t.onFrontier ?? frontier.has(t.name));
|
|
192
|
+
const out = byState((t) => t.state === "out-of-scope");
|
|
193
|
+
const done = byState((t) => t.state === "decided");
|
|
194
|
+
if (claimed.length > 0)
|
|
195
|
+
lines.push(` class ${claimed.join(",")} claimed`);
|
|
196
|
+
if (front.length > 0)
|
|
197
|
+
lines.push(` class ${front.join(",")} frontier`);
|
|
198
|
+
if (out.length > 0)
|
|
199
|
+
lines.push(` class ${out.join(",")} outofscope`);
|
|
200
|
+
if (done.length > 0)
|
|
201
|
+
lines.push(` class ${done.join(",")} decided`);
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|