@rtzll/reflow 0.5.0 → 0.7.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/drawer.css CHANGED
@@ -1,10 +1,13 @@
1
- /* Drawer: a modal panel attached to one edge of the viewport. It slides in
2
- on the moderate spring, which has no bounce, so the panel never lifts off
3
- its edge, and leaves on Dialog's exit tween. While dragged it follows
4
- --rf-drawer-drag, the distance toward its edge, and the backdrop fades
5
- with --rf-drawer-progress. A strip of panel color past the edge covers
6
- the gap when a drag pulls it the other way. */
1
+ /* Drawer: a modal panel attached to one edge of the viewport. The panel
2
+ and the backdrop share one motion, in and out and back from a drag: a
3
+ long ease that starts fast and settles gently, with no overshoot, so the
4
+ panel never lifts off its edge and the backdrop darkens as it arrives.
5
+ While dragged the panel follows --rf-drawer-drag, the distance toward its
6
+ edge, and the backdrop lightens with --rf-drawer-progress. A strip of
7
+ panel color past the edge covers the gap when a drag pulls it the other
8
+ way. */
7
9
  .rf-drawer {
10
+ --rf-drawer-motion: 500ms cubic-bezier(0.32, 0.72, 0, 1);
8
11
  --rf-drawer-bg: var(--rf-surface-5);
9
12
  --rf-drawer-drag: 0px;
10
13
  --rf-drawer-closed: 0 100%;
@@ -32,24 +35,12 @@
32
35
  font: var(--rf-text-body) / var(--rf-leading-body) var(--rf-font);
33
36
  translate: var(--rf-drawer-closed);
34
37
  transition:
35
- translate var(--rf-exit-slow),
36
- overlay 160ms allow-discrete,
37
- display 160ms allow-discrete;
38
+ translate var(--rf-drawer-motion),
39
+ overlay var(--rf-drawer-motion) allow-discrete,
40
+ display var(--rf-drawer-motion) allow-discrete;
38
41
  }
39
42
  .rf-drawer[open] {
40
43
  translate: 0 var(--rf-drawer-drag);
41
- transition:
42
- translate var(--rf-spring-moderate),
43
- overlay 160ms allow-discrete,
44
- display 160ms allow-discrete;
45
- }
46
- @starting-style {
47
- .rf-drawer[open] {
48
- translate: var(--rf-drawer-closed);
49
- }
50
- .rf-drawer[open]::backdrop {
51
- opacity: 0;
52
- }
53
44
  }
54
45
 
55
46
  /* Top and bottom drawers keep their content in a readable column. */
@@ -100,23 +91,32 @@
100
91
  background: rgb(0 0 0 / 40%);
101
92
  opacity: 0;
102
93
  transition:
103
- opacity var(--rf-exit-slow),
104
- overlay 160ms allow-discrete,
105
- display 160ms allow-discrete;
94
+ opacity 500ms cubic-bezier(0.32, 0.72, 0, 1),
95
+ overlay 500ms cubic-bezier(0.32, 0.72, 0, 1) allow-discrete,
96
+ display 500ms cubic-bezier(0.32, 0.72, 0, 1) allow-discrete;
106
97
  }
107
98
  [data-theme='dark'] .rf-drawer::backdrop {
108
99
  background: rgb(0 0 0 / 70%);
109
100
  }
110
101
  .rf-drawer[open]::backdrop {
111
- opacity: calc(1 - var(--rf-drawer-progress, 0));
112
- transition:
113
- opacity var(--rf-spring-moderate),
114
- overlay 160ms allow-discrete,
115
- display 160ms allow-discrete;
102
+ opacity: 1;
116
103
  }
117
104
  .rf-drawer[data-dragging]::backdrop {
105
+ opacity: calc(1 - var(--rf-drawer-progress, 0));
118
106
  transition: none;
119
107
  }
108
+ /* Where the panel and backdrop start from as the drawer opens. Starting
109
+ styles cascade with the rest, so these follow the open rules above and
110
+ match the per-side ones in specificity, or those would win and the
111
+ drawer would appear without its slide. */
112
+ @starting-style {
113
+ .rf-drawer[data-side][open] {
114
+ translate: var(--rf-drawer-closed);
115
+ }
116
+ .rf-drawer[data-side][open]::backdrop {
117
+ opacity: 0;
118
+ }
119
+ }
120
120
 
121
121
  .rf-drawer-handle {
122
122
  inline-size: 40px;
@@ -193,19 +193,15 @@
193
193
  translate: none;
194
194
  opacity: 0;
195
195
  transition:
196
- opacity var(--rf-exit-slow),
197
- overlay 160ms allow-discrete,
198
- display 160ms allow-discrete;
196
+ opacity var(--rf-drawer-motion),
197
+ overlay var(--rf-drawer-motion) allow-discrete,
198
+ display var(--rf-drawer-motion) allow-discrete;
199
199
  }
200
200
  .rf-drawer[open] {
201
201
  opacity: 1;
202
- transition:
203
- opacity var(--rf-spring-moderate),
204
- overlay 160ms allow-discrete,
205
- display 160ms allow-discrete;
206
202
  }
207
203
  @starting-style {
208
- .rf-drawer[open] {
204
+ .rf-drawer[data-side][open] {
209
205
  translate: none;
210
206
  opacity: 0;
211
207
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `@rtzll/reflow/highlight`: Shiki behind one function, for CodeBlock.
3
+ *
4
+ * This entry is optional. It needs the `shiki` and `@shikijs/transformers`
5
+ * peer dependencies, and nothing else in Reflow imports it, so an app that
6
+ * never imports it never installs or loads Shiki.
7
+ */
8
+ import { type BundledTheme, type ShikiTransformer, type ThemeRegistrationAny } from 'shiki';
9
+ import type { HighlightedCode } from './code-block.tsx';
10
+ export type { CodeLine, CodeToken, HighlightedCode } from './code-block.tsx';
11
+ type Theme = BundledTheme | ThemeRegistrationAny;
12
+ export interface HighlightOptions {
13
+ /** Any language Shiki bundles, or one of its aliases. Unknown languages render as plain text. Default text. */
14
+ lang?: string;
15
+ /** A Shiki theme, by name or definition. Defaults to Reflow's, which colours tokens with `--rf-code-token-*` variables. */
16
+ theme?: Theme;
17
+ /** A light and a dark theme; the block follows the page's color scheme between them. */
18
+ themes?: {
19
+ light: Theme;
20
+ dark: Theme;
21
+ };
22
+ /**
23
+ * Reads `// [!code highlight]`, `[!code ++]`, `[!code --]`, `[!code focus]`,
24
+ * `[!code error]`, `[!code warning]` and `[!code word:name]` comments into
25
+ * line and word marks, and removes them from the code. Default true.
26
+ */
27
+ notation?: boolean;
28
+ /** More Shiki transformers, run after the notation ones. */
29
+ transformers?: ShikiTransformer[];
30
+ }
31
+ /**
32
+ * Highlights code for CodeBlock. Call it where the code is known: in a route
33
+ * handler, so the page arrives highlighted and the browser loads nothing, or
34
+ * in the browser for code that arrives later, such as a streamed reply.
35
+ */
36
+ export declare function highlight(code: string, options?: HighlightOptions): Promise<HighlightedCode>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `@rtzll/reflow/highlight`: Shiki behind one function, for CodeBlock.
3
+ *
4
+ * This entry is optional. It needs the `shiki` and `@shikijs/transformers`
5
+ * peer dependencies, and nothing else in Reflow imports it, so an app that
6
+ * never imports it never installs or loads Shiki.
7
+ */
8
+ import { bundledLanguages, bundledLanguagesAlias, createCssVariablesTheme, createHighlighter, createJavaScriptRegexEngine, } from 'shiki';
9
+ import { transformerNotationDiff, transformerNotationErrorLevel, transformerNotationFocus, transformerNotationHighlight, transformerNotationWordHighlight, } from '@shikijs/transformers';
10
+ // Reflow's theme: every colour is a CSS variable, set in code-block.css for
11
+ // the light and dark themes, so highlighted code follows the page.
12
+ const reflowTheme = createCssVariablesTheme({
13
+ name: 'reflow',
14
+ variablePrefix: '--rf-code-',
15
+ fontStyle: true,
16
+ });
17
+ let shared;
18
+ // One highlighter for the process or page, with the JavaScript regex engine,
19
+ // which needs no WebAssembly. Languages and themes load as they are first used.
20
+ function highlighter() {
21
+ shared ??= createHighlighter({
22
+ themes: [reflowTheme],
23
+ langs: [],
24
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
25
+ });
26
+ return shared;
27
+ }
28
+ function bundled(lang) {
29
+ return lang in bundledLanguages || lang in bundledLanguagesAlias;
30
+ }
31
+ function themeName(theme) {
32
+ return typeof theme === 'string' ? theme : (theme.name ?? 'custom');
33
+ }
34
+ function classes(node) {
35
+ const value = node.properties?.class ?? node.properties?.className;
36
+ return Array.isArray(value) ? value.join(' ') : typeof value === 'string' ? value : '';
37
+ }
38
+ // Flattens a line into runs of text, each with the innermost style and
39
+ // every class around it, such as a token inside a highlighted word.
40
+ function tokens(node, style = '', classList = []) {
41
+ if (node.type === 'text') {
42
+ if (!node.value)
43
+ return [];
44
+ const token = { content: node.value };
45
+ if (style)
46
+ token.style = style;
47
+ if (classList.length)
48
+ token.class = classList.join(' ');
49
+ return [token];
50
+ }
51
+ const own = typeof node.properties?.style === 'string' ? node.properties.style : '';
52
+ const name = classes(node);
53
+ const nested = name ? [...classList, name] : classList;
54
+ return (node.children ?? []).flatMap((child) => tokens(child, own || style, nested));
55
+ }
56
+ function trimEnd(list) {
57
+ while (list.length) {
58
+ const last = list[list.length - 1];
59
+ last.content = last.content.replace(/[ \t]+$/, '');
60
+ if (last.content)
61
+ return;
62
+ list.pop();
63
+ }
64
+ }
65
+ /**
66
+ * Highlights code for CodeBlock. Call it where the code is known: in a route
67
+ * handler, so the page arrives highlighted and the browser loads nothing, or
68
+ * in the browser for code that arrives later, such as a streamed reply.
69
+ */
70
+ export async function highlight(code, options = {}) {
71
+ const instance = await highlighter();
72
+ const requested = options.lang ?? 'text';
73
+ const lang = bundled(requested) ? requested : 'text';
74
+ if (lang !== 'text' && !instance.getLoadedLanguages().includes(lang))
75
+ await instance.loadLanguage(lang);
76
+ const themes = options.themes ? [options.themes.light, options.themes.dark] : [options.theme];
77
+ for (const theme of themes)
78
+ if (theme && !instance.getLoadedThemes().includes(themeName(theme)))
79
+ await instance.loadTheme(theme);
80
+ const transformers = [
81
+ ...(options.notation === false
82
+ ? []
83
+ : [
84
+ transformerNotationHighlight(),
85
+ transformerNotationDiff(),
86
+ transformerNotationFocus(),
87
+ transformerNotationErrorLevel(),
88
+ transformerNotationWordHighlight(),
89
+ ]),
90
+ ...(options.transformers ?? []),
91
+ ];
92
+ const root = instance.codeToHast(code, options.themes
93
+ ? {
94
+ lang,
95
+ themes: {
96
+ light: themeName(options.themes.light),
97
+ dark: themeName(options.themes.dark),
98
+ },
99
+ defaultColor: false,
100
+ transformers,
101
+ }
102
+ : { lang, theme: options.theme ? themeName(options.theme) : 'reflow', transformers });
103
+ const pre = root.children?.find((node) => node.tagName === 'pre');
104
+ const body = pre?.children?.find((node) => node.tagName === 'code');
105
+ const lines = (body?.children ?? [])
106
+ .filter((node) => node.type === 'element')
107
+ .map((node) => {
108
+ const mark = classes(node)
109
+ .split(/\s+/)
110
+ .filter((name) => name && name !== 'line')
111
+ .join(' ');
112
+ const line = { tokens: tokens({ ...node, properties: {} }) };
113
+ // Removing a notation comment leaves the space before it.
114
+ if (options.notation !== false)
115
+ trimEnd(line.tokens);
116
+ if (mark)
117
+ line.class = mark;
118
+ return line;
119
+ });
120
+ const flags = classes(pre ?? { type: 'element' })
121
+ .split(/\s+/)
122
+ .filter((name) => name.startsWith('has-'));
123
+ const style = typeof pre?.properties?.style === 'string' ? pre.properties.style : undefined;
124
+ const result = {
125
+ lang: requested === 'text' || lang === 'text' ? 'text' : requested,
126
+ code: lines.map((line) => line.tokens.map((token) => token.content).join('')).join('\n'),
127
+ lines,
128
+ flags,
129
+ };
130
+ // Reflow's own theme takes its colours from CSS; others bring theirs inline.
131
+ if (style && (options.theme || options.themes))
132
+ result.style = style;
133
+ return result;
134
+ }
@@ -27,6 +27,11 @@ export interface HoverCardProps {
27
27
  * supplementary: the trigger still works on its own, a tap on a touch screen
28
28
  * follows it, and the card never takes focus or locks scrolling. The pointer
29
29
  * can cross into the card and use its links; it closes once the pointer has
30
- * left both, or on Escape. It shares Popover's surface and motion.
30
+ * left both, on Escape, or when the page scrolls. It shares Popover's
31
+ * surface and motion.
32
+ *
33
+ * The card is a span, so a trigger can sit inside a paragraph. Keep its
34
+ * content to phrasing elements there, such as span and a, since a div or a
35
+ * list would end the paragraph when the browser parses server-rendered HTML.
31
36
  */
32
37
  export declare function HoverCard(handle: Handle<HoverCardProps>): () => import("remix/component").RemixElement;
@@ -7,7 +7,12 @@ import { anchor } from '@remix-run/ui/anchor';
7
7
  * supplementary: the trigger still works on its own, a tap on a touch screen
8
8
  * follows it, and the card never takes focus or locks scrolling. The pointer
9
9
  * can cross into the card and use its links; it closes once the pointer has
10
- * left both, or on Escape. It shares Popover's surface and motion.
10
+ * left both, on Escape, or when the page scrolls. It shares Popover's
11
+ * surface and motion.
12
+ *
13
+ * The card is a span, so a trigger can sit inside a paragraph. Keep its
14
+ * content to phrasing elements there, such as span and a, since a div or a
15
+ * list would end the paragraph when the browser parses server-rendered HTML.
11
16
  */
12
17
  export function HoverCard(handle) {
13
18
  let trigger = null;
@@ -84,6 +89,18 @@ export function HoverCard(handle) {
84
89
  return;
85
90
  setOpen(false);
86
91
  }, options);
92
+ // The card is placed by script, so while the trigger scrolls it trails a
93
+ // frame behind. A preview belongs to where the pointer rests, so when the
94
+ // page, or anything holding the trigger, scrolls, it closes at once. Other
95
+ // scrollers, such as a carousel elsewhere, leave it be.
96
+ element.ownerDocument.addEventListener('scroll', (event) => {
97
+ if (!isOpen())
98
+ return;
99
+ const source = event.target;
100
+ const movesTrigger = source === element.ownerDocument || (source instanceof Node && source.contains(element));
101
+ if (movesTrigger)
102
+ setOpen(false);
103
+ }, { capture: true, passive: true, signal });
87
104
  signal.addEventListener('abort', () => {
88
105
  clearTimeout(openTimer);
89
106
  clearTimeout(closeTimer);
@@ -114,6 +131,6 @@ export function HoverCard(handle) {
114
131
  });
115
132
  return () => {
116
133
  handle.queueTask(sync);
117
- return (_jsxs(_Fragment, { children: [handle.props.renderTrigger({ mix: triggerMix }), _jsx("div", { class: "rf-menu-surface rf-popover-surface rf-hover-card", popover: "manual", "data-density": handle.props.size, mix: cardMix, children: handle.props.children })] }));
134
+ return (_jsxs(_Fragment, { children: [handle.props.renderTrigger({ mix: triggerMix }), _jsx("span", { class: "rf-menu-surface rf-popover-surface rf-hover-card", popover: "manual", "data-density": handle.props.size, mix: cardMix, children: handle.props.children })] }));
118
135
  };
119
136
  }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,11 @@ export * from './popover.tsx';
15
15
  export * from './hover-card.tsx';
16
16
  export * from './toaster.tsx';
17
17
  export * from './drawer.tsx';
18
+ export * from './code-block.tsx';
19
+ export * from './citation.tsx';
20
+ export * from './avatar.tsx';
21
+ export * from './chat-layout.tsx';
22
+ export * from './carousel.tsx';
18
23
  export * from './systems.ts';
19
24
  export * from './queued-stack.tsx';
20
25
  export * from './scroll-area.tsx';
package/dist/index.js CHANGED
@@ -15,6 +15,11 @@ export * from './popover.js';
15
15
  export * from './hover-card.js';
16
16
  export * from './toaster.js';
17
17
  export * from './drawer.js';
18
+ export * from './code-block.js';
19
+ export * from './citation.js';
20
+ export * from './avatar.js';
21
+ export * from './chat-layout.js';
22
+ export * from './carousel.js';
18
23
  export * from './systems.js';
19
24
  export * from './queued-stack.js';
20
25
  export * from './scroll-area.js';
@@ -12,6 +12,41 @@ export function QueuedStack(handle) {
12
12
  list = node;
13
13
  });
14
14
  const sending = new Set();
15
+ // The action that had focus when its message moved or left, to hand focus
16
+ // on to once the queue has re-rendered.
17
+ let refocus;
18
+ function remember(action, id, index) {
19
+ if (list?.contains(document.activeElement))
20
+ refocus = { action, id, index };
21
+ }
22
+ function restoreFocus() {
23
+ const target = refocus;
24
+ if (!target || !list)
25
+ return;
26
+ const rows = Array.from(list.children);
27
+ const row = rows.find((item) => item.dataset.queueId === target.id);
28
+ let button;
29
+ if (target.action === 'up' || target.action === 'down') {
30
+ // The moved message keeps focus, on the other arrow if this one ran out.
31
+ if (!row)
32
+ return;
33
+ if (rows.indexOf(row) === target.index)
34
+ return;
35
+ button = row.querySelector(`[data-queue-action="${target.action}"]`);
36
+ if (button?.disabled)
37
+ button = row.querySelector(`[data-queue-action="${target.action === 'up' ? 'down' : 'up'}"]`);
38
+ }
39
+ else {
40
+ // The message that takes the sent or removed one's place, or the one before it.
41
+ if (row)
42
+ return;
43
+ const next = rows[Math.min(target.index, rows.length - 1)];
44
+ button = next?.querySelector(`[data-queue-action="${target.action}"]`);
45
+ }
46
+ refocus = undefined;
47
+ // It takes the place of the button that had focus, so nothing needs scrolling.
48
+ button?.focus({ preventScroll: true });
49
+ }
15
50
  async function send(id) {
16
51
  if (sending.has(id))
17
52
  return;
@@ -51,6 +86,8 @@ export function QueuedStack(handle) {
51
86
  const { messages, onRemove, onEdit, label = 'Queued messages' } = handle.props;
52
87
  if (!messages.length)
53
88
  expanded = false;
89
+ if (refocus)
90
+ handle.queueTask(restoreFocus);
54
91
  return (_jsxs("div", { class: "rf-queue-wrap", hidden: !messages.length, "data-expanded": expanded || undefined, children: [messages.length > 1 && (_jsx("button", { type: "button", class: "rf-queue-toggle", "aria-expanded": expanded, "aria-controls": handle.id, mix: on('click', () => {
55
92
  expanded = !expanded;
56
93
  handle.update();
@@ -133,6 +170,18 @@ export function QueuedStack(handle) {
133
170
  expanded = true;
134
171
  handle.update();
135
172
  }),
136
- ], children: [_jsx("span", { class: "rf-queue-content", children: message.content }), _jsxs("div", { class: "rf-queue-actions", children: [_jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === 0, "aria-label": `Move message ${index + 1} up`, mix: on('click', () => move(message.id, index - 1)), children: "\u2191" }), _jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === messages.length - 1, "aria-label": `Move message ${index + 1} down`, mix: on('click', () => move(message.id, index + 1)), children: "\u2193" }), onRemove && (_jsx(Button, { size: "icon-compact", variant: "ghost", "aria-label": `Remove message ${index + 1}`, mix: on('click', () => onRemove(message.id)), children: "\u00D7" })), _jsx(Button, { size: "compact", variant: "secondary", mix: on('click', () => send(message.id)), children: "Send" })] })] }, message.id))) })] }));
173
+ ], children: [_jsx("span", { class: "rf-queue-content", children: message.content }), _jsxs("div", { class: "rf-queue-actions", children: [_jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === 0, "aria-label": `Move message ${index + 1} up`, "data-queue-action": "up", mix: on('click', () => {
174
+ remember('up', message.id, index);
175
+ move(message.id, index - 1);
176
+ }), children: "\u2191" }), _jsx(Button, { size: "icon-compact", variant: "ghost", disabled: index === messages.length - 1, "aria-label": `Move message ${index + 1} down`, "data-queue-action": "down", mix: on('click', () => {
177
+ remember('down', message.id, index);
178
+ move(message.id, index + 1);
179
+ }), children: "\u2193" }), onRemove && (_jsx(Button, { size: "icon-compact", variant: "ghost", "aria-label": `Remove message ${index + 1}`, "data-queue-action": "remove", mix: on('click', () => {
180
+ remember('remove', message.id, index);
181
+ onRemove(message.id);
182
+ }), children: "\u00D7" })), _jsx(Button, { size: "compact", variant: "secondary", "data-queue-action": "send", mix: on('click', () => {
183
+ remember('send', message.id, index);
184
+ void send(message.id);
185
+ }), children: "Send" })] })] }, message.id))) })] }));
137
186
  };
138
187
  }
package/dist/styles.css CHANGED
@@ -14,4 +14,9 @@
14
14
  @import './popover.css';
15
15
  @import './toaster.css';
16
16
  @import './drawer.css';
17
+ @import './code-block.css';
18
+ @import './citation.css';
19
+ @import './avatar.css';
20
+ @import './chat-layout.css';
21
+ @import './carousel.css';
17
22
  @import './systems.css';
package/dist/toaster.js CHANGED
@@ -70,12 +70,13 @@ export const toast = Object.assign((title, options) => upsert(title, 'default',
70
70
  info: (title, options) => upsert(title, 'info', options),
71
71
  warning: (title, options) => upsert(title, 'warning', options),
72
72
  error: (title, options) => upsert(title, 'error', options),
73
- loading: (title, options) => upsert(title, 'loading', { duration: Infinity, ...options }),
73
+ // A loading toast stays without a duration of its own: the close timer
74
+ // skips loading toasts, so it starts once the toast settles.
75
+ loading: (title, options) => upsert(title, 'loading', options),
74
76
  promise(promise, messages, options = {}) {
75
77
  const id = upsert(messages.loading, 'loading', {
76
78
  description: messages.description,
77
79
  ...options,
78
- duration: Infinity,
79
80
  });
80
81
  const settle = (status, title) => {
81
82
  // A toast closed while it waited stays closed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtzll/reflow",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -13,13 +13,29 @@
13
13
  "types": "./dist/index.d.ts",
14
14
  "default": "./dist/index.js"
15
15
  },
16
+ "./highlight": {
17
+ "types": "./dist/highlight.d.ts",
18
+ "default": "./dist/highlight.js"
19
+ },
16
20
  "./styles.css": "./dist/styles.css"
17
21
  },
18
22
  "peerDependencies": {
19
- "remix": "3.2.0"
23
+ "@shikijs/transformers": "^4.5.0",
24
+ "remix": "3.2.0",
25
+ "shiki": "^4.5.0"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "@shikijs/transformers": {
29
+ "optional": true
30
+ },
31
+ "shiki": {
32
+ "optional": true
33
+ }
20
34
  },
21
35
  "devDependencies": {
22
- "remix": "3.2.0"
36
+ "@shikijs/transformers": "4.5.0",
37
+ "remix": "3.2.0",
38
+ "shiki": "4.5.0"
23
39
  },
24
40
  "dependencies": {
25
41
  "@remix-run/ui": "0.13.1"