@cueloop/schema 0.1.0-alpha.44 → 0.1.0-alpha.45
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/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/inline.test.ts +191 -0
- package/src/inline.ts +220 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.45",
|
|
4
4
|
"description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { inlineRuns, type InlineRun } from "./inline";
|
|
3
|
+
|
|
4
|
+
/** Every run concatenated back in order must equal the source (nothing dropped). */
|
|
5
|
+
function reconstruct(runs: InlineRun[]): string {
|
|
6
|
+
return runs.map((run) => run.text).join("");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Each visible run's text must sit at its claimed source offset. */
|
|
10
|
+
function offsetsAreExact(source: string, runs: InlineRun[]): boolean {
|
|
11
|
+
return runs.every(
|
|
12
|
+
(run) =>
|
|
13
|
+
run.start === null || source.slice(run.start, run.start + run.text.length) === run.text,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The visible (non-marker) runs, which are what an anchor can resolve against. */
|
|
18
|
+
function visible(runs: InlineRun[]): InlineRun[] {
|
|
19
|
+
return runs.filter((run) => run.start !== null);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("inlineRuns", () => {
|
|
23
|
+
test("plain text is a single positioned run", () => {
|
|
24
|
+
// Arrange
|
|
25
|
+
const source = "just plain words";
|
|
26
|
+
|
|
27
|
+
// Act
|
|
28
|
+
const runs = inlineRuns(source);
|
|
29
|
+
|
|
30
|
+
// Assert
|
|
31
|
+
expect(runs).toEqual([{ text: "just plain words", role: "text", start: 0 }]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("strong emphasis: markers concealed, content positioned", () => {
|
|
35
|
+
// Arrange
|
|
36
|
+
const source = "a **bold** b";
|
|
37
|
+
|
|
38
|
+
// Act
|
|
39
|
+
const runs = inlineRuns(source);
|
|
40
|
+
|
|
41
|
+
// Assert
|
|
42
|
+
expect(runs).toEqual([
|
|
43
|
+
{ text: "a ", role: "text", start: 0 },
|
|
44
|
+
{ text: "**", role: "marker", start: null },
|
|
45
|
+
{ text: "bold", role: "strong", start: 4 },
|
|
46
|
+
{ text: "**", role: "marker", start: null },
|
|
47
|
+
{ text: " b", role: "text", start: 10 },
|
|
48
|
+
]);
|
|
49
|
+
expect(reconstruct(runs)).toBe(source);
|
|
50
|
+
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("em, code, and strike each conceal their markers and keep offsets", () => {
|
|
54
|
+
// Assert
|
|
55
|
+
for (const [source, role, inner] of [
|
|
56
|
+
["x *em* y", "em", "em"],
|
|
57
|
+
["x `code` y", "code", "code"],
|
|
58
|
+
["x ~~gone~~ y", "strike", "gone"],
|
|
59
|
+
] as const) {
|
|
60
|
+
const runs = inlineRuns(source);
|
|
61
|
+
const content = runs.find((run) => run.role === role)!;
|
|
62
|
+
expect(content.text).toBe(inner);
|
|
63
|
+
expect(source.slice(content.start!, content.start! + inner.length)).toBe(inner);
|
|
64
|
+
expect(reconstruct(runs)).toBe(source);
|
|
65
|
+
expect(runs.filter((run) => run.role === "marker").every((run) => run.start === null)).toBe(
|
|
66
|
+
true,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("code spans do not parse markup inside them", () => {
|
|
72
|
+
// Act
|
|
73
|
+
const runs = inlineRuns("call `a**b**c` now");
|
|
74
|
+
|
|
75
|
+
// Assert - the backtick content is one literal code run, no strong inside
|
|
76
|
+
const code = runs.find((run) => run.role === "code")!;
|
|
77
|
+
expect(code.text).toBe("a**b**c");
|
|
78
|
+
expect(runs.some((run) => run.role === "strong")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("links: label positioned with href, brackets and url concealed", () => {
|
|
82
|
+
// Arrange
|
|
83
|
+
const source = "see [the docs](https://example.com) here";
|
|
84
|
+
|
|
85
|
+
// Act
|
|
86
|
+
const runs = inlineRuns(source);
|
|
87
|
+
const label = runs.find((run) => run.role === "link")!;
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
expect(label).toEqual({
|
|
91
|
+
text: "the docs",
|
|
92
|
+
role: "link",
|
|
93
|
+
start: 5,
|
|
94
|
+
href: "https://example.com",
|
|
95
|
+
});
|
|
96
|
+
expect(reconstruct(runs)).toBe(source);
|
|
97
|
+
// the url text is a concealed marker, never a visible run
|
|
98
|
+
expect(visible(runs).some((run) => run.text.includes("https"))).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("links: a destination with balanced parentheses stays whole", () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
const source = "see [foo](https://en.wikipedia.org/wiki/Foo_(bar)) here";
|
|
104
|
+
|
|
105
|
+
// Act
|
|
106
|
+
const runs = inlineRuns(source);
|
|
107
|
+
const label = runs.find((run) => run.role === "link")!;
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
expect(label.href).toBe("https://en.wikipedia.org/wiki/Foo_(bar)");
|
|
111
|
+
expect(reconstruct(runs)).toBe(source);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("links: an unclosed destination never becomes a link", () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const source = "see [foo](https://example.com/(open here";
|
|
117
|
+
|
|
118
|
+
// Act
|
|
119
|
+
const runs = inlineRuns(source);
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
122
|
+
expect(runs.some((run) => run.role === "link")).toBe(false);
|
|
123
|
+
expect(reconstruct(runs)).toBe(source);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("nested emphasis: inner marker wins, both markers concealed", () => {
|
|
127
|
+
// Act
|
|
128
|
+
const runs = inlineRuns("**a *b* c**");
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
expect(visible(runs)).toEqual([
|
|
132
|
+
{ text: "a ", role: "strong", start: 2 },
|
|
133
|
+
{ text: "b", role: "em", start: 5 },
|
|
134
|
+
{ text: " c", role: "strong", start: 7 },
|
|
135
|
+
]);
|
|
136
|
+
expect(reconstruct(runs)).toBe("**a *b* c**");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a lone em close is not stolen by an inner ** run", () => {
|
|
140
|
+
// Act - the em must span the whole thing, with **b** strong nested inside
|
|
141
|
+
const runs = inlineRuns("*a **b** c*");
|
|
142
|
+
|
|
143
|
+
// Assert
|
|
144
|
+
expect(runs.find((run) => run.role === "em" && run.text === "a ")).toBeDefined();
|
|
145
|
+
expect(runs.find((run) => run.role === "strong" && run.text === "b")).toBeDefined();
|
|
146
|
+
expect(reconstruct(runs)).toBe("*a **b** c*");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unbalanced markers fall back to literal text", () => {
|
|
150
|
+
// Assert
|
|
151
|
+
for (const source of ["**not closed", "a * b", "`open code", "[label](noclose"]) {
|
|
152
|
+
const runs = inlineRuns(source);
|
|
153
|
+
expect(reconstruct(runs)).toBe(source);
|
|
154
|
+
expect(runs.every((run) => run.role === "text" || run.role === "marker")).toBe(true);
|
|
155
|
+
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("empty spans do not match", () => {
|
|
160
|
+
// Act
|
|
161
|
+
const runs = inlineRuns("a **** b `` c [](x)");
|
|
162
|
+
|
|
163
|
+
// Assert - nothing emphasized; all reconstructs
|
|
164
|
+
expect(reconstruct(runs)).toBe("a **** b `` c [](x)");
|
|
165
|
+
expect(runs.some((run) => ["strong", "em", "code", "link"].includes(run.role))).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("backslash escapes render the punctuation literally, marker concealed", () => {
|
|
169
|
+
// Act
|
|
170
|
+
const runs = inlineRuns("not \\*emph\\* here");
|
|
171
|
+
|
|
172
|
+
// Assert - no em; the escaped stars are positioned text, backslashes concealed
|
|
173
|
+
expect(runs.some((run) => run.role === "em")).toBe(false);
|
|
174
|
+
expect(reconstruct(runs)).toBe("not \\*emph\\* here");
|
|
175
|
+
expect(offsetsAreExact("not \\*emph\\* here", runs)).toBe(true);
|
|
176
|
+
const star = runs.find((run) => run.text === "*" && run.start !== null)!;
|
|
177
|
+
expect("not \\*emph\\* here"[star.start!]).toBe("*");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("reconstruct + exact offsets hold on a mixed line", () => {
|
|
181
|
+
// Arrange
|
|
182
|
+
const source = "The **daemon** persists `sessions` to [disk](d) ~~fast~~";
|
|
183
|
+
|
|
184
|
+
// Act
|
|
185
|
+
const runs = inlineRuns(source);
|
|
186
|
+
|
|
187
|
+
// Assert
|
|
188
|
+
expect(reconstruct(runs)).toBe(source);
|
|
189
|
+
expect(offsetsAreExact(source, runs)).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
package/src/inline.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline markdown tokenizer: turns one block's text into styled runs while
|
|
3
|
+
* preserving exact source offsets, so the review surface can conceal the markup
|
|
4
|
+
* markers yet keep quote anchors char-precise.
|
|
5
|
+
*
|
|
6
|
+
* Each run is either VISIBLE text - carrying `start`, its offset in the input -
|
|
7
|
+
* or a CONCEALED marker (`**`, `` ` ``, `[`, `](url)`, ...) with `start: null`.
|
|
8
|
+
* A null run occupies no source offset, exactly like a word-diff removal, so the
|
|
9
|
+
* renderer's offset-mapping (overlayMarks / renderedOffsetFor / workRangeForRendered)
|
|
10
|
+
* paints highlights across the visible letters and skips the markers unchanged.
|
|
11
|
+
*
|
|
12
|
+
* Pure and dependency-free: the parse tree never escapes this module - the only
|
|
13
|
+
* output is the flat run list. Covers the plan subset: **strong**, *em*, `code`,
|
|
14
|
+
* ~~strike~~, [label](url), and backslash escapes. Unbalanced markup falls back
|
|
15
|
+
* to literal text.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** What a visible run represents; a marker run is concealed markup, no emphasis. */
|
|
19
|
+
export type InlineRole = "text" | "strong" | "em" | "code" | "strike" | "link" | "marker";
|
|
20
|
+
|
|
21
|
+
export interface InlineRun {
|
|
22
|
+
text: string;
|
|
23
|
+
role: InlineRole;
|
|
24
|
+
/** Offset of this run in the input text; null for a concealed marker. */
|
|
25
|
+
start: number | null;
|
|
26
|
+
/** Link target, present only on `link` runs. */
|
|
27
|
+
href?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An emphasis span opened by a delimiter at some index, once its close is found. */
|
|
31
|
+
interface Span {
|
|
32
|
+
/** Length of the opening marker (e.g. 2 for `**`). */
|
|
33
|
+
openLength: number;
|
|
34
|
+
/** Length of the closing marker. */
|
|
35
|
+
closeLength: number;
|
|
36
|
+
/** Index in `text` where the closing marker starts. */
|
|
37
|
+
closeIndex: number;
|
|
38
|
+
/** Emphasis role applied to the span's (recursively parsed) content. */
|
|
39
|
+
role: "strong" | "em" | "strike";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PUNCTUATION = new Set(["*", "_", "`", "~", "[", "]", "(", ")", "\\"]);
|
|
43
|
+
|
|
44
|
+
/** Tokenize one block's text into visible + concealed-marker runs, in order. */
|
|
45
|
+
export function inlineRuns(text: string): InlineRun[] {
|
|
46
|
+
const runs: InlineRun[] = [];
|
|
47
|
+
tokenize(text, 0, "text", runs);
|
|
48
|
+
return runs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Walk `source` (whose first character sits at absolute offset `base`), emitting
|
|
53
|
+
* plain text as `role` runs and recursing into emphasis spans. Links and code
|
|
54
|
+
* spans are handled inline because their content is not further emphasized the
|
|
55
|
+
* same way.
|
|
56
|
+
*/
|
|
57
|
+
function tokenize(source: string, base: number, role: InlineRole, out: InlineRun[]): void {
|
|
58
|
+
let plainStart = 0;
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
const flushPlain = (end: number): void => {
|
|
61
|
+
if (end > plainStart) {
|
|
62
|
+
out.push({ text: source.slice(plainStart, end), role, start: base + plainStart });
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
while (cursor < source.length) {
|
|
67
|
+
const char = source[cursor]!;
|
|
68
|
+
|
|
69
|
+
if (char === "\\" && cursor + 1 < source.length && PUNCTUATION.has(source[cursor + 1]!)) {
|
|
70
|
+
flushPlain(cursor);
|
|
71
|
+
out.push({ text: "\\", role: "marker", start: null });
|
|
72
|
+
out.push({ text: source[cursor + 1]!, role, start: base + cursor + 1 });
|
|
73
|
+
cursor += 2;
|
|
74
|
+
plainStart = cursor;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const link = matchLink(source, cursor);
|
|
79
|
+
if (link) {
|
|
80
|
+
flushPlain(cursor);
|
|
81
|
+
out.push({ text: source.slice(cursor, link.labelStart), role: "marker", start: null });
|
|
82
|
+
out.push({
|
|
83
|
+
text: source.slice(link.labelStart, link.labelEnd),
|
|
84
|
+
role: "link",
|
|
85
|
+
start: base + link.labelStart,
|
|
86
|
+
href: source.slice(link.hrefStart, link.hrefEnd),
|
|
87
|
+
});
|
|
88
|
+
out.push({ text: source.slice(link.labelEnd, link.end), role: "marker", start: null });
|
|
89
|
+
cursor = link.end;
|
|
90
|
+
plainStart = cursor;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const code = matchCode(source, cursor);
|
|
95
|
+
if (code) {
|
|
96
|
+
flushPlain(cursor);
|
|
97
|
+
out.push({ text: source.slice(cursor, code.contentStart), role: "marker", start: null });
|
|
98
|
+
out.push({
|
|
99
|
+
text: source.slice(code.contentStart, code.contentEnd),
|
|
100
|
+
role: "code",
|
|
101
|
+
start: base + code.contentStart,
|
|
102
|
+
});
|
|
103
|
+
out.push({ text: source.slice(code.contentEnd, code.end), role: "marker", start: null });
|
|
104
|
+
cursor = code.end;
|
|
105
|
+
plainStart = cursor;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const span = matchEmphasis(source, cursor);
|
|
110
|
+
if (span) {
|
|
111
|
+
flushPlain(cursor);
|
|
112
|
+
out.push({
|
|
113
|
+
text: source.slice(cursor, cursor + span.openLength),
|
|
114
|
+
role: "marker",
|
|
115
|
+
start: null,
|
|
116
|
+
});
|
|
117
|
+
const contentStart = cursor + span.openLength;
|
|
118
|
+
tokenize(source.slice(contentStart, span.closeIndex), base + contentStart, span.role, out);
|
|
119
|
+
out.push({
|
|
120
|
+
text: source.slice(span.closeIndex, span.closeIndex + span.closeLength),
|
|
121
|
+
role: "marker",
|
|
122
|
+
start: null,
|
|
123
|
+
});
|
|
124
|
+
cursor = span.closeIndex + span.closeLength;
|
|
125
|
+
plainStart = cursor;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
cursor++;
|
|
130
|
+
}
|
|
131
|
+
flushPlain(source.length);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A `[label](url)` link at `index`, or null. Empty label or url does not match. */
|
|
135
|
+
function matchLink(
|
|
136
|
+
text: string,
|
|
137
|
+
index: number,
|
|
138
|
+
): {
|
|
139
|
+
labelStart: number;
|
|
140
|
+
labelEnd: number;
|
|
141
|
+
hrefStart: number;
|
|
142
|
+
hrefEnd: number;
|
|
143
|
+
end: number;
|
|
144
|
+
} | null {
|
|
145
|
+
if (text[index] !== "[") return null;
|
|
146
|
+
const labelStart = index + 1;
|
|
147
|
+
const labelEnd = text.indexOf("]", labelStart);
|
|
148
|
+
if (labelEnd < 0 || labelEnd === labelStart) return null;
|
|
149
|
+
if (text[labelEnd + 1] !== "(") return null;
|
|
150
|
+
const hrefStart = labelEnd + 2;
|
|
151
|
+
// the destination may carry balanced parentheses (wiki URLs); the span ends
|
|
152
|
+
// at the first unmatched closer
|
|
153
|
+
let depth = 0;
|
|
154
|
+
for (let scan = hrefStart; scan < text.length; scan++) {
|
|
155
|
+
const character = text[scan];
|
|
156
|
+
if (character === "(") depth++;
|
|
157
|
+
else if (character === ")") {
|
|
158
|
+
if (depth > 0) {
|
|
159
|
+
depth--;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (scan === hrefStart) return null;
|
|
163
|
+
return { labelStart, labelEnd, hrefStart, hrefEnd: scan, end: scan + 1 };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A `` `code` `` span at `index` (n backticks .. n backticks), or null. */
|
|
170
|
+
function matchCode(
|
|
171
|
+
text: string,
|
|
172
|
+
index: number,
|
|
173
|
+
): { contentStart: number; contentEnd: number; end: number } | null {
|
|
174
|
+
if (text[index] !== "`") return null;
|
|
175
|
+
let fence = 0;
|
|
176
|
+
while (text[index + fence] === "`") fence++;
|
|
177
|
+
const contentStart = index + fence;
|
|
178
|
+
const closer = "`".repeat(fence);
|
|
179
|
+
const contentEnd = text.indexOf(closer, contentStart);
|
|
180
|
+
if (contentEnd < 0 || contentEnd === contentStart) return null;
|
|
181
|
+
return { contentStart, contentEnd, end: contentEnd + fence };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A `**strong**`, `*em*`, or `~~strike~~` span at `index`, or null. */
|
|
185
|
+
function matchEmphasis(text: string, index: number): Span | null {
|
|
186
|
+
const two = text.slice(index, index + 2);
|
|
187
|
+
if (two === "**") return closingSpan(text, index, 2, "strong");
|
|
188
|
+
if (two === "~~") return closingSpan(text, index, 2, "strike");
|
|
189
|
+
if (text[index] === "*") return closingSpan(text, index, 1, "em");
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Find the matching close for a marker of `length` at `index`; null if unbalanced. */
|
|
194
|
+
function closingSpan(
|
|
195
|
+
text: string,
|
|
196
|
+
index: number,
|
|
197
|
+
length: number,
|
|
198
|
+
role: "strong" | "em" | "strike",
|
|
199
|
+
): Span | null {
|
|
200
|
+
const marker = text.slice(index, index + length);
|
|
201
|
+
const contentStart = index + length;
|
|
202
|
+
const closeIndex =
|
|
203
|
+
length === 1 ? findLoneStar(text, contentStart) : text.indexOf(marker, contentStart);
|
|
204
|
+
if (closeIndex < 0 || closeIndex === contentStart) return null; // unbalanced or empty
|
|
205
|
+
return { openLength: length, closeLength: length, closeIndex, role };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Index of the next standalone `*` (not part of a `**` run), or -1. */
|
|
209
|
+
function findLoneStar(text: string, from: number): number {
|
|
210
|
+
let index = from;
|
|
211
|
+
while (index < text.length) {
|
|
212
|
+
if (text[index] === "*") {
|
|
213
|
+
if (text[index + 1] !== "*") return index;
|
|
214
|
+
while (text[index] === "*") index++; // skip the whole `**` run
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
index++;
|
|
218
|
+
}
|
|
219
|
+
return -1;
|
|
220
|
+
}
|