tosijs-styled-editor 0.4.4 → 0.5.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/SECURITY.md ADDED
@@ -0,0 +1,32 @@
1
+ # Security
2
+
3
+ ## Reporting
4
+
5
+ - **A sanitizer bypass** — pasted or dropped content that reaches the document
6
+ still able to execute — belongs to
7
+ [`tosijs-kilpi`](https://github.com/tonioloewald/kilpi/issues). That is where
8
+ the filtering code lives; this package only calls it.
9
+ - **Anything else** — [this repository's
10
+ issues](https://github.com/tonioloewald/tosijs-editor/issues).
11
+
12
+ If you would rather not disclose publicly first, open an issue with no details
13
+ and we will find a private channel.
14
+
15
+ ## What this package is responsible for
16
+
17
+ - applying sanitization at the **paste and drop choke point**, before any node
18
+ enters the document (`insertTransfer`)
19
+ - the `editor.sanitize` hook, so a host can substitute its own sanitizer
20
+ - the two URL guards this package owns: `setLink`, and following a link on
21
+ Ctrl/Cmd-click
22
+
23
+ ## What it is NOT responsible for
24
+
25
+ - **the sanitization policy itself** — that is kilpi's, and
26
+ [kilpi's SECURITY.md](https://github.com/tonioloewald/kilpi/blob/main/SECURITY.md)
27
+ is authoritative. It is deliberately not restated here, because a copy of a
28
+ policy drifts from the policy.
29
+ - **content the host supplies**: `editor.value = html` and initial light-DOM
30
+ content are inside your trust boundary and are not filtered.
31
+ - **documents stored before 0.4.4**, which may already contain a pasted payload.
32
+ Setting `value` does not filter, so sanitize your corpus as part of upgrading.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Tracked changes — insertions and deletions as CONTENT, with attribution.
3
+ *
4
+ * The alternative was an operation log alongside the undo snapshots.
5
+ * EXTENSIBILITY.md weighs both: an operation log is the only thing that gets you
6
+ * a merge story, and it is the most expensive decision in this codebase.
7
+ * Changes-as-content gets everything except merge, for a fraction of the work,
8
+ * and it fits the web-component substrate that footnotes and spelling already
9
+ * proved.
10
+ *
11
+ * WHAT THIS BUYS THAT AN OPERATION LOG DOES NOT: a tracked document is still a
12
+ * document. It serializes, round-trips, pastes into another editor, and can be
13
+ * read by something that has never heard of this component.
14
+ *
15
+ * ONE ASYMMETRY TO KNOW ABOUT. A footnote plugin that fails to load is benign —
16
+ * you see a stray marker. A `<tosi-del>` that fails to load renders deleted text
17
+ * as ordinary prose, which reads as the opposite of what the document means. So
18
+ * the strikethrough is load-bearing for CORRECTNESS, not decoration, and those
19
+ * styles belong in the core stylesheet even though the behaviour is a plugin.
20
+ */
21
+ /** Who made a change, and when. */
22
+ export interface ChangeAuthor {
23
+ /** Stable id — a user id, or a model name for an LLM pass. */
24
+ id: string;
25
+ /** What to show a reviewer. */
26
+ name?: string;
27
+ }
28
+ export interface TrackedChange {
29
+ id: string;
30
+ kind: 'insert' | 'delete';
31
+ author: string;
32
+ authorName: string;
33
+ time: string;
34
+ text: string;
35
+ element: HTMLElement;
36
+ }
37
+ export declare const INS_TAG = "tosi-ins";
38
+ export declare const DEL_TAG = "tosi-del";
39
+ /**
40
+ * A new change id.
41
+ *
42
+ * The sequence counter is not decoration. `Date.now()` alone collides whenever
43
+ * two marks are produced in one synchronous handler, which is the NORMAL case:
44
+ * typing over a selection and pasting over one both delete and then insert
45
+ * inside a single keydown. Measured at 29% collision without the counter — and
46
+ * a collision means `acceptChanges(deletionId)` silently also accepts the
47
+ * replacement insertion, so a reviewer cannot accept a deletion and reject
48
+ * what replaced it. Exported so there is ONE producer; there used to be three,
49
+ * and only this one had the guard.
50
+ */
51
+ export declare const changeId: () => string;
52
+ /**
53
+ * Insertion and deletion marks.
54
+ *
55
+ * Both are CONTAINERS — the text inside stays ordinary editable text, because a
56
+ * reviewer needs to be able to put the caret in a proposed sentence and adjust
57
+ * it before accepting. They hold no state beyond their attributes: what a change
58
+ * is, and who made it, is written in the document, not remembered by an
59
+ * instance that would not survive a round trip.
60
+ */
61
+ export declare class TosiIns extends HTMLElement {
62
+ }
63
+ export declare class TosiDel extends HTMLElement {
64
+ }
65
+ export declare function defineChanges(): void;
66
+ /** Every tracked change in a root, in document order. */
67
+ export declare function changesIn(root: Element): TrackedChange[];
68
+ /**
69
+ * Accept a change: the insertion becomes ordinary text, the deletion goes.
70
+ *
71
+ * Accept and reject are deliberately symmetric and deliberately *dumb* — each
72
+ * one resolves exactly one mark. Anything cleverer (accept all by this author,
73
+ * accept this paragraph) is a filter over `changesIn` plus a loop, which is the
74
+ * caller's policy rather than ours.
75
+ */
76
+ export declare function acceptChange(el: Element): void;
77
+ /** Reject a change: the insertion goes, the deletion becomes ordinary text. */
78
+ export declare function rejectChange(el: Element): void;
79
+ /**
80
+ * A word-level diff.
81
+ *
82
+ * Word-level, not character-level, because the unit has to be something a
83
+ * reviewer can meaningfully accept or reject. A character diff turns
84
+ * `teh -> the` into three separate changes and a rewritten sentence into
85
+ * confetti.
86
+ *
87
+ * Longest-common-subsequence over word tokens. Whitespace rides along with the
88
+ * word that follows it so that rejoining is lossless — the output of a diff with
89
+ * no changes is byte-identical to its input.
90
+ */
91
+ export declare function tokenize(text: string): string[];
92
+ export type DiffOp = {
93
+ op: 'same';
94
+ text: string;
95
+ } | {
96
+ op: 'insert';
97
+ text: string;
98
+ } | {
99
+ op: 'delete';
100
+ text: string;
101
+ };
102
+ /**
103
+ * Above this many tokens on either side, fall back to replace-the-whole-thing.
104
+ *
105
+ * The LCS table is (n+1)x(m+1) numbers, and one side of this diff is a REMOTE
106
+ * RESPONSE — whatever the proofreader returned. Measured: 8k tokens is 429 ms
107
+ * and +366 MB; 16k (a 78 kB text node) is 1.7 s and +1.2 GB, synchronously on
108
+ * the main thread. The asymmetric case is worse and cheaper to trigger: a
109
+ * 200-word paragraph against a 200k-word response is +226 MB PER TEXT NODE.
110
+ * 4000 tokens is a very long paragraph and costs about 128 MB worst case.
111
+ *
112
+ * Past the cap the change is still correct, just coarser: one deletion and one
113
+ * insertion rather than a word-level diff. Degrading the review experience
114
+ * beats freezing the tab.
115
+ */
116
+ export declare const MAX_DIFF_TOKENS = 4000;
117
+ export declare function diffWords(before: string, after: string): DiffOp[];
118
+ /**
119
+ * Replace a text node's contents with the tracked result of revising it.
120
+ *
121
+ * Returns the number of changes introduced. Zero means the revision was
122
+ * identical and the document was not touched at all — which matters, because a
123
+ * proofreading pass that changes nothing should not dirty the document or
124
+ * produce an undo step.
125
+ */
126
+ export declare function applyRevision(node: Text, revised: string, author: ChangeAuthor): number;
@@ -26,6 +26,51 @@ export interface EditableContext {
26
26
  normalize(): void;
27
27
  focus(): void;
28
28
  updateUndo(command?: string, reason?: string): void;
29
+ /**
30
+ * Delete a node — or, under change tracking, mark it deleted in place.
31
+ *
32
+ * **This is how a command deletes anything.** A bare `node.remove()` is
33
+ * correct only when you know tracking is off, and a command cannot know
34
+ * that. The fix for tracked deletion originally landed on the component's
35
+ * private helper and stopped at the keydown handlers, so the two table
36
+ * commands — and every host-authored command added through
37
+ * `editor.commands.x = fn` — removed content with no `<tosi-del>`, no entry
38
+ * in `changes`, and nothing for `rejectChanges()` to restore.
39
+ */
40
+ removeNode(node: Node): void;
41
+ /**
42
+ * Decline a structural edit, audibly.
43
+ *
44
+ * Returns true when the caller should go ahead and refuse; a host that calls
45
+ * `preventDefault()` on the `structural-edit-refused` event gets false back
46
+ * and the edit proceeds. Pair it with `tracksChanges()`:
47
+ *
48
+ * ```typescript
49
+ * if (ctx.tracksChanges() && ctx.refuseStructural('delete-table-row')) return
50
+ * ```
51
+ *
52
+ * A bare `return` is not good enough: `preventDefault()` has already run by
53
+ * the time a command executes, so a silent refusal is a dead menu item with
54
+ * no signal at any layer. Both table commands shipped that way for one
55
+ * commit while the README promised the event.
56
+ *
57
+ * **An override means the edit happens UNTRACKED.** If tracking could have
58
+ * represented it, the command would not have been refusing. So a command
59
+ * whose refusal was overridden should delete raw rather than through
60
+ * `removeNode` — half-tracking a structural edit is worse than either
61
+ * choice made cleanly.
62
+ */
63
+ refuseStructural(reason: string): boolean;
64
+ /**
65
+ * Whether edits are being recorded as tracked changes.
66
+ *
67
+ * A command that RESTRUCTURES rather than deletes text — removing a table
68
+ * row, merging blocks — should check this and decline, because a change mark
69
+ * wraps content and structure is not content. Declining is the house rule:
70
+ * silently restructuring with nothing in `changes` to show for it is the
71
+ * failure this whole mechanism exists to prevent.
72
+ */
73
+ tracksChanges(): boolean;
29
74
  }
30
75
  /** Parse a "key value key value" argument list into a CSS object */
31
76
  export declare function makeCSS(args: string[]): Record<string, string> | null;
@@ -84,26 +84,25 @@ export declare function caretGeometryAt(marker: Element, root: Element): {
84
84
  height: number;
85
85
  } | null;
86
86
  /**
87
- * Strip executable content from a subtree, IN PLACE.
87
+ * Sanitization lives in `tosijs-kilpi` — the same code, extracted so it is not
88
+ * maintained in two places.
88
89
  *
89
- * The editor replaced `contentEditable` but not the sanitization the browser
90
- * was doing on its behalf: pasted and dropped HTML is written into the live
91
- * document, and from there into `value`, `internals.setFormValue` and every
92
- * undo snapshot — so an unsanitized payload is stored, re-served, and re-fired
93
- * on undo. Must run BEFORE any node enters the document.
90
+ * It was duplicated briefly, and that is exactly the shape that produced review
91
+ * finding M1 of the 0.4.4 cycle: one URL-normalization defect fixed in one of
92
+ * two copies, silently leaving the other. Across two repositories that drift
93
+ * would not even be visible in a diff.
94
94
  *
95
- * This is deliberately a denylist for elements and an allowlist for URL
96
- * schemes: unknown ELEMENTS are content (including a plugin's custom elements,
97
- * which must survive — see EXTENSIBILITY.md), whereas unknown SCHEMES are not.
95
+ * Re-exported here so the editor's public API is unchanged and every call site
96
+ * keeps importing from `./dom-utils`.
98
97
  */
99
- export declare function sanitizeInPlace(root: Element | DocumentFragment): void;
98
+ export { sanitizeInPlace, isSafeNavigationUrl } from 'tosijs-kilpi';
100
99
  /**
101
- * Is this URL safe to NAVIGATE to, or to write into an href?
100
+ * A block with nothing left in it worth keeping.
102
101
  *
103
- * Stricter than `isSafeUrl`: that one allows raster `data:image/*` because an
104
- * `<img src>` may legitimately carry one, while a link must never — so this
105
- * rejects every `data:` URL. Both must normalize identically, or the stricter
106
- * check is the one that gets bypassed: `da&#9;ta:image/png;…` passed here while
107
- * `data:image/png;…` was correctly rejected.
102
+ * "No text" is not enough on its own: an image, a rule, a line break or a
103
+ * table is content with no text content, and a `<tosi-del>` is text somebody
104
+ * has proposed to remove and a reviewer still has to see. Two copies of this
105
+ * test existed and had already drifted in opposite directions — one checked
106
+ * the replaced elements and not the change mark, the other the reverse.
108
107
  */
109
- export declare function isSafeNavigationUrl(value: string): boolean;
108
+ export declare function blockIsEmpty(block: Element): boolean;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `<tosi-footnote>` — a footnote reference that maintains its own entry.
3
+ *
4
+ * This is the first feature converted to the web-component substrate described
5
+ * in EXTENSIBILITY.md, and it exists because of a shipped bug: `insertFootnote`
6
+ * called `renumberFootnotes` at insertion time and nothing called it again, so
7
+ * deleting a reference with Backspace left its text orphaned in the list and
8
+ * the survivors mis-numbered.
9
+ *
10
+ * `renumberFootnotes` was never wrong — it already removes orphans and derives
11
+ * numbers from document order. What was missing was anything to *call* it when
12
+ * the document changed. Custom element lifecycle is that caller, and it is a
13
+ * better one than a global "document changed" hook would have been: it fires
14
+ * per node, only for the nodes that actually moved, and it fires for edits
15
+ * nobody wrote code for — a deletion, a drag, a paste, an undo.
16
+ */
17
+ export declare class TosiFootnote extends HTMLElement {
18
+ /**
19
+ * Captured on connect, because `disconnectedCallback` runs when this element
20
+ * is ALREADY detached — `closest()` would find nothing at the only moment we
21
+ * most need the document.
22
+ */
23
+ private editorRoot;
24
+ connectedCallback(): void;
25
+ disconnectedCallback(): void;
26
+ }
27
+ /** The tag name, so callers do not hard-code a string that could drift. */
28
+ export declare const FOOTNOTE_TAG = "tosi-footnote";
29
+ /**
30
+ * Register the element.
31
+ *
32
+ * Idempotent, and safe to call in a non-browser environment — the editor's
33
+ * tests run under happy-dom, and a plugin should never be the reason an import
34
+ * throws somewhere it was not designed for.
35
+ */
36
+ export declare function defineFootnote(): void;
package/dist/index.d.ts CHANGED
@@ -2,6 +2,9 @@ export * from './dom-utils';
2
2
  export * from './selection';
3
3
  export * from './commands';
4
4
  export * from './tosijs-styled-editor';
5
+ export * from './changes';
6
+ export * from './spelling';
7
+ export * from './footnote';
5
8
  export * from './toolbar';
6
9
  export * from './table-utils';
7
10
  export { version } from './version';