sfora-cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +174 -0
  2. package/dist/SforaFs.js +8 -6
  3. package/dist/api-client.d.ts +344 -4
  4. package/dist/api-client.js +289 -21
  5. package/dist/block-commands.d.ts +84 -0
  6. package/dist/block-commands.js +155 -0
  7. package/dist/chat.d.ts +89 -0
  8. package/dist/chat.js +189 -0
  9. package/dist/cli-args.d.ts +32 -0
  10. package/dist/cli-args.js +88 -0
  11. package/dist/cli.js +530 -88
  12. package/dist/format/blockSplice.d.ts +135 -0
  13. package/dist/format/blockSplice.js +330 -0
  14. package/dist/format/blocks/dropClosure.d.ts +10 -1
  15. package/dist/format/blocks/dropClosure.js +11 -1
  16. package/dist/format/callout.d.ts +69 -7
  17. package/dist/format/callout.js +112 -15
  18. package/dist/format/checklist.js +11 -4
  19. package/dist/format/formatAxes.d.ts +228 -0
  20. package/dist/format/formatAxes.js +454 -0
  21. package/dist/format/index.d.ts +1 -0
  22. package/dist/format/index.js +4 -0
  23. package/dist/format/lineGeometry.d.ts +34 -4
  24. package/dist/format/lineGeometry.js +140 -40
  25. package/dist/format/lint/appliesTo.d.ts +92 -0
  26. package/dist/format/lint/appliesTo.js +369 -0
  27. package/dist/format/lint/config.d.ts +106 -0
  28. package/dist/format/lint/config.js +205 -0
  29. package/dist/format/lint/fixAll.d.ts +62 -0
  30. package/dist/format/lint/fixAll.js +107 -0
  31. package/dist/format/lint/frontmatterSchema.d.ts +181 -0
  32. package/dist/format/lint/frontmatterSchema.js +660 -0
  33. package/dist/format/lint/index.d.ts +34 -5
  34. package/dist/format/lint/index.js +34 -5
  35. package/dist/format/lint/lintSource.d.ts +27 -7
  36. package/dist/format/lint/lintSource.js +67 -33
  37. package/dist/format/lint/rules/frontmatter-schema.d.ts +2 -0
  38. package/dist/format/lint/rules/frontmatter-schema.js +92 -0
  39. package/dist/format/lint/rules/index.d.ts +2 -1
  40. package/dist/format/lint/rules/index.js +7 -1
  41. package/dist/format/lint/rules/malformed-callout.js +25 -16
  42. package/dist/format/lint/rules/malformed-checklist.js +8 -3
  43. package/dist/format/lint/rules/malformed-frontmatter.js +6 -1
  44. package/dist/format/lint/severity.d.ts +15 -0
  45. package/dist/format/lint/severity.js +50 -0
  46. package/dist/format/lint/textEdits.d.ts +86 -0
  47. package/dist/format/lint/textEdits.js +162 -0
  48. package/dist/format/lint/types.d.ts +44 -8
  49. package/dist/format/markdown/slug.d.ts +28 -0
  50. package/dist/format/markdown/slug.js +63 -0
  51. package/dist/format/plaintext.js +13 -3
  52. package/dist/format/sheetCellSpans.d.ts +95 -0
  53. package/dist/format/sheetCellSpans.js +223 -0
  54. package/dist/format/sheetSelection.d.ts +136 -0
  55. package/dist/format/sheetSelection.js +282 -0
  56. package/dist/format/textStats.d.ts +23 -0
  57. package/dist/format/textStats.js +80 -0
  58. package/dist/format/wikiLinks.d.ts +60 -1
  59. package/dist/format/wikiLinks.js +195 -9
  60. package/dist/index.d.ts +26 -1
  61. package/dist/index.js +20 -3
  62. package/dist/opener.d.ts +23 -0
  63. package/dist/opener.js +26 -0
  64. package/dist/render.d.ts +162 -0
  65. package/dist/render.js +280 -0
  66. package/dist/shell-commands.d.ts +34 -0
  67. package/dist/shell-commands.js +108 -0
  68. package/dist/watch.d.ts +79 -0
  69. package/dist/watch.js +113 -0
  70. package/dist/web-url.d.ts +39 -0
  71. package/dist/web-url.js +63 -0
  72. package/package.json +1 -1
@@ -0,0 +1,50 @@
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 four levels a sfora-law diagnostic can speak at, and what each one means.
4
+ //
5
+ // One level is not enough. Every rule in this directory answers the same
6
+ // question — "will this byte sequence render as the author obviously meant it
7
+ // to?" — but the ANSWERS differ in kind, and flattening them made the gutter
8
+ // lie in both directions: an unclosed frontmatter fence, which costs a document
9
+ // every field it claims to have, wore the same amber as a checkbox that renders
10
+ // perfectly and is merely uncounted.
11
+ //
12
+ // The ladder is ordered by WHAT IS LOST, not by how loud the rule feels:
13
+ //
14
+ // error the document as a whole stops meaning what it says. Everything
15
+ // downstream of the mark is read as something else entirely.
16
+ // warning something the author wrote will not render as they meant it. The
17
+ // bytes are there; the reading is wrong.
18
+ // info something the author wrote is silently dropped. The render is not
19
+ // wrong, it is missing a piece, and nothing on screen says so.
20
+ // hint the render is exactly right. Only a derived number — a progress
21
+ // count, an index — comes out different than the author expects.
22
+ //
23
+ // Every level has at least one rule standing on it (asserted in
24
+ // `__tests__/lint.test.ts`), because a severity nothing emits is a severity
25
+ // nobody has thought about.
26
+ //
27
+ // The names match LSP's `DiagnosticSeverity` and CodeMirror's `Severity`, in
28
+ // that order, so the editor adapter is a lookup rather than a translation.
29
+ /** Most severe first. The order IS the rank. */
30
+ export const LINT_SEVERITIES = ["error", "warning", "info", "hint"];
31
+ /** 0 for `error`, 3 for `hint`. Lower is more severe. */
32
+ export function severityRank(severity) {
33
+ const index = LINT_SEVERITIES.indexOf(severity);
34
+ // An unknown string is treated as the quietest thing we have rather than as
35
+ // a crash: a diagnostic that arrived from an older copy of the package must
36
+ // still sort somewhere.
37
+ return index === -1 ? LINT_SEVERITIES.length : index;
38
+ }
39
+ /** Negative when `a` is more severe than `b`. Sorts most-severe-first. */
40
+ export function compareSeverity(a, b) {
41
+ return severityRank(a) - severityRank(b);
42
+ }
43
+ /** True when `severity` is at least as severe as `floor`. */
44
+ export function atLeastAsSevere(severity, floor) {
45
+ return severityRank(severity) <= severityRank(floor);
46
+ }
47
+ export function isLintSeverity(value) {
48
+ return (typeof value === "string" &&
49
+ LINT_SEVERITIES.includes(value));
50
+ }
@@ -0,0 +1,86 @@
1
+ import type { LintEdit, LintFix } from "./types.js";
2
+ /** Why a list of edits cannot be applied as one atomic change. */
3
+ export type LintEditRejection = {
4
+ reason: "inverted";
5
+ edit: LintEdit;
6
+ } | {
7
+ reason: "out-of-range";
8
+ edit: LintEdit;
9
+ } | {
10
+ reason: "overlap";
11
+ edit: LintEdit;
12
+ conflictsWith: LintEdit;
13
+ };
14
+ export type LintEditResult = {
15
+ ok: true;
16
+ text: string;
17
+ } | {
18
+ ok: false;
19
+ rejection: LintEditRejection;
20
+ };
21
+ /**
22
+ * True when two edits cannot both be applied to the same document state.
23
+ *
24
+ * Proper overlap is the obvious case. The second clause is the one that bites:
25
+ * two pure INSERTIONS at the same offset do not overlap by any interval test,
26
+ * yet the text they produce depends entirely on which goes first, and a fix
27
+ * whose output depends on sort stability is not a fix. Adjacency
28
+ * (`a.to === b.from`) is fine and stays allowed — that is two rules repairing
29
+ * neighbouring spans, which is exactly what fix-all is for.
30
+ */
31
+ export declare function editsConflict(a: LintEdit, b: LintEdit): boolean;
32
+ /**
33
+ * The application order. Descending by start, then by end, so an edit that
34
+ * starts later lands first and leaves every earlier offset untouched.
35
+ *
36
+ * This function is the reason the offsets in a fix mean anything. Sorting it
37
+ * the other way still "works" for a single-edit fix — which is most of them,
38
+ * which is how a bug here would hide — and quietly corrupts every multi-edit
39
+ * one.
40
+ */
41
+ export declare function sortEditsForApply(edits: readonly LintEdit[]): LintEdit[];
42
+ /**
43
+ * Can these edits compose over `source`? Returns the first reason they cannot,
44
+ * or null. Checked against the source the edits were DERIVED from — an edit
45
+ * checked against a document that has moved on is meaningless.
46
+ */
47
+ export declare function checkEdits(source: string, edits: readonly LintEdit[]): LintEditRejection | null;
48
+ /**
49
+ * Apply `edits` to `source` as one atomic change, or say why not.
50
+ *
51
+ * Never throws and never half-applies: on any rejection the caller gets the
52
+ * reason and the original text is untouched. Lint is a background nicety in a
53
+ * text editor; it is never allowed to be the reason a document is damaged.
54
+ */
55
+ export declare function tryApplyEdits(source: string, edits: readonly LintEdit[]): LintEditResult;
56
+ /**
57
+ * `tryApplyEdits` for callers that have already validated, or that would
58
+ * rather lose the fix than reason about a rejection. Returns `source`
59
+ * unchanged when the edits do not compose.
60
+ */
61
+ export declare function applyEdits(source: string, edits: readonly LintEdit[]): string;
62
+ /**
63
+ * True when every edit still describes the bytes it was computed against.
64
+ *
65
+ * The staleness question, factored out of the editor adapter. A fix's offsets
66
+ * point into the source a rule read; applying them to a document that has been
67
+ * typed into since is not a repair, it is corruption at a plausible-looking
68
+ * offset. Both the CodeMirror action and the batch path ask this before they
69
+ * commit.
70
+ */
71
+ export declare function editsMatch(source: string, edits: readonly LintEdit[], expected: string): boolean;
72
+ /** LSP's `Position`: zero-based line, zero-based UTF-16 offset within it. */
73
+ export interface LintPosition {
74
+ line: number;
75
+ character: number;
76
+ }
77
+ /** LSP's `TextEdit`, for anything that talks over a wire instead of a buffer. */
78
+ export interface LintTextEdit {
79
+ range: {
80
+ start: LintPosition;
81
+ end: LintPosition;
82
+ };
83
+ newText: string;
84
+ }
85
+ /** One fix's edits in LSP wire form, in the order LSP wants them (any). */
86
+ export declare function fixToTextEdits(source: string, fix: LintFix): LintTextEdit[];
@@ -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
+ }
@@ -1,10 +1,8 @@
1
1
  import type { Root } from "mdast";
2
- /**
3
- * Nothing in sfora-law is an "error". A rule that is not sure stays silent,
4
- * and a rule that is sure still only says "this will not render the way you
5
- * think" — the bytes on disk are always the user's to keep.
6
- */
7
- export type LintSeverity = "error" | "warning" | "info";
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 };
8
6
  /** A replacement over the PRE-fix document. Offsets are absolute. */
9
7
  export interface LintEdit {
10
8
  from: number;
@@ -12,8 +10,15 @@ export interface LintEdit {
12
10
  insert: string;
13
11
  }
14
12
  /**
15
- * One quick fix. Its edits compose atomically — they are all applied against
16
- * the same document state, so they must not overlap.
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.
17
22
  */
18
23
  export interface LintFix {
19
24
  label: string;
@@ -48,6 +53,14 @@ export interface LintContext {
48
53
  * Rules never fire inside a fence: the bytes there are not markdown.
49
54
  */
50
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;
51
64
  /** True for the frontmatter fences and everything between them. */
52
65
  inFrontmatter: (lineIndex: number) => boolean;
53
66
  /** Line extents of the frontmatter block, or null when there is none. */
@@ -55,6 +68,16 @@ export interface LintContext {
55
68
  open: number;
56
69
  close: number;
57
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[];
58
81
  /** Present only when a parser was injected AND it succeeded. */
59
82
  ast?: Root;
60
83
  /**
@@ -77,4 +100,17 @@ export interface LintSourceOptions {
77
100
  /** Injected mdast parser — `parseMarkdownAst(source).root`. */
78
101
  parseAst?: (source: string) => Root;
79
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;
80
116
  }
@@ -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;
@@ -14,6 +14,69 @@ export function slugify(title) {
14
14
  .replace(/^-+|-+$/g, "");
15
15
  return s || "untitled";
16
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
+ }
17
80
  // Pull the slug back out of a filename: drop the .md and an optional leading
18
81
  // YYYY-MM-DD- date prefix. Used to match a requested filename to an entity.
19
82
  export function slugFromFilename(filename) {
@@ -23,9 +23,19 @@ export function stripToPlainText(body, options = {}) {
23
23
  .replace(/<[^>]+>/g, " ") // raw HTML tags
24
24
  .replace(/!\[([^\]]*)\]\([^)]*\)/g, images === "alt" ? "$1" : " ")
25
25
  .replace(/@\[([^\]]+)\]\([^)]*\)/g, "@$1") // mention → @Name
26
- .replace(/\[\[[^\]|]*\|([^\]]*)\]\]/g, "$1"); // [[target|Label]] → Label
27
- // Label-less tokens.
28
- out = out.replace(wikiLinkPattern(), wikiTokens === "keep" ? "$1" : " ");
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" : " ");
29
39
  return out
30
40
  .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) → text
31
41
  .replace(/^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/gm, " ") // table separator rows
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Where every sheet cell lives in the authored bytes.
3
+ *
4
+ * `parseSheet` hands the reader a grid of DECODED strings — trimmed, escapes
5
+ * resolved, short rows padded. That is the right shape to draw and the wrong
6
+ * shape to write back through: re-serializing the whole grid from it would
7
+ * rewrite the author's column padding, their outer pipes, their delimiter row,
8
+ * every byte they lined up by hand.
9
+ *
10
+ * So an edit is a splice, not a re-serialization. This module answers "which
11
+ * byte range is that cell?" and writes a single cell in place. Everything the
12
+ * author wrote outside the edited cell survives untouched, which is what makes
13
+ * the no-op case byte-identical by construction rather than by luck:
14
+ * `writeSheetCell(source, r, c, currentText)` returns the same string back.
15
+ *
16
+ * The escape rules here are a second reading of the same grammar `sheetCells`
17
+ * in `blocks/parsers.ts` reads, and a second reading is only trustworthy if
18
+ * something checks it against the first. `sheetCellSpans.test.ts` does: every
19
+ * span's slice is fed back through `parseSheet` and must equal the cell the
20
+ * parser reports. A divergence is a test failure, never a silent misalignment.
21
+ *
22
+ * The CODE-SPAN half is no longer a second reading at all. While this module
23
+ * lived in `src/components/editor/` it could not reach the engine's private
24
+ * helpers, so it carried a hand-rolled `codeSpanMask` that closed a backtick
25
+ * run with `indexOf` — which lets a longer run close a shorter one, where
26
+ * CommonMark (and `lineGeometry.codeSpanMask`, which `sheetCells` uses) wants
27
+ * the closing run to be exactly as long as the opening one. Moving the file
28
+ * into the engine (board card #278) retires the copy: both readings of "where
29
+ * are the code spans" are now the one function, so the two cannot drift.
30
+ */
31
+ /** One cell's trimmed content, addressed in the block source. */
32
+ export type SheetCellSpan = {
33
+ /** Offset of the first byte of trimmed content. */
34
+ start: number;
35
+ /** Offset just past the last byte. `start === end` for an empty cell. */
36
+ end: number;
37
+ /** The text the reader shows — the same string `parseSheet` reports. */
38
+ text: string;
39
+ };
40
+ /** One source line's cells, plus where a missing trailing cell would go. */
41
+ export type SheetRowSpan = {
42
+ /** Cells present in the source. May be shorter than the column count. */
43
+ cells: SheetCellSpan[];
44
+ /** End-of-line offset, where a short row grows. */
45
+ appendAt: number;
46
+ /** Whether the line closes with a pipe, which decides the append shape. */
47
+ trailingPipe: boolean;
48
+ };
49
+ export type SheetCellGrid = {
50
+ columns: number;
51
+ header: SheetRowSpan;
52
+ rows: SheetRowSpan[];
53
+ };
54
+ /**
55
+ * Build the source-anchored grid, or null when the source is not a sheet the
56
+ * reader would draw — the same null the renderer already falls back on.
57
+ *
58
+ * The cell TEXTS come from `parseSheet`, never from this file's own decoding:
59
+ * one reader of the grammar owns what a cell says, and this module only owns
60
+ * where it sits. When the two disagree about how many cells the header has,
61
+ * the grid degrades to null instead of handing back a misaligned map.
62
+ */
63
+ export declare function sheetCellGrid(source: string): SheetCellGrid | null;
64
+ /**
65
+ * Encode a value for a table cell.
66
+ *
67
+ * The inverse of the engine's decoding over the alphabet that decoding
68
+ * touches: `\` and `|`. Everything else is left as the author typed it —
69
+ * the sheet renders cell text literally, so nothing else needs a spelling.
70
+ * A row cannot hold a line break, so whitespace runs collapse to one space.
71
+ */
72
+ export declare function encodeSheetCell(value: string): string;
73
+ export type SheetWriteResult = {
74
+ ok: true;
75
+ source: string;
76
+ /** The value as the reader will now show it, after normalization. */
77
+ value: string;
78
+ /** False when the write was a no-op — `source` is then the input string. */
79
+ changed: boolean;
80
+ /** True when a line break or padding was collapsed to fit a row. */
81
+ normalized: boolean;
82
+ } | {
83
+ ok: false;
84
+ reason: "no-grid" | "row-out-of-range" | "column-out-of-range";
85
+ /** Always the untouched input, so a caller can assign unconditionally. */
86
+ source: string;
87
+ };
88
+ /**
89
+ * Write one cell and return the whole block source back.
90
+ *
91
+ * Total by construction: every refusal names itself, nothing returns
92
+ * `undefined`, and the unchanged source always comes back on the result so a
93
+ * caller cannot accidentally drop the document by not checking `ok`.
94
+ */
95
+ export declare function writeSheetCell(source: string, rowIndex: number, columnIndex: number, value: string): SheetWriteResult;