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.
- package/README.md +174 -0
- package/dist/SforaFs.js +8 -6
- package/dist/api-client.d.ts +344 -4
- package/dist/api-client.js +289 -21
- package/dist/block-commands.d.ts +84 -0
- package/dist/block-commands.js +155 -0
- package/dist/chat.d.ts +89 -0
- package/dist/chat.js +189 -0
- package/dist/cli-args.d.ts +32 -0
- package/dist/cli-args.js +88 -0
- package/dist/cli.js +530 -88
- package/dist/format/blockSplice.d.ts +135 -0
- package/dist/format/blockSplice.js +330 -0
- package/dist/format/blocks/dropClosure.d.ts +10 -1
- package/dist/format/blocks/dropClosure.js +11 -1
- package/dist/format/callout.d.ts +69 -7
- package/dist/format/callout.js +112 -15
- package/dist/format/checklist.js +11 -4
- package/dist/format/formatAxes.d.ts +228 -0
- package/dist/format/formatAxes.js +454 -0
- package/dist/format/index.d.ts +1 -0
- package/dist/format/index.js +4 -0
- package/dist/format/lineGeometry.d.ts +34 -4
- package/dist/format/lineGeometry.js +140 -40
- 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 +34 -5
- package/dist/format/lint/index.js +34 -5
- package/dist/format/lint/lintSource.d.ts +27 -7
- package/dist/format/lint/lintSource.js +67 -33
- 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 +2 -1
- package/dist/format/lint/rules/index.js +7 -1
- package/dist/format/lint/rules/malformed-callout.js +25 -16
- package/dist/format/lint/rules/malformed-checklist.js +8 -3
- package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
- 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 +44 -8
- package/dist/format/markdown/slug.d.ts +28 -0
- package/dist/format/markdown/slug.js +63 -0
- package/dist/format/plaintext.js +13 -3
- 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/textStats.d.ts +23 -0
- package/dist/format/textStats.js +80 -0
- package/dist/format/wikiLinks.d.ts +60 -1
- package/dist/format/wikiLinks.js +195 -9
- package/dist/index.d.ts +26 -1
- package/dist/index.js +20 -3
- package/dist/opener.d.ts +23 -0
- package/dist/opener.js +26 -0
- package/dist/render.d.ts +162 -0
- package/dist/render.js +280 -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 +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
|
|
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;
|
package/dist/format/wikiLinks.js
CHANGED
|
@@ -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
|
-
//
|
|
44
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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({
|
|
26
|
-
|
|
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";
|
package/dist/opener.d.ts
ADDED
|
@@ -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
|
+
}
|