@pilotiq/tiptap 3.13.0 → 3.14.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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @pilotiq/tiptap
2
2
 
3
+ ## 3.14.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b2fc753: Redesigned the **Alert** content block into an interactive, themeable callout — and made it round-trip through the Markdown editor.
8
+
9
+ **Rich-text + markdown editor:**
10
+
11
+ - shadcn-style card on the panel's theme tokens (icon column + editable **title** and **body** — previously the label was the fixed variant name).
12
+ - In-block **variant picker** — `info` / `warning` / `success` / `tip` / **`custom`** (the four slash-menu alert entries collapse into one "Alert").
13
+ - In-block **icon picker** — a curated inline-SVG library (~18 icons, ~1-2KB, no `lucide-react`) plus a **"Custom SVG"** paste field. Custom SVG is sanitized via a pure allowlist (`sanitizeIconSvg`) on input and on render — scripts, event handlers, external refs (`use`/`image`/`a`/`href`), `<style>`, `<foreignObject>` are all stripped.
14
+ - The **custom** variant gets an in-block **color** swatch; the box + icon tint via `color-mix` (the value is validated before it reaches inline CSS).
15
+ - **Markdown round-trip** — `:::alert{type=warning icon=rocket} Title` admonition syntax (title rides the opening fence line). `MarkdownField` gains an **Alert** toolbar button (added to the default toolbar).
16
+
17
+ **Read-side** (`renderRichTextToHtml`) emits the new `<div class="pilotiq-alert"><span class="pilotiq-alert-icon">…</span><div class="pilotiq-alert-title">…</div><div class="pilotiq-alert-description">…</div></div>` structure; icon SVGs are shared with the editor so the two never drift. Consumer owns the CSS.
18
+
19
+ **Back-compat:** the node's content model changed (`block+` → `alertTitle alertBody`). HTML from the previous alert (label + body divs) is parsed into the new shape; JSON-stored alerts from the prior release may lose their body and should be re-inserted.
20
+
3
21
  ## 3.13.0
4
22
 
5
23
  ### Minor Changes
@@ -189,24 +189,19 @@ export function buildSlashItems(blocks, mergeTags, query, insert) {
189
189
  }).run(),
190
190
  },
191
191
  {
192
- key: 'alert-info', label: 'Alert: Info', icon: 'ⓘ', group: 'Content',
193
- searchKey: 'alert callout notice info note',
194
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'info' }, content: [{ type: 'paragraph' }] }).run(),
195
- },
196
- {
197
- key: 'alert-warning', label: 'Alert: Warning', icon: '⚠️', group: 'Content',
198
- searchKey: 'alert callout notice warning caution danger',
199
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'warning' }, content: [{ type: 'paragraph' }] }).run(),
200
- },
201
- {
202
- key: 'alert-success', label: 'Alert: Success', icon: '', group: 'Content',
203
- searchKey: 'alert callout notice success ok done',
204
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'success' }, content: [{ type: 'paragraph' }] }).run(),
205
- },
206
- {
207
- key: 'alert-tip', label: 'Alert: Tip', icon: '💡', group: 'Content',
208
- searchKey: 'alert callout notice tip hint',
209
- command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({ type: 'alert', attrs: { type: 'tip' }, content: [{ type: 'paragraph' }] }).run(),
192
+ // One Alert block the variant (info/warning/success/tip/custom) is
193
+ // switched in-block via the NodeView's picker. Defaults to Info with the
194
+ // label pre-filled as the editable title.
195
+ key: 'alert', label: 'Alert', icon: '⚠️', group: 'Content',
196
+ searchKey: 'alert callout notice info warning success tip note custom caution danger hint',
197
+ command: ({ editor, range }) => editor.chain().focus().deleteRange(range).insertContent({
198
+ type: 'alert',
199
+ attrs: { type: 'info' },
200
+ content: [
201
+ { type: 'alertTitle', content: [{ type: 'text', text: 'Info' }] },
202
+ { type: 'alertBody', content: [{ type: 'paragraph' }] },
203
+ ],
204
+ }).run(),
210
205
  },
211
206
  // Image entry shares the toolbar's attach-files dialog; only surfaced
212
207
  // when the panel has wired an `UploadAdapter`. Without one, the dialog
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Alert variant primitives — shared by the node (`contentBlocks.ts`), its
3
+ * React NodeView (`AlertNodeView.tsx`), and the read-side serializer
4
+ * (`render.ts`). Kept dependency-free (no `@tiptap/*`) so the server
5
+ * renderer can import it without pulling a Tiptap runtime.
6
+ *
7
+ * `info` / `warning` / `success` / `tip` are presets (default icon + accent);
8
+ * `custom` is the user-driven variant (own icon + color, Phase 2).
9
+ */
10
+ export declare const ALERT_VARIANTS: readonly ["info", "warning", "success", "tip", "custom"];
11
+ export type AlertType = (typeof ALERT_VARIANTS)[number];
12
+ export declare const ALERT_VARIANT_LABEL: Record<AlertType, string>;
13
+ /** Coerce an unknown value to a known variant, defaulting to `info`. */
14
+ export declare function coerceAlertType(value: unknown): AlertType;
15
+ /**
16
+ * Curated icon library — inner SVG markup (lucide paths) keyed by name. Shared
17
+ * by the NodeView picker (`AlertNodeView`) and the read-side renderer
18
+ * (`render.ts`) so the editor and published output never drift. Hand-picked
19
+ * (no `lucide-react` dep, no icon font) so the whole set is ~1-2KB gzipped.
20
+ * Wrap in `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
21
+ * stroke-width="2" stroke-linecap="round" stroke-linejoin="round">…</svg>`.
22
+ */
23
+ export declare const ALERT_ICONS: Record<string, string>;
24
+ /** Icon keys, in picker display order. */
25
+ export declare const ALERT_ICON_KEYS: string[];
26
+ /** The default icon key per variant (used when the block sets no `icon`). */
27
+ export declare const VARIANT_DEFAULT_ICON: Record<AlertType, string>;
28
+ /**
29
+ * Resolve the inner SVG for a block: the explicit `icon` override when set +
30
+ * known, else the variant's default. Shared by the editor + read-side render.
31
+ */
32
+ export declare function resolveAlertIconInner(iconKey: string | undefined, variant: AlertType): string;
33
+ /**
34
+ * Sanitize a user-pasted SVG string to a safe subset. Returns '' for anything
35
+ * that isn't an `<svg>` element or that sanitizes to nothing.
36
+ */
37
+ export declare function sanitizeIconSvg(raw: unknown): string;
38
+ /**
39
+ * Build the FULL `<svg>` string for a block: a sanitized user `iconSvg` when
40
+ * present, else the (library or variant-default) icon wrapped in the standard
41
+ * stroke svg. Shared by the editor NodeView and the read-side renderer.
42
+ */
43
+ export declare function buildAlertIconSvg(iconKey: string | undefined, iconSvg: string | undefined, variant: AlertType): string;
44
+ //# sourceMappingURL=alertVariants.d.ts.map
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Alert variant primitives — shared by the node (`contentBlocks.ts`), its
3
+ * React NodeView (`AlertNodeView.tsx`), and the read-side serializer
4
+ * (`render.ts`). Kept dependency-free (no `@tiptap/*`) so the server
5
+ * renderer can import it without pulling a Tiptap runtime.
6
+ *
7
+ * `info` / `warning` / `success` / `tip` are presets (default icon + accent);
8
+ * `custom` is the user-driven variant (own icon + color, Phase 2).
9
+ */
10
+ export const ALERT_VARIANTS = ['info', 'warning', 'success', 'tip', 'custom'];
11
+ export const ALERT_VARIANT_LABEL = {
12
+ info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip', custom: 'Custom',
13
+ };
14
+ /** Coerce an unknown value to a known variant, defaulting to `info`. */
15
+ export function coerceAlertType(value) {
16
+ return ALERT_VARIANTS.includes(String(value)) ? value : 'info';
17
+ }
18
+ /**
19
+ * Curated icon library — inner SVG markup (lucide paths) keyed by name. Shared
20
+ * by the NodeView picker (`AlertNodeView`) and the read-side renderer
21
+ * (`render.ts`) so the editor and published output never drift. Hand-picked
22
+ * (no `lucide-react` dep, no icon font) so the whole set is ~1-2KB gzipped.
23
+ * Wrap in `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
24
+ * stroke-width="2" stroke-linecap="round" stroke-linejoin="round">…</svg>`.
25
+ */
26
+ export const ALERT_ICONS = {
27
+ info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
28
+ warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
29
+ check: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
30
+ lightbulb: '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>',
31
+ sparkles: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .962 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.962 0z"/>',
32
+ bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
33
+ megaphone: '<path d="m3 11 18-5v12L3 14v-3z"/><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6"/>',
34
+ flame: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
35
+ star: '<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z"/>',
36
+ heart: '<path d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"/>',
37
+ shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
38
+ zap: '<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/>',
39
+ flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" x2="4" y1="22" y2="15"/>',
40
+ bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>',
41
+ rocket: '<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/>',
42
+ help: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/>',
43
+ error: '<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>',
44
+ pin: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
45
+ };
46
+ /** Icon keys, in picker display order. */
47
+ export const ALERT_ICON_KEYS = Object.keys(ALERT_ICONS);
48
+ /** The default icon key per variant (used when the block sets no `icon`). */
49
+ export const VARIANT_DEFAULT_ICON = {
50
+ info: 'info', warning: 'warning', success: 'check', tip: 'lightbulb', custom: 'sparkles',
51
+ };
52
+ /**
53
+ * Resolve the inner SVG for a block: the explicit `icon` override when set +
54
+ * known, else the variant's default. Shared by the editor + read-side render.
55
+ */
56
+ export function resolveAlertIconInner(iconKey, variant) {
57
+ if (iconKey && ALERT_ICONS[iconKey])
58
+ return ALERT_ICONS[iconKey];
59
+ return ALERT_ICONS[VARIANT_DEFAULT_ICON[variant]];
60
+ }
61
+ // ── Custom SVG (user-pasted) — sanitized allowlist ───────────────────────────
62
+ //
63
+ // Custom SVG is raw user markup that renders on PUBLIC pages, so it's an XSS
64
+ // surface. We sanitize with a conservative pure-string allowlist (no DOMPurify
65
+ // — keeps the bundle lean and works server-side in render.ts): only known
66
+ // presentational SVG tags + geometry/paint attributes survive; scripts, event
67
+ // handlers, external refs (`use`/`image`/`a`/`href`), `<style>`, `<animate>`,
68
+ // `<foreignObject>` and unknown attributes are dropped. Applied on input AND on
69
+ // every render (defence in depth — a tampered stored attr stays safe).
70
+ const SVG_ALLOWED_TAGS = new Set([
71
+ 'svg', 'g', 'path', 'circle', 'ellipse', 'rect', 'line', 'polyline', 'polygon',
72
+ 'defs', 'lineargradient', 'radialgradient', 'stop', 'title', 'desc', 'clippath',
73
+ ]);
74
+ const SVG_ALLOWED_ATTRS = new Set([
75
+ 'viewbox', 'xmlns', 'width', 'height', 'preserveaspectratio',
76
+ 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin',
77
+ 'stroke-dasharray', 'stroke-dashoffset', 'stroke-opacity', 'stroke-miterlimit',
78
+ 'fill-opacity', 'fill-rule', 'clip-rule', 'clip-path', 'opacity', 'transform',
79
+ 'd', 'cx', 'cy', 'r', 'rx', 'ry', 'x', 'y', 'x1', 'y1', 'x2', 'y2',
80
+ 'points', 'class', 'gradientunits', 'gradienttransform', 'offset',
81
+ 'stop-color', 'stop-opacity', 'id',
82
+ ]);
83
+ /**
84
+ * Sanitize a user-pasted SVG string to a safe subset. Returns '' for anything
85
+ * that isn't an `<svg>` element or that sanitizes to nothing.
86
+ */
87
+ export function sanitizeIconSvg(raw) {
88
+ if (typeof raw !== 'string')
89
+ return '';
90
+ let s = raw.trim();
91
+ if (!/^<svg[\s>]/i.test(s))
92
+ return '';
93
+ // Drop comments / CDATA / PIs / doctype, then dangerous element blocks whole.
94
+ s = s.replace(/<!--[\s\S]*?-->/g, '').replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, '')
95
+ .replace(/<\?[\s\S]*?\?>/g, '').replace(/<!DOCTYPE[^>]*>/gi, '');
96
+ s = s.replace(/<(script|style|foreignObject)\b[\s\S]*?<\/\1\s*>/gi, '');
97
+ // Allowlist every remaining tag + its attributes.
98
+ s = s.replace(/<\/?([a-zA-Z][\w:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)\s*(\/?)>/g, (_m, tag, attrs, slash) => {
99
+ const name = String(tag).toLowerCase();
100
+ if (!SVG_ALLOWED_TAGS.has(name))
101
+ return '';
102
+ if (_m.startsWith('</'))
103
+ return `</${name}>`;
104
+ const kept = [];
105
+ const attrRe = /([a-zA-Z_:][\w:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g;
106
+ let am;
107
+ while ((am = attrRe.exec(attrs)) !== null) {
108
+ const an = (am[1] ?? '').toLowerCase();
109
+ if (!SVG_ALLOWED_ATTRS.has(an))
110
+ continue;
111
+ const av = (am[2] ?? '').replace(/^["']|["']$/g, '');
112
+ if (/javascript:|expression\(|[<>]/i.test(av))
113
+ continue;
114
+ kept.push(`${an}="${av.replace(/"/g, '&quot;')}"`);
115
+ }
116
+ return `<${name}${kept.length ? ' ' + kept.join(' ') : ''}${slash ? ' /' : ''}>`;
117
+ });
118
+ s = s.trim();
119
+ return /^<svg[\s>]/i.test(s) ? s : '';
120
+ }
121
+ const ICON_SVG_WRAP_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">';
122
+ /**
123
+ * Build the FULL `<svg>` string for a block: a sanitized user `iconSvg` when
124
+ * present, else the (library or variant-default) icon wrapped in the standard
125
+ * stroke svg. Shared by the editor NodeView and the read-side renderer.
126
+ */
127
+ export function buildAlertIconSvg(iconKey, iconSvg, variant) {
128
+ const custom = sanitizeIconSvg(iconSvg);
129
+ if (custom)
130
+ return custom;
131
+ return `${ICON_SVG_WRAP_OPEN}${resolveAlertIconInner(iconKey, variant)}</svg>`;
132
+ }
@@ -1,15 +1,30 @@
1
1
  import { Node, Extension } from '@tiptap/core';
2
+ export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType, type AlertType } from './alertVariants.js';
2
3
  export declare const KeyTakeaways: Node<any, any>;
3
4
  export declare const Summary: Node<any, any>;
4
5
  export declare const Faq: Node<any, any>;
5
6
  export declare const FaqItem: Node<any, any>;
6
7
  export declare const FaqQuestion: Node<any, any>;
7
8
  export declare const FaqAnswer: Node<any, any>;
8
- export declare const ALERT_TYPES: readonly ["info", "warning", "success", "tip"];
9
- export type AlertType = (typeof ALERT_TYPES)[number];
10
- /** Exported so `render.ts` shares the same coercion at the server boundary. */
11
- export declare function coerceAlertType(value: unknown): AlertType;
9
+ type AnyMd = any;
10
+ /** Parse a directive info string (`alert{type=warning}`) into an attrs map. */
11
+ export declare function parseDirectiveAttrs(info: string): Record<string, string>;
12
+ /**
13
+ * Register the `:::alert{…}` block rule + HTML renderers on a markdown-it
14
+ * instance. Idempotent per instance. Wired as `Alert`'s `markdown.parse.setup`.
15
+ */
16
+ export declare function setupAlertDirective(md: AnyMd): void;
17
+ /**
18
+ * `tiptap-markdown` serializer for the Alert node → `:::alert{type=…} Title`.
19
+ * The title rides the opening fence line (admonition style); the body markdown
20
+ * sits between the fences.
21
+ */
22
+ export declare function serializeAlertMarkdown(state: AnyMd, node: AnyMd): void;
12
23
  export declare const Alert: Node<any, any>;
24
+ /** The Alert heading — a single editable line, defaults to the variant label. */
25
+ export declare const AlertTitle: Node<any, any>;
26
+ /** The Alert body — the editable description (`block+`). */
27
+ export declare const AlertBody: Node<any, any>;
13
28
  export declare const ProsCons: Node<any, any>;
14
29
  export declare const ProsColumn: Node<any, any>;
15
30
  export declare const ConsColumn: Node<any, any>;
@@ -1,4 +1,10 @@
1
1
  import { Node, Extension, mergeAttributes } from '@tiptap/core';
2
+ import { ReactNodeViewRenderer } from '@tiptap/react';
3
+ import { AlertNodeView } from '../react/AlertNodeView.js';
4
+ import { coerceAlertType } from './alertVariants.js';
5
+ // Re-exported for back-compat — the canonical definitions live in
6
+ // `alertVariants.ts` (shared with the React NodeView + the read-side renderer).
7
+ export { ALERT_VARIANTS, ALERT_VARIANT_LABEL, coerceAlertType } from './alertVariants.js';
2
8
  /** A labelled region whose body is ordinary editable content (`block+`). */
3
9
  function labeledBlock(spec) {
4
10
  return Node.create({
@@ -158,18 +164,172 @@ export const FaqAnswer = Node.create({
158
164
  ];
159
165
  },
160
166
  });
161
- // ── Alert — a typed notice; the label IS the type (Info/Warning/Success/Tip) ──
162
- export const ALERT_TYPES = ['info', 'warning', 'success', 'tip'];
163
- const ALERT_LABEL = { info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip' };
164
- /** Exported so `render.ts` shares the same coercion at the server boundary. */
165
- export function coerceAlertType(value) {
166
- return ALERT_TYPES.includes(String(value)) ? value : 'info';
167
+ const DIRECTIVE_MARKER = 0x3a; // ':'
168
+ const DIRECTIVE_MIN_MARKERS = 3;
169
+ /** Parse a directive info string (`alert{type=warning}`) into an attrs map. */
170
+ export function parseDirectiveAttrs(info) {
171
+ const out = {};
172
+ const m = /\{([^}]*)\}/.exec(info);
173
+ if (!m || !m[1])
174
+ return out;
175
+ for (const pair of m[1].split(/[\s,]+/)) {
176
+ const eq = pair.indexOf('=');
177
+ if (eq === -1)
178
+ continue;
179
+ out[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
180
+ }
181
+ return out;
167
182
  }
183
+ /** The directive name — the token before the first `{` or whitespace. */
184
+ function directiveName(info) {
185
+ return info.trim().split(/[\s{]/, 1)[0] ?? '';
186
+ }
187
+ /**
188
+ * The directive title — the free text after `{attrs}` on the opening fence
189
+ * line (Docusaurus-admonition style: `:::alert{type=warning} Heads up`).
190
+ * Empty when omitted.
191
+ */
192
+ function directiveTitle(info) {
193
+ const close = info.lastIndexOf('}');
194
+ const rest = close === -1 ? info.replace(/^\S+/, '') : info.slice(close + 1);
195
+ return rest.trim();
196
+ }
197
+ /** Minimal HTML escape for text interpolated into the directive's HTML. */
198
+ function escapeDirectiveHtml(s) {
199
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
200
+ }
201
+ /**
202
+ * Register the `:::alert{…}` block rule + HTML renderers on a markdown-it
203
+ * instance. Idempotent per instance. Wired as `Alert`'s `markdown.parse.setup`.
204
+ */
205
+ export function setupAlertDirective(md) {
206
+ if (md.__pilotiqAlertDirective)
207
+ return;
208
+ md.__pilotiqAlertDirective = true;
209
+ md.block.ruler.before('fence', 'pilotiqAlert', (state, startLine, endLine, silent) => {
210
+ let pos = state.bMarks[startLine] + state.tShift[startLine];
211
+ let max = state.eMarks[startLine];
212
+ if (state.src.charCodeAt(pos) !== DIRECTIVE_MARKER)
213
+ return false;
214
+ const memStart = pos;
215
+ while (pos <= max && state.src.charCodeAt(pos) === DIRECTIVE_MARKER)
216
+ pos++;
217
+ const markerCount = pos - memStart;
218
+ if (markerCount < DIRECTIVE_MIN_MARKERS)
219
+ return false;
220
+ const info = state.src.slice(pos, max).trim();
221
+ if (directiveName(info) !== 'alert')
222
+ return false;
223
+ if (silent)
224
+ return true;
225
+ // Scan for the closing fence (>= as many markers, nothing but space after).
226
+ let nextLine = startLine;
227
+ let autoClosed = false;
228
+ for (;;) {
229
+ nextLine++;
230
+ if (nextLine >= endLine)
231
+ break;
232
+ pos = state.bMarks[nextLine] + state.tShift[nextLine];
233
+ max = state.eMarks[nextLine];
234
+ if (state.src.charCodeAt(pos) !== DIRECTIVE_MARKER)
235
+ continue;
236
+ if (state.sCount[nextLine] - state.blkIndent >= 4)
237
+ continue;
238
+ let closePos = pos;
239
+ while (closePos <= max && state.src.charCodeAt(closePos) === DIRECTIVE_MARKER)
240
+ closePos++;
241
+ if (closePos - pos < markerCount)
242
+ continue;
243
+ if (state.src.slice(closePos, max).trim() !== '')
244
+ continue;
245
+ autoClosed = true;
246
+ break;
247
+ }
248
+ const oldParent = state.parentType;
249
+ const oldLineMax = state.lineMax;
250
+ state.parentType = 'pilotiqAlert';
251
+ state.lineMax = nextLine;
252
+ const attrs = parseDirectiveAttrs(info);
253
+ let token = state.push('pilotiq_alert_open', 'div', 1);
254
+ token.markup = ':'.repeat(markerCount);
255
+ token.block = true;
256
+ token.info = info;
257
+ token.meta = { type: attrs['type'], icon: attrs['icon'], color: attrs['color'], title: directiveTitle(info) };
258
+ token.map = [startLine, nextLine];
259
+ state.md.block.tokenize(state, startLine + 1, nextLine);
260
+ token = state.push('pilotiq_alert_close', 'div', -1);
261
+ token.markup = ':'.repeat(markerCount);
262
+ token.block = true;
263
+ state.parentType = oldParent;
264
+ state.lineMax = oldLineMax;
265
+ state.line = nextLine + (autoClosed ? 1 : 0);
266
+ return true;
267
+ }, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
268
+ // Open emits the alert wrapper + the (closed) title div + the OPEN body div;
269
+ // the tokenized content renders into the body; close shuts body + wrapper.
270
+ // The data-types match `Alert`/`AlertTitle`/`AlertBody`'s `parseHTML`, so the
271
+ // HTML-based tiptap-markdown parse path revives the full node structure.
272
+ md.renderer.rules['pilotiq_alert_open'] = (tokens, idx) => {
273
+ const meta = tokens[idx].meta ?? {};
274
+ const type = coerceAlertType(meta.type);
275
+ const title = escapeDirectiveHtml(String(meta.title ?? ''));
276
+ const icon = meta.icon ? ` data-icon="${escapeDirectiveHtml(String(meta.icon))}"` : '';
277
+ const color = meta.color ? ` data-color="${escapeDirectiveHtml(String(meta.color))}"` : '';
278
+ return (`<div data-type="alert" class="pilotiq-alert pilotiq-alert-${type}" data-alert-type="${type}"${icon}${color}>` +
279
+ `<div data-type="alertTitle" class="pilotiq-alert-title">${title}</div>` +
280
+ `<div data-type="alertBody" class="pilotiq-alert-description">\n`);
281
+ };
282
+ md.renderer.rules['pilotiq_alert_close'] = () => '</div></div>\n';
283
+ }
284
+ /**
285
+ * `tiptap-markdown` serializer for the Alert node → `:::alert{type=…} Title`.
286
+ * The title rides the opening fence line (admonition style); the body markdown
287
+ * sits between the fences.
288
+ */
289
+ export function serializeAlertMarkdown(state, node) {
290
+ const type = coerceAlertType(node.attrs?.type);
291
+ const icon = String(node.attrs?.icon ?? '').trim();
292
+ const color = String(node.attrs?.color ?? '').trim();
293
+ const title = String(node.firstChild?.textContent ?? '').replace(/\s+/g, ' ').trim();
294
+ const body = node.childCount > 1 ? node.child(node.childCount - 1) : null;
295
+ // `icon` / `color` are space-free tokens (icon keys + hex), so they ride the
296
+ // whitespace-split directive attrs cleanly.
297
+ const attrs = [`type=${type}`];
298
+ if (icon)
299
+ attrs.push(`icon=${icon}`);
300
+ if (color)
301
+ attrs.push(`color=${color}`);
302
+ state.write(`:::alert{${attrs.join(' ')}}${title ? ' ' + title : ''}\n`);
303
+ if (body)
304
+ state.renderContent(body);
305
+ state.ensureNewLine();
306
+ state.write(':::');
307
+ state.closeBlock(node);
308
+ }
309
+ // ── Alert — shadcn-style callout: icon + editable title + editable body ───────
310
+ //
311
+ // `alert` wraps two editable children: `alertTitle` (the heading, defaults to
312
+ // the variant label) and `alertBody` (the description). The in-editor chrome —
313
+ // icon, variant picker, theme styling — lives in the React NodeView
314
+ // (`AlertNodeView`); `renderHTML` here is the headless / clipboard / getHTML
315
+ // fallback, and `render.ts` owns the published read-side HTML. Variant lives on
316
+ // the `type` attr (info/warning/success/tip/custom); `icon` / `color` back the
317
+ // Phase-2 per-block pickers.
168
318
  export const Alert = Node.create({
169
319
  name: 'alert',
170
320
  group: 'block',
171
- content: 'block+',
321
+ content: 'alertTitle alertBody',
172
322
  defining: true,
323
+ // Markdown round-trip — `:::alert{type=…} Title` (only consulted when the
324
+ // `tiptap-markdown` extension is present, i.e. the Markdown editor).
325
+ addStorage() {
326
+ return {
327
+ markdown: {
328
+ serialize: serializeAlertMarkdown,
329
+ parse: { setup: setupAlertDirective },
330
+ },
331
+ };
332
+ },
173
333
  addAttributes() {
174
334
  return {
175
335
  type: {
@@ -177,20 +337,68 @@ export const Alert = Node.create({
177
337
  parseHTML: (el) => coerceAlertType(el.getAttribute('data-alert-type')),
178
338
  renderHTML: (attrs) => ({ 'data-alert-type': coerceAlertType(attrs['type']) }),
179
339
  },
340
+ icon: {
341
+ default: '',
342
+ parseHTML: (el) => el.getAttribute('data-icon') ?? '',
343
+ renderHTML: (attrs) => (attrs['icon'] ? { 'data-icon': String(attrs['icon']) } : {}),
344
+ },
345
+ // User-pasted custom SVG (sanitized). Wins over `icon` when set; not
346
+ // carried in markdown (would bloat the source) — rich-text only.
347
+ iconSvg: {
348
+ default: '',
349
+ parseHTML: (el) => el.getAttribute('data-icon-svg') ?? '',
350
+ renderHTML: (attrs) => (attrs['iconSvg'] ? { 'data-icon-svg': String(attrs['iconSvg']) } : {}),
351
+ },
352
+ color: {
353
+ default: '',
354
+ parseHTML: (el) => el.getAttribute('data-color') ?? '',
355
+ renderHTML: (attrs) => (attrs['color'] ? { 'data-color': String(attrs['color']) } : {}),
356
+ },
180
357
  };
181
358
  },
182
359
  parseHTML() {
183
- return [{ tag: 'div[data-type="alert"]', contentElement: '.pilotiq-block-body' }];
360
+ return [{ tag: 'div[data-type="alert"]' }];
361
+ },
362
+ renderHTML({ HTMLAttributes }) {
363
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alert', class: 'pilotiq-alert' }), 0];
364
+ },
365
+ addNodeView() {
366
+ return ReactNodeViewRenderer(AlertNodeView);
367
+ },
368
+ });
369
+ /** The Alert heading — a single editable line, defaults to the variant label. */
370
+ export const AlertTitle = Node.create({
371
+ name: 'alertTitle',
372
+ content: 'inline*',
373
+ defining: true,
374
+ parseHTML() {
375
+ return [
376
+ { tag: 'div[data-type="alertTitle"]' },
377
+ { tag: '.pilotiq-alert-title' },
378
+ // Back-compat: the pre-redesign alert's non-editable label.
379
+ { tag: '[data-type="alert"] > .pilotiq-block-label' },
380
+ ];
381
+ },
382
+ renderHTML({ HTMLAttributes }) {
383
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertTitle', class: 'pilotiq-alert-title' }), 0];
184
384
  },
185
- renderHTML({ node, HTMLAttributes }) {
186
- const type = coerceAlertType(node.attrs['type']);
385
+ });
386
+ /** The Alert body — the editable description (`block+`). */
387
+ export const AlertBody = Node.create({
388
+ name: 'alertBody',
389
+ content: 'block+',
390
+ defining: true,
391
+ parseHTML() {
187
392
  return [
188
- 'div',
189
- mergeAttributes(HTMLAttributes, { 'data-type': 'alert', class: `pilotiq-alert pilotiq-alert-${type}` }),
190
- ['div', { class: 'pilotiq-block-label', contenteditable: 'false' }, ALERT_LABEL[type]],
191
- ['div', { class: 'pilotiq-block-body' }, 0],
393
+ { tag: 'div[data-type="alertBody"]' },
394
+ { tag: '.pilotiq-alert-description' },
395
+ // Back-compat: the pre-redesign alert's body wrapper.
396
+ { tag: '[data-type="alert"] > .pilotiq-block-body' },
192
397
  ];
193
398
  },
399
+ renderHTML({ HTMLAttributes }) {
400
+ return ['div', mergeAttributes(HTMLAttributes, { 'data-type': 'alertBody', class: 'pilotiq-alert-description' }), 0];
401
+ },
194
402
  });
195
403
  // ── Pros & cons — two labelled columns (each a `block+` body, list by default) ──
196
404
  export const ProsCons = Node.create({
@@ -274,6 +482,8 @@ export const contentBlockNodes = [
274
482
  FaqQuestion,
275
483
  FaqAnswer,
276
484
  Alert,
485
+ AlertTitle,
486
+ AlertBody,
277
487
  ProsCons,
278
488
  ProsColumn,
279
489
  ConsColumn,
@@ -0,0 +1,4 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type NodeViewProps } from '@tiptap/react';
3
+ export declare function AlertNodeView({ node, updateAttributes, editor }: NodeViewProps): ReactElement;
4
+ //# sourceMappingURL=AlertNodeView.d.ts.map
@@ -0,0 +1,92 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ import { NodeViewWrapper, NodeViewContent } from '@tiptap/react';
4
+ import { Popover } from '@base-ui/react/popover';
5
+ import { ALERT_VARIANTS, ALERT_ICON_KEYS, buildAlertIconSvg, sanitizeIconSvg, coerceAlertType, } from '../extensions/alertVariants.js';
6
+ import { Palette } from './Palette.js';
7
+ /**
8
+ * React NodeView for the `alert` content block — a shadcn-style callout on the
9
+ * panel's theme tokens. Icon in column one, editable title + body in column
10
+ * two. Editable mode adds in-block controls (top-right): a variant picker, an
11
+ * icon picker (curated inline-SVG library + a "Custom SVG" paste field — no
12
+ * `lucide-react`), and a color swatch for the `custom` variant.
13
+ *
14
+ * Editable regions are the child nodes (`alertTitle` / `alertBody`) rendered
15
+ * through the single `<NodeViewContent>` hole; the wrapper styles them via
16
+ * child selectors so the nodes' `renderHTML` stays semantic (consumer owns the
17
+ * read-side CSS — see `render.ts`). Custom SVG is sanitized via
18
+ * `sanitizeIconSvg` before it's stored AND when it renders.
19
+ */
20
+ const VARIANT_BOX = {
21
+ info: 'border-blue-500/30 bg-blue-50/40 dark:bg-blue-950/20',
22
+ warning: 'border-amber-500/30 bg-amber-50/40 dark:bg-amber-950/20',
23
+ success: 'border-emerald-500/30 bg-emerald-50/40 dark:bg-emerald-950/20',
24
+ tip: 'border-violet-500/30 bg-violet-50/40 dark:bg-violet-950/20',
25
+ custom: 'border-border bg-card',
26
+ };
27
+ const VARIANT_ICON_COLOR = {
28
+ info: 'text-blue-600 dark:text-blue-400',
29
+ warning: 'text-amber-600 dark:text-amber-400',
30
+ success: 'text-emerald-600 dark:text-emerald-400',
31
+ tip: 'text-violet-600 dark:text-violet-400',
32
+ custom: 'text-foreground',
33
+ };
34
+ const VARIANT_LABEL = {
35
+ info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip', custom: 'Custom',
36
+ };
37
+ const COLOR_SWATCHES = [
38
+ { value: '#ef4444', label: 'Red' },
39
+ { value: '#f97316', label: 'Orange' },
40
+ { value: '#eab308', label: 'Yellow' },
41
+ { value: '#22c55e', label: 'Green' },
42
+ { value: '#06b6d4', label: 'Cyan' },
43
+ { value: '#3b82f6', label: 'Blue' },
44
+ { value: '#8b5cf6', label: 'Violet' },
45
+ { value: '#ec4899', label: 'Pink' },
46
+ { value: '#64748b', label: 'Slate' },
47
+ { value: '#0f172a', label: 'Ink' },
48
+ ];
49
+ // Renders a full <svg> string (library or sanitized custom) sized to fill.
50
+ function IconSlot({ svg, className }) {
51
+ return (_jsx("span", { className: 'inline-flex shrink-0 [&>svg]:size-full ' + (className ?? ''), dangerouslySetInnerHTML: { __html: svg } }));
52
+ }
53
+ const chevron = (_jsx("svg", { viewBox: "0 0 24 24", className: "h-3 w-3", fill: "none", stroke: "currentColor", strokeWidth: 2, "aria-hidden": true, children: _jsx("path", { d: "m6 9 6 6 6-6" }) }));
54
+ const ctrlBtn = 'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-muted-foreground transition-opacity ' +
55
+ 'hover:bg-accent hover:text-accent-foreground focus-visible:opacity-100 opacity-0 [.pilotiq-alert:hover_&]:opacity-100';
56
+ export function AlertNodeView({ node, updateAttributes, editor }) {
57
+ const variant = coerceAlertType(node.attrs['type']);
58
+ const iconKey = String(node.attrs['icon'] ?? '');
59
+ const iconSvg = String(node.attrs['iconSvg'] ?? '');
60
+ const color = String(node.attrs['color'] ?? '');
61
+ const editable = editor.isEditable;
62
+ const iconFull = buildAlertIconSvg(iconKey, iconSvg, variant);
63
+ const tinted = variant === 'custom' && color !== '';
64
+ const [iconOpen, setIconOpen] = useState(false);
65
+ const [svgMode, setSvgMode] = useState(false);
66
+ const [svgDraft, setSvgDraft] = useState('');
67
+ const [svgError, setSvgError] = useState(false);
68
+ const boxStyle = tinted
69
+ ? {
70
+ borderColor: `color-mix(in srgb, ${color} 35%, transparent)`,
71
+ backgroundColor: `color-mix(in srgb, ${color} 8%, transparent)`,
72
+ }
73
+ : undefined;
74
+ const closeIconPicker = () => { setIconOpen(false); setSvgMode(false); setSvgDraft(''); setSvgError(false); };
75
+ const pickIcon = (key) => { updateAttributes({ icon: key, iconSvg: '' }); closeIconPicker(); };
76
+ const applyCustomSvg = () => {
77
+ const clean = sanitizeIconSvg(svgDraft);
78
+ if (!clean) {
79
+ setSvgError(true);
80
+ return;
81
+ }
82
+ updateAttributes({ iconSvg: clean, icon: '' });
83
+ closeIconPicker();
84
+ };
85
+ return (_jsxs(NodeViewWrapper, { "data-type": "alert", "data-alert-type": variant, style: boxStyle, className: 'pilotiq-alert relative my-3 grid grid-cols-[auto_1fr] items-start gap-x-3 gap-y-1 rounded-lg border px-4 py-3 text-sm ' +
86
+ '[&_.pilotiq-alert-title]:font-medium [&_.pilotiq-alert-title]:leading-tight ' +
87
+ '[&_.pilotiq-alert-description]:text-muted-foreground [&_.pilotiq-alert-description_p]:my-0 ' +
88
+ VARIANT_BOX[variant], children: [editable ? (_jsxs(Popover.Root, { open: iconOpen, onOpenChange: (o) => (o ? setIconOpen(true) : closeIconPicker()), children: [_jsx(Popover.Trigger, { render: _jsx("button", { type: "button", contentEditable: false, "aria-label": "Alert icon", className: 'mt-0.5 rounded hover:bg-accent ' + (tinted ? '' : VARIANT_ICON_COLOR[variant]), style: tinted ? { color } : undefined, children: _jsx(IconSlot, { svg: iconFull, className: "h-4 w-4" }) }) }), _jsx(Popover.Portal, { children: _jsx(Popover.Positioner, { side: "bottom", align: "start", sideOffset: 6, className: "isolate z-50", children: _jsx(Popover.Popup, { className: "w-64 rounded-md border bg-popover p-2 text-popover-foreground shadow-md outline-hidden", children: !svgMode ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "grid grid-cols-6 gap-1", children: ALERT_ICON_KEYS.map((key) => (_jsx("button", { type: "button", title: key, "aria-label": key, onClick: () => pickIcon(key), className: 'flex size-8 items-center justify-center rounded hover:bg-accent hover:text-accent-foreground ' +
89
+ (!iconSvg && key === iconKey ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'), children: _jsx(IconSlot, { svg: buildAlertIconSvg(key, '', variant), className: "h-4 w-4" }) }, key))) }), _jsxs("div", { className: "mt-2 flex items-center gap-1", children: [_jsx("button", { type: "button", onClick: () => { setSvgMode(true); setSvgDraft(iconSvg); setSvgError(false); }, className: "flex-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "Custom SVG\u2026" }), _jsx("button", { type: "button", onClick: () => { updateAttributes({ icon: '', iconSvg: '' }); closeIconPicker(); }, className: "flex-1 rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "Default" })] })] })) : (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("textarea", { value: svgDraft, onChange: (e) => { setSvgDraft(e.target.value); setSvgError(false); }, rows: 5, spellCheck: false, placeholder: "<svg viewBox='0 0 24 24'>\u2026</svg>", className: 'w-full resize-y rounded border bg-background p-2 font-mono text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring ' +
90
+ (svgError ? 'border-destructive' : 'border-input') }), svgError && _jsx("p", { className: "text-xs text-destructive", children: "Not a valid SVG (must start with <svg>)." }), _jsxs("div", { className: "flex items-center justify-end gap-1", children: [_jsx("button", { type: "button", onClick: () => setSvgMode(false), className: "rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "Back" }), _jsx("button", { type: "button", onClick: applyCustomSvg, className: "rounded bg-primary px-2 py-1 text-xs text-primary-foreground hover:bg-primary/90", children: "Apply" })] })] })) }) }) })] })) : (_jsx("div", { contentEditable: false, className: 'mt-0.5 ' + (tinted ? '' : VARIANT_ICON_COLOR[variant]), style: tinted ? { color } : undefined, children: _jsx(IconSlot, { svg: iconFull, className: "h-4 w-4" }) })), editable && (_jsxs("div", { className: "absolute right-1.5 top-1.5 flex items-center gap-1", contentEditable: false, children: [variant === 'custom' && (_jsx(Palette, { trigger: _jsx("button", { type: "button", "aria-label": "Alert color", className: ctrlBtn, children: _jsx("span", { className: "size-3 rounded-full border border-border/60", style: { background: color || 'var(--color-muted-foreground)' } }) }), swatches: COLOR_SWATCHES, custom: true, activeColor: color || undefined, onPick: (value) => updateAttributes({ color: value }), onClear: () => updateAttributes({ color: '' }), clearLabel: "No color" })), _jsxs(Popover.Root, { children: [_jsx(Popover.Trigger, { render: _jsxs("button", { type: "button", "aria-label": "Alert variant", className: ctrlBtn, children: [VARIANT_LABEL[variant], chevron] }) }), _jsx(Popover.Portal, { children: _jsx(Popover.Positioner, { side: "bottom", align: "end", sideOffset: 4, className: "isolate z-50", children: _jsx(Popover.Popup, { className: "min-w-32 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-hidden", children: ALERT_VARIANTS.map((v) => (_jsx(Popover.Close, { render: _jsxs("button", { type: "button", onClick: () => updateAttributes({ type: v }), className: 'flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm hover:bg-accent hover:text-accent-foreground ' +
91
+ (v === variant ? 'bg-accent/50' : ''), children: [_jsx("span", { className: VARIANT_ICON_COLOR[v], children: _jsx(IconSlot, { svg: buildAlertIconSvg('', '', v), className: "h-3.5 w-3.5" }) }), VARIANT_LABEL[v]] }) }, v))) }) }) })] })] })), _jsx(NodeViewContent, { className: "col-start-2 min-w-0" })] }));
92
+ }
@@ -15,6 +15,7 @@ import { useCollabRoom, getCollabExtensions, useToast, } from '@pilotiq/pilotiq/
15
15
  import { useCollabSeed } from '@rudderjs/sync/react';
16
16
  import { AiSuggestionExtension } from '../extensions/AiSuggestionExtension.js';
17
17
  import { AiInlineDiffExtension } from '../extensions/AiInlineDiffExtension.js';
18
+ import { Alert, AlertTitle, AlertBody, ContentBlockKeymap } from '../extensions/contentBlocks.js';
18
19
  import { useAiSuggestionBridge } from './useAiSuggestionBridge.js';
19
20
  import { useAiInlineDiff, useIsAiInlineDiffActive, readAiDiffViewMarker } from './useAiInlineDiff.js';
20
21
  import { AiSuggestionBanner } from './AiSuggestionBanner.js';
@@ -39,6 +40,7 @@ const SvgIcons = {
39
40
  orderedList: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("line", { x1: "10", y1: "6", x2: "21", y2: "6" }), _jsx("line", { x1: "10", y1: "12", x2: "21", y2: "12" }), _jsx("line", { x1: "10", y1: "18", x2: "21", y2: "18" }), _jsx("path", { d: "M4 6h1v4" }), _jsx("path", { d: "M4 10h2" }), _jsx("path", { d: "M6 18H4c0-1 2-2 2-3s-1-1.5-2-1" })] })),
40
41
  blockquote: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "M3 21c3 0 7-1 7-8V5a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h3" }), _jsx("path", { d: "M15 21c3 0 7-1 7-8V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h3" })] })),
41
42
  codeBlock: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("polyline", { points: "16 18 22 12 16 6" }), _jsx("polyline", { points: "8 6 2 12 8 18" })] })),
43
+ alert: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" }), _jsx("path", { d: "M12 9v4" }), _jsx("path", { d: "M12 17h.01" })] })),
42
44
  attachFiles: (_jsx("svg", { ...ICON_PROPS, children: _jsx("path", { d: "m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" }) })),
43
45
  pencil: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "M12 20h9" }), _jsx("path", { d: "M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" })] })),
44
46
  source: (_jsxs("svg", { ...ICON_PROPS, children: [_jsx("path", { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }), _jsx("polyline", { points: "14 2 14 8 20 8" }), _jsx("line", { x1: "9", y1: "13", x2: "15", y2: "13" }), _jsx("line", { x1: "9", y1: "17", x2: "15", y2: "17" })] })),
@@ -140,6 +142,12 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
140
142
  // for deleted text. Host's `<AiSuggestionBanner>` drives Accept /
141
143
  // Reject via the extension's commands.
142
144
  AiInlineDiffExtension,
145
+ // Alert content block — round-trips to `:::alert{type=…} Title` via the
146
+ // node's `markdown` storage spec; renders the same shadcn NodeView.
147
+ Alert,
148
+ AlertTitle,
149
+ AlertBody,
150
+ ContentBlockKeymap,
143
151
  ...collabExtensions,
144
152
  ],
145
153
  // Collab takes ownership of the document — passing `content` would
@@ -366,6 +374,16 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
366
374
  case 'codeBlock':
367
375
  c.toggleCodeBlock().run();
368
376
  break;
377
+ case 'alert':
378
+ c.insertContent({
379
+ type: 'alert',
380
+ attrs: { type: 'info' },
381
+ content: [
382
+ { type: 'alertTitle', content: [{ type: 'text', text: 'Info' }] },
383
+ { type: 'alertBody', content: [{ type: 'paragraph' }] },
384
+ ],
385
+ }).run();
386
+ break;
369
387
  case 'attachFiles':
370
388
  onAttachClick();
371
389
  break;
@@ -398,6 +416,7 @@ export function MarkdownEditor({ name, fragmentKey, defaultValue, placeholder, d
398
416
  orderedList: 'Numbered list',
399
417
  blockquote: 'Quote',
400
418
  codeBlock: 'Code block',
419
+ alert: 'Alert',
401
420
  attachFiles: 'Attach file',
402
421
  };
403
422
  const wrapperStyle = {};
package/dist/render.js CHANGED
@@ -32,6 +32,9 @@
32
32
  * custom blocks — any other type renders to `<div data-type="..."
33
33
  * data-attrs="...">` so consumers can replay or style by data-type.
34
34
  */
35
+ // Pure variant primitives (no Tiptap runtime) — keeps render.ts server-safe
36
+ // while sharing the alert icon/label source of truth with the editor NodeView.
37
+ import { coerceAlertType, ALERT_VARIANT_LABEL, buildAlertIconSvg } from './extensions/alertVariants.js';
35
38
  /**
36
39
  * Render Tiptap content to HTML.
37
40
  *
@@ -365,17 +368,42 @@ function labeledBlockHtml(cssClass, label, n, opts) {
365
368
  `<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
366
369
  `</div>`);
367
370
  }
368
- const ALERT_NODE_TYPES = new Set(['info', 'warning', 'success', 'tip']);
369
- const ALERT_NODE_LABEL = { info: 'Info', warning: 'Warning', success: 'Success', tip: 'Tip' };
371
+ // shadcn-style callout: icon + editable title + description. Mirrors the
372
+ // editor NodeView (`AlertNodeView`); `coerceAlertType` / `ALERT_ICON_INNER` /
373
+ // labels are shared from `alertVariants` so the two never drift. Consumer owns
374
+ // the `.pilotiq-alert*` CSS.
370
375
  function renderAlertNode(n, opts) {
371
- let type = String(n.attrs?.['type'] ?? '').trim().toLowerCase();
372
- if (!ALERT_NODE_TYPES.has(type))
373
- type = 'info';
374
- return (`<div class="pilotiq-alert pilotiq-alert-${type}" role="note">` +
375
- `<div class="pilotiq-block-label">${ALERT_NODE_LABEL[type]}</div>` +
376
- `<div class="pilotiq-block-body">${renderChildren(n, opts)}</div>` +
376
+ const type = coerceAlertType(n.attrs?.['type']);
377
+ const icon = typeof n.attrs?.['icon'] === 'string' ? n.attrs['icon'] : '';
378
+ const iconSvg = typeof n.attrs?.['iconSvg'] === 'string' ? n.attrs['iconSvg'] : '';
379
+ const color = typeof n.attrs?.['color'] === 'string' ? n.attrs['color'] : '';
380
+ const kids = Array.isArray(n.content) ? n.content : [];
381
+ const title = kids.find((k) => k?.type === 'alertTitle');
382
+ const body = kids.find((k) => k?.type === 'alertBody');
383
+ const titleHtml = title ? renderChildren(title, opts) : escapeHtml(ALERT_VARIANT_LABEL[type]);
384
+ const bodyHtml = body ? renderChildren(body, opts) : '';
385
+ // Custom variant paints from the chosen color (mirrors the editor's
386
+ // `color-mix` tint); CSS-injection-safe because the value is the parsed
387
+ // attr, only emitted when it matches a strict color literal.
388
+ const tinted = type === 'custom' && isSafeColor(color);
389
+ const style = tinted
390
+ ? ` style="border-color:color-mix(in srgb,${color} 35%,transparent);background-color:color-mix(in srgb,${color} 8%,transparent)"`
391
+ : '';
392
+ const iconStyle = tinted ? ` style="color:${color}"` : '';
393
+ return (`<div class="pilotiq-alert pilotiq-alert-${type}" data-alert-type="${type}" role="note"${style}>` +
394
+ `<span class="pilotiq-alert-icon" aria-hidden="true"${iconStyle}>${buildAlertIconSvg(icon, iconSvg, type)}</span>` +
395
+ `<div class="pilotiq-alert-title">${titleHtml}</div>` +
396
+ `<div class="pilotiq-alert-description">${bodyHtml}</div>` +
377
397
  `</div>`);
378
398
  }
399
+ // Only emit a color into inline CSS when it's a plain literal (hex / rgb(a) /
400
+ // hsl(a) / a CSS keyword) — defends the `style="…"` interpolation against
401
+ // injection via a tampered `color` attr.
402
+ function isSafeColor(value) {
403
+ return /^#[0-9a-fA-F]{3,8}$/.test(value)
404
+ || /^(rgb|hsl)a?\([0-9.,%\s/]+\)$/.test(value)
405
+ || /^[a-zA-Z]+$/.test(value);
406
+ }
379
407
  // ─── Merge tags + mentions ───────────────────────────────────────────
380
408
  /**
381
409
  * Render a `mergeTag` atom — either substitute the value from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pilotiq/tiptap",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
4
4
  "description": "Tiptap rich-text editor adapter for @pilotiq/pilotiq — slash menu, draggable blocks, custom-block API",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -87,11 +87,13 @@
87
87
  "@types/react-dom": "^19",
88
88
  "esbuild": "^0.27",
89
89
  "jsdom": "^29.1.1",
90
+ "markdown-it": "^14",
91
+ "@types/markdown-it": "^14",
90
92
  "react": "^19",
91
93
  "react-dom": "^19",
92
94
  "tiptap-markdown": "^0.9",
93
95
  "typescript": "^5",
94
- "@pilotiq/pilotiq": "^0.37.0"
96
+ "@pilotiq/pilotiq": "^0.40.0"
95
97
  },
96
98
  "author": "Suleiman Shahbari",
97
99
  "scripts": {