@charcuterie/ui 3.17.0 → 3.19.1

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 (81) hide show
  1. package/dist/Combobox/Combobox.d.ts +14 -1
  2. package/dist/Combobox/Combobox.d.ts.map +1 -1
  3. package/dist/Combobox/Combobox.js +124 -56
  4. package/dist/Combobox/Combobox.js.map +1 -1
  5. package/dist/Combobox/ComboboxOption.d.ts +4 -1
  6. package/dist/Combobox/ComboboxOption.d.ts.map +1 -1
  7. package/dist/Combobox/ComboboxOption.js +8 -2
  8. package/dist/Combobox/ComboboxOption.js.map +1 -1
  9. package/dist/Listbox/Listbox.d.ts +13 -1
  10. package/dist/Listbox/Listbox.d.ts.map +1 -1
  11. package/dist/Listbox/Listbox.js +4 -2
  12. package/dist/Listbox/Listbox.js.map +1 -1
  13. package/dist/Listbox/ListboxOption.d.ts +4 -1
  14. package/dist/Listbox/ListboxOption.d.ts.map +1 -1
  15. package/dist/Listbox/ListboxOption.js +7 -2
  16. package/dist/Listbox/ListboxOption.js.map +1 -1
  17. package/dist/Menu/Menu.d.ts +15 -1
  18. package/dist/Menu/Menu.d.ts.map +1 -1
  19. package/dist/Menu/Menu.js +39 -5
  20. package/dist/Menu/Menu.js.map +1 -1
  21. package/dist/Menu/MenuAction.d.ts +4 -1
  22. package/dist/Menu/MenuAction.d.ts.map +1 -1
  23. package/dist/Menu/MenuAction.js +22 -4
  24. package/dist/Menu/MenuAction.js.map +1 -1
  25. package/dist/Overlay/useAnchoredOverlay.d.ts +25 -1
  26. package/dist/Overlay/useAnchoredOverlay.d.ts.map +1 -1
  27. package/dist/Overlay/useAnchoredOverlay.js +8 -4
  28. package/dist/Overlay/useAnchoredOverlay.js.map +1 -1
  29. package/dist/Overlay/usePanelItemSize.d.ts +3 -0
  30. package/dist/Overlay/usePanelItemSize.d.ts.map +1 -0
  31. package/dist/Overlay/usePanelItemSize.js +59 -0
  32. package/dist/Overlay/usePanelItemSize.js.map +1 -0
  33. package/dist/controlStyles.d.ts +30 -0
  34. package/dist/controlStyles.d.ts.map +1 -1
  35. package/dist/controlStyles.js +34 -0
  36. package/dist/controlStyles.js.map +1 -1
  37. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  38. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
  39. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  40. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts +24 -1
  41. package/dist/markdownEditorCodeMirror/MarkdownView.d.ts.map +1 -1
  42. package/dist/markdownEditorCodeMirror/MarkdownView.js +14 -2
  43. package/dist/markdownEditorCodeMirror/MarkdownView.js.map +1 -1
  44. package/dist/markdownEditorCodeMirror/livePreview.d.ts +7 -0
  45. package/dist/markdownEditorCodeMirror/livePreview.d.ts.map +1 -1
  46. package/dist/markdownEditorCodeMirror/livePreview.js +28 -2
  47. package/dist/markdownEditorCodeMirror/livePreview.js.map +1 -1
  48. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts +22 -2
  49. package/dist/markdownEditorCodeMirror/livePreviewRanges.d.ts.map +1 -1
  50. package/dist/markdownEditorCodeMirror/livePreviewRanges.js +26 -7
  51. package/dist/markdownEditorCodeMirror/livePreviewRanges.js.map +1 -1
  52. package/dist/viewport.testHelpers.d.ts +18 -4
  53. package/dist/viewport.testHelpers.d.ts.map +1 -1
  54. package/dist/viewport.testHelpers.js +18 -6
  55. package/dist/viewport.testHelpers.js.map +1 -1
  56. package/package.json +2 -2
  57. package/src/Combobox/Combobox.mdx +26 -0
  58. package/src/Combobox/Combobox.stories.tsx +65 -0
  59. package/src/Combobox/Combobox.test.tsx +135 -0
  60. package/src/Combobox/Combobox.tsx +176 -83
  61. package/src/Combobox/ComboboxOption.tsx +11 -1
  62. package/src/Listbox/Listbox.mdx +24 -0
  63. package/src/Listbox/Listbox.stories.tsx +81 -5
  64. package/src/Listbox/Listbox.tsx +17 -0
  65. package/src/Listbox/ListboxOption.tsx +10 -1
  66. package/src/Menu/Menu.mdx +49 -0
  67. package/src/Menu/Menu.stories.tsx +113 -2
  68. package/src/Menu/Menu.test.tsx +129 -0
  69. package/src/Menu/Menu.tsx +62 -3
  70. package/src/Menu/MenuAction.tsx +29 -4
  71. package/src/Overlay/useAnchoredOverlay.ts +33 -5
  72. package/src/Overlay/usePanelItemSize.ts +71 -0
  73. package/src/controlStyles.ts +38 -0
  74. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.test.tsx +185 -0
  75. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +42 -1
  76. package/src/markdownEditorCodeMirror/MarkdownView.tsx +40 -1
  77. package/src/markdownEditorCodeMirror/livePreview.ts +40 -3
  78. package/src/markdownEditorCodeMirror/livePreviewRanges.test.ts +137 -0
  79. package/src/markdownEditorCodeMirror/livePreviewRanges.ts +50 -6
  80. package/src/sourceRules.test.ts +9 -0
  81. package/src/viewport.testHelpers.ts +21 -6
@@ -0,0 +1,71 @@
1
+ import { useMediaQuery } from "@charcuterie/logic"
2
+ import { matchMediaMatcher } from "@charcuterie/logic/browser"
3
+ import type { ControlSize } from "@charcuterie/tokens"
4
+
5
+ /**
6
+ * The requested row size, stepped down when the window is too short
7
+ * to spend the height.
8
+ *
9
+ * A fat option row is a bigger click target, which is the whole point
10
+ * of `itemSize` — but a nine-item menu at `lg` is 400px of panel, and
11
+ * on a half-height window that is the difference between a menu you
12
+ * read at a glance and a menu you scroll. So the size a panel asks
13
+ * for is a *ceiling*: below 40rem of viewport height it drops one
14
+ * step, and below 30rem it drops to the floor.
15
+ *
16
+ * ### Why a hook and not a media query in CSS
17
+ *
18
+ * Both were written. CSS is the library's usual answer — the density
19
+ * axis rewrites `--control-height-*` and nothing re-renders — but it
20
+ * cannot step *`text-sm` to `text-md`*: those are Tailwind bridges
21
+ * onto `--font-size-*` rather than variables a media query can
22
+ * reassign, and the alternative (`text-(length:--panel-item-font-size-lg)`
23
+ * everywhere, over nine new tokens) is the indirection
24
+ * `buildCss.ts` explicitly argues against. Stepping the whole
25
+ * `ControlSize` in JS keeps one literal class string per size, which
26
+ * is also what `tailwindCandidates.test.ts` can see.
27
+ *
28
+ * The re-render this costs happens on a window resize, not on a
29
+ * scheme or density flip — those stay repaints.
30
+ *
31
+ * ### `height`, not `width`
32
+ *
33
+ * Rows stack down the panel, so height is the axis that runs out.
34
+ * The Narrow View is a separate question and this is not it: a 390px
35
+ * phone held upright has plenty of height, and its menu stays fat.
36
+ */
37
+ const SHORT_VIEWPORT_QUERY = "(height <= 40rem)"
38
+
39
+ const VERY_SHORT_VIEWPORT_QUERY = "(height <= 30rem)"
40
+
41
+ const STEP_DOWN: Record<ControlSize, ControlSize> = {
42
+ sm: "sm",
43
+ md: "sm",
44
+ lg: "md",
45
+ }
46
+
47
+ export const usePanelItemSize = (
48
+ itemSize: ControlSize,
49
+ ): ControlSize => {
50
+ // Both hooks run unconditionally and the *narrower* one wins, so
51
+ // the two queries never disagree about which step applies.
52
+ const { isMatching: isShortViewport } = useMediaQuery({
53
+ matcher: matchMediaMatcher(SHORT_VIEWPORT_QUERY),
54
+ })
55
+
56
+ const { isMatching: isVeryShortViewport } = useMediaQuery(
57
+ {
58
+ matcher: matchMediaMatcher(VERY_SHORT_VIEWPORT_QUERY),
59
+ },
60
+ )
61
+
62
+ if (isVeryShortViewport) {
63
+ return "sm"
64
+ }
65
+
66
+ if (isShortViewport) {
67
+ return STEP_DOWN[itemSize]
68
+ }
69
+
70
+ return itemSize
71
+ }
@@ -38,6 +38,44 @@ export const CONTROL_SIZE_CLASS: Record<
38
38
  lg: "h-(--control-height-lg) gap-(--control-gap-lg) px-(--control-padding-inline-lg) text-lg",
39
39
  }
40
40
 
41
+ /**
42
+ * A row inside an overlay panel — a `Menu` item, a `Listbox` option,
43
+ * a `Combobox` option. The same three sizes as a control, read from
44
+ * the same density-aware tokens, so a `md` option row and a `md`
45
+ * `Button` are the same height.
46
+ *
47
+ * ### Why this is not `CONTROL_SIZE_CLASS`
48
+ *
49
+ * Two differences, and both are load-bearing.
50
+ *
51
+ * `min-h-`, not `h-`. A control on a form row holds one line by
52
+ * contract; an option may be rich — an icon, two lines, a trailing
53
+ * badge — and a fixed height clips it. The token is a **floor** here,
54
+ * which is what makes the row at least as big as the button that
55
+ * opened it while still growing for a label that wraps.
56
+ *
57
+ * `py-*`, which a control has none of. It is what a wrapped second
58
+ * line breathes on once `min-h-` stops being the binding constraint.
59
+ *
60
+ * ### The sizes are click targets, not decoration
61
+ *
62
+ * The owner's request is the whole reason this exists: *"I personally
63
+ * like larger click areas because it makes it easier to not mess up a
64
+ * click. I can be much faster the less precise I need to be when
65
+ * clicking."* At `comfortable` density `lg` is 2.75rem — 44px, the
66
+ * WCAG 2.5.5 target — and on the kiosk density it is 3.75rem with no
67
+ * prop change. See
68
+ * `docs/decisions/2026-08-25-a-panel-row-is-sized-by-itemsize-and-menus-default-to-lg.md`.
69
+ */
70
+ export const PANEL_ITEM_SIZE_CLASS: Record<
71
+ ControlSize,
72
+ string
73
+ > = {
74
+ sm: "min-h-(--control-height-sm) gap-(--control-gap-sm) px-(--control-padding-inline-sm) py-1 text-sm",
75
+ md: "min-h-(--control-height-md) gap-(--control-gap-md) px-(--control-padding-inline-md) py-1.5 text-md",
76
+ lg: "min-h-(--control-height-lg) gap-(--control-gap-lg) px-(--control-padding-inline-lg) py-2 text-lg",
77
+ }
78
+
41
79
  /**
42
80
  * Square, and sized by the *height* token on both axes so an icon
43
81
  * button lines up with the text button beside it in a toolbar. No
@@ -0,0 +1,185 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { mountStory } from "../mountStory.testHelpers.ts"
6
+ import * as stories from "./MarkdownEditorCodeMirror.stories.tsx"
7
+
8
+ const { AllStates, CaretReveal, Default } =
9
+ composeStories(stories)
10
+
11
+ /**
12
+ * The two ways this surface stopped behaving like the `<textarea>`
13
+ * it replaced. Both are invisible to every other gate: the stories
14
+ * render, the toolbar works, axe is clean, and the field is still
15
+ * one you cannot see the caret in and cannot click into.
16
+ */
17
+
18
+ /**
19
+ * Every `caret-color` this element is actually told, in cascade
20
+ * order, ignoring the suite's own override.
21
+ *
22
+ * A computed style cannot answer this question **here**:
23
+ * `FREEZE_MOTION_CSS` forces `caret-color: transparent !important`
24
+ * on `*` for the whole `ui-dom` suite, deliberately, because a
25
+ * blinking caret is a pixel diff that means nothing. So the test
26
+ * reads the declarations instead — which is the honest subject
27
+ * anyway, since what regressed is *whose rule wins*.
28
+ */
29
+ const caretDeclarationsFor = (element: Element) => {
30
+ const declarations: string[] = []
31
+
32
+ for (const sheet of Array.from(document.styleSheets)) {
33
+ let rules: CSSRuleList | null = null
34
+
35
+ try {
36
+ rules = sheet.cssRules
37
+ } catch {
38
+ // A cross-origin sheet throws on access and has nothing to
39
+ // say about a CodeMirror caret.
40
+ continue
41
+ }
42
+
43
+ for (const rule of Array.from(rules ?? [])) {
44
+ if (!(rule instanceof CSSStyleRule)) {
45
+ continue
46
+ }
47
+
48
+ const value =
49
+ rule.style.getPropertyValue("caret-color")
50
+
51
+ if (
52
+ value === "" ||
53
+ // The suite's own freeze, which is not part of the
54
+ // component's cascade.
55
+ rule.selectorText.includes("*")
56
+ ) {
57
+ continue
58
+ }
59
+
60
+ if (element.matches(rule.selectorText)) {
61
+ declarations.push(value)
62
+ }
63
+ }
64
+ }
65
+
66
+ return declarations
67
+ }
68
+
69
+ /**
70
+ * CodeMirror hard-codes the caret **black** for any editor whose
71
+ * theme did not declare itself dark — and Charcuterie's is one
72
+ * theme for both schemes, because every colour in it is a token. So
73
+ * the editor is always `cm-light`, and in dark mode the caret was
74
+ * black on `surface-base`:
75
+ *
76
+ * *"this comments area has a black blinking cursor in dark mode
77
+ * that I can't see."*
78
+ *
79
+ * It is `caret-color` and not `.cm-cursor` because `drawSelection`
80
+ * is **not** installed on this surface: the caret is the browser's
81
+ * own, so the property the browser reads is the only lever. Last
82
+ * declaration wins at equal specificity — two classes each — and
83
+ * the base theme is `Prec.lowest`, which is what puts it first in
84
+ * the sheet.
85
+ */
86
+ test("the caret is a token, not CodeMirror's black", async () => {
87
+ const { canvas } = await mountStory(CaretReveal)
88
+
89
+ const content = canvas.getByRole("textbox", {
90
+ name: "Description",
91
+ })
92
+
93
+ const declarations = caretDeclarationsFor(content)
94
+
95
+ await expect(declarations.at(-1)).toBe(
96
+ "var(--color-content-primary)",
97
+ )
98
+ })
99
+
100
+ /**
101
+ * The whole box takes a click, because the whole box IS the text
102
+ * field:
103
+ *
104
+ * *"the markdown box isn't a textarea, it's an input field or
105
+ * something. It should be the whole box that's clickable to type
106
+ * into."*
107
+ *
108
+ * `.cm-content` is the `contenteditable`, and it used to be as tall
109
+ * as its text — one line, in an empty field inside an 8rem frame.
110
+ * Everything below it belonged to the wrapper `<div>`, so the
111
+ * bottom three quarters of a text field focused nothing.
112
+ *
113
+ * The number is the frame's own minimum (`min-h-32`), so this fails
114
+ * on any change that puts the minimum back on a box the caret does
115
+ * not live in.
116
+ */
117
+ test("an empty field's text area fills the frame", async () => {
118
+ const { canvas } = await mountStory(AllStates)
119
+
120
+ const content = canvas.getByRole("textbox", {
121
+ name: "Empty description",
122
+ })
123
+
124
+ await waitFor(() => {
125
+ expect(
126
+ content.getBoundingClientRect().height,
127
+ ).toBeGreaterThanOrEqual(128)
128
+ })
129
+ })
130
+
131
+ /** And the point three quarters of the way down an empty field
132
+ * belongs to the document rather than to the frame around it. */
133
+ test("a click low in an empty field lands in the document", async () => {
134
+ const { canvas } = await mountStory(AllStates)
135
+
136
+ const content = canvas.getByRole("textbox", {
137
+ name: "Empty description",
138
+ })
139
+
140
+ const box = content.getBoundingClientRect()
141
+
142
+ const hit = document.elementFromPoint(
143
+ box.left + box.width / 2,
144
+ box.bottom - 8,
145
+ )
146
+
147
+ await expect(content.contains(hit)).toBe(true)
148
+
149
+ await userEvent.click(content)
150
+
151
+ await expect(
152
+ content === document.activeElement ||
153
+ content.contains(document.activeElement),
154
+ ).toBe(true)
155
+ })
156
+
157
+ /**
158
+ * The other end of the same change: filling the frame must not
159
+ * turn the frame into a scroll box.
160
+ *
161
+ * A description is read in place, and an editor that keeps its own
162
+ * 8rem window over a 40-line document is the shape this surface
163
+ * exists to avoid. The frame grows instead — which is what
164
+ * `min-height: auto` on a flex item preserves, and what a `height`
165
+ * anywhere in this stack would have quietly taken away.
166
+ */
167
+ test("a document taller than the frame grows it", async () => {
168
+ const { canvas } = await mountStory(Default)
169
+
170
+ const content = canvas.getByRole("textbox", {
171
+ name: "Description",
172
+ })
173
+
174
+ const scroller = content.parentElement as HTMLElement
175
+
176
+ await waitFor(() => {
177
+ expect(
178
+ content.getBoundingClientRect().height,
179
+ ).toBeGreaterThan(128)
180
+ })
181
+
182
+ await expect(scroller.scrollHeight).toBe(
183
+ scroller.clientHeight,
184
+ )
185
+ })
@@ -834,8 +834,49 @@ export const MarkdownEditorCodeMirror = ({
834
834
  />
835
835
  </div>
836
836
 
837
+ {/*
838
+ The frame's height reaches the `contenteditable`, so the
839
+ whole box takes a click.
840
+
841
+ A `<textarea>` is one box: it is as tall as you make it and
842
+ every pixel of it accepts the caret. CodeMirror is a stack
843
+ — `.cm-editor` in this host, `.cm-scroller` in that, and
844
+ the `contenteditable` `.cm-content` in that — and each of
845
+ those was only as tall as the text. A frame with a
846
+ `min-height` and a two-line document therefore had a large
847
+ band that belonged to *this div*, and pressing it focused
848
+ nothing:
849
+
850
+ *"the markdown box isn't a textarea, it's an input field
851
+ or something. It should be the whole box that's clickable
852
+ to type into."*
853
+
854
+ So the height is handed down the stack, one utility per
855
+ step, and each one is there because the step above it does
856
+ not arrive on its own:
857
+
858
+ 1. `flex flex-col` + `[&>.cm-editor]:flex-1` — a
859
+ `min-height` is not a definite height, so nothing
860
+ inside the editor could be a percentage *of* it.
861
+ 2. `[&_.cm-scroller]:flex-1` — CodeMirror's base theme
862
+ gives the scroller `height: 100%`, and that percentage
863
+ does not resolve against a parent whose own height came
864
+ out of flexing. Stretching it by flex does.
865
+ 3. `[&_.cm-content]:self-stretch` — the scroller is a flex
866
+ ROW pinned to `align-items: flex-start !important`, so
867
+ the content sits at its natural height. `align-self` is
868
+ the item's own property and is not what that
869
+ `!important` is on, which is why this is the one line
870
+ that reaches the box the caret actually lives in.
871
+
872
+ Everything here grows, and nothing clips: a document taller
873
+ than the frame still makes the frame taller rather than
874
+ scrolling inside it, which is what `min-height: auto` on a
875
+ flex item preserves. Measured both ways in
876
+ `MarkdownEditorCodeMirror.test.tsx`.
877
+ */}
837
878
  <div
838
- className="min-h-32 px-1 text-md cq-md:min-h-48"
879
+ className="flex min-h-32 flex-col px-1 text-md cq-md:min-h-48 [&>.cm-editor]:flex-1 [&_.cm-scroller]:flex-1 [&_.cm-content]:self-stretch"
839
880
  ref={hostRef}
840
881
  />
841
882
 
@@ -50,6 +50,29 @@ export type MarkdownViewProps = SlotProps & {
50
50
  * back.
51
51
  */
52
52
  onToggleTask?: (nextValue: string) => void
53
+ /**
54
+ * Re-point a link before it becomes an `href`, for a document
55
+ * whose paths mean something only the app knows.
56
+ *
57
+ * The case it exists for is a **relative** path in a fetched or
58
+ * imported file. `[the runbook](../docs/runbook.md)` is resolved
59
+ * by the browser against the page the reader is on — the app —
60
+ * so it lands on nothing. Return the URL it should point at
61
+ * instead, or `undefined` to leave the link exactly as written.
62
+ *
63
+ * The document's own URL has already been through the scheme
64
+ * guard when it arrives, so `javascript:` never reaches this.
65
+ * What comes back is the app's own string and is used as given.
66
+ *
67
+ * **Links only.** An image's `src` is deliberately not offered:
68
+ * an app that maps a document path to a page URL would turn a
69
+ * working image into a broken one.
70
+ *
71
+ * Read once, at mount, through a ref — so a resolver defined
72
+ * inline in a render does not rebuild the view and lose the
73
+ * reader's scroll position.
74
+ */
75
+ resolveUrl?: (url: string) => string | undefined
53
76
  /**
54
77
  * The markdown to render, and **controlled** — unlike either
55
78
  * editor.
@@ -170,6 +193,7 @@ export const MarkdownView = ({
170
193
  className,
171
194
  label,
172
195
  onToggleTask,
196
+ resolveUrl,
173
197
  value,
174
198
  ...receivedSlotProps
175
199
  }: MarkdownViewProps): ReactNode => {
@@ -193,6 +217,16 @@ export const MarkdownView = ({
193
217
 
194
218
  handlerRef.current = onToggleTask
195
219
 
220
+ /**
221
+ * Same treatment, and for the same reason: a resolver written
222
+ * inline in the consumer's render is a new function every time,
223
+ * and rebuilding the view on it would throw away the scroll
224
+ * position of whatever the reader was reading.
225
+ */
226
+ const resolveUrlRef = useRef(resolveUrl)
227
+
228
+ resolveUrlRef.current = resolveUrl
229
+
196
230
  /**
197
231
  * Frozen at mount, and named so rather than read from the render
198
232
  * closure, because the build-once effect below is honestly
@@ -224,7 +258,12 @@ export const MarkdownView = ({
224
258
  // component exists to avoid.
225
259
  markdown({ base: markdownLanguage }),
226
260
  EditorView.lineWrapping,
227
- livePreview({ hasDocumentSemantics: true }),
261
+ livePreview({
262
+ hasDocumentSemantics: true,
263
+ // Stable by construction — the ref is what changes.
264
+ resolveUrl: (url) =>
265
+ resolveUrlRef.current?.(url),
266
+ }),
228
267
  /**
229
268
  * The one answer that can change after mount, and only
230
269
  * that answer.
@@ -104,6 +104,13 @@ export type LivePreviewOptions = {
104
104
  hasDocumentSemantics?: boolean
105
105
  /** Whether a `- [ ]` checkbox can be ticked. Defaults to `true`. */
106
106
  isTaskListInteractive?: boolean
107
+ /**
108
+ * Re-point a link before it becomes an `href` — see
109
+ * `ToLivePreviewRangesOptions.resolveUrl`, which this is handed
110
+ * to. Links only, and the document's URL has already been through
111
+ * the scheme guard when it arrives.
112
+ */
113
+ resolveUrl?: (url: string) => string | undefined
107
114
  }
108
115
 
109
116
  /**
@@ -130,10 +137,12 @@ export const livePreviewOptions = Facet.define<
130
137
  isTaskListInteractive:
131
138
  value.isTaskListInteractive ??
132
139
  merged.isTaskListInteractive,
140
+ resolveUrl: value.resolveUrl ?? merged.resolveUrl,
133
141
  }),
134
142
  {
135
143
  hasDocumentSemantics: false,
136
144
  isTaskListInteractive: true,
145
+ resolveUrl: () => undefined,
137
146
  },
138
147
  ),
139
148
  })
@@ -672,8 +681,11 @@ const toDecorations = (view: EditorView): DecorationSet => {
672
681
  false,
673
682
  )
674
683
 
675
- const { hasDocumentSemantics, isTaskListInteractive } =
676
- toOptions(view.state)
684
+ const {
685
+ hasDocumentSemantics,
686
+ isTaskListInteractive,
687
+ resolveUrl,
688
+ } = toOptions(view.state)
677
689
 
678
690
  const text = view.state.doc.toString()
679
691
 
@@ -709,6 +721,7 @@ const toDecorations = (view: EditorView): DecorationSet => {
709
721
  for (const range of toLivePreviewRanges({
710
722
  from: visible.from,
711
723
  isRawMode: isRawMode ?? false,
724
+ resolveUrl,
712
725
  selections,
713
726
  text,
714
727
  to: visible.to,
@@ -838,7 +851,8 @@ const toTableDecorations = (state: EditorState) => {
838
851
  false,
839
852
  )
840
853
 
841
- const { hasDocumentSemantics } = toOptions(state)
854
+ const { hasDocumentSemantics, resolveUrl } =
855
+ toOptions(state)
842
856
 
843
857
  const selections =
844
858
  state.field(isEditorFocusedField, false) &&
@@ -854,6 +868,7 @@ const toTableDecorations = (state: EditorState) => {
854
868
  return Decoration.set(
855
869
  toLivePreviewTableRanges({
856
870
  isRawMode: isRawMode ?? false,
871
+ resolveUrl,
857
872
  selections,
858
873
  text,
859
874
  tree: syntaxTree(state),
@@ -1138,6 +1153,28 @@ const livePreviewTheme = EditorView.theme({
1138
1153
  * it.
1139
1154
  */
1140
1155
  ".cm-content": {
1156
+ /**
1157
+ * The caret, which CodeMirror hard-codes **black**.
1158
+ *
1159
+ * `drawSelection` is not installed, so the caret is the
1160
+ * browser's own and `caret-color` decides what it looks like.
1161
+ * The base theme sets that per light/dark class — `&light .cm-
1162
+ * content { caret-color: black }` — and those classes track
1163
+ * whether a *CodeMirror theme* declared `dark: true`, not what
1164
+ * scheme the page is painted in. Charcuterie has one theme for
1165
+ * both schemes because every colour in it is a token, so the
1166
+ * editor is always `cm-light` and the caret was always black:
1167
+ * invisible on `surface-base` in dark mode, which is a text
1168
+ * field you cannot find the insertion point in.
1169
+ *
1170
+ * The token is the same one the text uses, so it follows the
1171
+ * scheme by construction and cannot be black on black again.
1172
+ * Same specificity as the base rule it replaces (two classes),
1173
+ * and later in the sheet — a theme's rules are inserted after
1174
+ * the base theme's, which is what CodeMirror's `Prec.lowest`
1175
+ * on the base module guarantees.
1176
+ */
1177
+ caretColor: "var(--color-content-primary)",
1141
1178
  lineHeight: "1.6",
1142
1179
  },
1143
1180
  /**
@@ -1003,3 +1003,140 @@ describe("toLivePreviewRanges", () => {
1003
1003
  })
1004
1004
  })
1005
1005
  })
1006
+
1007
+ describe("resolveUrl", () => {
1008
+ const toResolved = (
1009
+ text: string,
1010
+ resolveUrl: (url: string) => string | undefined,
1011
+ ) =>
1012
+ toLivePreviewRanges({
1013
+ isRawMode: false,
1014
+ resolveUrl,
1015
+ selections: [],
1016
+ text,
1017
+ tree: markdownLanguage.parser.parse(text),
1018
+ })
1019
+
1020
+ /**
1021
+ * The case the option exists for: a relative path in a document
1022
+ * the app fetched from somewhere else. The browser resolves it
1023
+ * against the page the reader is ON, which is the app, so it
1024
+ * lands on nothing. Only the app knows what the path means.
1025
+ */
1026
+ test("re-points a link the consumer claims", () => {
1027
+ const text = "see [the runbook](../docs/runbook.md)"
1028
+
1029
+ const linkText = toRanges(text).find(
1030
+ (range) =>
1031
+ range.type === "mark" &&
1032
+ range.markKind === "linkText",
1033
+ )
1034
+
1035
+ expect(linkText).toMatchObject({
1036
+ url: "../docs/runbook.md",
1037
+ })
1038
+
1039
+ const resolved = toResolved(text, (url) =>
1040
+ url.startsWith("../")
1041
+ ? `/files?path=${url.slice(3)}`
1042
+ : undefined,
1043
+ ).find(
1044
+ (range) =>
1045
+ range.type === "mark" &&
1046
+ range.markKind === "linkText",
1047
+ )
1048
+
1049
+ expect(resolved).toMatchObject({
1050
+ url: "/files?path=docs/runbook.md",
1051
+ })
1052
+ })
1053
+
1054
+ test("leaves a link alone when the consumer returns undefined", () => {
1055
+ const text = "see [the docs](https://example.invalid/a)"
1056
+
1057
+ expect(
1058
+ toResolved(text, () => undefined).find(
1059
+ (range) =>
1060
+ range.type === "mark" &&
1061
+ range.markKind === "linkText",
1062
+ ),
1063
+ ).toMatchObject({ url: "https://example.invalid/a" })
1064
+ })
1065
+
1066
+ test("re-points a bare URL too", () => {
1067
+ const text = "see https://example.invalid/a for more"
1068
+
1069
+ expect(
1070
+ toResolved(text, () => "/elsewhere").find(
1071
+ (range) =>
1072
+ range.type === "mark" &&
1073
+ range.markKind === "autolink",
1074
+ ),
1075
+ ).toMatchObject({ url: "/elsewhere" })
1076
+ })
1077
+
1078
+ /**
1079
+ * The guard runs FIRST, so a refused URL never reaches the
1080
+ * consumer — it is not a link at all by then, and offering an app
1081
+ * the chance to resurrect one would undo `safeUrls.ts`.
1082
+ */
1083
+ test("never offers a URL the scheme guard refused", () => {
1084
+ const seen: string[] = []
1085
+
1086
+ const text = "[click me](javascript:alert(1))"
1087
+
1088
+ toResolved(text, (url) => {
1089
+ seen.push(url)
1090
+
1091
+ return undefined
1092
+ })
1093
+
1094
+ expect(seen).toEqual([])
1095
+ })
1096
+
1097
+ /**
1098
+ * An image is deliberately not offered. An app that maps a
1099
+ * document path to a PAGE url would turn a working image into a
1100
+ * broken one, and the image's `src` is the one URL here that has
1101
+ * to stay fetchable.
1102
+ */
1103
+ test("does not offer an image's src", () => {
1104
+ const seen: string[] = []
1105
+
1106
+ const text = "![a diagram](../docs/a.png)"
1107
+
1108
+ const image = toResolved(text, (url) => {
1109
+ seen.push(url)
1110
+
1111
+ return "/elsewhere.png"
1112
+ }).find((range) => range.type === "image")
1113
+
1114
+ expect(seen).toEqual([])
1115
+
1116
+ expect(image).toMatchObject({
1117
+ url: "../docs/a.png",
1118
+ })
1119
+ })
1120
+
1121
+ test("re-points a link inside a table cell", () => {
1122
+ const text = [
1123
+ "| Port | Notes |",
1124
+ "| --- | --- |",
1125
+ "| 1 | see [the runbook](../docs/runbook.md) |",
1126
+ ].join("\n")
1127
+
1128
+ const cell = toLivePreviewTableRanges({
1129
+ isRawMode: false,
1130
+ resolveUrl: () => "/files?path=docs/runbook.md",
1131
+ selections: [],
1132
+ text,
1133
+ tree: markdownLanguage.parser.parse(text),
1134
+ })[0]?.rows[1]?.cells[1]
1135
+
1136
+ expect(toFound(cell).segments).toContainEqual(
1137
+ expect.objectContaining({
1138
+ url: "/files?path=docs/runbook.md",
1139
+ }),
1140
+ )
1141
+ })
1142
+ })