@dojo-ng/rich-text-criticmarkup 0.1.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 ADDED
@@ -0,0 +1,44 @@
1
+ # @dojo-ng/rich-text-criticmarkup
2
+
3
+ CriticMarkup tracked-changes plugin for @dojo-ng/rich-text
4
+
5
+ Part of [Dojo NG](../../README.md), a framework-agnostic web component library. BSD-3-Clause.
6
+
7
+ > An opt-in plugin for `@dojo-ng/rich-text` (not a custom element). Tracks changes in CriticMarkup, the five-mark plain-text convention: `{++inserted++}`, `{--deleted--}`, `{~~old~>new~~}` (a substitution — imported as a deletion immediately followed by an insertion, and resolved as that pair, never as a lone half), `{>>comment<<}` (bare, or anchored right after a highlight), and `{==highlighted==}` (an annotation, kept on both accept and decline). Turn suggestion mode on with `setSuggestionMode(editor, true)` (or `createCriticMarkupPlugin({ suggesting: true })`): typed/deleted text is wrapped as marks instead of applied directly. Resolve one mark with `markAtSelection`/`acceptMark`/`declineMark`, or the whole document with `acceptAllMarks`/`declineAllMarks` — a bare comment is left standing either way, and a highlight's own anchored comment goes with it, since it is state on the node, not a separate mark. A paragraph split or merge proposed in suggestion mode is carried as a token (`¶`) inside a ONE-LINE mark rather than a real newline, because `@lexical/markdown` splits the document on `\n` before any transformer runs and a mark spanning that split could never be seen on import; `structuralEdits` (plugin option, default `"mark"`) controls this — `"annotate"` lets the edit happen untracked and drops a bare comment at the boundary instead, `"block"` refuses it outright, `"apply"` allows it silently. THE PARAGRAPH TOKEN IS A DIALECT, NOT CRITICMARKUP: no other CriticMarkup tool knows it, so a document written this way shows a literal `¶` to anything else — call `toPortableCriticMarkup(value)` to convert back to plain CriticMarkup before handing it to another tool (splitting a multi-paragraph mark into one mark per block, at the cost of a blank line left behind on decline — the price of interop), or set `structuralEdits: "annotate"` so this plugin never emits the token at all. A LITERAL PILCROW AN AUTHOR TYPES IS WRITTEN DOUBLED (`¶¶`) so it round-trips as itself rather than a break. A mark nested inside another (same kind or different) is detected and refused as its own node — masked to a private-use sentinel on import, unmasked back to literal delimiter text on export, rather than mangled the way a naive regex import would mangle it — firing `dj-criticmarkup-refused`. The grammar (`parseMarks`, `accept`/`decline`/`acceptAll`/`declineAll`, `stripComments`, the token functions) is a standalone string API with no Lexical import anywhere in it, for a consumer with markdown in hand and no editor — a build step, a server, a CLI; `fixtures/conformance.json` ships in the package so a second-language port of the same grammar (this plugin's own origin: a clean-room port of NovelMaker's Python `critic.py`) can run the identical cases. Comments are authored in the editor: `insertComment(editor, text)` inserts bare at a collapsed caret or anchors `{==selection==}{>>note<<}` over a range, `editComment` changes an existing note, and the two deletes are separately labeled because they are genuinely different — `removeComment` drops just the note and leaves the highlight, `removeHighlight` drops both. A NOTE BODY CANNOT CONTAIN `<<}` OR `{>>` (either would break its own mark on the next round trip); `isValidCommentText` checks this against the same grammar the string API uses, and an invalid save is refused inline, naming the offending sequence. To recognize CriticMarkup inside `@dojo-ng/rich-text-markdown`'s own general markdown format, compose `criticMarkupTransformers` into its transformer set; this plugin also registers its own `criticmarkup` format (`format="criticmarkup"`) so tracked changes work with no markdown plugin loaded at all. Setting `plugins` REPLACES the default set, so spread `...defaultPlugins` to keep bold/italic/underline + undo/redo.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @dojo-ng/rich-text-criticmarkup
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ Compose the plugin with the default set, starting in suggestion mode: typed/deleted text is wrapped as marks instead of applied directly.
18
+
19
+ ```html
20
+ <dj-rich-text id="editor" label="Draft"></dj-rich-text>
21
+ <script type="module">
22
+ import "@dojo-ng/rich-text";
23
+ import { defaultPlugins } from "@dojo-ng/rich-text";
24
+ import { createCriticMarkupPlugin } from "@dojo-ng/rich-text-criticmarkup";
25
+ const el = document.getElementById("editor");
26
+ el.plugins = [...defaultPlugins, createCriticMarkupPlugin({ suggesting: true })];
27
+ el.value = "The quick brown fox.";
28
+ </script>
29
+ ```
30
+
31
+ ## Examples
32
+
33
+ ### Resolve CriticMarkup with no editor
34
+
35
+ The grammar is a plain string API — parse and resolve marks from a server, a build step, or a CLI, with no Lexical/DOM dependency at all.
36
+
37
+ ```html
38
+ import { parseMarks, acceptAll, declineAll } from "@dojo-ng/rich-text-criticmarkup";
39
+
40
+ const draft = "The {--old--}{++new++} plan is set.";
41
+ acceptAll(draft); // "The new plan is set."
42
+ declineAll(draft); // "The old plan is set."
43
+ parseMarks(draft).map((m) => m.kind); // ["deletion", "insertion"]
44
+ ```
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Comment authoring (Track T4, decision 15): insert (bare or anchored), edit, and the two deletes.
3
+ * The UI shell (the popup with its text area and buttons) is Track T3/T4's `nodes.ts`/toolbar
4
+ * concern; these are the plain editor-level operations it calls.
5
+ */
6
+ import { type LexicalEditor, type LexicalNode } from "lexical";
7
+ /** Whether `text` is safe as a note body: `"{>>" + text + "<<}"` must parse as EXACTLY one comment
8
+ * mark spanning the whole string. A note containing `<<}` would terminate its own mark early on the
9
+ * next round trip; one containing `{>>` nests. This is the grammar module earning its place a second
10
+ * time (decision 2): the editor and the string API agree because they call the same parser. */
11
+ export declare function isValidCommentText(text: string): boolean;
12
+ /** Insert a comment at the current selection: bare at a collapsed caret, anchored (a `HighlightNode`
13
+ * carrying the note) over a range. */
14
+ export declare function insertComment(editor: LexicalEditor, text: string): void;
15
+ /** Change an existing note's text — a bare `CommentNode`'s own text, or a `HighlightNode`'s anchored
16
+ * comment. */
17
+ export declare function editComment(editor: LexicalEditor, node: LexicalNode, text: string): void;
18
+ /** "Remove note": drop an anchored comment's note, leaving the highlight and its text standing. */
19
+ export declare function removeComment(editor: LexicalEditor, node: LexicalNode): void;
20
+ /** "Remove highlight": drop both the note and the highlight, leaving the text unmarked. */
21
+ export declare function removeHighlight(editor: LexicalEditor, node: LexicalNode): void;
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Comment authoring (Track T4, decision 15): insert (bare or anchored), edit, and the two deletes.
3
+ * The UI shell (the popup with its text area and buttons) is Track T3/T4's `nodes.ts`/toolbar
4
+ * concern; these are the plain editor-level operations it calls.
5
+ */
6
+ import { $getNodeByKey, $getSelection, $isRangeSelection } from "lexical";
7
+ import { parseMarks } from "./grammar.js";
8
+ import { $createCommentNode, $createHighlightNode, $isCommentNode, $isHighlightNode } from "./nodes.js";
9
+ /** Whether `text` is safe as a note body: `"{>>" + text + "<<}"` must parse as EXACTLY one comment
10
+ * mark spanning the whole string. A note containing `<<}` would terminate its own mark early on the
11
+ * next round trip; one containing `{>>` nests. This is the grammar module earning its place a second
12
+ * time (decision 2): the editor and the string API agree because they call the same parser. */
13
+ export function isValidCommentText(text) {
14
+ const candidate = `{>>${text}<<}`;
15
+ const marks = parseMarks(candidate);
16
+ return marks.length === 1 && marks[0].kind === "comment" && marks[0].start === 0 && marks[0].end === candidate.length;
17
+ }
18
+ function assertValidCommentText(text) {
19
+ if (isValidCommentText(text))
20
+ return;
21
+ const offending = text.includes("<<}") ? "<<}" : text.includes("{>>") ? "{>>" : "a sequence that breaks the mark";
22
+ throw new Error(`Comment text is not valid: it contains "${offending}", which would not round-trip through value.`);
23
+ }
24
+ /** Insert a comment at the current selection: bare at a collapsed caret, anchored (a `HighlightNode`
25
+ * carrying the note) over a range. */
26
+ export function insertComment(editor, text) {
27
+ assertValidCommentText(text);
28
+ editor.update(() => {
29
+ const selection = $getSelection();
30
+ if (!$isRangeSelection(selection))
31
+ return;
32
+ if (selection.isCollapsed()) {
33
+ selection.insertNodes([$createCommentNode(text)]);
34
+ return;
35
+ }
36
+ const nodes = selection.extract();
37
+ if (nodes.length === 0)
38
+ return;
39
+ const highlightNode = $createHighlightNode();
40
+ highlightNode.setComment(text);
41
+ nodes[0].insertBefore(highlightNode);
42
+ for (const node of nodes)
43
+ highlightNode.append(node);
44
+ }, { discrete: true });
45
+ }
46
+ /** Change an existing note's text — a bare `CommentNode`'s own text, or a `HighlightNode`'s anchored
47
+ * comment. */
48
+ export function editComment(editor, node, text) {
49
+ assertValidCommentText(text);
50
+ editor.update(() => {
51
+ const fresh = $getNodeByKey(node.getKey());
52
+ if ($isCommentNode(fresh))
53
+ fresh.setText(text);
54
+ else if ($isHighlightNode(fresh))
55
+ fresh.setComment(text);
56
+ }, { discrete: true });
57
+ }
58
+ /** "Remove note": drop an anchored comment's note, leaving the highlight and its text standing. */
59
+ export function removeComment(editor, node) {
60
+ editor.update(() => {
61
+ const fresh = $getNodeByKey(node.getKey());
62
+ if ($isHighlightNode(fresh))
63
+ fresh.setComment(null);
64
+ }, { discrete: true });
65
+ }
66
+ /** "Remove highlight": drop both the note and the highlight, leaving the text unmarked. */
67
+ export function removeHighlight(editor, node) {
68
+ editor.update(() => {
69
+ const fresh = $getNodeByKey(node.getKey());
70
+ if ($isHighlightNode(fresh))
71
+ unwrap(fresh);
72
+ }, { discrete: true });
73
+ }
74
+ /** Replace `node` with its own children, in place (the standard Lexical "unlink" shape). */
75
+ function unwrap(node) {
76
+ for (const child of node.getChildren())
77
+ node.insertBefore(child);
78
+ node.remove();
79
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The comment popup shell (Track T3, filled in by T4): ONE shared `dj-popup` holding a
3
+ * `dj-text-area` and Save/Cancel/"Remove note"/"Remove highlight" controls, opened against
4
+ * whichever control triggered it — the toolbar's add-comment button, a bare `CommentNode`'s own
5
+ * button, or a `HighlightNode` with a comment. Built once by the plugin's `setup()` (decision 15's
6
+ * "the popup and its focus handling exist in one place"), not per node instance.
7
+ *
8
+ * Focus management (decision 15): opening moves focus to the text area; Escape closes without
9
+ * saving; Save and Cancel both return focus to the control that opened the popup.
10
+ */
11
+ import "@dojo-ng/popup";
12
+ import "@dojo-ng/text-area";
13
+ import "@dojo-ng/button";
14
+ import type { LexicalEditor, LexicalNode } from "lexical";
15
+ export type CommentPopupMode = "insert-bare" | "insert-anchored" | "edit-comment" | "edit-highlight";
16
+ export interface CommentPopupOpenOptions {
17
+ anchor: HTMLElement;
18
+ mode: CommentPopupMode;
19
+ /** The current text to seed the field with — "" for an insert. */
20
+ text: string;
21
+ /** Required for "edit-comment"/"edit-highlight"; ignored for the insert modes. */
22
+ node?: LexicalNode;
23
+ }
24
+ export interface CommentPopupController {
25
+ /** The `dj-popup` element — append it once, anywhere reachable in the DOM. */
26
+ element: HTMLElement;
27
+ open(options: CommentPopupOpenOptions): void;
28
+ }
29
+ export declare function createCommentPopupController(editor: LexicalEditor, msg: (key: string) => string): CommentPopupController;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The comment popup shell (Track T3, filled in by T4): ONE shared `dj-popup` holding a
3
+ * `dj-text-area` and Save/Cancel/"Remove note"/"Remove highlight" controls, opened against
4
+ * whichever control triggered it — the toolbar's add-comment button, a bare `CommentNode`'s own
5
+ * button, or a `HighlightNode` with a comment. Built once by the plugin's `setup()` (decision 15's
6
+ * "the popup and its focus handling exist in one place"), not per node instance.
7
+ *
8
+ * Focus management (decision 15): opening moves focus to the text area; Escape closes without
9
+ * saving; Save and Cancel both return focus to the control that opened the popup.
10
+ */
11
+ import "@dojo-ng/popup";
12
+ import "@dojo-ng/text-area";
13
+ import "@dojo-ng/button";
14
+ import { editComment, insertComment, removeComment, removeHighlight } from "./comment-authoring.js";
15
+ export function createCommentPopupController(editor, msg) {
16
+ const popup = document.createElement("dj-popup");
17
+ popup.position = "below";
18
+ popup.className = "dj-cm-comment-popup";
19
+ const panel = document.createElement("div");
20
+ panel.className = "dj-cm-comment-popup-panel";
21
+ const textArea = document.createElement("dj-text-area");
22
+ textArea.setAttribute("label", msg("comment"));
23
+ textArea.setAttribute("label-hidden", "");
24
+ textArea.setAttribute("rows", "3");
25
+ const error = document.createElement("div");
26
+ error.className = "dj-cm-comment-popup-error";
27
+ error.setAttribute("role", "alert");
28
+ error.hidden = true;
29
+ const actions = document.createElement("div");
30
+ actions.className = "dj-cm-comment-popup-actions";
31
+ const saveButton = document.createElement("dj-button");
32
+ saveButton.setAttribute("kind", "contained");
33
+ saveButton.textContent = msg("save");
34
+ const cancelButton = document.createElement("dj-button");
35
+ cancelButton.setAttribute("kind", "text");
36
+ cancelButton.textContent = msg("cancel");
37
+ const removeNoteButton = document.createElement("dj-button");
38
+ removeNoteButton.setAttribute("kind", "text");
39
+ removeNoteButton.textContent = msg("removeNote");
40
+ const removeHighlightButton = document.createElement("dj-button");
41
+ removeHighlightButton.setAttribute("kind", "text");
42
+ removeHighlightButton.textContent = msg("removeHighlight");
43
+ actions.append(saveButton, cancelButton, removeNoteButton, removeHighlightButton);
44
+ panel.append(textArea, error, actions);
45
+ popup.append(panel);
46
+ let state = {
47
+ mode: "insert-bare",
48
+ node: null,
49
+ returnFocus: null,
50
+ };
51
+ function showError(message) {
52
+ error.textContent = message;
53
+ error.hidden = false;
54
+ }
55
+ function clearError() {
56
+ error.hidden = true;
57
+ error.textContent = "";
58
+ }
59
+ function close() {
60
+ popup.open = false;
61
+ state.returnFocus?.focus();
62
+ }
63
+ function save() {
64
+ clearError();
65
+ const text = textArea.value;
66
+ try {
67
+ switch (state.mode) {
68
+ case "insert-bare":
69
+ case "insert-anchored":
70
+ insertComment(editor, text);
71
+ break;
72
+ case "edit-comment":
73
+ case "edit-highlight":
74
+ if (state.node)
75
+ editComment(editor, state.node, text);
76
+ break;
77
+ }
78
+ }
79
+ catch (err) {
80
+ showError(err instanceof Error ? err.message : String(err));
81
+ return;
82
+ }
83
+ close();
84
+ }
85
+ function doRemoveNote() {
86
+ if (state.node)
87
+ removeComment(editor, state.node);
88
+ close();
89
+ }
90
+ function doRemoveHighlight() {
91
+ if (state.node)
92
+ removeHighlight(editor, state.node);
93
+ close();
94
+ }
95
+ saveButton.addEventListener("click", save);
96
+ cancelButton.addEventListener("click", close);
97
+ removeNoteButton.addEventListener("click", doRemoveNote);
98
+ removeHighlightButton.addEventListener("click", doRemoveHighlight);
99
+ popup.addEventListener("dj-close", () => {
100
+ state.returnFocus?.focus();
101
+ });
102
+ textArea.addEventListener("keydown", (e) => {
103
+ if (e.key === "Escape") {
104
+ e.stopPropagation();
105
+ close();
106
+ }
107
+ });
108
+ function open(options) {
109
+ state = { mode: options.mode, node: options.node ?? null, returnFocus: options.anchor };
110
+ clearError();
111
+ textArea.value = options.text;
112
+ const showDeletes = options.mode === "edit-highlight";
113
+ removeNoteButton.hidden = !showDeletes;
114
+ removeHighlightButton.hidden = !showDeletes;
115
+ popup.anchor = options.anchor;
116
+ popup.open = true;
117
+ queueMicrotask(() => textArea.focus());
118
+ }
119
+ return { element: popup, open };
120
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The "criticmarkup" format's import/export walk (Track N3). Decision 13: this plugin registers its
3
+ * own `criticmarkup` format so `format="criticmarkup"` works with no markdown plugin loaded at all —
4
+ * these functions match `RichTextFormat`'s `serialize`/`deserialize` shape but stay free of a
5
+ * `@dojo-ng/rich-text` dependency; whichever track assembles the full plugin object wires them into
6
+ * `formats: { criticmarkup: { serialize, deserialize } }`.
7
+ *
8
+ * Both functions are `$`-prefixed-helper callers, not scope-openers: `deserializeCriticMarkup` runs
9
+ * inside an `editor.update`, `serializeCriticMarkup` inside an `editor.getEditorState().read` — the
10
+ * same contract every other `RichTextFormat` in this codebase follows.
11
+ */
12
+ import { type LexicalEditor } from "lexical";
13
+ export interface CriticMarkupRefusedDetail {
14
+ /** "nested": a same- or different-kind nested mark, masked rather than imported as its own node.
15
+ * "block-spanning": a mark `tokenizeBlockSpanning` left spanning a block — unreachable with the
16
+ * default paragraph token, since tokenizing eliminates every case that reason describes; kept for
17
+ * a host that configures `paragraphToken: ""` and gets the pre-decision-16 behavior back. */
18
+ reason: "nested" | "block-spanning";
19
+ /** Offsets into the tokenized text this pipeline actually masked and imported — not the caller's
20
+ * original `data`, which tokenizing may have shortened. */
21
+ start: number;
22
+ end: number;
23
+ }
24
+ declare global {
25
+ interface GlobalEventHandlersEventMap {
26
+ "dj-criticmarkup-refused": CustomEvent<CriticMarkupRefusedDetail>;
27
+ }
28
+ }
29
+ export interface DeserializeCriticMarkupOptions {
30
+ /** The paragraph-break token (decision 16). Default `PARAGRAPH_TOKEN`. */
31
+ paragraphToken?: string;
32
+ }
33
+ /**
34
+ * Import `data` as CriticMarkup: `tokenizeBlockSpanning`, then `maskNested`, then the markdown
35
+ * conversion through `criticMarkupTransformers`, then an unmask walk over the resulting text nodes.
36
+ * Emits `dj-criticmarkup-refused` on `editor.getRootElement()` for anything the grammar reports as
37
+ * nested or (in the `paragraphToken: ""` case) still block-spanning — computed from the tokenized
38
+ * text BEFORE masking, since masking is what makes those marks invisible to `parseMarks` afterward.
39
+ */
40
+ export declare function deserializeCriticMarkup(editor: LexicalEditor, data: string, options?: DeserializeCriticMarkupOptions): void;
41
+ /** The plain export walk: the markdown conversion through `criticMarkupTransformers`. */
42
+ export declare function serializeCriticMarkup(_editor: LexicalEditor): string;
package/dist/format.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The "criticmarkup" format's import/export walk (Track N3). Decision 13: this plugin registers its
3
+ * own `criticmarkup` format so `format="criticmarkup"` works with no markdown plugin loaded at all —
4
+ * these functions match `RichTextFormat`'s `serialize`/`deserialize` shape but stay free of a
5
+ * `@dojo-ng/rich-text` dependency; whichever track assembles the full plugin object wires them into
6
+ * `formats: { criticmarkup: { serialize, deserialize } }`.
7
+ *
8
+ * Both functions are `$`-prefixed-helper callers, not scope-openers: `deserializeCriticMarkup` runs
9
+ * inside an `editor.update`, `serializeCriticMarkup` inside an `editor.getEditorState().read` — the
10
+ * same contract every other `RichTextFormat` in this codebase follows.
11
+ */
12
+ import { $getRoot, $isElementNode, $isTextNode } from "lexical";
13
+ import { $convertFromMarkdownString, $convertToMarkdownString } from "@lexical/markdown";
14
+ import { parseMarks, tokenizeBlockSpanning, maskNested, unmaskNested, PARAGRAPH_TOKEN } from "./grammar.js";
15
+ import { criticMarkupTransformers } from "./transformers.js";
16
+ /**
17
+ * Import `data` as CriticMarkup: `tokenizeBlockSpanning`, then `maskNested`, then the markdown
18
+ * conversion through `criticMarkupTransformers`, then an unmask walk over the resulting text nodes.
19
+ * Emits `dj-criticmarkup-refused` on `editor.getRootElement()` for anything the grammar reports as
20
+ * nested or (in the `paragraphToken: ""` case) still block-spanning — computed from the tokenized
21
+ * text BEFORE masking, since masking is what makes those marks invisible to `parseMarks` afterward.
22
+ */
23
+ export function deserializeCriticMarkup(editor, data, options = {}) {
24
+ const token = options.paragraphToken ?? PARAGRAPH_TOKEN;
25
+ const tokenized = tokenizeBlockSpanning(data, token);
26
+ const refusals = refusalsIn(tokenized);
27
+ const { masked } = maskNested(tokenized);
28
+ $convertFromMarkdownString(masked, criticMarkupTransformers);
29
+ unmaskTextNodes($getRoot());
30
+ for (const detail of refusals)
31
+ dispatchRefused(editor, detail);
32
+ }
33
+ /** The plain export walk: the markdown conversion through `criticMarkupTransformers`. */
34
+ export function serializeCriticMarkup(_editor) {
35
+ return $convertToMarkdownString(criticMarkupTransformers);
36
+ }
37
+ function refusalsIn(text) {
38
+ const marks = parseMarks(text);
39
+ const refusals = [];
40
+ for (const mark of marks) {
41
+ const container = marks.find((p) => p !== mark && p.start <= mark.start && mark.end <= p.end);
42
+ if (container)
43
+ refusals.push({ reason: "nested", start: mark.start, end: mark.end });
44
+ else if (mark.spansBlock)
45
+ refusals.push({ reason: "block-spanning", start: mark.start, end: mark.end });
46
+ }
47
+ return refusals;
48
+ }
49
+ function unmaskTextNodes(node) {
50
+ if ($isTextNode(node)) {
51
+ const current = node.getTextContent();
52
+ const unmasked = unmaskNested(current);
53
+ if (unmasked !== current)
54
+ node.setTextContent(unmasked);
55
+ return;
56
+ }
57
+ if ($isElementNode(node)) {
58
+ for (const child of node.getChildren())
59
+ unmaskTextNodes(child);
60
+ }
61
+ }
62
+ function dispatchRefused(editor, detail) {
63
+ const root = editor.getRootElement();
64
+ root?.dispatchEvent(new CustomEvent("dj-criticmarkup-refused", { detail, bubbles: true, composed: true }));
65
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * CriticMarkup grammar: parsing the five marks, and resolving them.
3
+ *
4
+ * No Lexical import here — this module is a plain string API so a consumer with markdown in hand
5
+ * and no editor (a build step, a server, a CLI) can parse and resolve CriticMarkup too. The editor
6
+ * package (Track N) calls into this rather than keeping its own copy.
7
+ *
8
+ * Not a regex per mark: a lazily-matched regex pairs the FIRST close token it finds, so
9
+ * `{--a {--b--} c--}` mis-pairs with the INNER close and leaves ` c--}` sitting in the prose as
10
+ * literal characters. `parseMarks` is a small stack machine instead: it opens a frame on any of the
11
+ * five open tokens and closes the frame ON TOP OF THE STACK on its matching close token, so a nested
12
+ * mark — same kind or different — always closes before the mark around it does.
13
+ */
14
+ export type MarkKind = "insertion" | "deletion" | "substitution" | "comment" | "highlight";
15
+ export interface Mark {
16
+ kind: MarkKind;
17
+ /** Offsets into the ORIGINAL string; text.slice(start, end) is the mark as written. */
18
+ start: number;
19
+ end: number;
20
+ /** True when a blank line falls inside the mark's own span. */
21
+ spansBlock: boolean;
22
+ /** True when this mark contains another mark. */
23
+ nested: boolean;
24
+ /** insertion | deletion | comment | highlight */
25
+ text?: string;
26
+ /** substitution only */
27
+ old?: string;
28
+ new?: string;
29
+ }
30
+ /**
31
+ * Every CriticMarkup mark in `text`, in reading order — nested marks included as their own entries,
32
+ * alongside the outer mark that contains them.
33
+ *
34
+ * An unterminated mark — an open token with no matching close anywhere after it — produces no entry
35
+ * at all, and is left as the literal characters it is. The alternative, scanning until some LATER,
36
+ * unrelated mark's close token turns up, would swallow everything in between as one giant mark,
37
+ * which is worse than finding nothing.
38
+ */
39
+ export declare function parseMarks(text: string): Mark[];
40
+ /**
41
+ * Apply `mark`: keep an insertion's (or a substitution's new) text; drop a deletion; keep a
42
+ * highlight's text, its own anchored comment going with it; leave a bare comment untouched.
43
+ */
44
+ export declare function accept(text: string, mark: Mark): string;
45
+ /**
46
+ * Reject `mark`: restore a deletion's (or a substitution's old) text; drop an insertion; keep a
47
+ * highlight's text (and its own anchored comment, if any); leave a bare comment untouched.
48
+ */
49
+ export declare function decline(text: string, mark: Mark): string;
50
+ /**
51
+ * Resolve every mark as `accept` would, nested ones included. Idempotent: a body with no marks is
52
+ * returned unchanged.
53
+ *
54
+ * Re-parses after each single resolution rather than applying a batch of offsets: resolving an
55
+ * outer mark moves everything after its opening delimiter, so a stale offset for a nested mark would
56
+ * land on the wrong span.
57
+ */
58
+ export declare function acceptAll(text: string): string;
59
+ /** `acceptAll`'s opposite. See its docstring for why this re-parses. */
60
+ export declare function declineAll(text: string): string;
61
+ /** Remove every bare comment mark, right to left, leaving everything else untouched. */
62
+ export declare function stripComments(text: string): string;
63
+ /**
64
+ * Split a block-spanning insertion, deletion, or highlight into one mark per block, so every mark
65
+ * ends up on a single line: `{++A\n\nB++}` becomes `{++A++}\n\n{++B++}`, which renders identically.
66
+ *
67
+ * Superseded as the import-time normalization by `tokenizeBlockSpanning` (decision 16); this
68
+ * function is unchanged from decision 8 and survives only as the step `toPortableCriticMarkup`
69
+ * uses to turn the dialect back into plain CriticMarkup.
70
+ *
71
+ * A substitution is left alone — splitting one means deciding how `old` and `new` pair up block for
72
+ * block, which is not always well defined — and so is a comment, which has no per-block content to
73
+ * repeat. Both arrive as literal delimiter characters: visible to the author, never silently merged
74
+ * into the prose.
75
+ *
76
+ * Declining the SPLIT form leaves the blank line between the pieces where declining the unsplit form
77
+ * would have removed it too — no prose is lost either way, but it is a known, accepted cost of this
78
+ * route, not an oversight.
79
+ */
80
+ export declare function normalizeBlockSpanning(text: string): string;
81
+ /** The paragraph-break token: a break carried inside a mark instead of a real newline. */
82
+ export declare const PARAGRAPH_TOKEN = "\u00B6";
83
+ /** A literal token character in prose is escaped by doubling, so it round-trips through a mark. */
84
+ export declare function escapeToken(text: string, token?: string): string;
85
+ /** The exact inverse of `escapeToken`: a doubled token becomes one literal character again. */
86
+ export declare function unescapeToken(text: string, token?: string): string;
87
+ /**
88
+ * Real newlines inside a mark become the paragraph token, one mark's content at a time — decision
89
+ * 16's replacement for `normalizeBlockSpanning`'s splitting at import time. Unlike that route, this
90
+ * applies to all five kinds: a block-spanning substitution or comment tokenizes too, since the break
91
+ * is now an ordinary character inside a one-line mark rather than a structural split.
92
+ *
93
+ * Escapes any existing literal token character WITHIN a spanning mark's own content first, so a
94
+ * literal pilcrow sitting next to the blank line being converted is not confused with the new
95
+ * structural break — scoped to that one mark's content, not the whole document: a document that
96
+ * ROUND-TRIPS through this dialect already satisfies "single token is structural, doubled token is
97
+ * literal" everywhere else (decision 16's own invariant, kept by `escapeToken` on every export), and
98
+ * escaping the whole text here would re-double a break token that already exists in some OTHER,
99
+ * non-spanning mark — corrupting exactly the values this function is meant to leave alone.
100
+ */
101
+ export declare function tokenizeBlockSpanning(text: string, token?: string): string;
102
+ /**
103
+ * Convert a dialect document back to plain CriticMarkup, for handing to a tool that does not know
104
+ * the paragraph token: structural tokens become real newlines (and escaped literal tokens become the
105
+ * single character they stand for), then `normalizeBlockSpanning` splits the resulting block-spanning
106
+ * marks the decision-8 way — accepting that route's blank-line-on-decline cost as the price of
107
+ * portability.
108
+ */
109
+ export declare function toPortableCriticMarkup(text: string, token?: string): string;
110
+ /**
111
+ * Replace the delimiter characters of every mark nested inside another mark with a sentinel from the
112
+ * U+E000 private-use block, one code point per delimiter character — leaving the nested mark's own
113
+ * content untouched. The editor's import path is regex-based and cannot see a same-kind nested mark
114
+ * correctly (`parseMarks` can); masking lets the OUTER mark import correctly, with the inner
115
+ * delimiters arriving as ordinary text. `unmaskNested` is this function's exact inverse.
116
+ */
117
+ export declare function maskNested(text: string): {
118
+ masked: string;
119
+ masks: number;
120
+ };
121
+ /** The exact inverse of `maskNested`: every sentinel code point becomes its real delimiter character. */
122
+ export declare function unmaskNested(text: string): string;