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,162 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// The edit mechanics every quick fix rides on.
|
|
4
|
+
//
|
|
5
|
+
// A fix is a LIST of replacements over ONE document state, not a sequence of
|
|
6
|
+
// steps. That distinction is the whole of this file. Each edit's offsets are
|
|
7
|
+
// absolute into the PRE-fix source, so applying them in document order is
|
|
8
|
+
// wrong by construction: the first edit moves every offset after it, and the
|
|
9
|
+
// second one then cuts the wrong bytes out of a string that no longer matches
|
|
10
|
+
// the one the rule read. Applying them last-one-first is the fix — an edit at a
|
|
11
|
+
// LOWER offset cannot disturb an edit at a higher one that has already landed.
|
|
12
|
+
//
|
|
13
|
+
// (`applyLintFix` in lintSource.ts has always reverse-sorted. What it did not
|
|
14
|
+
// do was check that the edits could compose at all: it skipped an
|
|
15
|
+
// out-of-range edit and applied the rest, which is a HALF-applied fix — the
|
|
16
|
+
// one outcome worse than no fix, because the author accepted a repair and got
|
|
17
|
+
// a different broken document. Everything here exists so the answer is "all of
|
|
18
|
+
// it or none of it".)
|
|
19
|
+
//
|
|
20
|
+
// Two edits that overlap have no correct order in either direction, so they
|
|
21
|
+
// are rejected rather than resolved: whichever one is applied second is
|
|
22
|
+
// reading bytes the first one already replaced. That is the shape of the guard
|
|
23
|
+
// `fixAll` needs when it composes fixes from different rules over one
|
|
24
|
+
// document.
|
|
25
|
+
//
|
|
26
|
+
// Offsets, not `{line, character}`. LSP's `TextEdit` carries positions because
|
|
27
|
+
// its wire protocol has no shared buffer; here the source string is right
|
|
28
|
+
// there, CodeMirror's `changes` spec wants offsets, and a line/character pair
|
|
29
|
+
// would be converted twice for no one's benefit. The SHAPE is LSP's — a fix is
|
|
30
|
+
// a list of ranged replacements applied reverse-sorted — and `lintEditToLsp`
|
|
31
|
+
// below converts on the way out for anything that wants the wire form.
|
|
32
|
+
/**
|
|
33
|
+
* True when two edits cannot both be applied to the same document state.
|
|
34
|
+
*
|
|
35
|
+
* Proper overlap is the obvious case. The second clause is the one that bites:
|
|
36
|
+
* two pure INSERTIONS at the same offset do not overlap by any interval test,
|
|
37
|
+
* yet the text they produce depends entirely on which goes first, and a fix
|
|
38
|
+
* whose output depends on sort stability is not a fix. Adjacency
|
|
39
|
+
* (`a.to === b.from`) is fine and stays allowed — that is two rules repairing
|
|
40
|
+
* neighbouring spans, which is exactly what fix-all is for.
|
|
41
|
+
*/
|
|
42
|
+
export function editsConflict(a, b) {
|
|
43
|
+
if (a.from < b.to && b.from < a.to)
|
|
44
|
+
return true;
|
|
45
|
+
return a.from === a.to && b.from === b.to && a.from === b.from;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The application order. Descending by start, then by end, so an edit that
|
|
49
|
+
* starts later lands first and leaves every earlier offset untouched.
|
|
50
|
+
*
|
|
51
|
+
* This function is the reason the offsets in a fix mean anything. Sorting it
|
|
52
|
+
* the other way still "works" for a single-edit fix — which is most of them,
|
|
53
|
+
* which is how a bug here would hide — and quietly corrupts every multi-edit
|
|
54
|
+
* one.
|
|
55
|
+
*/
|
|
56
|
+
export function sortEditsForApply(edits) {
|
|
57
|
+
return [...edits].sort((a, b) => b.from - a.from || b.to - a.to);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Can these edits compose over `source`? Returns the first reason they cannot,
|
|
61
|
+
* or null. Checked against the source the edits were DERIVED from — an edit
|
|
62
|
+
* checked against a document that has moved on is meaningless.
|
|
63
|
+
*/
|
|
64
|
+
export function checkEdits(source, edits) {
|
|
65
|
+
for (const edit of edits) {
|
|
66
|
+
if (edit.from > edit.to)
|
|
67
|
+
return { reason: "inverted", edit };
|
|
68
|
+
if (edit.from < 0 || edit.to > source.length) {
|
|
69
|
+
return { reason: "out-of-range", edit };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// O(n²) over the edits of one fix, which is at most a handful. `fixAll`
|
|
73
|
+
// pays the same price across a document's worth of fixes and it is still
|
|
74
|
+
// nothing next to the parse that produced them.
|
|
75
|
+
for (let i = 0; i < edits.length; i++) {
|
|
76
|
+
for (let j = i + 1; j < edits.length; j++) {
|
|
77
|
+
const a = edits[i];
|
|
78
|
+
const b = edits[j];
|
|
79
|
+
if (editsConflict(a, b)) {
|
|
80
|
+
return { reason: "overlap", edit: b, conflictsWith: a };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Apply `edits` to `source` as one atomic change, or say why not.
|
|
88
|
+
*
|
|
89
|
+
* Never throws and never half-applies: on any rejection the caller gets the
|
|
90
|
+
* reason and the original text is untouched. Lint is a background nicety in a
|
|
91
|
+
* text editor; it is never allowed to be the reason a document is damaged.
|
|
92
|
+
*/
|
|
93
|
+
export function tryApplyEdits(source, edits) {
|
|
94
|
+
const rejection = checkEdits(source, edits);
|
|
95
|
+
if (rejection)
|
|
96
|
+
return { ok: false, rejection };
|
|
97
|
+
let out = source;
|
|
98
|
+
for (const edit of sortEditsForApply(edits)) {
|
|
99
|
+
out = out.slice(0, edit.from) + edit.insert + out.slice(edit.to);
|
|
100
|
+
}
|
|
101
|
+
return { ok: true, text: out };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* `tryApplyEdits` for callers that have already validated, or that would
|
|
105
|
+
* rather lose the fix than reason about a rejection. Returns `source`
|
|
106
|
+
* unchanged when the edits do not compose.
|
|
107
|
+
*/
|
|
108
|
+
export function applyEdits(source, edits) {
|
|
109
|
+
const result = tryApplyEdits(source, edits);
|
|
110
|
+
return result.ok ? result.text : source;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* True when every edit still describes the bytes it was computed against.
|
|
114
|
+
*
|
|
115
|
+
* The staleness question, factored out of the editor adapter. A fix's offsets
|
|
116
|
+
* point into the source a rule read; applying them to a document that has been
|
|
117
|
+
* typed into since is not a repair, it is corruption at a plausible-looking
|
|
118
|
+
* offset. Both the CodeMirror action and the batch path ask this before they
|
|
119
|
+
* commit.
|
|
120
|
+
*/
|
|
121
|
+
export function editsMatch(source, edits, expected) {
|
|
122
|
+
if (checkEdits(expected, edits) !== null)
|
|
123
|
+
return false;
|
|
124
|
+
return edits.every((edit) => edit.to <= source.length &&
|
|
125
|
+
source.slice(edit.from, edit.to) === expected.slice(edit.from, edit.to));
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Offsets → line/character, for one document. Built once per conversion so a
|
|
129
|
+
* fix with several edits does not rescan the source per edit.
|
|
130
|
+
*/
|
|
131
|
+
function lineStartsOf(source) {
|
|
132
|
+
const starts = [0];
|
|
133
|
+
for (let i = 0; i < source.length; i++) {
|
|
134
|
+
if (source[i] === "\n")
|
|
135
|
+
starts.push(i + 1);
|
|
136
|
+
}
|
|
137
|
+
return starts;
|
|
138
|
+
}
|
|
139
|
+
function positionAt(lineStarts, offset) {
|
|
140
|
+
// Binary search for the last line start at or before `offset`.
|
|
141
|
+
let lo = 0;
|
|
142
|
+
let hi = lineStarts.length - 1;
|
|
143
|
+
while (lo < hi) {
|
|
144
|
+
const mid = (lo + hi + 1) >> 1;
|
|
145
|
+
if (lineStarts[mid] <= offset)
|
|
146
|
+
lo = mid;
|
|
147
|
+
else
|
|
148
|
+
hi = mid - 1;
|
|
149
|
+
}
|
|
150
|
+
return { line: lo, character: offset - lineStarts[lo] };
|
|
151
|
+
}
|
|
152
|
+
/** One fix's edits in LSP wire form, in the order LSP wants them (any). */
|
|
153
|
+
export function fixToTextEdits(source, fix) {
|
|
154
|
+
const lineStarts = lineStartsOf(source);
|
|
155
|
+
return fix.edits.map((edit) => ({
|
|
156
|
+
range: {
|
|
157
|
+
start: positionAt(lineStarts, edit.from),
|
|
158
|
+
end: positionAt(lineStarts, edit.to),
|
|
159
|
+
},
|
|
160
|
+
newText: edit.insert,
|
|
161
|
+
}));
|
|
162
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Root } from "mdast";
|
|
2
|
+
import type { LintConfig } from "./config.js";
|
|
3
|
+
import type { SelectedFrontmatterSchema } from "./frontmatterSchema.js";
|
|
4
|
+
import type { LintSeverity } from "./severity.js";
|
|
5
|
+
export type { LintSeverity };
|
|
6
|
+
/** A replacement over the PRE-fix document. Offsets are absolute. */
|
|
7
|
+
export interface LintEdit {
|
|
8
|
+
from: number;
|
|
9
|
+
to: number;
|
|
10
|
+
insert: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* One quick fix, LSP-shaped: a LIST of ranged replacements over ONE document
|
|
14
|
+
* state, not a script of steps. All the edits are computed against the same
|
|
15
|
+
* pre-fix source, so they compose atomically and must not overlap — see
|
|
16
|
+
* ./textEdits, which owns the ordering, the overlap check and the application.
|
|
17
|
+
*
|
|
18
|
+
* A fix with several edits is the normal case, not an exotic one: repairing a
|
|
19
|
+
* fenced block's opening and closing delimiter together, or removing a dead
|
|
20
|
+
* frontmatter line while rewriting the live one, is two edits far apart in the
|
|
21
|
+
* file that only mean anything if they land as a unit.
|
|
22
|
+
*/
|
|
23
|
+
export interface LintFix {
|
|
24
|
+
label: string;
|
|
25
|
+
edits: LintEdit[];
|
|
26
|
+
}
|
|
27
|
+
export interface SforaDiagnostic {
|
|
28
|
+
from: number;
|
|
29
|
+
to: number;
|
|
30
|
+
severity: LintSeverity;
|
|
31
|
+
/** Namespaced, e.g. `sfora/broken-wiki-link`. Stable — the UI keys off it. */
|
|
32
|
+
ruleId: string;
|
|
33
|
+
message: string;
|
|
34
|
+
fixes?: LintFix[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* What a link resolver tells us about a target. Structurally the same shape as
|
|
38
|
+
* the editor's `WikiLinkResolution`, restated here so the core does not import
|
|
39
|
+
* from the app.
|
|
40
|
+
*/
|
|
41
|
+
export interface LintLinkResolution {
|
|
42
|
+
exists: boolean;
|
|
43
|
+
title?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface LintContext {
|
|
46
|
+
source: string;
|
|
47
|
+
/** `source` split on newlines. A trailing `\r` is left on the line. */
|
|
48
|
+
lines: string[];
|
|
49
|
+
/** Absolute offset of the first character of line `i`. */
|
|
50
|
+
lineStart: (i: number) => number;
|
|
51
|
+
/**
|
|
52
|
+
* True for a fenced code block's delimiters and everything between them.
|
|
53
|
+
* Rules never fire inside a fence: the bytes there are not markdown.
|
|
54
|
+
*/
|
|
55
|
+
inFence: (lineIndex: number) => boolean;
|
|
56
|
+
/**
|
|
57
|
+
* True for a line that holds bytes, none of which render — inside an HTML
|
|
58
|
+
* comment, or wholly inside an inline code span. The wider judgement behind
|
|
59
|
+
* `inFence`, from the same shared mask every other scanner in the package
|
|
60
|
+
* reads, so a rule cannot decide that a line is markdown when the reader has
|
|
61
|
+
* already decided it is not.
|
|
62
|
+
*/
|
|
63
|
+
nonRendering: (lineIndex: number) => boolean;
|
|
64
|
+
/** True for the frontmatter fences and everything between them. */
|
|
65
|
+
inFrontmatter: (lineIndex: number) => boolean;
|
|
66
|
+
/** Line extents of the frontmatter block, or null when there is none. */
|
|
67
|
+
frontmatter: {
|
|
68
|
+
open: number;
|
|
69
|
+
close: number;
|
|
70
|
+
} | null;
|
|
71
|
+
/**
|
|
72
|
+
* The frontmatter schemas the workspace declared for THIS document's path,
|
|
73
|
+
* scope already applied. Absent or empty on every document until a workspace
|
|
74
|
+
* writes one down — see ./frontmatterSchema, which owns the selection.
|
|
75
|
+
*
|
|
76
|
+
* Selected here rather than in the rule for the same reason the fence mask
|
|
77
|
+
* is: the runner knows the document's path and the rule does not, and a rule
|
|
78
|
+
* that re-derived the scope would be a second place for the two to disagree.
|
|
79
|
+
*/
|
|
80
|
+
frontmatterSchemas?: readonly SelectedFrontmatterSchema[];
|
|
81
|
+
/** Present only when a parser was injected AND it succeeded. */
|
|
82
|
+
ast?: Root;
|
|
83
|
+
/**
|
|
84
|
+
* SYNCHRONOUS cache lookup over the target as written, prefix included
|
|
85
|
+
* (`c:42`, not `42`). `undefined` means "unknown, still resolving" and is
|
|
86
|
+
* the signal to stay silent — only an explicit `{ exists: false }` is a
|
|
87
|
+
* broken link.
|
|
88
|
+
*/
|
|
89
|
+
resolveLink?: (target: string) => LintLinkResolution | undefined;
|
|
90
|
+
}
|
|
91
|
+
export interface LintRule {
|
|
92
|
+
id: string;
|
|
93
|
+
/** When true the rule is skipped unless `ctx.ast` is present. */
|
|
94
|
+
needsAst?: boolean;
|
|
95
|
+
run(ctx: LintContext): SforaDiagnostic[];
|
|
96
|
+
}
|
|
97
|
+
export interface LintSourceOptions {
|
|
98
|
+
/** Defaults to the full registry, `SFORA_LINT_RULES`. */
|
|
99
|
+
rules?: readonly LintRule[];
|
|
100
|
+
/** Injected mdast parser — `parseMarkdownAst(source).root`. */
|
|
101
|
+
parseAst?: (source: string) => Root;
|
|
102
|
+
resolveLink?: LintContext["resolveLink"];
|
|
103
|
+
/**
|
|
104
|
+
* What the workspace says about these rules: which are off, which report at
|
|
105
|
+
* another level, which are scoped to a set of paths. Absent means every rule
|
|
106
|
+
* runs at the severity it declares, which is what every caller got before
|
|
107
|
+
* the config existed.
|
|
108
|
+
*/
|
|
109
|
+
config?: LintConfig;
|
|
110
|
+
/**
|
|
111
|
+
* The document's path, for `appliesTo` scoping —
|
|
112
|
+
* `projects/sfora/docs/plan.md`. A scoped rule runs anyway when this is
|
|
113
|
+
* missing; see `scopeAdmits` for why silence is the worse failure.
|
|
114
|
+
*/
|
|
115
|
+
path?: string;
|
|
116
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
3
|
+
// The contracts sfora-law rules are written against.
|
|
4
|
+
//
|
|
5
|
+
// A rule is a pure function from a document to spans-with-messages. It gets a
|
|
6
|
+
// prepared view of the source — lines, line starts, where the code fences and
|
|
7
|
+
// the frontmatter block are — so no rule re-derives that geometry and no two
|
|
8
|
+
// rules disagree about whether a line is inside a fence.
|
|
9
|
+
//
|
|
10
|
+
// Two things are deliberately absent. There is no CodeMirror here: diagnostics
|
|
11
|
+
// carry absolute offsets, and the editor adapter
|
|
12
|
+
// (src/components/notes/cm-lint.ts) translates. And there is no parser here:
|
|
13
|
+
// the mdast tree is INJECTED by the caller (`parseAst` in LintSourceOptions),
|
|
14
|
+
// so the lint core stays dependency-free and ships in the CLI tarball with the
|
|
15
|
+
// rest of the engine. `import type { Root }` is erased at compile time.
|
|
16
|
+
export {};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Date <-> frontmatter helpers. Timestamps are ms-epoch internally; on the wire
|
|
2
4
|
// they're ISO-8601 (or date-only for coarse fields like a card due date).
|
|
3
5
|
export function toISO(ms) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// The Jekyll-style document shape shared by every entity: an optional YAML
|
|
2
4
|
// frontmatter fence, an H1 title (first non-empty line), then the body.
|
|
3
5
|
import { parseYaml } from "./yaml.js";
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Shared markdown core for the agent filesystem API (/v1/fs). One battle-tested
|
|
2
4
|
// implementation of frontmatter/YAML, mention rendering, slugs, dates, and the
|
|
3
5
|
// document (frontmatter + H1 + body) shape — consumed by postMarkdown,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Canonical mention syntax shared by messages / posts / cards / notes:
|
|
2
4
|
// @[Display Name](memberId)
|
|
3
5
|
// On the wire we render that to a human-readable @Name and keep the display
|
|
@@ -1,2 +1,30 @@
|
|
|
1
1
|
export declare function slugify(title: string): string;
|
|
2
|
+
/**
|
|
3
|
+
* The slug of one heading's text, and the anchor half of `[[target#anchor]]`.
|
|
4
|
+
*
|
|
5
|
+
* THE WHOLE POINT is that one function serves both sides: the read path stamps
|
|
6
|
+
* `id={toAnchorSlug(headingText)}` on the rendered heading, and a wiki link's
|
|
7
|
+
* anchor resolves through `toAnchorSlug(anchor)`. Two spellings of this rule
|
|
8
|
+
* would mean `[[page#my-heading]]` scrolls to nothing, and nothing would say
|
|
9
|
+
* so — which is why `anchorSlugAgreement` in the tests mutates one caller and
|
|
10
|
+
* asserts the other fails.
|
|
11
|
+
*
|
|
12
|
+
* Returns "" for text with no letters or numbers in it at all. That is not a
|
|
13
|
+
* fallback: a heading of "***" has no name to be linked by, and inventing one
|
|
14
|
+
* ("untitled") would hand two such headings the same anchor.
|
|
15
|
+
*/
|
|
16
|
+
export declare function toAnchorSlug(text: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Suffix a repeated slug so every id in one document is unique: the first
|
|
19
|
+
* `## Notes` is `notes`, the second `notes-1`, the third `notes-2`.
|
|
20
|
+
*
|
|
21
|
+
* `counts` is the caller's ledger for ONE document, mutated in place — pass a
|
|
22
|
+
* fresh `Map` per render. The suffix is a positional fact, so a link written
|
|
23
|
+
* as `[[page#notes-1]]` means "the second heading called Notes" and moves when
|
|
24
|
+
* a third one is inserted above it. That is the same bargain every heading-
|
|
25
|
+
* anchor scheme makes; there is no stable name for a name used twice.
|
|
26
|
+
*/
|
|
27
|
+
export declare function disambiguateSlug(baseSlug: string, counts: Map<string, number>): string;
|
|
28
|
+
/** `toAnchorSlug` + dedupe. The id a rendered heading carries. */
|
|
29
|
+
export declare function headingSlug(text: string, counts: Map<string, number>): string;
|
|
2
30
|
export declare function slugFromFilename(filename: string): string;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Slug + filename helpers shared by the markdown serializers.
|
|
2
4
|
// kebab-case a title: lowercase, strip accents, collapse non-alphanumerics to
|
|
3
5
|
// single hyphens, trim. Empty titles fall back to "untitled". Dedupe of
|
|
@@ -12,6 +14,69 @@ export function slugify(title) {
|
|
|
12
14
|
.replace(/^-+|-+$/g, "");
|
|
13
15
|
return s || "untitled";
|
|
14
16
|
}
|
|
17
|
+
// ─── heading anchors ──────────────────────────────────────────────────
|
|
18
|
+
//
|
|
19
|
+
// A DIFFERENT slug from `slugify` above, and the difference is the point.
|
|
20
|
+
// `slugify` names FILES, so it is ASCII-only on purpose: a filename crosses a
|
|
21
|
+
// filesystem, a URL path, and a tarball, and `café.md` is three different byte
|
|
22
|
+
// strings depending on which one wrote it. A heading anchor never leaves the
|
|
23
|
+
// document — it is an `id` on an `<h2>` and a `#fragment` pointing at it — so
|
|
24
|
+
// throwing away every non-ASCII letter there would collapse `## Café` and
|
|
25
|
+
// `## 咖啡` to the same empty slug and make both unlinkable.
|
|
26
|
+
//
|
|
27
|
+
// The two must not be merged. Widening `slugify` to Unicode would rename every
|
|
28
|
+
// file the fs API has ever written.
|
|
29
|
+
//
|
|
30
|
+
// Rules follow open-knowledge's `toWikiLinkSlug`
|
|
31
|
+
// (context/open-knowledge/packages/core/src/utils/slug.ts): trim, NFKD, drop
|
|
32
|
+
// combining marks, lowercase, any run of non-letter/non-number to one hyphen,
|
|
33
|
+
// strip edge hyphens.
|
|
34
|
+
const COMBINING_MARK_RE = /\p{M}+/gu;
|
|
35
|
+
const NON_LETTER_OR_NUMBER_RE = /[^\p{L}\p{N}]+/gu;
|
|
36
|
+
const EDGE_HYPHENS_RE = /^-+|-+$/g;
|
|
37
|
+
/**
|
|
38
|
+
* The slug of one heading's text, and the anchor half of `[[target#anchor]]`.
|
|
39
|
+
*
|
|
40
|
+
* THE WHOLE POINT is that one function serves both sides: the read path stamps
|
|
41
|
+
* `id={toAnchorSlug(headingText)}` on the rendered heading, and a wiki link's
|
|
42
|
+
* anchor resolves through `toAnchorSlug(anchor)`. Two spellings of this rule
|
|
43
|
+
* would mean `[[page#my-heading]]` scrolls to nothing, and nothing would say
|
|
44
|
+
* so — which is why `anchorSlugAgreement` in the tests mutates one caller and
|
|
45
|
+
* asserts the other fails.
|
|
46
|
+
*
|
|
47
|
+
* Returns "" for text with no letters or numbers in it at all. That is not a
|
|
48
|
+
* fallback: a heading of "***" has no name to be linked by, and inventing one
|
|
49
|
+
* ("untitled") would hand two such headings the same anchor.
|
|
50
|
+
*/
|
|
51
|
+
export function toAnchorSlug(text) {
|
|
52
|
+
return text
|
|
53
|
+
.trim()
|
|
54
|
+
.normalize("NFKD")
|
|
55
|
+
.replace(COMBINING_MARK_RE, "")
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(NON_LETTER_OR_NUMBER_RE, "-")
|
|
58
|
+
.replace(EDGE_HYPHENS_RE, "");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Suffix a repeated slug so every id in one document is unique: the first
|
|
62
|
+
* `## Notes` is `notes`, the second `notes-1`, the third `notes-2`.
|
|
63
|
+
*
|
|
64
|
+
* `counts` is the caller's ledger for ONE document, mutated in place — pass a
|
|
65
|
+
* fresh `Map` per render. The suffix is a positional fact, so a link written
|
|
66
|
+
* as `[[page#notes-1]]` means "the second heading called Notes" and moves when
|
|
67
|
+
* a third one is inserted above it. That is the same bargain every heading-
|
|
68
|
+
* anchor scheme makes; there is no stable name for a name used twice.
|
|
69
|
+
*/
|
|
70
|
+
export function disambiguateSlug(baseSlug, counts) {
|
|
71
|
+
const count = counts.get(baseSlug) ?? 0;
|
|
72
|
+
counts.set(baseSlug, count + 1);
|
|
73
|
+
return count === 0 ? baseSlug : `${baseSlug}-${count}`;
|
|
74
|
+
}
|
|
75
|
+
/** `toAnchorSlug` + dedupe. The id a rendered heading carries. */
|
|
76
|
+
export function headingSlug(text, counts) {
|
|
77
|
+
const base = toAnchorSlug(text);
|
|
78
|
+
return base ? disambiguateSlug(base, counts) : "";
|
|
79
|
+
}
|
|
15
80
|
// Pull the slug back out of a filename: drop the .md and an optional leading
|
|
16
81
|
// YYYY-MM-DD- date prefix. Used to match a requested filename to an entity.
|
|
17
82
|
export function slugFromFilename(filename) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Tiny YAML for markdown frontmatter: scalars + flat string arrays only.
|
|
2
4
|
// Shared by the post / card / note serializers (see ./index). No Convex imports
|
|
3
5
|
// — pure string transforms, safe to import from httpActions, internal Convex
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Pure markdown <-> note (doc) serialization for the agent filesystem API
|
|
2
4
|
// (/v1/fs). Notes are Notion-style single-author docs surfaced under the in-app
|
|
3
5
|
// "Docs" tab; over the FS API they're stable named files `<slug>.md` (no date
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ParsedDocument } from "./markdown/document.js";
|
|
2
|
+
export declare const MAX_PARSE_INPUT_BYTES = 4000000;
|
|
3
|
+
export declare const MAX_FRONTMATTER_SCAN_BYTES = 64000;
|
|
4
|
+
export declare const MAX_PARSE_WALLCLOCK_MS = 250;
|
|
5
|
+
export type ParseFallbackReason = "input-too-large" | "unterminated-frontmatter" | "parse-threw" | "budget-exceeded";
|
|
6
|
+
export interface ParseFallbackIssue {
|
|
7
|
+
reason: ParseFallbackReason;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FallbackParsedDocument extends ParsedDocument {
|
|
11
|
+
errors: ParseFallbackIssue[];
|
|
12
|
+
}
|
|
13
|
+
export declare function parseDocumentWithFallback(source: string): FallbackParsedDocument;
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
// A parseDocument that cannot throw and cannot run unbounded.
|
|
4
|
+
//
|
|
5
|
+
// Every byte that reaches the document parser is hostile until proven otherwise:
|
|
6
|
+
// agents PUT files over /v1/fs, humans paste whatever their clipboard held, and
|
|
7
|
+
// the local `.sfora/` workspace is an ordinary directory anyone can write to. A
|
|
8
|
+
// throw there is not a parse failure, it is a 500 on a file the user can still
|
|
9
|
+
// see on disk. So the contract here is: always return a ParsedDocument, and say
|
|
10
|
+
// what was given up.
|
|
11
|
+
//
|
|
12
|
+
// Adapted from inkeep/open-knowledge's `parseWithFallback` (see
|
|
13
|
+
// docs/research/open-knowledge-engine.md §5.3). Theirs recurses to isolate the
|
|
14
|
+
// offending block because their parser is a 26-plugin mdast pipeline; ours is a
|
|
15
|
+
// frontmatter fence plus an H1 scan, so there is no sub-document to isolate and
|
|
16
|
+
// the only sensible degradation is the whole document as body text. What we keep
|
|
17
|
+
// is the shape: bounded work, a typed reason per degradation, never a throw.
|
|
18
|
+
import { parseDocument } from "./markdown/document.js";
|
|
19
|
+
// Above this, we do not parse at all. 4 MB is ~40x the largest document the fs
|
|
20
|
+
// API will accept today; anything past it is a paste accident or an attack.
|
|
21
|
+
export const MAX_PARSE_INPUT_BYTES = 4_000_000;
|
|
22
|
+
// A frontmatter fence must close inside this prefix. Bounding the search means a
|
|
23
|
+
// document that opens with `---` and never closes it costs a fixed scan instead
|
|
24
|
+
// of one proportional to the whole file.
|
|
25
|
+
export const MAX_FRONTMATTER_SCAN_BYTES = 64_000;
|
|
26
|
+
// Not a cutoff — parseDocument is single-pass and has nothing to abort — but a
|
|
27
|
+
// tripwire: if the scan ever exceeds this, something has become superlinear and
|
|
28
|
+
// we want it in the errors array rather than in a latency graph.
|
|
29
|
+
export const MAX_PARSE_WALLCLOCK_MS = 250;
|
|
30
|
+
function now() {
|
|
31
|
+
return typeof performance !== "undefined" &&
|
|
32
|
+
typeof performance.now === "function"
|
|
33
|
+
? performance.now()
|
|
34
|
+
: Date.now();
|
|
35
|
+
}
|
|
36
|
+
const MAX_ERROR_MESSAGE_LEN = 500;
|
|
37
|
+
function messageOf(err) {
|
|
38
|
+
if (err instanceof Error)
|
|
39
|
+
return err.message.slice(0, MAX_ERROR_MESSAGE_LEN);
|
|
40
|
+
return String(err ?? "unknown").slice(0, MAX_ERROR_MESSAGE_LEN);
|
|
41
|
+
}
|
|
42
|
+
// The raw-text degradation: no title, no frontmatter, the source verbatim as
|
|
43
|
+
// body. Deliberately lossy but never destructive — the bytes survive, so the
|
|
44
|
+
// file still renders as a code-ish blob and a later write does not erase it.
|
|
45
|
+
function degraded(source, issue) {
|
|
46
|
+
return { title: "", body: source, frontmatter: {}, errors: [issue] };
|
|
47
|
+
}
|
|
48
|
+
// True when the document opens a frontmatter fence it never closes within the
|
|
49
|
+
// scan window. Cheap: bounded slice, indexOf, no backtracking.
|
|
50
|
+
function hasUnterminatedFrontmatter(source) {
|
|
51
|
+
const head = source.startsWith("") ? source.slice(1) : source;
|
|
52
|
+
// Exactly parseDocument's opener — a `--- ` line is not a fence, so flagging
|
|
53
|
+
// it here would invent a degradation the real parser never suffers.
|
|
54
|
+
if (!/^---\r?\n/.test(head))
|
|
55
|
+
return false;
|
|
56
|
+
const window = head.slice(0, MAX_FRONTMATTER_SCAN_BYTES);
|
|
57
|
+
return !/\r?\n---[ \t]*(\r?\n|$)/.test(window.slice(3));
|
|
58
|
+
}
|
|
59
|
+
export function parseDocumentWithFallback(source) {
|
|
60
|
+
if (source.length > MAX_PARSE_INPUT_BYTES) {
|
|
61
|
+
return degraded(source, {
|
|
62
|
+
reason: "input-too-large",
|
|
63
|
+
message: `${source.length} chars exceeds MAX_PARSE_INPUT_BYTES (${MAX_PARSE_INPUT_BYTES})`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (hasUnterminatedFrontmatter(source)) {
|
|
67
|
+
return degraded(source, {
|
|
68
|
+
reason: "unterminated-frontmatter",
|
|
69
|
+
message: `no closing --- fence within MAX_FRONTMATTER_SCAN_BYTES (${MAX_FRONTMATTER_SCAN_BYTES})`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const started = now();
|
|
73
|
+
let parsed;
|
|
74
|
+
try {
|
|
75
|
+
parsed = parseDocument(source);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return degraded(source, {
|
|
79
|
+
reason: "parse-threw",
|
|
80
|
+
message: messageOf(err),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const elapsed = now() - started;
|
|
84
|
+
if (elapsed > MAX_PARSE_WALLCLOCK_MS) {
|
|
85
|
+
// The parse succeeded, so we keep its result — this records that it cost
|
|
86
|
+
// more than it should have.
|
|
87
|
+
return {
|
|
88
|
+
...parsed,
|
|
89
|
+
errors: [
|
|
90
|
+
{
|
|
91
|
+
reason: "budget-exceeded",
|
|
92
|
+
message: `parse took ${Math.round(elapsed)}ms (budget ${MAX_PARSE_WALLCLOCK_MS}ms)`,
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return { ...parsed, errors: [] };
|
|
98
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
// Flatten a markdown body to a single line of plain text — the shared engine
|
|
4
|
+
// behind every excerpt/preview in sfora (feed rows, search hits, the docs
|
|
5
|
+
// library card, the briefing). Strips everything that would otherwise leak raw
|
|
6
|
+
// syntax into a one-liner: fenced code, HTML, images, mentions, wiki tokens,
|
|
7
|
+
// links, tables, list and checkbox markers, headings, blockquotes, emphasis.
|
|
8
|
+
//
|
|
9
|
+
// Pure: no React, no Convex, no Node.
|
|
10
|
+
//
|
|
11
|
+
// Two call sites disagreed on two details when this was three copies, so those
|
|
12
|
+
// two stayed as options rather than being flattened to one answer:
|
|
13
|
+
// • a label-less `[[token]]` — the web renderer drops it (the raw id is
|
|
14
|
+
// noise in a preview), the backend keeps it (its tokens are human forms
|
|
15
|
+
// like `c:42` that read fine).
|
|
16
|
+
// • an image — the web renderer drops it entirely, the backend keeps the alt
|
|
17
|
+
// text as words.
|
|
18
|
+
import { wikiLinkPattern } from "./wikiLinks.js";
|
|
19
|
+
export function stripToPlainText(body, options = {}) {
|
|
20
|
+
const { wikiTokens = "drop", images = "drop" } = options;
|
|
21
|
+
let out = (body ?? "")
|
|
22
|
+
.replace(/```[\s\S]*?```/g, " ") // fenced code blocks
|
|
23
|
+
.replace(/<[^>]+>/g, " ") // raw HTML tags
|
|
24
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, images === "alt" ? "$1" : " ")
|
|
25
|
+
.replace(/@\[([^\]]+)\]\([^)]*\)/g, "@$1") // mention → @Name
|
|
26
|
+
// `[[target|Label]]` → Label, and `![[target|Label]]` with it. The optional
|
|
27
|
+
// bang is the embed marker (card #298) and it has to be eaten HERE: left
|
|
28
|
+
// out of the pattern, every embed in every excerpt turns into a stray
|
|
29
|
+
// exclamation mark in front of its label. An embed flattens to the same
|
|
30
|
+
// words a link does — a one-line preview has nowhere to put a card.
|
|
31
|
+
.replace(/!?\[\[[^\]|]*\|([^\]]*)\]\]/g, "$1");
|
|
32
|
+
// Label-less tokens. `wikiLinkPattern()` matches the brackets only, so the
|
|
33
|
+
// bang is stripped separately rather than by widening the grammar's own
|
|
34
|
+
// pattern — that pattern's span is what `canonicalizeReferences` rewrites,
|
|
35
|
+
// and a span that swallowed the bang would rewrite embeds into links.
|
|
36
|
+
out = out
|
|
37
|
+
.replace(/!\[\[([^\]]+)\]\]/g, wikiTokens === "keep" ? "$1" : " ")
|
|
38
|
+
.replace(wikiLinkPattern(), wikiTokens === "keep" ? "$1" : " ");
|
|
39
|
+
return out
|
|
40
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
|
|
41
|
+
.replace(/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/gm, " ") // table separator rows
|
|
42
|
+
.replace(/^\s*\|(.+)\|\s*$/gm, (_, inner) => inner.replace(/\s*\|\s*/g, " · ")) // table rows → cell · cell
|
|
43
|
+
.replace(/^#{1,6}\s+/gm, "") // headings
|
|
44
|
+
.replace(/^\s*[-*+]\s+\[[ xX]\]\s+/gm, "") // task checkbox bullets
|
|
45
|
+
.replace(/^\s*[-*+]\s+/gm, "") // list bullets
|
|
46
|
+
.replace(/^\s*\d+[.)]\s+/gm, "") // ordered list markers
|
|
47
|
+
.replace(/^\s*>\s?/gm, "") // blockquotes
|
|
48
|
+
.replace(/(\*\*|__|\*|_|~~|`)/g, "") // emphasis + inline code marks
|
|
49
|
+
.replace(/\s+/g, " ")
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// GENERATED by scripts/sync-format.mjs from packages/markdown/src — DO NOT EDIT.
|
|
2
|
+
// Edit packages/markdown/src and re-run the sync (any sfora-cli build does it).
|
|
1
3
|
// Pure markdown <-> post serialization for the agent filesystem API (/v1/fs).
|
|
2
4
|
//
|
|
3
5
|
// Frontmatter/YAML, mention rendering, slugs, and the document shape now live in
|
|
@@ -11,7 +13,7 @@
|
|
|
11
13
|
// trip can be reconstructed (see `rehydrateMentions`).
|
|
12
14
|
import { buildDocument, parseDocument, rehydrateMentions, renderMentions, serializeFrontmatter, slugFromFilename, slugify, toISO, } from "./markdown/index.js";
|
|
13
15
|
// Re-export the shared helpers so existing importers (cardMarkdown, httpHelpers)
|
|
14
|
-
// keep their `from "./postMarkdown"` paths working.
|
|
16
|
+
// keep their `from "./postMarkdown.js"` paths working.
|
|
15
17
|
export { rehydrateMentions, slugFromFilename, slugify };
|
|
16
18
|
// ─── Filenames ─────────────────────────────────────────────────────
|
|
17
19
|
// The YYYY-MM-DD part of a filename. Published posts use publishedAt; drafts
|