sfora-cli 0.10.0 → 0.12.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.
Files changed (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. package/package.json +1 -1
@@ -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
+ }
@@ -1,19 +1,78 @@
1
1
  export type WikiLinkKind = "post" | "card" | "note" | "pr";
2
2
  export declare const WIKI_LINK_PREFIXES: ReadonlyArray<readonly [prefix: string, kind: WikiLinkKind]>;
3
3
  export declare const WIKI_LINK_PREFIX_FOR: Readonly<Record<WikiLinkKind, string>>;
4
+ export declare const WIKI_EMBED_MARKER = "!";
5
+ export declare const BLOCK_ANCHOR_MARKER = "^";
4
6
  export declare function buildWikiLink(kind: WikiLinkKind, id: string, label: string): string;
7
+ export declare function buildWikiEmbed(kind: WikiLinkKind, id: string, label: string): string;
8
+ export declare function cleanWikiTarget(value: string): string;
9
+ export declare function cleanWikiLabel(value: string): string;
10
+ export declare function cleanWikiAnchor(value: string): string;
11
+ /**
12
+ * Split a target-with-anchor into its two halves, honouring the prefix table.
13
+ *
14
+ * The `#` is only a separator after at least one character of the BARE id, and
15
+ * that is not a nicety — `[[c:#42]]` is the existing "card by number" form
16
+ * (`parseWikiTarget` yields `{kind:"card", id:"#42"}`). Splitting on the first
17
+ * `#` anywhere would turn it into a link to the prefix `c:` with an anchor of
18
+ * `42`, silently unlinking every task referenced by number. So: find the
19
+ * prefix, then look for a `#` past the first character after it.
20
+ *
21
+ * An empty anchor (`[[n:abc#]]`) is NOT an anchor. The trailing `#` stays part
22
+ * of the target, which resolves to nothing and renders as the muted
23
+ * unknown-entity text the grammar already gives any unresolvable target —
24
+ * open-knowledge's tokenizer takes the same position from the other side, by
25
+ * refusing to close a wiki link on a zero-length anchor at all.
26
+ */
27
+ export declare function splitWikiAnchor(target: string): {
28
+ target: string;
29
+ anchor: string | null;
30
+ };
31
+ /**
32
+ * The BLOCK id an anchor addresses, or null when the anchor names a heading.
33
+ *
34
+ * One character decides it, and the split is done here rather than at each
35
+ * consumer so "does this token point at a block" has one answer. Three cases
36
+ * are deliberate:
37
+ *
38
+ * `^k7f3a2cx` → `"k7f3a2cx"`. The ordinal form of a duplicated block,
39
+ * `^k7f3a2cx.2`, comes back whole — the dot is part of the id
40
+ * (`fingerprintBlocks`), not a fourth separator.
41
+ * `Rollout` → null. A heading, and the reader slugs it as it always has.
42
+ * `^` → null. A marker with nothing after it addresses no block, the
43
+ * same position `splitWikiAnchor` takes on `[[n:abc#]]`: an
44
+ * empty half is not a half. It stays a (meaningless) heading
45
+ * anchor rather than becoming a block id of `""`, because an id
46
+ * nothing can resolve is worse than an anchor that resolves to
47
+ * no heading.
48
+ *
49
+ * The cost, stated: a heading whose text genuinely begins with `^` cannot be
50
+ * addressed as a heading. That is inherent in a one-character prefix and it is
51
+ * Obsidian's trade too; the alternative — a separate segment — would have every
52
+ * consumer of `anchor` learn a new shape to gain a case nobody writes.
53
+ */
54
+ export declare function blockAnchorId(anchor: string | null | undefined): string | null;
55
+ /** The anchor bytes that address `blockId` — the inverse of `blockAnchorId`. */
56
+ export declare function blockAnchor(blockId: string): string;
57
+ /** Re-join a target and anchor into the bytes that go between the brackets. */
58
+ export declare function joinWikiAnchor(target: string, anchor: string | null | undefined): string;
5
59
  export interface WikiLink {
6
60
  kind: WikiLinkKind;
7
61
  id: string;
8
62
  target: string;
63
+ anchor: string | null;
64
+ blockId: string | null;
9
65
  label: string;
10
66
  hasLabel: boolean;
67
+ embed: boolean;
11
68
  }
12
69
  export declare function wikiLinkPattern(): RegExp;
13
- export declare function parseWikiToken(raw: string): WikiLink;
70
+ export declare function wikiTokenPattern(): RegExp;
71
+ export declare function parseWikiToken(raw: string, embed?: boolean): WikiLink;
14
72
  export declare function parseWikiTarget(target: string): {
15
73
  kind: WikiLinkKind;
16
74
  id: string;
17
75
  };
18
76
  export declare function parseWikiLinks(body: string): WikiLink[];
19
77
  export declare function matchWikiToken(text: string): WikiLink | null;
78
+ export declare function matchWikiEmbed(text: string): WikiLink | null;
@@ -8,7 +8,33 @@
8
8
  // [[c:<cardId>]] → a task / card (also accepts `c:#42` / `c:42`)
9
9
  // [[pr:<n>]] / [[gh:<n>]] → a pull request
10
10
  //
11
- // Any form may carry a display label after a pipe: `[[c:42|Fix login]]`.
11
+ // Any form may carry a display label after a pipe: `[[c:42|Fix login]]`, and
12
+ // any form may point INTO its target at a heading, after a hash:
13
+ //
14
+ // [[n:<noteId>#<heading>]]
15
+ // [[n:<noteId>#<heading>|Label]]
16
+ //
17
+ // The three segments are ordered — target, then anchor, then label — which is
18
+ // what lets a `#` inside a label stay an ordinary character and a `|` after an
19
+ // anchor still open the label.
20
+ //
21
+ // The anchor has TWO readings, told apart by one character:
22
+ //
23
+ // [[n:<noteId>#Rollout]] → a heading, matched through `toAnchorSlug`
24
+ // [[n:<noteId>#^k7f3a2cx]] → a BLOCK, matched by its derived fingerprint
25
+ //
26
+ // The `^` is Obsidian's spelling and it is a prefix rather than a separate
27
+ // segment on purpose: a block id and a heading slug are the same KIND of thing
28
+ // — a place inside the target — so they share the anchor position, the anchor
29
+ // splitter, the anchor cleaner and the label rule, and no consumer has to learn
30
+ // a fourth segment to keep up. Card #325, against
31
+ // `docs/research/durable-block-ids-design.md` card 7.
32
+ //
33
+ // A leading `!` turns any of those forms into an EMBED — `![[c:42]]` — which
34
+ // says "show the thing here" rather than "point at it". The bang is the only
35
+ // difference: everything after it is the same three segments, read by the same
36
+ // splitters and written by the same sanitizers, so an embed can never disagree
37
+ // with a link about what a target or an anchor is. Card #298.
12
38
  //
13
39
  // Pure string transforms — no React, no Convex, no Node. Consumers: the Convex
14
40
  // reference indexer (convex/references.ts), the web renderer
@@ -29,29 +55,173 @@ export const WIKI_LINK_PREFIX_FOR = {
29
55
  note: "n:",
30
56
  pr: "pr:",
31
57
  };
58
+ // The one byte that separates an embed from a link, named so no consumer has
59
+ // to spell `"!"` and mean "the embed marker".
60
+ export const WIKI_EMBED_MARKER = "!";
61
+ // The one byte that separates a BLOCK anchor from a heading anchor, named for
62
+ // the same reason. `#^k7f3a2cx` addresses a block by the fingerprint
63
+ // `@sfora/markdown/blockIds` derives from its content; `#Rollout` addresses a
64
+ // heading by its slug.
65
+ export const BLOCK_ANCHOR_MARKER = "^";
32
66
  // Build the canonical `[[<prefix><id>|<label>]]` form. The label is sanitized:
33
67
  // `[`, `]`, and `|` inside it would break the token grammar.
34
68
  export function buildWikiLink(kind, id, label) {
35
69
  const clean = label.replace(/[[\]|]/g, " ").trim() || "Untitled";
36
70
  return `[[${WIKI_LINK_PREFIX_FOR[kind]}${id}|${clean}]]`;
37
71
  }
72
+ // The same token with the embed marker in front. Built by delegating rather
73
+ // than by re-spelling the brackets: an embed is a link with a bang, and the day
74
+ // `buildWikiLink` learns a fourth segment this keeps up for free.
75
+ export function buildWikiEmbed(kind, id, label) {
76
+ return `${WIKI_EMBED_MARKER}${buildWikiLink(kind, id, label)}`;
77
+ }
78
+ // The two sanitizers that guard the token's own grammar on the WRITE side, and
79
+ // the one place they live. Both halves of `[[target|label]]` pass through one
80
+ // of them, and the two halves are not treated alike:
81
+ //
82
+ // target loses `[`, `]`, `|`; newlines and tabs FOLD to a space. Interior
83
+ // spaces stay — posts and notes resolve by exact title
84
+ // (`resolveRefToken` in convex/references.ts), so stripping them
85
+ // turns `[[The card]]` into a reference that can never match.
86
+ // label loses the same three characters plus newlines outright. It is
87
+ // display text, so a fold would invent whitespace the title
88
+ // never had.
89
+ //
90
+ // Two callers, one spelling: the walker's write path
91
+ // (`editorWalker/toMdast.ts`) and the live paste/typing path
92
+ // (`src/components/editor/extensions/work-link.ts`). They were separate copies
93
+ // until card #255 — the walker's had witnesses and the extension's did not,
94
+ // which is the shape where one copy drifts and nothing says so.
95
+ export function cleanWikiTarget(value) {
96
+ return value
97
+ .replace(/[[\]|]/g, "")
98
+ .replace(/[\r\n\t]+/g, " ")
99
+ .trim();
100
+ }
101
+ export function cleanWikiLabel(value) {
102
+ return value.replace(/[[\]|\r\n]/g, "").trim();
103
+ }
104
+ // The anchor's cleaner, and it is a THIRD one rather than a reuse of either
105
+ // above, for a reason worth stating: the anchor is the only segment whose own
106
+ // separator can appear inside it. A `#` surviving into an anchor would give
107
+ // `[[a#b#c]]` two readings, and the split below takes the first `#` — so the
108
+ // write side removes the character the read side would trip over, exactly as
109
+ // `cleanWikiTarget` removes the `|` that would open a label.
110
+ //
111
+ // Otherwise it follows the TARGET, not the label: interior spaces stay, and
112
+ // newlines/tabs fold to a space. An anchor is matched against heading text
113
+ // (`toAnchorSlug` collapses the spaces at resolve time), so `#Open questions`
114
+ // must stay two words rather than becoming `Openquestions`.
115
+ export function cleanWikiAnchor(value) {
116
+ return value
117
+ .replace(/[[\]|#]/g, "")
118
+ .replace(/[\r\n\t]+/g, " ")
119
+ .trim();
120
+ }
121
+ /**
122
+ * Split a target-with-anchor into its two halves, honouring the prefix table.
123
+ *
124
+ * The `#` is only a separator after at least one character of the BARE id, and
125
+ * that is not a nicety — `[[c:#42]]` is the existing "card by number" form
126
+ * (`parseWikiTarget` yields `{kind:"card", id:"#42"}`). Splitting on the first
127
+ * `#` anywhere would turn it into a link to the prefix `c:` with an anchor of
128
+ * `42`, silently unlinking every task referenced by number. So: find the
129
+ * prefix, then look for a `#` past the first character after it.
130
+ *
131
+ * An empty anchor (`[[n:abc#]]`) is NOT an anchor. The trailing `#` stays part
132
+ * of the target, which resolves to nothing and renders as the muted
133
+ * unknown-entity text the grammar already gives any unresolvable target —
134
+ * open-knowledge's tokenizer takes the same position from the other side, by
135
+ * refusing to close a wiki link on a zero-length anchor at all.
136
+ */
137
+ export function splitWikiAnchor(target) {
138
+ const prefix = WIKI_LINK_PREFIXES.find(([p]) => target.startsWith(p))?.[0] ?? "";
139
+ const hash = target.indexOf("#", prefix.length + 1);
140
+ if (hash < 0)
141
+ return { target, anchor: null };
142
+ const anchor = target.slice(hash + 1).trim();
143
+ if (!anchor)
144
+ return { target, anchor: null };
145
+ return { target: target.slice(0, hash).trim(), anchor };
146
+ }
147
+ /**
148
+ * The BLOCK id an anchor addresses, or null when the anchor names a heading.
149
+ *
150
+ * One character decides it, and the split is done here rather than at each
151
+ * consumer so "does this token point at a block" has one answer. Three cases
152
+ * are deliberate:
153
+ *
154
+ * `^k7f3a2cx` → `"k7f3a2cx"`. The ordinal form of a duplicated block,
155
+ * `^k7f3a2cx.2`, comes back whole — the dot is part of the id
156
+ * (`fingerprintBlocks`), not a fourth separator.
157
+ * `Rollout` → null. A heading, and the reader slugs it as it always has.
158
+ * `^` → null. A marker with nothing after it addresses no block, the
159
+ * same position `splitWikiAnchor` takes on `[[n:abc#]]`: an
160
+ * empty half is not a half. It stays a (meaningless) heading
161
+ * anchor rather than becoming a block id of `""`, because an id
162
+ * nothing can resolve is worse than an anchor that resolves to
163
+ * no heading.
164
+ *
165
+ * The cost, stated: a heading whose text genuinely begins with `^` cannot be
166
+ * addressed as a heading. That is inherent in a one-character prefix and it is
167
+ * Obsidian's trade too; the alternative — a separate segment — would have every
168
+ * consumer of `anchor` learn a new shape to gain a case nobody writes.
169
+ */
170
+ export function blockAnchorId(anchor) {
171
+ if (!anchor || !anchor.startsWith(BLOCK_ANCHOR_MARKER))
172
+ return null;
173
+ return anchor.slice(BLOCK_ANCHOR_MARKER.length).trim() || null;
174
+ }
175
+ /** The anchor bytes that address `blockId` — the inverse of `blockAnchorId`. */
176
+ export function blockAnchor(blockId) {
177
+ return `${BLOCK_ANCHOR_MARKER}${blockId}`;
178
+ }
179
+ /** Re-join a target and anchor into the bytes that go between the brackets. */
180
+ export function joinWikiAnchor(target, anchor) {
181
+ return anchor ? `${target}#${anchor}` : target;
182
+ }
38
183
  // A fresh global matcher each call: a shared /g regex carries `lastIndex`
39
184
  // between callers and would silently skip tokens.
185
+ //
186
+ // This one matches the BRACKETS ONLY, bang excluded, and that is load-bearing
187
+ // rather than an oversight. `canonicalizeReferences` (convex/references.ts)
188
+ // rewrites the span this pattern reports, so a span that had swallowed the bang
189
+ // would rewrite an embed into a link on the first canonicalisation pass. Reach
190
+ // for `wikiTokenPattern()` when the bang is part of what you are reading.
40
191
  export function wikiLinkPattern() {
41
192
  return /\[\[([^\]]+)\]\]/g;
42
193
  }
43
- // Split a token body (the text between `[[` and `]]`) into target + label.
44
- export function parseWikiToken(raw) {
194
+ // Link and embed together: the marker in group 1, the token body in group 2.
195
+ // The `!` is optional, so every `[[…]]` this finds `wikiLinkPattern()` finds
196
+ // too — the two never disagree about where a token is, only about whether the
197
+ // bang belongs to it.
198
+ export function wikiTokenPattern() {
199
+ return /(!?)\[\[([^\]]+)\]\]/g;
200
+ }
201
+ // Split a token body (the text between `[[` and `]]`) into target + anchor +
202
+ // label. Order matters: the pipe is found first, so a `#` in a label is just a
203
+ // character; then the anchor is split off what is left.
204
+ //
205
+ // `embed` is passed IN rather than sniffed: the body between the brackets is
206
+ // identical either way, and the caller is the only one holding the bang.
207
+ export function parseWikiToken(raw, embed = false) {
45
208
  const pipe = raw.indexOf("|");
46
- const target = (pipe >= 0 ? raw.slice(0, pipe) : raw).trim();
209
+ const written = (pipe >= 0 ? raw.slice(0, pipe) : raw).trim();
47
210
  const explicit = pipe >= 0 ? raw.slice(pipe + 1).trim() : "";
211
+ const { target, anchor } = splitWikiAnchor(written);
48
212
  const { kind, id } = parseWikiTarget(target);
49
213
  return {
50
214
  kind,
51
215
  id,
52
216
  target,
217
+ anchor,
218
+ blockId: blockAnchorId(anchor),
219
+ // Still the TARGET, not the target-plus-anchor. A label-less
220
+ // `[[n:abc#Rollout]]` displays the doc's resolved title (or its id), the
221
+ // same as `[[n:abc]]` does — the anchor is a destination, not a name.
53
222
  label: explicit || target,
54
223
  hasLabel: explicit.length > 0,
224
+ embed,
55
225
  };
56
226
  }
57
227
  // Classify a target (prefix included) into its kind + bare identifier.
@@ -63,18 +233,34 @@ export function parseWikiTarget(target) {
63
233
  }
64
234
  return { kind: "post", id: target };
65
235
  }
66
- // Every `[[…]]` token in a body, in document order, not deduped.
236
+ // Every `[[…]]` and `![[…]]` token in a body, in document order, not deduped.
237
+ //
238
+ // Embeds are INCLUDED, each flagged by its own `embed`. Callers that build the
239
+ // links graph want them: `![[c:42]]` references the card exactly as `[[c:42]]`
240
+ // does, and dropping embeds here would unlink every entity a document shows
241
+ // instead of naming.
67
242
  export function parseWikiLinks(body) {
68
243
  const out = [];
69
- const re = wikiLinkPattern();
244
+ const re = wikiTokenPattern();
70
245
  let m;
71
- while ((m = re.exec(body)) !== null)
72
- out.push(parseWikiToken(m[1]));
246
+ while ((m = re.exec(body)) !== null) {
247
+ out.push(parseWikiToken(m[2], m[1] === WIKI_EMBED_MARKER));
248
+ }
73
249
  return out;
74
250
  }
75
- // Parse a string that must be EXACTLY one token and nothing else — the test
251
+ // Parse a string that must be EXACTLY one LINK and nothing else — the test
76
252
  // behind "this whole line is a single link, so unfurl it as a card".
253
+ //
254
+ // Strict about the bang on purpose: an embed on its own line is a different
255
+ // rendering decision (it shows the entity because the author asked, not because
256
+ // the line happened to hold one link), and the two callers of this in the
257
+ // reader would otherwise stop being able to tell them apart.
77
258
  export function matchWikiToken(text) {
78
259
  const m = /^\[\[([^\]]+)\]\]$/.exec(text.trim());
79
260
  return m ? parseWikiToken(m[1]) : null;
80
261
  }
262
+ // The same test for an embed: exactly `![[…]]`, nothing else on the line.
263
+ export function matchWikiEmbed(text) {
264
+ const m = /^!\[\[([^\]]+)\]\]$/.exec(text.trim());
265
+ return m ? parseWikiToken(m[1], true) : null;
266
+ }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,9 @@
8
8
  * server so agents operate sfora natively.
9
9
  */
10
10
  import { Bash, ReadWriteFs } from "just-bash";
11
+ import { SforaApiClient } from "./api-client.js";
11
12
  import { SforaFs } from "./SforaFs.js";
13
+ import { type PresenceNotice } from "./block-commands.js";
12
14
  export interface CreateSforaShellOptions {
13
15
  /** Base URL of the sfora deployment, e.g. `http://localhost:2222`. */
14
16
  baseUrl: string;
@@ -23,10 +25,27 @@ export interface CreateSforaShellOptions {
23
25
  cwd?: string;
24
26
  /** Act/post as an owned agent, using this key (sent as `X-Sfora-Act-As`). */
25
27
  actAs?: string;
28
+ /**
29
+ * The run's "you are visible" latch. Pass one when something OUTSIDE this
30
+ * shell can print the note too — the CLI does, after any line that wrote —
31
+ * so the two share a single "once". Omitted, the shell owns its own.
32
+ */
33
+ presence?: PresenceNotice;
26
34
  }
27
35
  export interface SforaShell {
28
36
  bash: Bash;
29
37
  fs: SforaFs;
38
+ /**
39
+ * The transport the shell and fs share.
40
+ *
41
+ * Exposed so the CLI can read what the LAST request reported about itself —
42
+ * the page a response named, the effect report a write returned — without
43
+ * making a second one. `IFileSystem` has nowhere to put either, and a
44
+ * separately-constructed client would be watching a different conversation.
45
+ */
46
+ client: SforaApiClient;
47
+ /** The latch the shell's commands announce presence through. */
48
+ presence: PresenceNotice;
30
49
  }
31
50
  export declare function createSforaShell(options: CreateSforaShellOptions): SforaShell;
32
51
  export interface LocalShell {
@@ -43,4 +62,10 @@ export declare function createLocalShell(root: string, cwd?: string): LocalShell
43
62
  export { SforaFs } from "./SforaFs.js";
44
63
  export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, type TaskEntry, type TaskWriteResult, } from "./local/workspace.js";
45
64
  export * from "./format/index.js";
46
- export { SforaApiClient, SforaApiError, type SforaApiConfig, type Project, type Entry, type PostKind, type WriteResult, } from "./api-client.js";
65
+ export { SforaApiClient, SforaApiError, blockConflictFrom, writeEffectFrom, type SforaApiConfig, type Project, type Entry, type PostKind, type WriteResult, type WriteOptions, type WriteEffect, type RebindCounts, type ResponseInfo, type BlockView, type BlocksView, type BlockConflict, type BlockSummary, type AgentEvent, type AgentEventsPage, type DocPresence, type DocPresenceMember, } from "./api-client.js";
66
+ export { WEB_URL_HEADER, fsRequestPath, webUrlFromResponse, } from "./web-url.js";
67
+ export { openerCommand, type OpenerCommand } from "./opener.js";
68
+ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, type CommandOutput, type PresenceNotice, } from "./block-commands.js";
69
+ export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
70
+ export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, type WatchDeps, type WatchOptions, type WatchTarget, } from "./watch.js";
71
+ export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, type DocPing, } from "./render.js";
package/dist/index.js CHANGED
@@ -10,20 +10,31 @@
10
10
  import { Bash, ReadWriteFs } from "just-bash";
11
11
  import { SforaApiClient } from "./api-client.js";
12
12
  import { SforaFs } from "./SforaFs.js";
13
+ import { sforaShellCommands } from "./shell-commands.js";
14
+ import { presenceNotice } from "./block-commands.js";
13
15
  export function createSforaShell(options) {
14
16
  const client = new SforaApiClient({
15
17
  baseUrl: options.baseUrl,
16
18
  apiKey: options.apiKey,
17
19
  actAs: options.actAs,
18
20
  });
21
+ const presence = options.presence ?? presenceNotice();
19
22
  const fs = new SforaFs(client);
20
23
  // Disable just-bash's in-process defense-in-depth sandbox. It defaults on and
21
24
  // hardens against *untrusted* scripts by blocking globals like `WeakRef` —
22
25
  // which undici's `fetch` uses internally, so it would break every network-backed
23
26
  // fs call. sfora runs the user's / agent's own commands against their own
24
27
  // workspace over HTTPS, so this hardening is unnecessary here.
25
- const bash = new Bash({ fs, cwd: options.cwd ?? "/", defenseInDepth: false });
26
- return { bash, fs };
28
+ const bash = new Bash({
29
+ fs,
30
+ cwd: options.cwd ?? "/",
31
+ defenseInDepth: false,
32
+ // sfora's own verbs, inside the shell (card #333). They are registered
33
+ // rather than special-cased in the REPL so they compose like every other
34
+ // command: `blocks x.md | grep heading`, `sed … | put x.md --block <id>`.
35
+ customCommands: sforaShellCommands(client, presence),
36
+ });
37
+ return { bash, fs, client, presence };
27
38
  }
28
39
  /**
29
40
  * Shell over a local `.sfora/` workspace — the OSS serverless mode. The same
@@ -39,4 +50,10 @@ export function createLocalShell(root, cwd = "/") {
39
50
  export { SforaFs } from "./SforaFs.js";
40
51
  export { LocalWorkspace, initWorkspace, findWorkspace, WORKSPACE_DIR, } from "./local/workspace.js";
41
52
  export * from "./format/index.js";
42
- export { SforaApiClient, SforaApiError, } from "./api-client.js";
53
+ export { SforaApiClient, SforaApiError, blockConflictFrom, writeEffectFrom, } from "./api-client.js";
54
+ export { WEB_URL_HEADER, fsRequestPath, webUrlFromResponse, } from "./web-url.js";
55
+ export { openerCommand } from "./opener.js";
56
+ export { blocksCommand, putCommand, urlCommand, resolveFsPath, presenceNotice, } from "./block-commands.js";
57
+ export { sforaShellCommands, parseShellArgs } from "./shell-commands.js";
58
+ export { watchLoop, parseWatchTarget, MAX_BACKOFF_MS, } from "./watch.js";
59
+ export { renderPing, renderBlocks, renderBlockConflict, renderWriteEffect, ndjson, } from "./render.js";
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Handing a URL to the desktop — the one command per platform, as data.
3
+ *
4
+ * Split out from the spawn so it can be tested: what a test can meaningfully
5
+ * assert about `sfora open` is that it builds the RIGHT command for the
6
+ * platform, not that a browser appeared. `openerCommand` is pure, so the three
7
+ * platforms are three assertions; `openUrl` is the four-line side effect that
8
+ * cannot be, and holds nothing worth testing.
9
+ */
10
+ export interface OpenerCommand {
11
+ command: string;
12
+ args: string[];
13
+ }
14
+ /**
15
+ * The opener for a platform, as `NodeJS.Process["platform"]` spells it.
16
+ *
17
+ * Windows is the case worth stating: `start` is not an executable, it is a
18
+ * `cmd.exe` builtin, so spawning it directly fails with ENOENT. It has to be
19
+ * run THROUGH cmd — and the empty string after `start` is not padding: `start`
20
+ * reads its first quoted argument as the new window's TITLE, so a URL in quotes
21
+ * would open a console window titled with the link instead of the link.
22
+ */
23
+ export declare function openerCommand(platform: NodeJS.Platform | string, url: string): OpenerCommand;
package/dist/opener.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Handing a URL to the desktop — the one command per platform, as data.
3
+ *
4
+ * Split out from the spawn so it can be tested: what a test can meaningfully
5
+ * assert about `sfora open` is that it builds the RIGHT command for the
6
+ * platform, not that a browser appeared. `openerCommand` is pure, so the three
7
+ * platforms are three assertions; `openUrl` is the four-line side effect that
8
+ * cannot be, and holds nothing worth testing.
9
+ */
10
+ /**
11
+ * The opener for a platform, as `NodeJS.Process["platform"]` spells it.
12
+ *
13
+ * Windows is the case worth stating: `start` is not an executable, it is a
14
+ * `cmd.exe` builtin, so spawning it directly fails with ENOENT. It has to be
15
+ * run THROUGH cmd — and the empty string after `start` is not padding: `start`
16
+ * reads its first quoted argument as the new window's TITLE, so a URL in quotes
17
+ * would open a console window titled with the link instead of the link.
18
+ */
19
+ export function openerCommand(platform, url) {
20
+ if (platform === "darwin")
21
+ return { command: "open", args: [url] };
22
+ if (platform === "win32") {
23
+ return { command: "cmd", args: ["/c", "start", "", url] };
24
+ }
25
+ return { command: "xdg-open", args: [url] };
26
+ }