@djangocfg/ui-tools 2.1.290 → 2.1.292

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.290",
3
+ "version": "2.1.292",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -74,7 +74,8 @@
74
74
  "import": "./src/tools/CodeEditor/index.ts",
75
75
  "require": "./src/tools/CodeEditor/index.ts"
76
76
  },
77
- "./styles": "./src/styles/index.css"
77
+ "./styles": "./src/styles/index.css",
78
+ "./dist.css": "./dist/index.css"
78
79
  },
79
80
  "files": [
80
81
  "dist",
@@ -90,8 +91,8 @@
90
91
  "check": "tsc --noEmit"
91
92
  },
92
93
  "peerDependencies": {
93
- "@djangocfg/i18n": "^2.1.290",
94
- "@djangocfg/ui-core": "^2.1.290",
94
+ "@djangocfg/i18n": "^2.1.292",
95
+ "@djangocfg/ui-core": "^2.1.292",
95
96
  "consola": "^3.4.2",
96
97
  "lodash-es": "^4.18.1",
97
98
  "lucide-react": "^0.545.0",
@@ -101,6 +102,7 @@
101
102
  "zustand": "^5.0.0"
102
103
  },
103
104
  "dependencies": {
105
+ "@floating-ui/dom": "^1.7.4",
104
106
  "@rjsf/core": "^6.1.2",
105
107
  "@rjsf/utils": "^6.1.2",
106
108
  "@rjsf/validator-ajv8": "^6.1.2",
@@ -138,10 +140,10 @@
138
140
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
139
141
  },
140
142
  "devDependencies": {
141
- "@djangocfg/i18n": "^2.1.290",
143
+ "@djangocfg/i18n": "^2.1.292",
142
144
  "@djangocfg/playground": "workspace:*",
143
- "@djangocfg/typescript-config": "^2.1.290",
144
- "@djangocfg/ui-core": "^2.1.290",
145
+ "@djangocfg/typescript-config": "^2.1.292",
146
+ "@djangocfg/ui-core": "^2.1.292",
145
147
  "@types/lodash-es": "^4.17.12",
146
148
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
147
149
  "@types/node": "^24.7.2",
@@ -1,7 +1,8 @@
1
1
  import { defineStory } from '@djangocfg/playground';
2
2
  import { MarkdownEditor } from './MarkdownEditor';
3
- import { useState } from 'react';
4
- import type { MentionConfig } from './types';
3
+ import { mentionPresets } from './mentionPresets';
4
+ import { useState, type ReactNode } from 'react';
5
+ import type { MentionConfig, MentionMarkdownRenderer } from './types';
5
6
 
6
7
  export default defineStory({
7
8
  title: 'Tools/Markdown Editor',
@@ -109,6 +110,111 @@ export function Compact() {
109
110
  );
110
111
  }
111
112
 
113
+ export function WithCustomUriPreset() {
114
+ const [value, setValue] = useState(
115
+ 'Reference @Alice and @Bob — markdown will carry machine-readable URIs.\n\nType @ to add more.',
116
+ );
117
+
118
+ return (
119
+ <SerializationDemo
120
+ description={
121
+ <>
122
+ <code>mentionPresets.customUri('myapp', 'user')</code> — emits
123
+ <code>{' @[Label](myapp://user/id)'}</code>. Useful when downstream
124
+ parses the markdown back into deep-links while keeping the visible
125
+ <code>@</code> handle.
126
+ </>
127
+ }
128
+ value={value}
129
+ onChange={setValue}
130
+ renderMarkdown={mentionPresets.customUri('myapp', 'user')}
131
+ />
132
+ );
133
+ }
134
+
135
+ export function WithMarkdownLinkPreset() {
136
+ const [value, setValue] = useState('Ping @Alice when ready, cc @Charlie.');
137
+
138
+ return (
139
+ <SerializationDemo
140
+ description={
141
+ <>
142
+ <code>mentionPresets.markdownLink('https://example.com/u/')</code> —
143
+ serializes mentions as ordinary clickable links: <code>[@Label](https://example.com/u/id)</code>.
144
+ </>
145
+ }
146
+ value={value}
147
+ onChange={setValue}
148
+ renderMarkdown={mentionPresets.markdownLink('https://example.com/u/')}
149
+ />
150
+ );
151
+ }
152
+
153
+ export function WithCustomRenderer() {
154
+ const [value, setValue] = useState('Tag @Alice and @Bob to assign the task.');
155
+
156
+ // Hand-rolled serializer — any (attrs) => string works.
157
+ const renderMarkdown: MentionMarkdownRenderer = ({ id, label }) =>
158
+ `{{user:${id}|${label || 'unknown'}}}`;
159
+
160
+ return (
161
+ <SerializationDemo
162
+ description={
163
+ <>
164
+ Inline custom renderer: <code>{`({ id, label }) => \`{{user:\${id}|\${label}}}\``}</code>.
165
+ Shows that the serializer is just a function — you control the wire format.
166
+ </>
167
+ }
168
+ value={value}
169
+ onChange={setValue}
170
+ renderMarkdown={renderMarkdown}
171
+ />
172
+ );
173
+ }
174
+
175
+ interface SerializationDemoProps {
176
+ description: ReactNode;
177
+ value: string;
178
+ onChange: (v: string) => void;
179
+ renderMarkdown: MentionMarkdownRenderer;
180
+ }
181
+
182
+ function SerializationDemo({ description, value, onChange, renderMarkdown }: SerializationDemoProps) {
183
+ const mentions: MentionConfig = { ...MENTION_ITEMS, renderMarkdown };
184
+
185
+ return (
186
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, maxWidth: 900 }}>
187
+ <div>
188
+ <p style={{ fontSize: 12, opacity: 0.7, marginBottom: 8 }}>{description}</p>
189
+ <MarkdownEditor
190
+ value={value}
191
+ onChange={onChange}
192
+ mentions={mentions}
193
+ placeholder="Type @ to insert a mention..."
194
+ />
195
+ </div>
196
+ <div>
197
+ <div style={{ fontSize: 11, opacity: 0.5, marginBottom: 4, textTransform: 'uppercase', letterSpacing: 0.5 }}>
198
+ Serialized markdown
199
+ </div>
200
+ <pre
201
+ style={{
202
+ fontSize: 12,
203
+ padding: 12,
204
+ background: 'rgba(127,127,127,0.08)',
205
+ borderRadius: 6,
206
+ whiteSpace: 'pre-wrap',
207
+ wordBreak: 'break-word',
208
+ margin: 0,
209
+ }}
210
+ >
211
+ {value || '(empty)'}
212
+ </pre>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
112
218
  function RawPreview({ value }: { value: string }) {
113
219
  return (
114
220
  <details style={{ marginTop: 16 }}>
@@ -12,7 +12,8 @@ import {
12
12
  List, ListOrdered, Quote, Minus, Code, type LucideIcon,
13
13
  } from 'lucide-react';
14
14
  import { createMentionSuggestion } from './createMentionSuggestion';
15
- import type { MentionConfig } from './types';
15
+ import { mentionPresets } from './mentionPresets';
16
+ import type { MentionAttrs, MentionConfig } from './types';
16
17
  import './styles.css';
17
18
 
18
19
  // ── Helpers ──
@@ -47,7 +48,23 @@ export interface MarkdownEditorProps {
47
48
  className?: string;
48
49
  disabled?: boolean;
49
50
  showToolbar?: boolean;
50
- /** @mention autocomplete config */
51
+ /**
52
+ * `@`-mention autocomplete config.
53
+ *
54
+ * IMPORTANT: Tiptap's `useEditor` initialises the editor exactly once.
55
+ * The `Mention` extension is only registered when `mentions` is truthy
56
+ * on the FIRST render — handing in a real config later (e.g. after an
57
+ * async items fetch) silently does nothing, and typing `@` will not
58
+ * open the popover.
59
+ *
60
+ * If you want mentions even with async-loaded items, pass
61
+ * `{ items: [] }` from the very first render and update the array
62
+ * when data arrives. Either keep the `MentionConfig` object identity
63
+ * stable across renders and mutate `items` in place (the suggestion
64
+ * plugin captures the config by closure and reads `items` on each
65
+ * query), or accept that swapping the whole object reference is a
66
+ * no-op for the live editor.
67
+ */
51
68
  mentions?: MentionConfig;
52
69
  /** Called when mentioned IDs change */
53
70
  onMentionIdsChange?: (ids: string[]) => void;
@@ -68,6 +85,31 @@ export function MarkdownEditor({
68
85
  }: MarkdownEditorProps) {
69
86
  const isExternalUpdate = useRef(false);
70
87
 
88
+ // ── Dev-mode trap detector ──
89
+ // Tiptap initialises the editor once with the extensions array from
90
+ // first render. If `mentions` is undefined on mount and becomes
91
+ // truthy later, the Mention extension is never installed and the
92
+ // user-visible @-trigger silently does nothing. Catch this early so
93
+ // future consumers don't spend hours debugging why @ does nothing.
94
+ const initialMentionsDefinedRef = useRef<boolean>(mentions !== undefined);
95
+ const warnedRef = useRef(false);
96
+ if (
97
+ process.env.NODE_ENV !== 'production' &&
98
+ !initialMentionsDefinedRef.current &&
99
+ mentions !== undefined &&
100
+ !warnedRef.current
101
+ ) {
102
+ warnedRef.current = true;
103
+ // eslint-disable-next-line no-console
104
+ console.warn(
105
+ '[MarkdownEditor] `mentions` flipped from undefined to a config ' +
106
+ 'after mount. Tiptap only installs the Mention extension on first ' +
107
+ 'render — the @-popover will NOT work for this editor instance. ' +
108
+ 'Pass `{ items: [] }` from the very first render and mutate `.items` ' +
109
+ 'in place instead.',
110
+ );
111
+ }
112
+
71
113
  const extensions = useMemo(() => {
72
114
  const exts: AnyExtension[] = [
73
115
  StarterKit.configure({ heading: { levels: [1, 2, 3] } }),
@@ -76,8 +118,51 @@ export function MarkdownEditor({
76
118
  ];
77
119
 
78
120
  if (mentions) {
121
+ // ── Why .extend() with renderMarkdown ──
122
+ //
123
+ // Tiptap's `Mention` extension ships a default markdown serializer
124
+ // (via `createInlineMarkdownSpec`, see @tiptap/extension-mention)
125
+ // that emits a shortcode like `[@ id="..." label="..."]`. That's
126
+ // round-trippable but useless for most consumers: a chat composer
127
+ // feeding an LLM wants `@<label>`, a deep-linking app wants a
128
+ // markdown link, etc.
129
+ //
130
+ // `renderText` only affects `editor.getText()` and the rendered
131
+ // node — it does NOT influence `@tiptap/markdown`'s serializer.
132
+ // The serializer reads `renderMarkdown` off the extension config
133
+ // (see MarkdownManager.registerExtension → getExtensionField).
134
+ // Overriding it via `.extend({ renderMarkdown })` replaces the
135
+ // shortcode output with whatever the consumer supplied (or the
136
+ // `plainAt` default — see ./mentionPresets.ts).
137
+ //
138
+ // Renderer-choice capture: useEditor only initialises once, so the
139
+ // chosen renderer is captured by closure on first render. This is
140
+ // intentional — swapping renderers per render would mean tearing
141
+ // the editor down anyway, which we don't do for any other prop.
142
+ //
143
+ // Round-trip note: we intentionally do NOT also override
144
+ // `parseMarkdown` / `markdownTokenizer`. Once a mention is
145
+ // serialized, parsing it back would need the original mention
146
+ // items list to look up the id, which we don't have at parse time.
147
+ // Mentions are write-only by design here — `setContent(value)`
148
+ // after submit gets back a plain string, which is fine for the
149
+ // chat use case.
150
+ const renderMarkdown = mentions.renderMarkdown ?? mentionPresets.plainAt;
79
151
  exts.push(
80
- Mention.configure({
152
+ Mention.extend({
153
+ renderMarkdown(node) {
154
+ const raw = node.attrs as { label?: string | null; id?: string | null };
155
+ const attrs: MentionAttrs = {
156
+ id: raw?.id ?? '',
157
+ label: raw?.label ?? '',
158
+ };
159
+ // Defensive: if both are empty (shouldn't happen for a real
160
+ // mention node, but `setContent` of malformed data could),
161
+ // emit nothing rather than a stray "@" or invalid link.
162
+ if (!attrs.id && !attrs.label) return '';
163
+ return renderMarkdown(attrs);
164
+ },
165
+ }).configure({
81
166
  HTMLAttributes: { class: 'markdown-mention' },
82
167
  suggestion: createMentionSuggestion(mentions),
83
168
  renderText: ({ node }) => `@${node.attrs.label}`,
@@ -10,6 +10,8 @@ WYSIWYG markdown editor based on Tiptap. Renders markdown visually (headings, li
10
10
  - Blockquotes with left border
11
11
  - Horizontal rules
12
12
  - Toolbar with icon buttons
13
+ - `@`-mentions with auto-flipping popup (`@floating-ui/dom`)
14
+ - Customizable markdown serialization for mentions (six built-in presets)
13
15
  - Markdown input/output (stored as plain markdown string)
14
16
  - SSR-safe (`immediatelyRender: false`)
15
17
 
@@ -31,6 +33,18 @@ function MyComponent() {
31
33
  }
32
34
  ```
33
35
 
36
+ ## CSS
37
+
38
+ Two ways to load styles, depending on your build:
39
+
40
+ ```ts
41
+ // Tailwind-based apps — pulls in source utilities so Tailwind's JIT picks them up.
42
+ import '@djangocfg/ui-tools/styles';
43
+
44
+ // Plain Vite / webpack / CRA — pre-compiled CSS, no Tailwind required.
45
+ import '@djangocfg/ui-tools/dist.css';
46
+ ```
47
+
34
48
  ## Props
35
49
 
36
50
  | Prop | Type | Default | Description |
@@ -42,9 +56,18 @@ function MyComponent() {
42
56
  | `className` | `string` | — | Additional CSS class |
43
57
  | `disabled` | `boolean` | `false` | Read-only mode |
44
58
  | `showToolbar` | `boolean` | `true` | Show formatting toolbar |
45
- | `mentions` | `MentionConfig` | — | @mention autocomplete config |
59
+ | `mentions` | `MentionConfig` | — | `@`-mention autocomplete config |
46
60
  | `onMentionIdsChange` | `(ids: string[]) => void` | — | Called when mentioned IDs change |
47
61
 
62
+ ### `MentionConfig` fields
63
+
64
+ | Field | Type | Default | Description |
65
+ |-------|------|---------|-------------|
66
+ | `items` | `MentionItem[]` | required | Available mention items (`id`, `label`, optional `description`, `thumbnail`) |
67
+ | `trigger` | `string` | `'@'` | Trigger character |
68
+ | `maxItems` | `number` | `5` | Max items shown in dropdown |
69
+ | `renderMarkdown` | `MentionMarkdownRenderer` | `mentionPresets.plainAt` | Serializes a mention to markdown — see below |
70
+
48
71
  ## Mentions
49
72
 
50
73
  ```tsx
@@ -61,8 +84,64 @@ function MyComponent() {
61
84
  />
62
85
  ```
63
86
 
64
- Type `@` to trigger autocomplete. Mentions render as inline chips.
87
+ Type `@` to trigger autocomplete. Mentions render as inline chips. The popup auto-flips above the trigger when there isn't enough room below, and tracks scroll/resize via `autoUpdate` — viewport-aware out of the box.
88
+
89
+ > Heads-up: Tiptap initialises the editor exactly once. If `mentions` is `undefined` on first render and becomes truthy later, the extension is never installed. Pass `{ items: [] }` from the start and mutate `.items` in place if items load async.
90
+
91
+ ## Mention serialization
92
+
93
+ By default Tiptap's `Mention` extension emits a useless shortcode like `[@ id="..." label="..."]` into markdown. `MarkdownEditor` overrides that with the renderer from `MentionConfig.renderMarkdown` (defaults to `mentionPresets.plainAt`, which yields `@<label>`).
94
+
95
+ Six presets are exported from `@djangocfg/ui-tools`:
96
+
97
+ ```tsx
98
+ import { MarkdownEditor, mentionPresets } from '@djangocfg/ui-tools';
99
+
100
+ <MarkdownEditor
101
+ value={text}
102
+ onChange={setText}
103
+ mentions={{
104
+ items,
105
+ renderMarkdown: mentionPresets.customUri('cmdop', 'machine'),
106
+ }}
107
+ />
108
+ ```
109
+
110
+ | Preset | Output for `{ label: 'Vps-audi', id: 'uuid' }` |
111
+ |--------|-------------------------------------------------|
112
+ | `plainAt` *(default)* | `@Vps-audi` |
113
+ | `plainLabel` | `Vps-audi` |
114
+ | `markdownLink(baseUrl)` | `[@Vps-audi](baseUrl/uuid)` |
115
+ | `customUri(scheme, kind)` | `@[Vps-audi](scheme://kind/uuid)` |
116
+ | `slackStyle` | `<@uuid>` |
117
+ | `htmlSpan(className?)` | `<span class="mention" data-mention-id="uuid">@Vps-audi</span>` |
118
+
119
+ Pick by use case:
120
+
121
+ - **`plainAt`** — chat composers feeding an LLM that reads `@<label>` natively.
122
+ - **`customUri`** — chat that also wants machine-readable IDs in `href` for downstream parsing (e.g. `@[Vps-audi](cmdop://machine/uuid)`).
123
+ - **`markdownLink`** — clickable mentions linking to a profile / detail page.
124
+ - **`slackStyle`** — interop with Slack-like backends that resolve `<@id>` server-side.
125
+ - **`htmlSpan`** — markdown consumers that allow inline HTML and want chip styling baked in.
126
+ - **`plainLabel`** — bare display string (no `@`), e.g. for `/`-commands or non-mention triggers.
127
+
128
+ ### Custom renderer
129
+
130
+ The signature is just `(attrs: MentionAttrs) => string`:
131
+
132
+ ```tsx
133
+ import type { MentionMarkdownRenderer } from '@djangocfg/ui-tools';
134
+
135
+ const renderMention: MentionMarkdownRenderer = ({ id, label }) =>
136
+ `{{user:${id}|${label}}}`;
137
+
138
+ <MarkdownEditor mentions={{ items, renderMarkdown: renderMention }} ... />
139
+ ```
140
+
141
+ Either `id` or `label` may be empty strings if upstream config didn't populate them — fall back accordingly. Returning `''` drops the mention from the output.
142
+
143
+ > Mentions are write-only: the markdown isn't parsed back into mention nodes on `setContent`. After submit/reset, the editor receives a plain string — fine for chat composers.
65
144
 
66
145
  ## Dependencies
67
146
 
68
- All Tiptap packages are included as direct dependencies — no extra installs needed.
147
+ All Tiptap packages and `@floating-ui/dom` are direct dependencies — no extra installs needed.
@@ -1,5 +1,6 @@
1
1
  import { ReactRenderer } from '@tiptap/react';
2
2
  import type { SuggestionOptions } from '@tiptap/suggestion';
3
+ import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
3
4
  import { MentionList, type MentionListRef } from './MentionList';
4
5
  import type { MentionItem, MentionConfig } from './types';
5
6
 
@@ -21,6 +22,47 @@ export function createMentionSuggestion(
21
22
  render: () => {
22
23
  let component: ReactRenderer<MentionListRef> | null = null;
23
24
  let popup: HTMLDivElement | null = null;
25
+ let cleanupAutoUpdate: (() => void) | null = null;
26
+ let getReferenceRect: (() => DOMRect | null) | null = null;
27
+
28
+ // Floating-UI virtual element backed by Tiptap's clientRect.
29
+ // We re-read it on every reposition so caret movement is tracked.
30
+ const buildVirtualElement = () => ({
31
+ getBoundingClientRect: () => {
32
+ const rect = getReferenceRect?.();
33
+ // Fallback to a zero-sized rect at origin if the editor is detached
34
+ // (e.g. mid-teardown). Floating-UI tolerates this.
35
+ return rect ?? new DOMRect(0, 0, 0, 0);
36
+ },
37
+ });
38
+
39
+ const updatePosition = () => {
40
+ if (!popup) return;
41
+ const virtualEl = buildVirtualElement();
42
+ void computePosition(virtualEl, popup, {
43
+ placement: 'bottom-start',
44
+ middleware: [
45
+ offset(4),
46
+ flip({ fallbackPlacements: ['top-start'] }),
47
+ shift({ padding: 8 }),
48
+ ],
49
+ }).then(({ x, y }) => {
50
+ if (!popup) return;
51
+ // transform is more performant than top/left and avoids
52
+ // sub-pixel layout thrash during scroll/resize.
53
+ popup.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
54
+ });
55
+ };
56
+
57
+ const teardown = () => {
58
+ cleanupAutoUpdate?.();
59
+ cleanupAutoUpdate = null;
60
+ popup?.remove();
61
+ popup = null;
62
+ component?.destroy();
63
+ component = null;
64
+ getReferenceRect = null;
65
+ };
24
66
 
25
67
  return {
26
68
  onStart: (props) => {
@@ -35,16 +77,17 @@ export function createMentionSuggestion(
35
77
  });
36
78
 
37
79
  popup = document.createElement('div');
38
- popup.style.cssText = 'position: absolute; z-index: 99999;';
80
+ // top/left at 0; actual position is applied via transform by computePosition.
81
+ popup.style.cssText = 'position: absolute; top: 0; left: 0; z-index: 99999;';
39
82
  popup.appendChild(component.element);
83
+ document.body.appendChild(popup);
40
84
 
41
- const rect = props.clientRect?.();
42
- if (rect) {
43
- popup.style.top = `${rect.bottom + window.scrollY + 4}px`;
44
- popup.style.left = `${rect.left + window.scrollX}px`;
45
- }
85
+ getReferenceRect = () => props.clientRect?.() ?? null;
46
86
 
47
- document.body.appendChild(popup);
87
+ // autoUpdate handles scroll, resize, ancestor scroll/resize, layout shifts.
88
+ // It calls updatePosition synchronously on registration too — no manual first call needed.
89
+ const virtualEl = buildVirtualElement();
90
+ cleanupAutoUpdate = autoUpdate(virtualEl, popup, updatePosition);
48
91
  },
49
92
 
50
93
  onUpdate: (props) => {
@@ -55,25 +98,21 @@ export function createMentionSuggestion(
55
98
  },
56
99
  });
57
100
 
58
- const rect = props.clientRect?.();
59
- if (rect && popup) {
60
- popup.style.top = `${rect.bottom + window.scrollY + 4}px`;
61
- popup.style.left = `${rect.left + window.scrollX}px`;
62
- }
101
+ // Refresh reference accessor so autoUpdate sees the new caret rect.
102
+ getReferenceRect = () => props.clientRect?.() ?? null;
103
+ updatePosition();
63
104
  },
64
105
 
65
106
  onKeyDown: (props) => {
66
107
  if (props.event.key === 'Escape') {
67
- popup?.remove();
68
- component?.destroy();
108
+ teardown();
69
109
  return true;
70
110
  }
71
111
  return component?.ref?.onKeyDown(props.event as unknown as React.KeyboardEvent) ?? false;
72
112
  },
73
113
 
74
114
  onExit: () => {
75
- popup?.remove();
76
- component?.destroy();
115
+ teardown();
77
116
  },
78
117
  };
79
118
  },
@@ -1,3 +1,9 @@
1
1
  export { MarkdownEditor } from './MarkdownEditor';
2
2
  export type { MarkdownEditorProps } from './MarkdownEditor';
3
- export type { MentionItem, MentionConfig } from './types';
3
+ export type {
4
+ MentionItem,
5
+ MentionConfig,
6
+ MentionAttrs,
7
+ MentionMarkdownRenderer,
8
+ } from './types';
9
+ export { mentionPresets } from './mentionPresets';
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Inline-assertion smoke test for mentionPresets.
3
+ *
4
+ * The `@djangocfg/ui-tools` package has no test runner configured
5
+ * (`package.json` only has build / clean / dev / playground / check).
6
+ * Rather than introduce vitest just for this, we keep the assertions
7
+ * inline and runnable as a one-shot Node script.
8
+ *
9
+ * Run:
10
+ * cd packages/ui-tools
11
+ * npx tsx src/tools/MarkdownEditor/mentionPresets.test.ts
12
+ *
13
+ * Or just trust `npm run check` (tsc --noEmit) to catch type drift —
14
+ * the file is excluded from tsup's `entry`, so it never ships in `dist`.
15
+ */
16
+ import { mentionPresets } from './mentionPresets';
17
+
18
+ let failures = 0;
19
+ const eq = (label: string, got: string, want: string) => {
20
+ const ok = got === want;
21
+ if (!ok) failures += 1;
22
+ // eslint-disable-next-line no-console
23
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
24
+ if (!ok) {
25
+ // eslint-disable-next-line no-console
26
+ console.log(` got: ${JSON.stringify(got)}`);
27
+ // eslint-disable-next-line no-console
28
+ console.log(` want: ${JSON.stringify(want)}`);
29
+ }
30
+ };
31
+ const includes = (label: string, got: string, needle: string) => {
32
+ const ok = got.includes(needle);
33
+ if (!ok) failures += 1;
34
+ // eslint-disable-next-line no-console
35
+ console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`);
36
+ if (!ok) {
37
+ // eslint-disable-next-line no-console
38
+ console.log(` got: ${JSON.stringify(got)}`);
39
+ // eslint-disable-next-line no-console
40
+ console.log(` needle: ${JSON.stringify(needle)}`);
41
+ }
42
+ };
43
+
44
+ // ── plainAt ──
45
+ eq(
46
+ 'plainAt label present',
47
+ mentionPresets.plainAt({ label: 'Vps-audi', id: 'uuid' }),
48
+ '@Vps-audi',
49
+ );
50
+ eq(
51
+ 'plainAt empty label falls back to id',
52
+ mentionPresets.plainAt({ label: '', id: 'uuid' }),
53
+ '@uuid',
54
+ );
55
+
56
+ // ── plainLabel ──
57
+ eq(
58
+ 'plainLabel returns label as-is',
59
+ mentionPresets.plainLabel({ label: 'X', id: '1' }),
60
+ 'X',
61
+ );
62
+
63
+ // ── markdownLink ──
64
+ {
65
+ const link = mentionPresets.markdownLink('https://x.com/u/');
66
+ const out = link({ label: 'X Y', id: '1' });
67
+ includes('markdownLink contains label text', out, 'X Y');
68
+ includes('markdownLink contains encoded id', out, 'https://x.com/u/1');
69
+ includes('markdownLink wraps with [@...](', out, '[@X Y](https://x.com/u/1)');
70
+
71
+ const escaped = link({ label: 'A_B*C[D]', id: 'id 2' });
72
+ includes('markdownLink escapes label specials', escaped, '\\_');
73
+ includes('markdownLink escapes brackets', escaped, '\\[');
74
+ includes('markdownLink escapes asterisk', escaped, '\\*');
75
+ includes('markdownLink encodes id space', escaped, 'id%202');
76
+ }
77
+
78
+ // ── customUri ──
79
+ eq(
80
+ 'customUri Notion-style',
81
+ mentionPresets.customUri('cmdop', 'machine')({ label: 'My Box', id: 'uuid' }),
82
+ '@[My Box](cmdop://machine/uuid)',
83
+ );
84
+
85
+ // ── slackStyle ──
86
+ eq(
87
+ 'slackStyle drops label, emits <@id>',
88
+ mentionPresets.slackStyle({ label: 'X', id: 'U123' }),
89
+ '<@U123>',
90
+ );
91
+
92
+ // ── htmlSpan ──
93
+ {
94
+ const out = mentionPresets.htmlSpan('m')({ label: 'X', id: '1' });
95
+ includes('htmlSpan uses class', out, 'class="m"');
96
+ includes('htmlSpan exposes data-mention-id', out, 'data-mention-id="1"');
97
+ includes('htmlSpan body shows label with @', out, '@X');
98
+ }
99
+
100
+ if (failures > 0) {
101
+ // eslint-disable-next-line no-console
102
+ console.error(`\n${failures} assertion(s) failed.`);
103
+ process.exit(1);
104
+ } else {
105
+ // eslint-disable-next-line no-console
106
+ console.log('\nAll mentionPresets assertions passed.');
107
+ }