@djangocfg/ui-tools 2.1.301 → 2.1.302

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/index.d.ts CHANGED
@@ -158,6 +158,19 @@ interface MarkdownMessageProps {
158
158
  isUser?: boolean;
159
159
  /** Use compact size (text-xs instead of text-sm) */
160
160
  isCompact?: boolean;
161
+ /**
162
+ * Force the plain-text rendering path (single `<div>` with
163
+ * `white-space: pre-wrap`, no ReactMarkdown). Use this for
164
+ * user-authored content where markdown shouldn't be parsed.
165
+ *
166
+ * When `undefined` (default) we run a small heuristic
167
+ * (`looksLikePlainProse`): short single-paragraph text without
168
+ * markdown markers → plain; everything else → ReactMarkdown.
169
+ *
170
+ * Pass an explicit `true` / `false` to opt out of the heuristic
171
+ * entirely.
172
+ */
173
+ plainText?: boolean;
161
174
  /**
162
175
  * Per-tag overrides merged on top of the built-in renderers.
163
176
  *
package/dist/index.mjs CHANGED
@@ -4,8 +4,8 @@ import './chunk-JWB2EWQO.mjs';
4
4
  export { ImageViewer } from './chunk-GGKGH5PM.mjs';
5
5
  export { generateContentKey, useAudioCache, useBlobUrlCleanup, useImageCache, useMediaCacheStore, useVideoCache, useVideoPlayerSettings } from './chunk-5LBDYFWH.mjs';
6
6
  export { CronSchedulerProvider, CustomInput, DayChips, MonthDayGrid, SchedulePreview, ScheduleTypeSelector, TimeSelector, buildCron, humanizeCron, isValidCron, parseCron, useCronCustom, useCronMonthDays, useCronPreview, useCronScheduler, useCronSchedulerContext, useCronTime, useCronType, useCronWeekDays } from './chunk-PZKAH7WQ.mjs';
7
- import { PlaygroundProvider } from './chunk-CKD7GNE5.mjs';
8
- export { MarkdownMessage, Mermaid_default as Mermaid, PrettyCode_default as PrettyCode, extractTextFromChildren, useCollapsibleContent } from './chunk-CKD7GNE5.mjs';
7
+ import { PlaygroundProvider } from './chunk-47NGNO5U.mjs';
8
+ export { MarkdownMessage, Mermaid_default as Mermaid, PrettyCode_default as PrettyCode, extractTextFromChildren, useCollapsibleContent } from './chunk-47NGNO5U.mjs';
9
9
  export { JsonTree_default as JsonTree } from './chunk-LFWQ36LJ.mjs';
10
10
  import './chunk-SSUOENAZ.mjs';
11
11
  export { ArrayFieldItemTemplate, ArrayFieldTemplate, BaseInputTemplate, CheckboxWidget, ColorWidget, ErrorListTemplate, FieldTemplate, JsonSchemaForm, NumberWidget, ObjectFieldTemplate, SelectWidget, SliderWidget, SwitchWidget, TextWidget, getRequiredFields, hasRequiredFields, mergeDefaults, normalizeFormData, safeJsonParse, safeJsonStringify, validateRequiredFields, validateSchema } from './chunk-JUGQNNDC.mjs';
@@ -238,7 +238,7 @@ function OpenapiLoadingFallback() {
238
238
  }
239
239
  __name(OpenapiLoadingFallback, "OpenapiLoadingFallback");
240
240
  var LazyDocsLayout = createLazyComponent(
241
- () => import('./DocsLayout-MWRKNFXR.mjs').then((mod) => ({ default: mod.DocsLayout })),
241
+ () => import('./DocsLayout-ZHNRRAKR.mjs').then((mod) => ({ default: mod.DocsLayout })),
242
242
  {
243
243
  displayName: "LazyDocsLayout",
244
244
  fallback: /* @__PURE__ */ jsx(OpenapiLoadingFallback, {})
@@ -393,7 +393,7 @@ function LottiePlayer(props) {
393
393
  }
394
394
  __name(LottiePlayer, "LottiePlayer");
395
395
  var DocsLayout = lazy(
396
- () => import('./DocsLayout-MWRKNFXR.mjs').then((mod) => ({ default: mod.DocsLayout }))
396
+ () => import('./DocsLayout-ZHNRRAKR.mjs').then((mod) => ({ default: mod.DocsLayout }))
397
397
  );
398
398
  var LoadingFallback7 = /* @__PURE__ */ __name(() => /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center min-h-[400px]", children: /* @__PURE__ */ jsx("div", { className: "text-muted-foreground", children: "Loading API Playground..." }) }), "LoadingFallback");
399
399
  var Playground = /* @__PURE__ */ __name(({ config }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.301",
3
+ "version": "2.1.302",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -91,8 +91,8 @@
91
91
  "check": "tsc --noEmit"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.301",
95
- "@djangocfg/ui-core": "^2.1.301",
94
+ "@djangocfg/i18n": "^2.1.302",
95
+ "@djangocfg/ui-core": "^2.1.302",
96
96
  "consola": "^3.4.2",
97
97
  "lodash-es": "^4.18.1",
98
98
  "lucide-react": "^0.545.0",
@@ -140,10 +140,10 @@
140
140
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
141
141
  },
142
142
  "devDependencies": {
143
- "@djangocfg/i18n": "^2.1.301",
143
+ "@djangocfg/i18n": "^2.1.302",
144
144
  "@djangocfg/playground": "workspace:*",
145
- "@djangocfg/typescript-config": "^2.1.301",
146
- "@djangocfg/ui-core": "^2.1.301",
145
+ "@djangocfg/typescript-config": "^2.1.302",
146
+ "@djangocfg/ui-core": "^2.1.302",
147
147
  "@types/lodash-es": "^4.17.12",
148
148
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
149
149
  "@types/node": "^24.7.2",
@@ -10,26 +10,20 @@ interface CodeBlockProps {
10
10
  isCompact?: boolean;
11
11
  }
12
12
 
13
- /** Code block with a hover-revealed Copy button on top of PrettyCode. */
13
+ /**
14
+ * Markdown code block. Renders <PrettyCode> directly — its own
15
+ * FloatingToolbar already carries the language tag and Copy action.
16
+ *
17
+ * Earlier versions stacked an extra <CopyButton> here too, which
18
+ * surfaced as two copy buttons on every code fence. The fallback
19
+ * branch below still ships its own button because the plain <pre>
20
+ * has no toolbar of its own.
21
+ */
14
22
  export const CodeBlock: React.FC<CodeBlockProps> = ({ code, language, isUser, isCompact = false }) => {
15
23
  const theme = useResolvedTheme();
16
24
 
17
25
  return (
18
- <div className="relative group my-3">
19
- <CopyButton
20
- value={code}
21
- variant="ghost"
22
- className={`
23
- absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity
24
- h-8 w-8
25
- ${isUser
26
- ? 'hover:bg-white/20 text-white'
27
- : 'hover:bg-muted-foreground/20 text-muted-foreground hover:text-foreground'
28
- }
29
- `}
30
- title="Copy code"
31
- />
32
-
26
+ <div className="my-3">
33
27
  <PrettyCode
34
28
  data={code}
35
29
  language={language}
@@ -169,3 +169,217 @@ export const LinkRules = () => (
169
169
  </div>
170
170
  </div>
171
171
  );
172
+
173
+ // Chat-style stories — exercise the plain-text fast path so short
174
+ // one-liners and prose paragraphs don't get prose vertical margins
175
+ // inside a chat bubble. Compare against `LongAssistantReply` below
176
+ // to see when the heavier ReactMarkdown pipeline kicks in.
177
+ const SHORT_USER_LINES = [
178
+ 'Hi',
179
+ 'А?',
180
+ 'How do I install cmdop?',
181
+ 'Can you ssh into vps-audi and check uptime?',
182
+ ];
183
+
184
+ const SHORT_ASSISTANT_LINES = [
185
+ 'Sure — what do you need?',
186
+ 'Готово, запросил статус.',
187
+ 'Done.',
188
+ ];
189
+
190
+ // Crafted to make the plain-vs-markdown contrast obvious: contains
191
+ // `*pair*` (italic in markdown), a `#hash` token (lone hash with no
192
+ // space — NOT a heading even in markdown, kept here to show the
193
+ // asymmetry), and embedded newlines that should survive in plain mode.
194
+ const CONTRAST_CONTENT = `Multi
195
+ line
196
+ user message
197
+ with *asterisks*
198
+ and #hash that should NOT format`;
199
+
200
+ const LONG_ASSISTANT_REPLY = `## Установка CMDOP
201
+
202
+ Запусти этот скрипт:
203
+
204
+ \`\`\`bash
205
+ curl -sSL cmdop.com/install.sh | bash
206
+ \`\`\`
207
+
208
+ Дальше:
209
+
210
+ 1. **Войти:** \`cmdop login\`
211
+ 2. **Подключить машину:** \`cmdop connect\`
212
+ 3. **Проверить:** \`cmdop doctor\`
213
+
214
+ Если что-то не работает — скажи.`;
215
+
216
+ function ChatBubble({
217
+ role,
218
+ content,
219
+ rules,
220
+ plainText,
221
+ }: {
222
+ role: 'user' | 'assistant';
223
+ content: string;
224
+ rules?: LinkRule[];
225
+ plainText?: boolean;
226
+ }) {
227
+ const isUser = role === 'user';
228
+ return (
229
+ <div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
230
+ <div
231
+ className={`max-w-[85%] rounded-xl px-3 py-2 ${
232
+ isUser
233
+ ? 'bg-primary text-primary-foreground'
234
+ : 'bg-card text-card-foreground border border-border'
235
+ }`}
236
+ >
237
+ <MarkdownMessage
238
+ content={content}
239
+ isUser={isUser}
240
+ linkRules={rules}
241
+ plainText={plainText}
242
+ />
243
+ </div>
244
+ </div>
245
+ );
246
+ }
247
+
248
+ export const ChatBubbles = () => (
249
+ <div className="mx-auto max-w-2xl space-y-6 p-6">
250
+ <div>
251
+ <h3 className="mb-2 text-sm font-semibold">
252
+ User messages — explicit <code>plainText</code>
253
+ </h3>
254
+ <p className="mb-3 text-xs text-muted-foreground">
255
+ ChatGPT/Claude.ai split: user-typed content stays as-typed.
256
+ <code> *stars* </code> aren't bold; <code># headers </code>
257
+ aren't headings. Newlines preserved via{' '}
258
+ <code>whitespace-pre-wrap</code>.
259
+ </p>
260
+ <div className="rounded-lg border border-border bg-card p-4 space-y-2">
261
+ {SHORT_USER_LINES.map((line, i) => (
262
+ <ChatBubble
263
+ key={`u-${i}`}
264
+ role="user"
265
+ content={line}
266
+ plainText
267
+ rules={onPrimaryRules}
268
+ />
269
+ ))}
270
+ <ChatBubble
271
+ role="user"
272
+ content={'Multi\nline\nuser message\nwith *asterisks*\nand #hash that should NOT format'}
273
+ plainText
274
+ rules={onPrimaryRules}
275
+ />
276
+ </div>
277
+ </div>
278
+
279
+ <div>
280
+ <h3 className="mb-2 text-sm font-semibold">
281
+ Heuristic auto-detect (no <code>plainText</code> prop)
282
+ </h3>
283
+ <p className="mb-3 text-xs text-muted-foreground">
284
+ Without <code>plainText</code>, <code>looksLikePlainProse</code>
285
+ decides: short / single-paragraph / no markers → flat render;
286
+ long / multi-paragraph / structural → full markdown. Same
287
+ renderer for both branches; only margins differ.
288
+ </p>
289
+ <div className="rounded-lg border border-border bg-card p-4 space-y-2">
290
+ {SHORT_ASSISTANT_LINES.map((line, i) => (
291
+ <ChatBubble
292
+ key={`a-${i}`}
293
+ role="assistant"
294
+ content={line}
295
+ rules={subtleRules}
296
+ />
297
+ ))}
298
+ <ChatBubble
299
+ role="assistant"
300
+ content="Sure — let me check uptime and disk on the machine and report back. Should only take a couple of seconds."
301
+ rules={subtleRules}
302
+ />
303
+ </div>
304
+ </div>
305
+
306
+ <div>
307
+ <h3 className="mb-2 text-sm font-semibold">
308
+ Bubble with mention chips
309
+ </h3>
310
+ <p className="mb-3 text-xs text-muted-foreground">
311
+ Content with <code>[label](cmdop://...)</code> always goes
312
+ through ReactMarkdown — that's how MentionChip renders.
313
+ </p>
314
+ <div className="rounded-lg border border-border bg-card p-4 space-y-2">
315
+ <ChatBubble
316
+ role="user"
317
+ content="Спроси у @[Vps-audi](cmdop://machine/abc-123) сколько свободного места."
318
+ rules={onPrimaryRules}
319
+ />
320
+ <ChatBubble
321
+ role="assistant"
322
+ content="Готово — спросил [Vps-audi](cmdop://machine/abc-123)."
323
+ rules={subtleRules}
324
+ />
325
+ </div>
326
+ </div>
327
+
328
+ <div>
329
+ <h3 className="mb-2 text-sm font-semibold">
330
+ Same content, two modes — side-by-side contrast
331
+ </h3>
332
+ <p className="mb-3 text-xs text-muted-foreground">
333
+ Identical input string. Left bubble forces{' '}
334
+ <code>plainText</code> (user-side default in cmdop):{' '}
335
+ <code>*asterisks*</code> stay literal, <code>#hash</code> stays
336
+ literal, newlines preserved verbatim. Right bubble omits{' '}
337
+ <code>plainText</code> so the heuristic kicks in — short
338
+ content but with <code>*pair*</code> markers, so it routes
339
+ through ReactMarkdown and renders <em>italic</em>.
340
+ </p>
341
+ <div className="grid grid-cols-2 gap-3">
342
+ <div className="rounded-lg border border-border bg-card p-4">
343
+ <div className="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">
344
+ plainText = true
345
+ </div>
346
+ <ChatBubble
347
+ role="user"
348
+ content={CONTRAST_CONTENT}
349
+ plainText
350
+ rules={onPrimaryRules}
351
+ />
352
+ </div>
353
+ <div className="rounded-lg border border-border bg-card p-4">
354
+ <div className="mb-2 text-[10px] uppercase tracking-wide text-muted-foreground">
355
+ plainText omitted → heuristic
356
+ </div>
357
+ <ChatBubble
358
+ role="assistant"
359
+ content={CONTRAST_CONTENT}
360
+ rules={subtleRules}
361
+ />
362
+ </div>
363
+ </div>
364
+ </div>
365
+
366
+ <div>
367
+ <h3 className="mb-2 text-sm font-semibold">
368
+ Long assistant reply (multi-paragraph + code + headings)
369
+ </h3>
370
+ <p className="mb-3 text-xs text-muted-foreground">
371
+ Full markdown pipeline. Tight CSS overrides
372
+ (<code>p+p mt-2</code>, <code>p my-0</code>) keep the bubble
373
+ from looking airy — defaults from Tailwind's prose are too
374
+ loose for chat density.
375
+ </p>
376
+ <div className="rounded-lg border border-border bg-card p-4">
377
+ <ChatBubble
378
+ role="assistant"
379
+ content={LONG_ASSISTANT_REPLY}
380
+ rules={subtleRules}
381
+ />
382
+ </div>
383
+ </div>
384
+ </div>
385
+ );
@@ -10,7 +10,7 @@ import type { Components } from 'react-markdown';
10
10
  import { useCollapsibleContent } from '../useCollapsibleContent';
11
11
  import type { MarkdownMessageProps } from './types';
12
12
  import { buildSchema, buildUrlTransform } from './sanitize';
13
- import { hasMarkdownSyntax } from './plainText';
13
+ import { looksLikePlainProse } from './plainText';
14
14
  import { createMarkdownComponents } from './components';
15
15
  import { CollapseToggle } from './CollapseToggle';
16
16
  import { applyPreprocess, buildLinkRulesComponent, collectProtocols } from './linkRules';
@@ -51,6 +51,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
51
51
  className = '',
52
52
  isUser = false,
53
53
  isCompact = false,
54
+ plainText,
54
55
  customComponents,
55
56
  extraHrefProtocols,
56
57
  linkRules,
@@ -121,17 +122,34 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
121
122
  const textSizeClass = isCompact ? 'text-xs' : 'text-sm';
122
123
  const proseClass = isCompact ? 'prose-xs' : 'prose-sm';
123
124
 
124
- // Plain-text fast path: skip ReactMarkdown when content is pure
125
- // prose. Bypassed when the caller wired up custom components or
126
- // link rules those need to fire on plain `[label](custom://…)`
127
- // links the fast path would otherwise print verbatim.
128
- const isPlainText =
129
- !effectiveCustomComponents && !hasMarkdownSyntax(displayContent);
125
+ // Resolve plain-vs-markdown branch:
126
+ // 1. Caller-passed `plainText` wins outright (explicit beats clever).
127
+ // 2. Caller-supplied non-`a` customComponents force the markdown
128
+ // pipeline those overrides only matter on real markdown nodes.
129
+ // 3. Otherwise auto-detect via `looksLikePlainProse` (short,
130
+ // single-paragraph, no markdown markers). This is the chat-bubble
131
+ // WhatsApp/Telegram heuristic — "would the human have written
132
+ // this in one keystroke?".
133
+ const customComponentsBeyondLinks = React.useMemo(() => {
134
+ if (!customComponents) return false;
135
+ return Object.keys(customComponents).some((k) => k !== 'a');
136
+ }, [customComponents]);
137
+ const isPlainText = plainText !== undefined
138
+ ? plainText
139
+ : !customComponentsBeyondLinks && looksLikePlainProse(displayContent);
130
140
 
131
141
  if (isPlainText) {
142
+ // <div> + whitespace-pre-wrap: respects newlines AND collapses
143
+ // double spaces, which is what users mean when they hit Enter
144
+ // twice. <span> would break flow inside a flex bubble.
145
+ //
146
+ // leading-snug (1.375), not leading-relaxed (1.625) — `pre-wrap`
147
+ // makes every `\n` a hard line break, so the relaxed leading
148
+ // turns multi-line user messages into airy ladders. snug matches
149
+ // WhatsApp/Telegram bubble density.
132
150
  return (
133
- <span
134
- className={`${textSizeClass} leading-7 break-words whitespace-pre-line font-light ${className}`}
151
+ <div
152
+ className={`${textSizeClass} leading-snug break-words whitespace-pre-wrap ${className}`}
135
153
  >
136
154
  {displayContent}
137
155
  {collapsible && shouldCollapse && (
@@ -147,7 +165,7 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
147
165
  />
148
166
  </>
149
167
  )}
150
- </span>
168
+ </div>
151
169
  );
152
170
  }
153
171
 
@@ -157,7 +175,11 @@ export const MarkdownMessage: React.FC<MarkdownMessageProps> = ({
157
175
  className={`
158
176
  prose ${proseClass} max-w-none break-words overflow-hidden ${textSizeClass}
159
177
  ${isUser ? 'prose-invert' : 'dark:prose-invert'}
160
- [&>*]:leading-7
178
+ [&>*]:leading-relaxed
179
+ [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
180
+ [&_p]:my-0 [&_p+p]:mt-2
181
+ [&_ul]:my-2 [&_ol]:my-2 [&_pre]:my-2 [&_blockquote]:my-2
182
+ [&_h1]:mt-3 [&_h1]:mb-1 [&_h2]:mt-3 [&_h2]:mb-1 [&_h3]:mt-2 [&_h3]:mb-1
161
183
  `}
162
184
  style={{
163
185
  // Inherit colors from parent — fixes issues with external
@@ -16,6 +16,39 @@ export function extractTextFromChildren(children: React.ReactNode): string {
16
16
  return '';
17
17
  }
18
18
 
19
+ /**
20
+ * Auto-detect whether `text` should bypass ReactMarkdown and render as
21
+ * a flat `<div whitespace-pre-wrap>`. Used as the *fallback* when the
22
+ * caller doesn't pass `plainText` explicitly to `MarkdownMessage`.
23
+ *
24
+ * The signal we trust: short, single-paragraph, no markdown markers.
25
+ * Anything longer / multi-paragraph / structurally suggestive falls
26
+ * through to the markdown pipeline — false negatives there are cheap
27
+ * (markdown renders prose correctly), false positives in the prose
28
+ * branch surface as escaped `*` / `#` / etc. so we err markdown-ward.
29
+ *
30
+ * Thresholds were picked from chat-bubble UX research (see ChatGPT,
31
+ * Claude.ai, WhatsApp): roughly "would a person have written this in
32
+ * one keystroke without thinking about formatting?". If you find them
33
+ * too tight or too loose, tune here — every consumer goes through
34
+ * `MarkdownMessage` so the change is universal.
35
+ */
36
+ export function looksLikePlainProse(text: string): boolean {
37
+ const trimmed = text.trim();
38
+ // Empty / whitespace-only — render as plain (cheap path, nothing to parse).
39
+ if (trimmed.length === 0) return true;
40
+ // Long enough that it's likely a structured assistant reply.
41
+ if (trimmed.length > 500) return false;
42
+ // Paragraph break → almost certainly markdown territory.
43
+ if (/\n\s*\n/.test(trimmed)) return false;
44
+ // Many single-line breaks → probably a list or stanza.
45
+ const newlineCount = (trimmed.match(/\n/g) || []).length;
46
+ if (newlineCount > 4) return false;
47
+ // Any markdown marker → defer to ReactMarkdown.
48
+ if (hasMarkdownSyntax(trimmed)) return false;
49
+ return true;
50
+ }
51
+
19
52
  /** Affordance test: does this string look like markdown? Used to skip
20
53
  * the (heavier) ReactMarkdown pipeline when the content is pure
21
54
  * prose. NOT a validator — false negatives are fine; false positives
@@ -52,6 +52,19 @@ export interface MarkdownMessageProps {
52
52
  isUser?: boolean;
53
53
  /** Use compact size (text-xs instead of text-sm) */
54
54
  isCompact?: boolean;
55
+ /**
56
+ * Force the plain-text rendering path (single `<div>` with
57
+ * `white-space: pre-wrap`, no ReactMarkdown). Use this for
58
+ * user-authored content where markdown shouldn't be parsed.
59
+ *
60
+ * When `undefined` (default) we run a small heuristic
61
+ * (`looksLikePlainProse`): short single-paragraph text without
62
+ * markdown markers → plain; everything else → ReactMarkdown.
63
+ *
64
+ * Pass an explicit `true` / `false` to opt out of the heuristic
65
+ * entirely.
66
+ */
67
+ plainText?: boolean;
55
68
  /**
56
69
  * Per-tag overrides merged on top of the built-in renderers.
57
70
  *