@charcuterie/ui 3.2.0 → 3.4.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/dist/Field/Field.d.ts +1 -1
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +19 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +21 -8
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.d.ts +32 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownCommands.js +47 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -1
- package/dist/MarkdownEditor/markdownSpans.js +30 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -1
- package/dist/Select/Select.d.ts +43 -2
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js +27 -2
- package/dist/Select/Select.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts +93 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +545 -0
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -0
- package/dist/markdownEditorCodeMirror/index.d.ts +16 -0
- package/dist/markdownEditorCodeMirror/index.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/index.js +14 -0
- package/dist/markdownEditorCodeMirror/index.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts +40 -0
- package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreview.js +428 -0
- package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +128 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js +345 -0
- package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -0
- package/package.json +36 -1
- package/src/Field/Field.mdx +2 -2
- package/src/Field/Field.stories.tsx +9 -5
- package/src/Field/Field.test.tsx +43 -6
- package/src/Field/Field.tsx +19 -1
- package/src/MarkdownEditor/MarkdownEditor.tsx +33 -7
- package/src/MarkdownEditor/markdownCommands.test.ts +55 -0
- package/src/MarkdownEditor/markdownCommands.ts +60 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +65 -0
- package/src/MarkdownEditor/markdownSpans.ts +35 -0
- package/src/Select/Select.mdx +27 -1
- package/src/Select/Select.stories.tsx +6 -1
- package/src/Select/Select.tsx +43 -2
- package/src/index.ts +5 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +144 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +147 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +866 -0
- package/src/markdownEditorCodeMirror/index.ts +26 -0
- package/src/markdownEditorCodeMirror/livePreview.ts +535 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +431 -0
- package/src/markdownEditorCodeMirror/livePreviewRanges.ts +581 -0
- package/src/sourceRules.test.ts +20 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown → the ranges a live-preview surface decorates, as pure
|
|
3
|
+
* tree-in / descriptors-out.
|
|
4
|
+
*
|
|
5
|
+
* This is the half of the CodeMirror editor that decides what a
|
|
6
|
+
* document *looks* like, and it is deliberately the half with no
|
|
7
|
+
* `@codemirror/view` import in it. Descriptors are plain
|
|
8
|
+
* `{ from, to, type }` objects; turning them into `Decoration`s is
|
|
9
|
+
* `livePreview.ts`'s job. The split buys two things:
|
|
10
|
+
*
|
|
11
|
+
* - **These tests run in Node.** `packages/ui`'s Vitest project is
|
|
12
|
+
* `.ts`-only and has no DOM, on purpose. A test here parses real
|
|
13
|
+
* markdown with the real GFM parser and asserts real offsets —
|
|
14
|
+
* no jsdom, no mount, no story.
|
|
15
|
+
* - **The runtime stays incremental.** Tests hand in
|
|
16
|
+
* `markdownLanguage.parser.parse(text)`; the view hands in
|
|
17
|
+
* `syntaxTree(state)`, which CodeMirror has already reparsed
|
|
18
|
+
* incrementally. Same function, same offsets, and the expensive
|
|
19
|
+
* path is never the one under test.
|
|
20
|
+
*
|
|
21
|
+
* ### What separates this from `markdownSpans.ts`
|
|
22
|
+
*
|
|
23
|
+
* The sibling `MarkdownEditor` paints *behind* a `<textarea>`, so
|
|
24
|
+
* every span it emits has to be metric-neutral — same glyph
|
|
25
|
+
* advances, or the caret shears away from the letters. That is the
|
|
26
|
+
* constraint this module does **not** have. CodeMirror owns the
|
|
27
|
+
* caret and reflows around decorations, so a heading can genuinely
|
|
28
|
+
* be larger, `**` can genuinely be hidden, and an image can
|
|
29
|
+
* genuinely replace its own markup.
|
|
30
|
+
*
|
|
31
|
+
* That is the entire reason this subpath exists, and it is also why
|
|
32
|
+
* it is a subpath: the capability costs ~176 KB gz of optional peer
|
|
33
|
+
* and only a consumer that opts in should pay it.
|
|
34
|
+
*
|
|
35
|
+
* ### The one invariant
|
|
36
|
+
*
|
|
37
|
+
* **Concealment never touches the document.** Every descriptor here
|
|
38
|
+
* describes a *view* over an unmodified markdown string. Hiding
|
|
39
|
+
* `**` does not delete it; replacing `` with an image
|
|
40
|
+
* does not rewrite it. The stored value stays byte-for-byte what
|
|
41
|
+
* the user typed, which is the same no-HTML-in-the-data guarantee
|
|
42
|
+
* the textarea version gets structurally — kept here by never
|
|
43
|
+
* having a serialiser at all.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import type { SyntaxNode, Tree } from "@lezer/common"
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A block-level treatment applied to a whole line.
|
|
50
|
+
*
|
|
51
|
+
* Headings carry their level because size is the point: this is the
|
|
52
|
+
* "block-level type scaling" the textarea version had to give up.
|
|
53
|
+
*/
|
|
54
|
+
export type LivePreviewLineKind =
|
|
55
|
+
| "blockquote"
|
|
56
|
+
| "code"
|
|
57
|
+
| "heading1"
|
|
58
|
+
| "heading2"
|
|
59
|
+
| "heading3"
|
|
60
|
+
| "heading4"
|
|
61
|
+
| "heading5"
|
|
62
|
+
| "heading6"
|
|
63
|
+
| "table"
|
|
64
|
+
|
|
65
|
+
/** An inline treatment applied to a character range. */
|
|
66
|
+
export type LivePreviewMarkKind =
|
|
67
|
+
| "autolink"
|
|
68
|
+
| "code"
|
|
69
|
+
| "emphasis"
|
|
70
|
+
| "linkText"
|
|
71
|
+
| "strikethrough"
|
|
72
|
+
| "strong"
|
|
73
|
+
| "url"
|
|
74
|
+
|
|
75
|
+
export type LivePreviewRange = {
|
|
76
|
+
from: number
|
|
77
|
+
to: number
|
|
78
|
+
} & (
|
|
79
|
+
| {
|
|
80
|
+
/** A rendered image, standing in for its own ``. */
|
|
81
|
+
alt: string
|
|
82
|
+
type: "image"
|
|
83
|
+
url: string
|
|
84
|
+
}
|
|
85
|
+
| {
|
|
86
|
+
/** A rendered task checkbox, standing in for `[ ]` / `[x]`. */
|
|
87
|
+
isChecked: boolean
|
|
88
|
+
/**
|
|
89
|
+
* The item's own text, which becomes the checkbox's
|
|
90
|
+
* accessible name — a checkbox inside a `contenteditable` has
|
|
91
|
+
* no `<label>` to inherit one from.
|
|
92
|
+
*/
|
|
93
|
+
label: string
|
|
94
|
+
type: "task"
|
|
95
|
+
}
|
|
96
|
+
| {
|
|
97
|
+
/**
|
|
98
|
+
* Syntax the reader does not need: `**`, `#`, `[`, `](…)`.
|
|
99
|
+
*
|
|
100
|
+
* `isConcealed` is the caret's answer, not the parser's. When
|
|
101
|
+
* the selection is inside the construct — or the whole
|
|
102
|
+
* surface is in raw mode — the marker stays in the flow and
|
|
103
|
+
* is merely dimmed, which is what makes the syntax editable
|
|
104
|
+
* at all.
|
|
105
|
+
*/
|
|
106
|
+
isConcealed: boolean
|
|
107
|
+
type: "marker"
|
|
108
|
+
}
|
|
109
|
+
| {
|
|
110
|
+
lineKind: LivePreviewLineKind
|
|
111
|
+
type: "line"
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
markKind: LivePreviewMarkKind
|
|
115
|
+
type: "mark"
|
|
116
|
+
/** Present on `linkText` and `autolink`; the click target. */
|
|
117
|
+
url?: string
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
/** A selection range, in the two fields this module needs. */
|
|
122
|
+
export type LivePreviewSelection = {
|
|
123
|
+
from: number
|
|
124
|
+
to: number
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ToLivePreviewRangesOptions = {
|
|
128
|
+
/**
|
|
129
|
+
* Limit the walk to a slice of the document — the view passes its
|
|
130
|
+
* visible ranges, so a long description costs what is on screen
|
|
131
|
+
* rather than what is stored. Omit both and the whole tree is
|
|
132
|
+
* walked, which is what the tests do.
|
|
133
|
+
*/
|
|
134
|
+
from?: number
|
|
135
|
+
/**
|
|
136
|
+
* Raw mode — the "edit Markdown" toggle.
|
|
137
|
+
*
|
|
138
|
+
* Nothing is concealed, nothing is replaced by a widget, and no
|
|
139
|
+
* line is rescaled. Syntax colouring stays, because the point of
|
|
140
|
+
* the toggle is to see the markup, not to lose the highlighting.
|
|
141
|
+
*/
|
|
142
|
+
isRawMode?: boolean
|
|
143
|
+
selections: readonly LivePreviewSelection[]
|
|
144
|
+
text: string
|
|
145
|
+
to?: number
|
|
146
|
+
tree: Tree
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const HEADING_LINE_KINDS: Record<
|
|
150
|
+
string,
|
|
151
|
+
LivePreviewLineKind
|
|
152
|
+
> = {
|
|
153
|
+
ATXHeading1: "heading1",
|
|
154
|
+
ATXHeading2: "heading2",
|
|
155
|
+
ATXHeading3: "heading3",
|
|
156
|
+
ATXHeading4: "heading4",
|
|
157
|
+
ATXHeading5: "heading5",
|
|
158
|
+
ATXHeading6: "heading6",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const INLINE_MARK_KINDS: Record<
|
|
162
|
+
string,
|
|
163
|
+
LivePreviewMarkKind
|
|
164
|
+
> = {
|
|
165
|
+
Emphasis: "emphasis",
|
|
166
|
+
InlineCode: "code",
|
|
167
|
+
Strikethrough: "strikethrough",
|
|
168
|
+
StrongEmphasis: "strong",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Marks that vanish with their construct once the caret leaves. */
|
|
172
|
+
const CONCEALABLE_MARK_NAMES = new Set([
|
|
173
|
+
"CodeMark",
|
|
174
|
+
"EmphasisMark",
|
|
175
|
+
"HeaderMark",
|
|
176
|
+
"LinkMark",
|
|
177
|
+
"QuoteMark",
|
|
178
|
+
"StrikethroughMark",
|
|
179
|
+
])
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A fence is structure, not noise.
|
|
183
|
+
*
|
|
184
|
+
* `CodeMark` covers both `` ` `` and ```` ``` ````, and concealing
|
|
185
|
+
* the fenced kind leaves a blank line at the top and bottom of the
|
|
186
|
+
* block where the fence used to be — which reads as a rendering bug
|
|
187
|
+
* rather than as tidiness. The language tag goes with it, for the
|
|
188
|
+
* same reason Obsidian keeps it: it is the one part of a code block
|
|
189
|
+
* that says what the code *is*.
|
|
190
|
+
*/
|
|
191
|
+
const FENCED_CODE_PARENTS = new Set(["FencedCode"])
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Marks that are always visible, only dimmed.
|
|
195
|
+
*
|
|
196
|
+
* Just the list bullet, and the distinction from `QuoteMark` is the
|
|
197
|
+
* interesting part. A blockquote gets a line decoration — an accent
|
|
198
|
+
* bar down its inline start — so the `>` is *redundant* once that
|
|
199
|
+
* bar is drawn, and Obsidian hides it for exactly that reason. A
|
|
200
|
+
* bullet has no such affordance: hide the `-` and an unordered list
|
|
201
|
+
* becomes indented prose. Obsidian substitutes a `•` glyph there,
|
|
202
|
+
* which means choosing a width, and this module has no business
|
|
203
|
+
* choosing one.
|
|
204
|
+
*
|
|
205
|
+
* So the rule is not "block marks persist" — it is **a mark
|
|
206
|
+
* persists unless something else already says what it said.**
|
|
207
|
+
*/
|
|
208
|
+
const PERSISTENT_MARK_NAMES = new Set(["ListMark"])
|
|
209
|
+
|
|
210
|
+
const isInside = (
|
|
211
|
+
selections: readonly LivePreviewSelection[],
|
|
212
|
+
from: number,
|
|
213
|
+
to: number,
|
|
214
|
+
) =>
|
|
215
|
+
selections.some(
|
|
216
|
+
(selection) =>
|
|
217
|
+
selection.from <= to && selection.to >= from,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
/** The start offset of the line containing `offset`. */
|
|
221
|
+
const toLineStart = (text: string, offset: number) => {
|
|
222
|
+
const previousBreak = text.lastIndexOf("\n", offset - 1)
|
|
223
|
+
|
|
224
|
+
return previousBreak + 1
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The end offset (exclusive of `\n`) of that line. */
|
|
228
|
+
const toLineEnd = (text: string, offset: number) => {
|
|
229
|
+
const nextBreak = text.indexOf("\n", offset)
|
|
230
|
+
|
|
231
|
+
return nextBreak === -1 ? text.length : nextBreak
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const toChildNamed = (
|
|
235
|
+
node: SyntaxNode,
|
|
236
|
+
name: string,
|
|
237
|
+
): SyntaxNode | null => {
|
|
238
|
+
for (
|
|
239
|
+
let child = node.firstChild;
|
|
240
|
+
child;
|
|
241
|
+
child = child.nextSibling
|
|
242
|
+
) {
|
|
243
|
+
if (child.name === name) {
|
|
244
|
+
return child
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return null
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* A heading's `#` run, plus the whitespace after it.
|
|
253
|
+
*
|
|
254
|
+
* Concealing `#` alone would leave the heading text indented by the
|
|
255
|
+
* space that used to separate them — a ragged left edge that reads
|
|
256
|
+
* as a bug. The parser hands back only the hashes, so the trailing
|
|
257
|
+
* run is walked here.
|
|
258
|
+
*/
|
|
259
|
+
const toHeaderMarkEnd = (text: string, to: number) => {
|
|
260
|
+
let end = to
|
|
261
|
+
|
|
262
|
+
while (end < text.length) {
|
|
263
|
+
const character = text[end]
|
|
264
|
+
|
|
265
|
+
if (character !== " " && character !== "\t") {
|
|
266
|
+
break
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
end += 1
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return end
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Every decoration the document wants, in tree order.
|
|
277
|
+
*
|
|
278
|
+
* Order is not sorted here on purpose — `Decoration.set(ranges,
|
|
279
|
+
* true)` sorts, and doing it twice is the sort of thing that looks
|
|
280
|
+
* free until a document is long.
|
|
281
|
+
*/
|
|
282
|
+
export const toLivePreviewRanges = ({
|
|
283
|
+
from,
|
|
284
|
+
isRawMode = false,
|
|
285
|
+
selections,
|
|
286
|
+
text,
|
|
287
|
+
to,
|
|
288
|
+
tree,
|
|
289
|
+
}: ToLivePreviewRangesOptions): LivePreviewRange[] => {
|
|
290
|
+
const ranges: LivePreviewRange[] = []
|
|
291
|
+
|
|
292
|
+
const pushMarker = (
|
|
293
|
+
from: number,
|
|
294
|
+
to: number,
|
|
295
|
+
isRevealed: boolean,
|
|
296
|
+
) => {
|
|
297
|
+
if (from >= to) {
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
ranges.push({
|
|
302
|
+
from,
|
|
303
|
+
isConcealed: !isRawMode && !isRevealed,
|
|
304
|
+
to,
|
|
305
|
+
type: "marker",
|
|
306
|
+
})
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
tree.iterate({
|
|
310
|
+
from,
|
|
311
|
+
to,
|
|
312
|
+
enter: (nodeRef) => {
|
|
313
|
+
const { name } = nodeRef
|
|
314
|
+
|
|
315
|
+
const headingKind = HEADING_LINE_KINDS[name]
|
|
316
|
+
|
|
317
|
+
if (headingKind) {
|
|
318
|
+
if (!isRawMode) {
|
|
319
|
+
const lineStart = toLineStart(text, nodeRef.from)
|
|
320
|
+
|
|
321
|
+
// Zero-length, at the line start. A line decoration is
|
|
322
|
+
// anchored to a position, not a span — give it a real
|
|
323
|
+
// range and CodeMirror throws rather than paints.
|
|
324
|
+
ranges.push({
|
|
325
|
+
from: lineStart,
|
|
326
|
+
lineKind: headingKind,
|
|
327
|
+
to: lineStart,
|
|
328
|
+
type: "line",
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return true
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (name === "Blockquote" || name === "FencedCode") {
|
|
336
|
+
if (!isRawMode) {
|
|
337
|
+
// One line decoration per line: CodeMirror anchors a line
|
|
338
|
+
// decoration at a line start, so a multi-line block needs
|
|
339
|
+
// one per line rather than one spanning range.
|
|
340
|
+
for (
|
|
341
|
+
let offset = toLineStart(text, nodeRef.from);
|
|
342
|
+
offset < nodeRef.to;
|
|
343
|
+
offset = toLineEnd(text, offset) + 1
|
|
344
|
+
) {
|
|
345
|
+
ranges.push({
|
|
346
|
+
from: offset,
|
|
347
|
+
lineKind:
|
|
348
|
+
name === "Blockquote"
|
|
349
|
+
? "blockquote"
|
|
350
|
+
: "code",
|
|
351
|
+
to: offset,
|
|
352
|
+
type: "line",
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (name === "Table") {
|
|
361
|
+
if (!isRawMode) {
|
|
362
|
+
for (
|
|
363
|
+
let offset = toLineStart(text, nodeRef.from);
|
|
364
|
+
offset < nodeRef.to;
|
|
365
|
+
offset = toLineEnd(text, offset) + 1
|
|
366
|
+
) {
|
|
367
|
+
ranges.push({
|
|
368
|
+
from: offset,
|
|
369
|
+
lineKind: "table",
|
|
370
|
+
to: offset,
|
|
371
|
+
type: "line",
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return true
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const inlineKind = INLINE_MARK_KINDS[name]
|
|
380
|
+
|
|
381
|
+
if (inlineKind) {
|
|
382
|
+
ranges.push({
|
|
383
|
+
from: nodeRef.from,
|
|
384
|
+
markKind: inlineKind,
|
|
385
|
+
to: nodeRef.to,
|
|
386
|
+
type: "mark",
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
return true
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (name === "Image") {
|
|
393
|
+
const node = nodeRef.node
|
|
394
|
+
|
|
395
|
+
const urlNode = toChildNamed(node, "URL")
|
|
396
|
+
|
|
397
|
+
const openMark = node.firstChild
|
|
398
|
+
|
|
399
|
+
const closeMark = openMark?.nextSibling
|
|
400
|
+
|
|
401
|
+
// An image whose caret is inside it is markup again: you
|
|
402
|
+
// cannot edit a URL you cannot see.
|
|
403
|
+
if (
|
|
404
|
+
!isRawMode &&
|
|
405
|
+
urlNode &&
|
|
406
|
+
openMark &&
|
|
407
|
+
closeMark &&
|
|
408
|
+
!isInside(selections, node.from, node.to)
|
|
409
|
+
) {
|
|
410
|
+
ranges.push({
|
|
411
|
+
alt: text.slice(openMark.to, closeMark.from),
|
|
412
|
+
from: node.from,
|
|
413
|
+
to: node.to,
|
|
414
|
+
type: "image",
|
|
415
|
+
url: text.slice(urlNode.from, urlNode.to),
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
return false
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return true
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (name === "Link") {
|
|
425
|
+
const node = nodeRef.node
|
|
426
|
+
|
|
427
|
+
const urlNode = toChildNamed(node, "URL")
|
|
428
|
+
|
|
429
|
+
const isRevealed = isInside(
|
|
430
|
+
selections,
|
|
431
|
+
node.from,
|
|
432
|
+
node.to,
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
const openMark = node.firstChild
|
|
436
|
+
|
|
437
|
+
const closeMark = openMark?.nextSibling
|
|
438
|
+
|
|
439
|
+
if (urlNode && openMark && closeMark) {
|
|
440
|
+
ranges.push({
|
|
441
|
+
from: openMark.to,
|
|
442
|
+
markKind: "linkText",
|
|
443
|
+
to: closeMark.from,
|
|
444
|
+
type: "mark",
|
|
445
|
+
url: text.slice(urlNode.from, urlNode.to),
|
|
446
|
+
})
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Children are walked so nested emphasis inside link text
|
|
450
|
+
// still paints, but the marks are emitted here where the
|
|
451
|
+
// whole construct's reveal state is known.
|
|
452
|
+
pushMarker(
|
|
453
|
+
node.from,
|
|
454
|
+
openMark?.to ?? node.from,
|
|
455
|
+
isRevealed,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
if (closeMark) {
|
|
459
|
+
pushMarker(closeMark.from, node.to, isRevealed)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return true
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (name === "URL") {
|
|
466
|
+
// A bare URL. GFM's autolink emits a `URL` with no `Link`
|
|
467
|
+
// parent, which is the whole of gap 2 in Docket's handoff:
|
|
468
|
+
// paste a link, get a link, with no `[](…)` typed at all.
|
|
469
|
+
const parentName = nodeRef.node.parent?.name
|
|
470
|
+
|
|
471
|
+
if (
|
|
472
|
+
parentName !== "Link" &&
|
|
473
|
+
parentName !== "Image"
|
|
474
|
+
) {
|
|
475
|
+
ranges.push({
|
|
476
|
+
from: nodeRef.from,
|
|
477
|
+
markKind: "autolink",
|
|
478
|
+
to: nodeRef.to,
|
|
479
|
+
type: "mark",
|
|
480
|
+
url: text.slice(nodeRef.from, nodeRef.to),
|
|
481
|
+
})
|
|
482
|
+
} else {
|
|
483
|
+
ranges.push({
|
|
484
|
+
from: nodeRef.from,
|
|
485
|
+
markKind: "url",
|
|
486
|
+
to: nodeRef.to,
|
|
487
|
+
type: "mark",
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return true
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (name === "TaskMarker") {
|
|
495
|
+
const isRevealed = isInside(
|
|
496
|
+
selections,
|
|
497
|
+
nodeRef.from,
|
|
498
|
+
nodeRef.to,
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
if (!isRawMode && !isRevealed) {
|
|
502
|
+
ranges.push({
|
|
503
|
+
from: nodeRef.from,
|
|
504
|
+
isChecked: text[nodeRef.from + 1] !== " ",
|
|
505
|
+
label: text
|
|
506
|
+
.slice(
|
|
507
|
+
nodeRef.to,
|
|
508
|
+
toLineEnd(text, nodeRef.to),
|
|
509
|
+
)
|
|
510
|
+
.trim(),
|
|
511
|
+
to: nodeRef.to,
|
|
512
|
+
type: "task",
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
return false
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
pushMarker(nodeRef.from, nodeRef.to, isRevealed)
|
|
519
|
+
|
|
520
|
+
return false
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (PERSISTENT_MARK_NAMES.has(name)) {
|
|
524
|
+
pushMarker(nodeRef.from, nodeRef.to, true)
|
|
525
|
+
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (name === "TableDelimiter") {
|
|
530
|
+
pushMarker(nodeRef.from, nodeRef.to, true)
|
|
531
|
+
|
|
532
|
+
return false
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (name === "CodeInfo") {
|
|
536
|
+
pushMarker(nodeRef.from, nodeRef.to, true)
|
|
537
|
+
|
|
538
|
+
return false
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (CONCEALABLE_MARK_NAMES.has(name)) {
|
|
542
|
+
const parent = nodeRef.node.parent
|
|
543
|
+
|
|
544
|
+
if (
|
|
545
|
+
parent &&
|
|
546
|
+
FENCED_CODE_PARENTS.has(parent.name)
|
|
547
|
+
) {
|
|
548
|
+
pushMarker(nodeRef.from, nodeRef.to, true)
|
|
549
|
+
|
|
550
|
+
return false
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// A link's marks were already emitted above, with the
|
|
554
|
+
// link's own reveal state spanning both sides of the text.
|
|
555
|
+
// An image's were not — it either replaced itself outright
|
|
556
|
+
// or fell through to here, where the generic parent-range
|
|
557
|
+
// rule is already the correct one.
|
|
558
|
+
if (parent?.name === "Link") {
|
|
559
|
+
return false
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
const isRevealed = parent
|
|
563
|
+
? isInside(selections, parent.from, parent.to)
|
|
564
|
+
: isInside(selections, nodeRef.from, nodeRef.to)
|
|
565
|
+
|
|
566
|
+
const to =
|
|
567
|
+
name === "HeaderMark"
|
|
568
|
+
? toHeaderMarkEnd(text, nodeRef.to)
|
|
569
|
+
: nodeRef.to
|
|
570
|
+
|
|
571
|
+
pushMarker(nodeRef.from, to, isRevealed)
|
|
572
|
+
|
|
573
|
+
return false
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return true
|
|
577
|
+
},
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
return ranges
|
|
581
|
+
}
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -561,6 +561,26 @@ const ENTRY_POINT_RUNTIMES: Record<
|
|
|
561
561
|
// silently, which is how it shipped broken in three repos.
|
|
562
562
|
"tailwind-merge",
|
|
563
563
|
],
|
|
564
|
+
// The opt-in live-preview editor. Every `@codemirror/*` and
|
|
565
|
+
// `@lezer/*` package appears **only** here, as an optional peer,
|
|
566
|
+
// which is the entire reason it is a subpath: measured at ~176 KB
|
|
567
|
+
// gz, it is 2.5x the total bundle budget of `slatecast`, and a
|
|
568
|
+
// consumer that renders a description field should not pay it to
|
|
569
|
+
// get one. The main entry's `MarkdownEditor` stays dependency-free
|
|
570
|
+
// and stays the default.
|
|
571
|
+
"./markdown-editor-codemirror": [
|
|
572
|
+
"@charcuterie/logic",
|
|
573
|
+
"@charcuterie/tokens",
|
|
574
|
+
"@codemirror/commands",
|
|
575
|
+
"@codemirror/lang-markdown",
|
|
576
|
+
"@codemirror/language",
|
|
577
|
+
"@codemirror/state",
|
|
578
|
+
"@codemirror/view",
|
|
579
|
+
"@floating-ui/react",
|
|
580
|
+
"@lezer/common",
|
|
581
|
+
"react",
|
|
582
|
+
"tailwind-merge",
|
|
583
|
+
],
|
|
564
584
|
// The router seam's one shipped adapter, and the reason it is a
|
|
565
585
|
// subpath rather than part of the barrel: `react-router` appears
|
|
566
586
|
// **only** here, as an optional peer, so the eleven consumers of
|