@dbx-tools/ui 0.9.110 → 0.9.112

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/index.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
5
  export const PACKAGE_IDENTIFIER = "@dbx-tools/ui";
6
- export const PACKAGE_VERSION = "0.9.110";
6
+ export const PACKAGE_VERSION = "0.9.112";
7
7
  export * as authReactAuthClient from "./src/auth/react/auth-client.ts";
8
8
  export * as authReactAuthGate from "./src/auth/react/auth-gate.tsx";
9
9
  export * as authReactPasskeyManager from "./src/auth/react/passkey-manager.tsx";
@@ -16,8 +16,10 @@ export * as emailReactEmailCompose from "./src/email/react/email-compose.tsx";
16
16
  export * as emailReactFields from "./src/email/react/fields.ts";
17
17
  export * as reactAppkitUi from "./src/react/appkit-ui.ts";
18
18
  export * as reactBrandPicker from "./src/react/brand-picker.tsx";
19
+ export * as reactHighlightedCode from "./src/react/highlighted-code.tsx";
19
20
  export * as reactRecordPreview from "./src/react/record-preview.tsx";
20
21
  export * as reactRecordPreviewData from "./src/react/record-preview-data.ts";
22
+ export * as reactShikiPlugin from "./src/react/shiki-plugin.ts";
21
23
  export * as searchReactSearchBox from "./src/search/react/search-box.tsx";
22
24
  export * as searchReactSearchResults from "./src/search/react/search-results.tsx";
23
25
  export * as searchReactUseSearch from "./src/search/react/use-search.ts";
@@ -36,6 +38,7 @@ export type { EmailComposeProps } from "./src/email/react/email-compose.tsx";
36
38
  export { joinAddresses, parseAddresses, attachmentNames } from "./src/email/react/fields.ts";
37
39
  export type { EmailDraft } from "./src/email/react/fields.ts";
38
40
  export type { BrandPreset, BrandPickerProps } from "./src/react/brand-picker.tsx";
41
+ export { JsonBlock } from "./src/react/highlighted-code.tsx";
39
42
  export type { RecordPreviewProps } from "./src/react/record-preview.tsx";
40
43
  export type { RecordPreviewRow } from "./src/react/record-preview-data.ts";
41
44
  export type { SearchBoxProps } from "./src/search/react/search-box.tsx";
package/package.json CHANGED
@@ -30,12 +30,13 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@databricks/appkit-ui": "0.81.0",
33
- "@dbx-tools/shared-auth": "^0.9.110",
34
- "@dbx-tools/shared-core": "^0.9.110",
35
- "@dbx-tools/shared-email": "^0.9.110",
36
- "@dbx-tools/shared-email-template": "^0.9.110",
37
- "@dbx-tools/shared-search": "^0.9.110",
33
+ "@dbx-tools/shared-auth": "^0.9.112",
34
+ "@dbx-tools/shared-core": "^0.9.112",
35
+ "@dbx-tools/shared-email": "^0.9.112",
36
+ "@dbx-tools/shared-email-template": "^0.9.112",
37
+ "@dbx-tools/shared-search": "^0.9.112",
38
38
  "lucide-react": "^0.554.0",
39
+ "shiki": "^3.0.0",
39
40
  "streamdown": "^2.5.0",
40
41
  "tailwindcss": "^4.3.2"
41
42
  },
@@ -43,7 +44,7 @@
43
44
  "publishConfig": {
44
45
  "access": "public"
45
46
  },
46
- "version": "0.9.110",
47
+ "version": "0.9.112",
47
48
  "dbxToolsConfig": {
48
49
  "tags": [
49
50
  "ui"
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Fine-grained Shiki runtime containing only the supported UI languages.
3
+ *
4
+ * This module is itself dynamically imported, so its grammar, theme, and WASM
5
+ * dependencies remain off the network until a supported code block appears.
6
+ */
7
+ import { createHighlighterCore } from "shiki/core";
8
+ import { createOnigurumaEngine } from "shiki/engine/oniguruma";
9
+ import bash from "shiki/langs/bash.mjs";
10
+ import javascript from "shiki/langs/javascript.mjs";
11
+ import json from "shiki/langs/json.mjs";
12
+ import markdown from "shiki/langs/markdown.mjs";
13
+ import python from "shiki/langs/python.mjs";
14
+ import sql from "shiki/langs/sql.mjs";
15
+ import typescript from "shiki/langs/typescript.mjs";
16
+ import yaml from "shiki/langs/yaml.mjs";
17
+ import githubDarkHighContrast from "shiki/themes/github-dark-high-contrast.mjs";
18
+ import githubLightHighContrast from "shiki/themes/github-light-high-contrast.mjs";
19
+
20
+ /** Create the shared highlighter with the exact configured language set. */
21
+ export function createHighlighter() {
22
+ return createHighlighterCore({
23
+ themes: [githubLightHighContrast, githubDarkHighContrast],
24
+ langs: [sql, python, typescript, javascript, json, bash, yaml, markdown],
25
+ engine: createOnigurumaEngine(import("shiki/wasm")),
26
+ });
27
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared syntax-highlighted code surfaces for AppKit-oriented UI packages.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { useEffect, useState } from "react";
8
+
9
+ import { cn } from "./appkit-ui.ts";
10
+ import { highlightToHtml } from "./shiki-plugin.ts";
11
+
12
+ export { createShikiPlugin, highlightToHtml } from "./shiki-plugin.ts";
13
+
14
+ /** Resolve syntax-highlighted HTML while preserving plaintext during lazy load. */
15
+ export function useHighlightedHtml(source: string, language: string): string | null {
16
+ const [highlighted, setHighlighted] = useState<{
17
+ source: string;
18
+ language: string;
19
+ html: string;
20
+ } | null>(null);
21
+ useEffect(() => {
22
+ let active = true;
23
+ void highlightToHtml(source, language).then((result) => {
24
+ if (active) setHighlighted({ source, language, html: result });
25
+ });
26
+ return () => {
27
+ active = false;
28
+ };
29
+ }, [language, source]);
30
+ return highlighted?.source === source && highlighted.language === language
31
+ ? highlighted.html
32
+ : null;
33
+ }
34
+
35
+ /** Render formatted JSON with the shared lazily loaded Shiki runtime. */
36
+ export const JsonBlock = ({ json, className }: { json: string; className?: string }) => {
37
+ const html = useHighlightedHtml(json, "json");
38
+ return (
39
+ <pre
40
+ className={cn(
41
+ "max-w-full overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed",
42
+ className,
43
+ )}
44
+ >
45
+ {html === null ? (
46
+ <code>{json}</code>
47
+ ) : (
48
+ <code data-dbx-highlighted-code dangerouslySetInnerHTML={{ __html: html }} />
49
+ )}
50
+ </pre>
51
+ );
52
+ };
@@ -1,3 +1,4 @@
1
1
  export * from "./appkit-ui.ts";
2
2
  export * from "./brand-picker.tsx";
3
+ export * from "./highlighted-code.tsx";
3
4
  export * from "./record-preview.tsx";
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { BracesIcon, Table2Icon } from "lucide-react";
9
- import { useState } from "react";
9
+ import { Fragment, useState } from "react";
10
10
  import { Streamdown } from "streamdown";
11
11
 
12
12
  import {
@@ -21,6 +21,7 @@ import {
21
21
  TooltipTrigger,
22
22
  cn,
23
23
  } from "./appkit-ui.ts";
24
+ import { JsonBlock } from "./highlighted-code.tsx";
24
25
  import {
25
26
  formatRecordPreviewJson,
26
27
  looksLikeMarkdown,
@@ -41,7 +42,7 @@ export {
41
42
  const MARKDOWN_CELL_CLASSES =
42
43
  "min-w-0 max-w-full text-[11px] leading-snug [&_p]:my-0.5 [&_p]:leading-snug [&_ul]:my-0.5 [&_ol]:my-0.5 [&_li]:my-0 [&_h1]:my-1 [&_h1]:text-[11px] [&_h1]:font-semibold [&_h2]:my-1 [&_h2]:text-[11px] [&_h2]:font-semibold [&_h3]:my-1 [&_h3]:text-[11px] [&_h3]:font-semibold [&_pre]:my-1 [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-background/60 [&_pre]:p-1.5 [&_pre]:text-[10px] [&_code]:text-[10px]";
43
44
 
44
- const NESTED_DEPTH = 2;
45
+ const NESTED_DEPTH = 4;
45
46
 
46
47
  /** Props for {@link RecordPreview}. */
47
48
  export interface RecordPreviewProps {
@@ -55,8 +56,14 @@ export interface RecordPreviewProps {
55
56
  export function RecordPreview({ value, className }: RecordPreviewProps) {
56
57
  const [raw, setRaw] = useState(false);
57
58
  const rows = recordPreviewRows(value);
59
+ const rawJson = formatRecordPreviewJson(value);
58
60
  return (
59
- <div className={cn("relative min-w-0", className)}>
61
+ <div
62
+ className={cn(
63
+ "relative min-w-0 overflow-hidden [&_[data-slot=table-container]]:overflow-x-hidden",
64
+ className,
65
+ )}
66
+ >
60
67
  <Tooltip>
61
68
  <TooltipTrigger asChild>
62
69
  <Button
@@ -73,13 +80,17 @@ export function RecordPreview({ value, className }: RecordPreviewProps) {
73
80
  </TooltipTrigger>
74
81
  <TooltipContent>{raw ? "Table" : "JSON"}</TooltipContent>
75
82
  </Tooltip>
76
- {raw ? (
77
- <pre className="max-h-80 overflow-auto whitespace-pre-wrap break-words rounded bg-background/40 p-2 pr-8 font-mono text-[11px] leading-relaxed">
78
- {formatRecordPreviewJson(value)}
79
- </pre>
80
- ) : (
81
- <RecordTable rows={rows} depth={0} />
82
- )}
83
+ <div className="max-h-[inherit] min-w-0 overflow-auto">
84
+ {raw ? (
85
+ <JsonBlock json={rawJson} className="rounded bg-background/40 p-2 pr-8 text-[11px]" />
86
+ ) : rows.length === 0 ? (
87
+ <pre className="whitespace-pre-wrap break-words rounded bg-background/40 p-2 pr-8 font-mono text-[11px] leading-relaxed">
88
+ {rawJson}
89
+ </pre>
90
+ ) : (
91
+ <RecordTable rows={rows} depth={0} />
92
+ )}
93
+ </div>
83
94
  </div>
84
95
  );
85
96
  }
@@ -87,23 +98,51 @@ export function RecordPreview({ value, className }: RecordPreviewProps) {
87
98
  function RecordTable({ rows, depth }: { rows: RecordPreviewRow[]; depth: number }) {
88
99
  if (rows.length === 0) return null;
89
100
  return (
90
- <Table className="text-[11px]">
101
+ <Table className="table-fixed text-[11px]">
91
102
  <TableBody>
92
- {rows.map((row) => (
93
- <TableRow key={row.key} className="hover:bg-transparent">
94
- <TableHead className="w-[8.5rem] align-top whitespace-nowrap px-2 py-1.5 font-medium text-muted-foreground">
95
- {row.label}
96
- </TableHead>
97
- <TableCell className="align-top break-words px-2 py-1.5 pr-8">
98
- <RecordValue value={row.value} depth={depth} />
99
- </TableCell>
100
- </TableRow>
101
- ))}
103
+ {rows.map((row) => {
104
+ const nested = isNestedValue(row.value) && depth < NESTED_DEPTH;
105
+ return nested ? (
106
+ <Fragment key={row.key}>
107
+ <TableRow className="border-b-0 hover:bg-transparent">
108
+ <TableHead
109
+ colSpan={2}
110
+ className="h-auto whitespace-normal px-2 pb-1 pt-2 font-medium text-muted-foreground"
111
+ >
112
+ {row.label}
113
+ </TableHead>
114
+ </TableRow>
115
+ <TableRow className="hover:bg-transparent">
116
+ <TableCell
117
+ colSpan={2}
118
+ className="min-w-0 whitespace-normal p-0 pb-1 pl-2 [overflow-wrap:anywhere]"
119
+ >
120
+ <RecordValue value={row.value} depth={depth} />
121
+ </TableCell>
122
+ </TableRow>
123
+ </Fragment>
124
+ ) : (
125
+ <TableRow key={row.key} className="hover:bg-transparent">
126
+ <TableHead className="h-auto w-28 align-top whitespace-normal px-2 py-1.5 font-medium text-muted-foreground sm:w-[8.5rem]">
127
+ {row.label}
128
+ </TableHead>
129
+ <TableCell className="min-w-0 whitespace-normal px-2 py-1.5 pr-8 align-top [overflow-wrap:anywhere]">
130
+ <RecordValue value={row.value} depth={depth} />
131
+ </TableCell>
132
+ </TableRow>
133
+ );
134
+ })}
102
135
  </TableBody>
103
136
  </Table>
104
137
  );
105
138
  }
106
139
 
140
+ function isNestedValue(value: unknown): boolean {
141
+ if (value == null || typeof value !== "object") return false;
142
+ if (!Array.isArray(value)) return true;
143
+ return value.some((item) => item != null && typeof item === "object");
144
+ }
145
+
107
146
  function RecordValue({ value, depth }: { value: unknown; depth: number }) {
108
147
  if (value == null) {
109
148
  return <span className="text-muted-foreground">null</span>;
@@ -124,8 +163,23 @@ function RecordValue({ value, depth }: { value: unknown; depth: number }) {
124
163
  if (typeof value === "object" && !Array.isArray(value) && depth < NESTED_DEPTH) {
125
164
  return <RecordTable rows={recordPreviewRows(value)} depth={depth + 1} />;
126
165
  }
127
- if (Array.isArray(value) && value.every((item) => item == null || typeof item !== "object")) {
128
- return <span className="whitespace-pre-wrap break-words">{value.map(String).join(", ")}</span>;
166
+ if (Array.isArray(value)) {
167
+ if (value.every((item) => item == null || typeof item !== "object")) {
168
+ return (
169
+ <span className="whitespace-pre-wrap break-words">{value.map(String).join(", ")}</span>
170
+ );
171
+ }
172
+ if (depth < NESTED_DEPTH) {
173
+ return (
174
+ <div className="min-w-0 divide-y divide-border/60">
175
+ {value.map((item, index) => (
176
+ <div key={index} className="min-w-0 py-1 first:pt-0 last:pb-0">
177
+ <RecordValue value={item} depth={depth + 1} />
178
+ </div>
179
+ ))}
180
+ </div>
181
+ );
182
+ }
129
183
  }
130
184
  return (
131
185
  <pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed">
@@ -0,0 +1,158 @@
1
+ import { stringUtils } from "@dbx-tools/shared-core";
2
+ import type { BundledLanguage, BundledTheme } from "shiki";
3
+ import type { HighlighterCore } from "shiki/core";
4
+ import type { CodeHighlighterPlugin } from "streamdown";
5
+
6
+ // Streamdown 2.x ships syntax highlighting as an opt-in plugin and has
7
+ // no shiki dependency of its own; without a `code` plugin every fenced
8
+ // block renders as uncolored plaintext. This module provides a small
9
+ // shiki-backed highlighter to wire in via `plugins={{ code }}`.
10
+
11
+ /**
12
+ * Languages we highlight in the chat. SQL is the primary one (Genie
13
+ * query previews); the rest cover code the assistant might emit. Kept
14
+ * to a curated set so shiki only bundles these grammars.
15
+ */
16
+ const LANGUAGES = [
17
+ "text",
18
+ "plaintext",
19
+ "txt",
20
+ "sql",
21
+ "python",
22
+ "typescript",
23
+ "javascript",
24
+ "json",
25
+ "bash",
26
+ "yaml",
27
+ "markdown",
28
+ ] as const;
29
+
30
+ /** High-contrast theme pair used for every syntax surface. */
31
+ const THEMES = [
32
+ "github-light-high-contrast",
33
+ "github-dark-high-contrast",
34
+ ] as const satisfies readonly [BundledTheme, BundledTheme];
35
+
36
+ /** Languages we can tokenize, as a set for O(1) support checks. */
37
+ const SUPPORTED = new Set<string>(LANGUAGES);
38
+
39
+ let _highlighter: HighlighterCore | null = null;
40
+ let _loading: Promise<HighlighterCore> | null = null;
41
+
42
+ /** Lazily create the shared shiki highlighter (singleton, loaded once). */
43
+ function loadHighlighter(): Promise<HighlighterCore> {
44
+ _loading ??= import("./_shiki-runtime.ts")
45
+ .then(({ createHighlighter }) => createHighlighter())
46
+ .then((highlighter) => {
47
+ _highlighter = highlighter;
48
+ return highlighter;
49
+ });
50
+ return _loading;
51
+ }
52
+
53
+ function splitThemeColor(
54
+ value: string | undefined,
55
+ darkVariable: "--shiki-dark" | "--shiki-dark-bg",
56
+ ): { light?: string; dark?: string } {
57
+ if (!value) return {};
58
+ const marker = `;${darkVariable}:`;
59
+ const index = value.indexOf(marker);
60
+ if (index < 0) return { light: value };
61
+ return {
62
+ light: value.slice(0, index),
63
+ dark: value.slice(index + marker.length),
64
+ };
65
+ }
66
+
67
+ /** Tokenize `code` with the active themes into Streamdown's result shape. */
68
+ function highlightTokens(h: HighlighterCore, code: string, language: string) {
69
+ const { tokens, fg, bg, rootStyle } = h.codeToTokens(code, {
70
+ lang: language as BundledLanguage,
71
+ themes: { light: THEMES[0], dark: THEMES[1] },
72
+ });
73
+ const foreground = splitThemeColor(fg, "--shiki-dark");
74
+ const background = splitThemeColor(bg, "--shiki-dark-bg");
75
+ const darkStyles = [
76
+ foreground.dark ? `--shiki-dark:${foreground.dark}` : "",
77
+ background.dark ? `--shiki-dark-bg:${background.dark}` : "",
78
+ ]
79
+ .filter(Boolean)
80
+ .join(";");
81
+ return {
82
+ tokens,
83
+ fg: foreground.light,
84
+ bg: background.light,
85
+ rootStyle: [rootStyle, darkStyles].filter(Boolean).join(";"),
86
+ };
87
+ }
88
+
89
+ /** Escape HTML-significant characters (from the shared string utils). */
90
+ const escapeHtml = stringUtils.escapeHtml;
91
+
92
+ /**
93
+ * Highlight `code` into minimal inline HTML: one colored `<span>` per
94
+ * token, lines joined by real newlines, with no line-number gutter or
95
+ * per-line wrapper elements. Meant to drop straight into a
96
+ * `<pre><code>` so the rendered text stays cleanly selectable and
97
+ * copyable. Falls back to plain escaped text when the language isn't
98
+ * supported or shiki fails to parse the snippet.
99
+ */
100
+ export async function highlightToHtml(code: string, language: string): Promise<string> {
101
+ if (!SUPPORTED.has(language)) return escapeHtml(code);
102
+ const h = await loadHighlighter();
103
+ try {
104
+ const { tokens } = highlightTokens(h, code, language);
105
+ return tokens
106
+ .map((line) =>
107
+ line
108
+ .map((token) => {
109
+ const light = token.htmlStyle?.color ?? token.color ?? "inherit";
110
+ const dark = token.htmlStyle?.["--shiki-dark"] ?? light;
111
+ const style = `--sdm-c:${light};--shiki-dark:${dark}`;
112
+ return `<span style="${escapeHtml(style)}">${escapeHtml(token.content)}</span>`;
113
+ })
114
+ .join(""),
115
+ )
116
+ .join("\n");
117
+ } catch {
118
+ return escapeHtml(code);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * shiki-backed code highlighter plugin for Streamdown. The highlighter
124
+ * loads asynchronously: the first call for any block returns `null`
125
+ * and resolves through the `callback` once shiki is ready, after which
126
+ * results are synchronous. Unsupported languages return `null` so the
127
+ * block stays plaintext rather than throwing.
128
+ */
129
+ export function createShikiPlugin(): CodeHighlighterPlugin {
130
+ const isSupported = (language: string): boolean => SUPPORTED.has(language);
131
+
132
+ return {
133
+ name: "shiki",
134
+ type: "code-highlighter",
135
+ getSupportedLanguages: () => [...LANGUAGES],
136
+ getThemes: () => [...THEMES],
137
+ supportsLanguage: (language) => isSupported(language),
138
+ highlight: (options, callback) => {
139
+ if (!isSupported(options.language)) return null;
140
+ const language = options.language;
141
+ if (_highlighter) {
142
+ try {
143
+ return highlightTokens(_highlighter, options.code, language);
144
+ } catch {
145
+ return null;
146
+ }
147
+ }
148
+ void loadHighlighter().then((h) => {
149
+ try {
150
+ callback?.(highlightTokens(h, options.code, language));
151
+ } catch {
152
+ // Parse failure for this block - leave it as plaintext.
153
+ }
154
+ });
155
+ return null;
156
+ },
157
+ };
158
+ }