@open-mercato/ui 0.6.4-develop.4382.1.6b4f656b77 → 0.6.4

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 (133) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +2 -1
  3. package/dist/backend/AppShell.js +6 -0
  4. package/dist/backend/AppShell.js.map +2 -2
  5. package/dist/backend/CrudForm.js +212 -85
  6. package/dist/backend/CrudForm.js.map +3 -3
  7. package/dist/backend/DataTable.js +77 -26
  8. package/dist/backend/DataTable.js.map +3 -3
  9. package/dist/backend/FlashMessages.js +21 -2
  10. package/dist/backend/FlashMessages.js.map +2 -2
  11. package/dist/backend/JsonBuilder.js +64 -41
  12. package/dist/backend/JsonBuilder.js.map +2 -2
  13. package/dist/backend/PerspectiveSidebar.js +8 -1
  14. package/dist/backend/PerspectiveSidebar.js.map +2 -2
  15. package/dist/backend/RowActions.js +1 -1
  16. package/dist/backend/RowActions.js.map +2 -2
  17. package/dist/backend/conflicts/RecordConflictBanner.js +73 -0
  18. package/dist/backend/conflicts/RecordConflictBanner.js.map +7 -0
  19. package/dist/backend/conflicts/index.js +32 -0
  20. package/dist/backend/conflicts/index.js.map +7 -0
  21. package/dist/backend/conflicts/store.js +50 -0
  22. package/dist/backend/conflicts/store.js.map +7 -0
  23. package/dist/backend/detail/ActivitiesSection.js +1 -0
  24. package/dist/backend/detail/ActivitiesSection.js.map +2 -2
  25. package/dist/backend/detail/InlineEditors.js +2 -51
  26. package/dist/backend/detail/InlineEditors.js.map +3 -3
  27. package/dist/backend/detail/RecordNotFoundState.js +10 -5
  28. package/dist/backend/detail/RecordNotFoundState.js.map +2 -2
  29. package/dist/backend/filters/AdvancedFilterPanel.js +14 -0
  30. package/dist/backend/filters/AdvancedFilterPanel.js.map +2 -2
  31. package/dist/backend/filters/FilteredEmptyResults.js +12 -4
  32. package/dist/backend/filters/FilteredEmptyResults.js.map +2 -2
  33. package/dist/backend/filters/ListEmptyState.js +44 -0
  34. package/dist/backend/filters/ListEmptyState.js.map +7 -0
  35. package/dist/backend/filters/SearchEmptyResults.js +28 -0
  36. package/dist/backend/filters/SearchEmptyResults.js.map +7 -0
  37. package/dist/backend/icons/lucideRegistry.generated.js +8 -0
  38. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  39. package/dist/backend/injection/useGuardedMutation.js +8 -1
  40. package/dist/backend/injection/useGuardedMutation.js.map +2 -2
  41. package/dist/backend/inputs/MarkdownField.js +20 -0
  42. package/dist/backend/inputs/MarkdownField.js.map +7 -0
  43. package/dist/backend/inputs/MdxEditorImpl.js +98 -0
  44. package/dist/backend/inputs/MdxEditorImpl.js.map +7 -0
  45. package/dist/backend/inputs/SwitchableMarkdownInput.js +6 -38
  46. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +2 -2
  47. package/dist/backend/markdown/markdownToPlainText.js +29 -0
  48. package/dist/backend/markdown/markdownToPlainText.js.map +2 -2
  49. package/dist/backend/messages/EmailThreadsPanel.js +212 -0
  50. package/dist/backend/messages/EmailThreadsPanel.js.map +7 -0
  51. package/dist/backend/messages/index.js +4 -0
  52. package/dist/backend/messages/index.js.map +2 -2
  53. package/dist/backend/messages/mergeOptimisticEmailThreads.js +55 -0
  54. package/dist/backend/messages/mergeOptimisticEmailThreads.js.map +7 -0
  55. package/dist/backend/notifications/NotificationDispatcher.js +1 -0
  56. package/dist/backend/notifications/NotificationDispatcher.js.map +2 -2
  57. package/dist/backend/notifications/useNotificationsPoll.js +7 -0
  58. package/dist/backend/notifications/useNotificationsPoll.js.map +2 -2
  59. package/dist/backend/notifications/useNotificationsSse.js +8 -0
  60. package/dist/backend/notifications/useNotificationsSse.js.map +2 -2
  61. package/dist/backend/sidebar/SidebarCustomizationEditor.js +23 -7
  62. package/dist/backend/sidebar/SidebarCustomizationEditor.js.map +2 -2
  63. package/dist/backend/utils/optimisticLock.js +34 -0
  64. package/dist/backend/utils/optimisticLock.js.map +7 -0
  65. package/dist/portal/hooks/useTenantContext.js +1 -1
  66. package/dist/portal/hooks/useTenantContext.js.map +2 -2
  67. package/dist/primitives/alert.js.map +1 -1
  68. package/dist/primitives/checkbox.js +2 -1
  69. package/dist/primitives/checkbox.js.map +2 -2
  70. package/dist/types/css.d.js +1 -0
  71. package/dist/types/css.d.js.map +7 -0
  72. package/package.json +17 -13
  73. package/src/backend/AppShell.tsx +10 -0
  74. package/src/backend/CrudForm.tsx +320 -97
  75. package/src/backend/DataTable.tsx +113 -34
  76. package/src/backend/FlashMessages.tsx +31 -2
  77. package/src/backend/JsonBuilder.tsx +79 -26
  78. package/src/backend/PerspectiveSidebar.tsx +8 -1
  79. package/src/backend/RowActions.tsx +1 -1
  80. package/src/backend/__tests__/CrudForm.custom-fields.test.tsx +0 -1
  81. package/src/backend/__tests__/CrudForm.datetime.test.tsx +0 -1
  82. package/src/backend/__tests__/CrudForm.defaults.test.tsx +0 -1
  83. package/src/backend/__tests__/CrudForm.dotPath.test.tsx +311 -0
  84. package/src/backend/__tests__/CrudForm.doubleSubmit.test.tsx +0 -1
  85. package/src/backend/__tests__/CrudForm.navigation.test.tsx +120 -1
  86. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +287 -0
  87. package/src/backend/__tests__/CrudForm.render.test.tsx +74 -2
  88. package/src/backend/__tests__/CrudForm.sortable.test.tsx +0 -1
  89. package/src/backend/__tests__/CrudForm.transformData.test.tsx +0 -1
  90. package/src/backend/__tests__/CrudForm.unsavedNavigation.test.tsx +51 -3
  91. package/src/backend/__tests__/CrudForm.validation.test.tsx +0 -1
  92. package/src/backend/__tests__/CustomDataSection.test.tsx +0 -1
  93. package/src/backend/__tests__/FlashMessages.test.tsx +47 -0
  94. package/src/backend/__tests__/JsonBuilder.test.tsx +135 -0
  95. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +16 -3
  96. package/src/backend/conflicts/RecordConflictBanner.tsx +77 -0
  97. package/src/backend/conflicts/__tests__/RecordConflictBanner.test.tsx +61 -0
  98. package/src/backend/conflicts/__tests__/store.test.ts +78 -0
  99. package/src/backend/conflicts/index.ts +49 -0
  100. package/src/backend/conflicts/store.ts +94 -0
  101. package/src/backend/detail/ActivitiesSection.tsx +1 -0
  102. package/src/backend/detail/InlineEditors.tsx +14 -76
  103. package/src/backend/detail/RecordNotFoundState.tsx +13 -6
  104. package/src/backend/detail/__tests__/RecordNotFoundState.test.tsx +80 -0
  105. package/src/backend/filters/AdvancedFilterPanel.tsx +27 -0
  106. package/src/backend/filters/FilteredEmptyResults.tsx +23 -10
  107. package/src/backend/filters/ListEmptyState.tsx +69 -0
  108. package/src/backend/filters/SearchEmptyResults.tsx +42 -0
  109. package/src/backend/filters/__tests__/AdvancedFilterPanel.test.tsx +98 -0
  110. package/src/backend/filters/__tests__/ListEmptyState.test.tsx +53 -0
  111. package/src/backend/icons/lucideRegistry.generated.tsx +8 -0
  112. package/src/backend/injection/__tests__/useGuardedMutation.optimisticLock.test.tsx +118 -0
  113. package/src/backend/injection/useGuardedMutation.ts +16 -1
  114. package/src/backend/inputs/MarkdownField.tsx +34 -0
  115. package/src/backend/inputs/MdxEditorImpl.tsx +113 -0
  116. package/src/backend/inputs/SwitchableMarkdownInput.tsx +5 -54
  117. package/src/backend/markdown/__tests__/markdownToPlainText.test.ts +13 -0
  118. package/src/backend/markdown/markdownToPlainText.ts +41 -0
  119. package/src/backend/messages/EmailThreadsPanel.tsx +351 -0
  120. package/src/backend/messages/__tests__/mergeOptimisticEmailThreads.test.ts +126 -0
  121. package/src/backend/messages/index.ts +12 -0
  122. package/src/backend/messages/mergeOptimisticEmailThreads.ts +84 -0
  123. package/src/backend/notifications/NotificationDispatcher.ts +2 -0
  124. package/src/backend/notifications/useNotificationsPoll.ts +5 -0
  125. package/src/backend/notifications/useNotificationsSse.ts +6 -0
  126. package/src/backend/sidebar/SidebarCustomizationEditor.tsx +35 -11
  127. package/src/backend/utils/__tests__/optimisticLock.test.ts +126 -0
  128. package/src/backend/utils/optimisticLock.ts +70 -0
  129. package/src/portal/hooks/useTenantContext.ts +7 -2
  130. package/src/primitives/__tests__/checkbox.test.tsx +28 -0
  131. package/src/primitives/alert.tsx +1 -1
  132. package/src/primitives/checkbox.tsx +2 -1
  133. package/src/types/css.d.ts +5 -0
@@ -4,6 +4,8 @@ import * as React from 'react'
4
4
  import { useInjectionSpotEvents } from './InjectionSpot'
5
5
  import { GLOBAL_MUTATION_INJECTION_SPOT_ID, dispatchBackendMutationError } from './mutationEvents'
6
6
  import { withScopedApiRequestHeaders } from '../utils/apiCall'
7
+ import { surfaceRecordConflict } from '../conflicts'
8
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
9
 
8
10
  type GuardedMutationContext = Record<string, unknown>
9
11
 
@@ -30,6 +32,7 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
30
32
  blockedMessage = 'Save blocked by validation',
31
33
  spotId = GLOBAL_MUTATION_INJECTION_SPOT_ID,
32
34
  }: UseGuardedMutationOptions) {
35
+ const t = useT()
33
36
  const { triggerEvent } = useInjectionSpotEvents<TContext, Record<string, unknown>>(spotId)
34
37
  const lastMutationRef = React.useRef<StoredMutation<TContext> | null>(null)
35
38
 
@@ -39,7 +42,19 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
39
42
  formId: contextId,
40
43
  error,
41
44
  })
42
- }, [contextId])
45
+ // Default UX for OSS optimistic-lock conflicts (spec
46
+ // .ai/specs/2026-05-25-oss-optimistic-locking.md §3.6): when the
47
+ // server returns 409 with `code: 'optimistic_lock_conflict'`,
48
+ // surface the conflict on the unified, persistent, error-styled
49
+ // RecordConflictBanner (rendered in AppShell) instead of a transient
50
+ // toast. Callers that have their own handler still see the dispatched
51
+ // event above and can suppress/override by catching the error first.
52
+ try {
53
+ surfaceRecordConflict(error, t)
54
+ } catch {
55
+ // ignore conflict-bar dispatch failures
56
+ }
57
+ }, [contextId, t])
43
58
 
44
59
  const runMutation = React.useCallback(async <TResult,>({
45
60
  operation,
@@ -0,0 +1,34 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import type { ComponentType } from 'react'
6
+
7
+ export type MarkdownFieldProps = {
8
+ value?: string
9
+ onChange: (markdown: string) => void
10
+ }
11
+
12
+ const isTestEnv =
13
+ typeof process !== 'undefined' &&
14
+ (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
15
+
16
+ // Lightweight stand-in for unit tests (jsdom): MDXEditor/Lexical pull in ESM + CSS that jest
17
+ // would have to transform, so under test we render a plain controlled textarea instead.
18
+ const MarkdownFieldTestStub: ComponentType<MarkdownFieldProps> = ({ value, onChange }) => (
19
+ <textarea
20
+ data-testid="markdown-field"
21
+ className="min-h-[160px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
22
+ value={value ?? ''}
23
+ onChange={(event) => onChange(event.target.value)}
24
+ />
25
+ )
26
+
27
+ const MarkdownFieldLive = dynamic(() => import('./MdxEditorImpl'), { ssr: false }) as ComponentType<MarkdownFieldProps>
28
+
29
+ // Canonical Markdown editor for the whole app: a WYSIWYG MDXEditor (Lexical) that emits Markdown.
30
+ // Client-only (Lexical needs the DOM), loaded via next/dynamic with ssr disabled. Use this
31
+ // everywhere a Markdown editing field is needed.
32
+ const MarkdownField: ComponentType<MarkdownFieldProps> = isTestEnv ? MarkdownFieldTestStub : MarkdownFieldLive
33
+
34
+ export default MarkdownField
@@ -0,0 +1,113 @@
1
+ /// <reference path="../../types/css.d.ts" />
2
+ "use client"
3
+
4
+ import * as React from 'react'
5
+ import {
6
+ MDXEditor,
7
+ type MDXEditorMethods,
8
+ headingsPlugin,
9
+ listsPlugin,
10
+ quotePlugin,
11
+ thematicBreakPlugin,
12
+ linkPlugin,
13
+ linkDialogPlugin,
14
+ imagePlugin,
15
+ tablePlugin,
16
+ markdownShortcutPlugin,
17
+ diffSourcePlugin,
18
+ toolbarPlugin,
19
+ UndoRedo,
20
+ BoldItalicUnderlineToggles,
21
+ CodeToggle,
22
+ StrikeThroughSupSubToggles,
23
+ ListsToggle,
24
+ BlockTypeSelect,
25
+ CreateLink,
26
+ InsertImage,
27
+ InsertTable,
28
+ InsertThematicBreak,
29
+ Separator,
30
+ DiffSourceToggleWrapper,
31
+ } from '@mdxeditor/editor'
32
+ import '@mdxeditor/editor/style.css'
33
+ import { cn } from '@open-mercato/shared/lib/utils'
34
+ import { useTheme } from '../../theme'
35
+
36
+ type MdxEditorImplProps = {
37
+ value?: string
38
+ onChange: (markdown: string) => void
39
+ }
40
+
41
+ // WYSIWYG Markdown editor (MDXEditor / Lexical) wired to the CrudForm value/onChange
42
+ // contract. Markdown is the source of truth. Edits are buffered locally and committed
43
+ // on blur to avoid re-rendering the whole form on every keystroke.
44
+ export default function MdxEditorImpl({ value = '', onChange }: MdxEditorImplProps) {
45
+ const editorRef = React.useRef<MDXEditorMethods>(null)
46
+ const latestRef = React.useRef<string>(value)
47
+ const typingRef = React.useRef(false)
48
+ const { resolvedTheme } = useTheme()
49
+
50
+ // External value changes (form reset / async initial load) are pushed into the editor,
51
+ // but never while the user is actively typing (would reset the caret).
52
+ React.useEffect(() => {
53
+ if (typingRef.current) return
54
+ if (value !== latestRef.current) {
55
+ latestRef.current = value
56
+ editorRef.current?.setMarkdown(value ?? '')
57
+ }
58
+ }, [value])
59
+
60
+ const commit = React.useCallback(() => {
61
+ if (!typingRef.current) return
62
+ typingRef.current = false
63
+ onChange(latestRef.current)
64
+ }, [onChange])
65
+
66
+ return (
67
+ <div className="w-full overflow-hidden rounded-md border border-input bg-background" onBlur={commit}>
68
+ <MDXEditor
69
+ ref={editorRef}
70
+ markdown={value ?? ''}
71
+ onChange={(markdown) => {
72
+ typingRef.current = true
73
+ latestRef.current = markdown
74
+ }}
75
+ className={cn('om-mdx-editor', resolvedTheme === 'dark' && 'dark-theme')}
76
+ contentEditableClassName="om-mdx-prose"
77
+ plugins={[
78
+ headingsPlugin(),
79
+ listsPlugin(),
80
+ quotePlugin(),
81
+ thematicBreakPlugin(),
82
+ linkPlugin(),
83
+ linkDialogPlugin(),
84
+ imagePlugin(),
85
+ tablePlugin(),
86
+ markdownShortcutPlugin(),
87
+ diffSourcePlugin({ viewMode: 'rich-text' }),
88
+ toolbarPlugin({
89
+ toolbarContents: () => (
90
+ <DiffSourceToggleWrapper>
91
+ <UndoRedo />
92
+ <Separator />
93
+ <BoldItalicUnderlineToggles />
94
+ <CodeToggle />
95
+ <StrikeThroughSupSubToggles />
96
+ <Separator />
97
+ <ListsToggle />
98
+ <Separator />
99
+ <BlockTypeSelect />
100
+ <Separator />
101
+ <CreateLink />
102
+ <InsertImage />
103
+ <Separator />
104
+ <InsertTable />
105
+ <InsertThematicBreak />
106
+ </DiffSourceToggleWrapper>
107
+ ),
108
+ }),
109
+ ]}
110
+ />
111
+ </div>
112
+ )
113
+ }
@@ -29,11 +29,8 @@
29
29
  "use client"
30
30
 
31
31
  import * as React from 'react'
32
- import dynamic from 'next/dynamic'
33
32
  import type { PluggableList } from 'unified'
34
- import { LoadingMessage } from '../detail/LoadingMessage'
35
- import { useMarkdownRemarkPlugins } from '../markdown/useMarkdownRemarkPlugins'
36
- import { useTheme } from '../../theme'
33
+ import MarkdownField from './MarkdownField'
37
34
 
38
35
  /** @deprecated See module-level JSDoc. */
39
36
  export type SwitchableMarkdownInputProps = {
@@ -53,75 +50,29 @@ export type SwitchableMarkdownInputProps = {
53
50
  remarkPlugins?: PluggableList
54
51
  }
55
52
 
56
- type UiMarkdownEditorProps = {
57
- value?: string
58
- height?: number
59
- onChange?: (value?: string) => void
60
- previewOptions?: { remarkPlugins?: unknown[] }
61
- }
62
-
63
- const isTestEnv =
64
- typeof process !== 'undefined' &&
65
- (process.env.NODE_ENV === 'test' || typeof process.env.JEST_WORKER_ID !== 'undefined')
66
-
67
- const MarkdownEditorTestStub: React.ComponentType<UiMarkdownEditorProps> = ({ value, onChange }) => (
68
- <textarea
69
- className="min-h-[160px] w-full rounded border px-3 py-2 text-sm"
70
- value={value ?? ''}
71
- onChange={(event) => onChange?.(event.target.value)}
72
- />
73
- )
74
-
75
- const UiMarkdownEditor = isTestEnv
76
- ? MarkdownEditorTestStub
77
- : (dynamic(() => import('@uiw/react-md-editor'), {
78
- ssr: false,
79
- loading: () => (
80
- <LoadingMessage
81
- label="Loading editor..."
82
- className="min-h-[220px] justify-center"
83
- />
84
- ),
85
- }) as unknown as React.ComponentType<UiMarkdownEditorProps>)
86
-
87
53
  /** @deprecated Use `RichEditor` from `@open-mercato/ui/primitives/rich-editor` for new code. See module JSDoc. */
88
54
  export function SwitchableMarkdownInput({
89
55
  value,
90
56
  onChange,
91
57
  isMarkdownEnabled,
92
58
  disableMarkdown,
93
- height = 220,
94
59
  placeholder,
95
60
  rows = 3,
96
61
  textareaRef,
97
62
  onTextareaInput,
98
63
  textareaClassName,
99
- editorWrapperClassName,
100
- editorClassName,
101
64
  disabled,
102
- remarkPlugins,
103
65
  }: SwitchableMarkdownInputProps) {
104
- const resolvedPlugins = useMarkdownRemarkPlugins(remarkPlugins)
105
- const { resolvedTheme } = useTheme()
106
- const editorWrapperClasses =
107
- editorWrapperClassName ?? 'w-full rounded-lg border border-muted-foreground/20 bg-background p-2'
108
- const editorClasses = editorClassName ?? 'w-full'
109
66
  const textareaClasses =
110
67
  textareaClassName
111
68
  ?? 'w-full resize-none overflow-hidden rounded-lg border border-muted-foreground/20 bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
112
69
 
113
70
  if (isMarkdownEnabled && !disableMarkdown) {
114
71
  return (
115
- <div className={editorWrapperClasses}>
116
- <div data-color-mode={resolvedTheme} className={editorClasses}>
117
- <UiMarkdownEditor
118
- value={value}
119
- height={height}
120
- onChange={(nextValue) => onChange(typeof nextValue === 'string' ? nextValue : '')}
121
- previewOptions={resolvedPlugins.length ? { remarkPlugins: resolvedPlugins } : undefined}
122
- />
123
- </div>
124
- </div>
72
+ <MarkdownField
73
+ value={value}
74
+ onChange={(nextValue) => onChange(typeof nextValue === 'string' ? nextValue : '')}
75
+ />
125
76
  )
126
77
  }
127
78
 
@@ -26,4 +26,17 @@ describe('markdownToPlainText', () => {
26
26
  const input = '> quoted text\n\n~~gone~~\n\n| a | b |\n| - | - |\n| 1 | 2 |'
27
27
  expect(markdownToPlainText(input)).toBe('quoted text gone a b - - 1 2')
28
28
  })
29
+
30
+ it('strips legacy HTML tags and separates block content', () => {
31
+ expect(markdownToPlainText('<span style="font-weight:bold">Backend</span> and frontend')).toBe(
32
+ 'Backend and frontend',
33
+ )
34
+ expect(markdownToPlainText('<p>Hello</p><p>World</p>')).toBe('Hello World')
35
+ })
36
+
37
+ it('decodes named, decimal, and hex HTML entities', () => {
38
+ expect(markdownToPlainText('Tom &amp; Jerry')).toBe('Tom & Jerry')
39
+ expect(markdownToPlainText('it&#39;s &#x2014; done')).toBe("it's — done")
40
+ expect(markdownToPlainText('a&nbsp;b')).toBe('a b')
41
+ })
29
42
  })
@@ -1,7 +1,47 @@
1
+ const NAMED_HTML_ENTITIES: Record<string, string> = {
2
+ amp: '&',
3
+ lt: '<',
4
+ gt: '>',
5
+ quot: '"',
6
+ apos: "'",
7
+ nbsp: ' ',
8
+ hellip: '…',
9
+ mdash: '—',
10
+ ndash: '–',
11
+ rsquo: '’',
12
+ lsquo: '‘',
13
+ rdquo: '”',
14
+ ldquo: '“',
15
+ }
16
+
17
+ function decodeHtmlEntities(value: string): string {
18
+ return value
19
+ .replace(/&#(\d+);/g, (match, dec: string) => {
20
+ const code = Number(dec)
21
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match
22
+ })
23
+ .replace(/&#x([0-9a-fA-F]+);/g, (match, hex: string) => {
24
+ const code = parseInt(hex, 16)
25
+ return Number.isFinite(code) ? String.fromCodePoint(code) : match
26
+ })
27
+ .replace(/&([a-zA-Z][a-zA-Z0-9]*);/g, (match, name: string) =>
28
+ Object.prototype.hasOwnProperty.call(NAMED_HTML_ENTITIES, name) ? NAMED_HTML_ENTITIES[name] : match,
29
+ )
30
+ }
31
+
32
+ /**
33
+ * Converts a Markdown (or legacy HTML) string into a plain-text preview suitable for compact,
34
+ * read-only contexts such as DataTable list cells. Strips Markdown syntax, strips HTML tags, and
35
+ * decodes HTML entities, then collapses whitespace. Handling legacy HTML keeps list previews clean
36
+ * for records authored before the editor moved back to Markdown — no data migration required.
37
+ *
38
+ * This is NOT a sanitizer — render rich content with the Markdown components, not this helper.
39
+ */
1
40
  export function markdownToPlainText(input: string | null | undefined): string {
2
41
  if (!input) return ''
3
42
  let text = String(input)
4
43
  text = text.replace(/```[\s\S]*?```/g, ' ')
44
+ text = text.replace(/<[^>]+>/g, ' ')
5
45
  text = text.replace(/`([^`]+)`/g, '$1')
6
46
  text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
7
47
  text = text.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
@@ -15,6 +55,7 @@ export function markdownToPlainText(input: string | null | undefined): string {
15
55
  text = text.replace(/\*([^*]+)\*/g, '$1')
16
56
  text = text.replace(/_([^_]+)_/g, '$1')
17
57
  text = text.replace(/\|/g, ' ')
58
+ text = decodeHtmlEntities(text)
18
59
  text = text.replace(/\s+/g, ' ').trim()
19
60
  return text
20
61
  }