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.
Files changed (108) hide show
  1. package/README.md +147 -6
  2. package/dist/SforaFs.js +278 -10
  3. package/dist/api-client.d.ts +290 -5
  4. package/dist/api-client.js +307 -22
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/cli.js +323 -29
  8. package/dist/format/__tests__/byteStable.d.ts +5 -0
  9. package/dist/format/__tests__/byteStable.js +64 -0
  10. package/dist/format/blockSplice.d.ts +135 -0
  11. package/dist/format/blockSplice.js +330 -0
  12. package/dist/format/blocks/dropClosure.d.ts +81 -0
  13. package/dist/format/blocks/dropClosure.js +196 -0
  14. package/dist/format/blocks/markdown-block-catalog.d.ts +18 -0
  15. package/dist/format/blocks/markdown-block-catalog.js +162 -0
  16. package/dist/format/blocks/markdown-block-ids.d.mts +1 -0
  17. package/dist/format/blocks/markdown-block-ids.mjs +25 -0
  18. package/dist/format/blocks/parsers.d.ts +105 -0
  19. package/dist/format/blocks/parsers.js +442 -0
  20. package/dist/format/blocks/structured-block-schema.d.ts +8 -0
  21. package/dist/format/blocks/structured-block-schema.js +30 -0
  22. package/dist/format/callout.d.ts +128 -0
  23. package/dist/format/callout.js +227 -0
  24. package/dist/format/cardMarkdown.d.ts +2 -0
  25. package/dist/format/cardMarkdown.js +10 -0
  26. package/dist/format/checklist.d.ts +34 -0
  27. package/dist/format/checklist.js +158 -0
  28. package/dist/format/formatAxes.d.ts +228 -0
  29. package/dist/format/formatAxes.js +454 -0
  30. package/dist/format/index.d.ts +19 -4
  31. package/dist/format/index.js +28 -4
  32. package/dist/format/lineGeometry.d.ts +100 -0
  33. package/dist/format/lineGeometry.js +424 -0
  34. package/dist/format/lint/appliesTo.d.ts +92 -0
  35. package/dist/format/lint/appliesTo.js +369 -0
  36. package/dist/format/lint/config.d.ts +106 -0
  37. package/dist/format/lint/config.js +205 -0
  38. package/dist/format/lint/fixAll.d.ts +62 -0
  39. package/dist/format/lint/fixAll.js +107 -0
  40. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  41. package/dist/format/lint/frontmatterSchema.js +660 -0
  42. package/dist/format/lint/index.d.ts +49 -0
  43. package/dist/format/lint/index.js +51 -0
  44. package/dist/format/lint/lintSource.d.ts +56 -0
  45. package/dist/format/lint/lintSource.js +188 -0
  46. package/dist/format/lint/rules/broken-wiki-link.d.ts +2 -0
  47. package/dist/format/lint/rules/broken-wiki-link.js +45 -0
  48. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  49. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  50. package/dist/format/lint/rules/index.d.ts +11 -0
  51. package/dist/format/lint/rules/index.js +32 -0
  52. package/dist/format/lint/rules/malformed-callout.d.ts +2 -0
  53. package/dist/format/lint/rules/malformed-callout.js +88 -0
  54. package/dist/format/lint/rules/malformed-checklist.d.ts +2 -0
  55. package/dist/format/lint/rules/malformed-checklist.js +65 -0
  56. package/dist/format/lint/rules/malformed-frontmatter.d.ts +2 -0
  57. package/dist/format/lint/rules/malformed-frontmatter.js +98 -0
  58. package/dist/format/lint/rules/malformed-structured-block.d.ts +2 -0
  59. package/dist/format/lint/rules/malformed-structured-block.js +134 -0
  60. package/dist/format/lint/rules/malformed-wiki-link.d.ts +2 -0
  61. package/dist/format/lint/rules/malformed-wiki-link.js +43 -0
  62. package/dist/format/lint/rules/orphan-reference.d.ts +2 -0
  63. package/dist/format/lint/rules/orphan-reference.js +87 -0
  64. package/dist/format/lint/severity.d.ts +15 -0
  65. package/dist/format/lint/severity.js +50 -0
  66. package/dist/format/lint/textEdits.d.ts +86 -0
  67. package/dist/format/lint/textEdits.js +162 -0
  68. package/dist/format/lint/types.d.ts +116 -0
  69. package/dist/format/lint/types.js +16 -0
  70. package/dist/format/markdown/dates.js +2 -0
  71. package/dist/format/markdown/document.js +2 -0
  72. package/dist/format/markdown/index.js +2 -0
  73. package/dist/format/markdown/mentions.js +2 -0
  74. package/dist/format/markdown/slug.d.ts +28 -0
  75. package/dist/format/markdown/slug.js +65 -0
  76. package/dist/format/markdown/yaml.js +2 -0
  77. package/dist/format/noteMarkdown.js +2 -0
  78. package/dist/format/parseWithFallback.d.ts +13 -0
  79. package/dist/format/parseWithFallback.js +98 -0
  80. package/dist/format/plaintext.d.ts +5 -0
  81. package/dist/format/plaintext.js +51 -0
  82. package/dist/format/postMarkdown.js +3 -1
  83. package/dist/format/sheetCellSpans.d.ts +95 -0
  84. package/dist/format/sheetCellSpans.js +223 -0
  85. package/dist/format/sheetSelection.d.ts +136 -0
  86. package/dist/format/sheetSelection.js +282 -0
  87. package/dist/format/taskUploadFilename.d.ts +6 -0
  88. package/dist/format/taskUploadFilename.js +13 -0
  89. package/dist/format/textStats.d.ts +23 -0
  90. package/dist/format/textStats.js +80 -0
  91. package/dist/format/wayfinder.d.ts +50 -0
  92. package/dist/format/wayfinder.js +203 -0
  93. package/dist/format/wikiLinks.d.ts +78 -0
  94. package/dist/format/wikiLinks.js +266 -0
  95. package/dist/index.d.ts +26 -1
  96. package/dist/index.js +20 -3
  97. package/dist/mcp-server.js +5 -2
  98. package/dist/opener.d.ts +23 -0
  99. package/dist/opener.js +26 -0
  100. package/dist/render.d.ts +132 -0
  101. package/dist/render.js +208 -0
  102. package/dist/shell-commands.d.ts +34 -0
  103. package/dist/shell-commands.js +108 -0
  104. package/dist/watch.d.ts +79 -0
  105. package/dist/watch.js +113 -0
  106. package/dist/web-url.d.ts +39 -0
  107. package/dist/web-url.js +63 -0
  108. 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,5 @@
1
+ export interface StripToPlainTextOptions {
2
+ wikiTokens?: "drop" | "keep";
3
+ images?: "drop" | "alt";
4
+ }
5
+ export declare function stripToPlainText(body: string | undefined | null, options?: StripToPlainTextOptions): string;
@@ -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