@olonjs/cli 3.0.131 → 3.0.133

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.
@@ -1674,7 +1674,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1674
1674
  "@tiptap/extension-link": "^2.11.5",
1675
1675
  "@tiptap/react": "^2.11.5",
1676
1676
  "@tiptap/starter-kit": "^2.11.5",
1677
- "@olonjs/core": "^1.0.122",
1677
+ "@olonjs/core": "^1.0.124",
1678
1678
  "class-variance-authority": "^0.7.1",
1679
1679
  "clsx": "^2.1.1",
1680
1680
  "lucide-react": "^0.474.0",
@@ -5144,6 +5144,608 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/save-drawer/saverStyle.css"
5144
5144
  }
5145
5145
 
5146
5146
 
5147
+ END_OF_FILE_CONTENT
5148
+ mkdir -p "src/components/tiptap"
5149
+ echo "Creating src/components/tiptap/INTEGRATION.md..."
5150
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/INTEGRATION.md"
5151
+ # Tiptap Editorial — Integration Guide
5152
+
5153
+ How to add the `tiptap` section to a new tenant.
5154
+
5155
+ ---
5156
+
5157
+ ## 1. Copy the component
5158
+
5159
+ Copy the entire folder into the new tenant:
5160
+
5161
+ ```
5162
+ src/components/tiptap/
5163
+ index.ts
5164
+ types.ts
5165
+ View.tsx
5166
+ ```
5167
+
5168
+ ---
5169
+
5170
+ ## 2. Install npm dependencies
5171
+
5172
+ Add to the tenant's `package.json` and run `npm install`:
5173
+
5174
+ ```json
5175
+ "@tiptap/extension-image": "^2.11.5",
5176
+ "@tiptap/extension-link": "^2.11.5",
5177
+ "@tiptap/react": "^2.11.5",
5178
+ "@tiptap/starter-kit": "^2.11.5",
5179
+ "react-markdown": "^9.0.1",
5180
+ "rehype-sanitize": "^6.0.0",
5181
+ "remark-gfm": "^4.0.1",
5182
+ "tiptap-markdown": "^0.8.10"
5183
+ ```
5184
+
5185
+ ---
5186
+
5187
+ ## 3. Add CSS to `src/index.css`
5188
+
5189
+ Two blocks are required — one for the public (visitor) view, one for the editor (studio) view.
5190
+
5191
+ ```css
5192
+ /* ==========================================================================
5193
+ TIPTAP — Public content typography (visitor view)
5194
+ ========================================================================== */
5195
+ .jp-tiptap-content > * + * { margin-top: 0.75em; }
5196
+
5197
+ .jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
5198
+ .jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
5199
+ .jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
5200
+ .jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
5201
+
5202
+ .jp-tiptap-content p { line-height: 1.7; }
5203
+ .jp-tiptap-content strong { font-weight: 700; }
5204
+ .jp-tiptap-content em { font-style: italic; }
5205
+ .jp-tiptap-content s { text-decoration: line-through; }
5206
+
5207
+ .jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
5208
+ .jp-tiptap-content a:hover { opacity: 0.8; }
5209
+
5210
+ .jp-tiptap-content code {
5211
+ font-family: var(--font-mono, ui-monospace, monospace);
5212
+ font-size: 0.875em;
5213
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
5214
+ border-radius: 0.25em;
5215
+ padding: 0.1em 0.35em;
5216
+ }
5217
+ .jp-tiptap-content pre {
5218
+ background: color-mix(in oklch, var(--background) 60%, black);
5219
+ border-radius: 0.5em;
5220
+ padding: 1em 1.25em;
5221
+ overflow-x: auto;
5222
+ }
5223
+ .jp-tiptap-content pre code { background: none; padding: 0; }
5224
+
5225
+ .jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
5226
+ .jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
5227
+ .jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
5228
+ .jp-tiptap-content li + li { margin-top: 0.25em; }
5229
+
5230
+ .jp-tiptap-content blockquote {
5231
+ border-left: 3px solid var(--border);
5232
+ padding-left: 1em;
5233
+ color: var(--muted-foreground);
5234
+ font-style: italic;
5235
+ }
5236
+ .jp-tiptap-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
5237
+ .jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
5238
+
5239
+ /* ==========================================================================
5240
+ TIPTAP / PROSEMIRROR — Editor typography (studio view)
5241
+ ========================================================================== */
5242
+ .jp-simple-editor .ProseMirror { outline: none; word-break: break-word; }
5243
+ .jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
5244
+
5245
+ .jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
5246
+ .jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
5247
+ .jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
5248
+ .jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
5249
+
5250
+ .jp-simple-editor .ProseMirror p { line-height: 1.7; }
5251
+ .jp-simple-editor .ProseMirror strong { font-weight: 700; }
5252
+ .jp-simple-editor .ProseMirror em { font-style: italic; }
5253
+ .jp-simple-editor .ProseMirror s { text-decoration: line-through; }
5254
+
5255
+ .jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
5256
+ .jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
5257
+
5258
+ .jp-simple-editor .ProseMirror code {
5259
+ font-family: var(--font-mono, ui-monospace, monospace);
5260
+ font-size: 0.875em;
5261
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
5262
+ border-radius: 0.25em;
5263
+ padding: 0.1em 0.35em;
5264
+ }
5265
+ .jp-simple-editor .ProseMirror pre {
5266
+ background: color-mix(in oklch, var(--background) 60%, black);
5267
+ border-radius: 0.5em;
5268
+ padding: 1em 1.25em;
5269
+ overflow-x: auto;
5270
+ }
5271
+ .jp-simple-editor .ProseMirror pre code { background: none; padding: 0; }
5272
+
5273
+ .jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
5274
+ .jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
5275
+ .jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
5276
+ .jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
5277
+
5278
+ .jp-simple-editor .ProseMirror blockquote {
5279
+ border-left: 3px solid var(--border);
5280
+ padding-left: 1em;
5281
+ color: var(--muted-foreground);
5282
+ font-style: italic;
5283
+ }
5284
+ .jp-simple-editor .ProseMirror hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
5285
+
5286
+ .jp-simple-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.5rem; }
5287
+ .jp-simple-editor .ProseMirror img[data-uploading="true"] {
5288
+ opacity: 0.6;
5289
+ filter: grayscale(0.25);
5290
+ outline: 2px dashed rgb(59 130 246 / 0.7);
5291
+ outline-offset: 2px;
5292
+ }
5293
+ .jp-simple-editor .ProseMirror img[data-upload-error="true"] {
5294
+ outline: 2px solid rgb(239 68 68 / 0.8);
5295
+ outline-offset: 2px;
5296
+ }
5297
+ .jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
5298
+ content: attr(data-placeholder);
5299
+ color: var(--muted-foreground);
5300
+ opacity: 0.5;
5301
+ pointer-events: none;
5302
+ float: left;
5303
+ height: 0;
5304
+ }
5305
+ ```
5306
+
5307
+ ---
5308
+
5309
+ ## 4. Register in `src/lib/schemas.ts`
5310
+
5311
+ ```ts
5312
+ import { TiptapSchema } from '@/components/tiptap';
5313
+
5314
+ export const SECTION_SCHEMAS = {
5315
+ // ... existing schemas
5316
+ 'tiptap': TiptapSchema,
5317
+ } as const;
5318
+ ```
5319
+
5320
+ ---
5321
+
5322
+ ## 5. Register in `src/lib/addSectionConfig.ts`
5323
+
5324
+ ```ts
5325
+ const addableSectionTypes = [
5326
+ // ... existing types
5327
+ 'tiptap',
5328
+ ] as const;
5329
+
5330
+ const sectionTypeLabels = {
5331
+ // ... existing labels
5332
+ 'tiptap': 'Tiptap Editorial',
5333
+ };
5334
+
5335
+ function getDefaultSectionData(type: string) {
5336
+ switch (type) {
5337
+ // ... existing cases
5338
+ case 'tiptap': return { content: '# Post title\n\nStart writing in Markdown...' };
5339
+ }
5340
+ }
5341
+ ```
5342
+
5343
+ ---
5344
+
5345
+ ## 6. Register in `src/lib/ComponentRegistry.tsx`
5346
+
5347
+ ```tsx
5348
+ import { Tiptap } from '@/components/tiptap';
5349
+
5350
+ export const ComponentRegistry = {
5351
+ // ... existing components
5352
+ 'tiptap': Tiptap,
5353
+ };
5354
+ ```
5355
+
5356
+ ---
5357
+
5358
+ ## 7. Register in `src/types.ts`
5359
+
5360
+ ```ts
5361
+ import type { TiptapData, TiptapSettings } from '@/components/tiptap';
5362
+
5363
+ export type SectionComponentPropsMap = {
5364
+ // ... existing entries
5365
+ 'tiptap': { data: TiptapData; settings?: TiptapSettings };
5366
+ };
5367
+
5368
+ declare module '@jsonpages/core' {
5369
+ export interface SectionDataRegistry {
5370
+ // ... existing entries
5371
+ 'tiptap': TiptapData;
5372
+ }
5373
+ export interface SectionSettingsRegistry {
5374
+ // ... existing entries
5375
+ 'tiptap': TiptapSettings;
5376
+ }
5377
+ }
5378
+ ```
5379
+
5380
+ ---
5381
+
5382
+ ## Notes
5383
+
5384
+ - Typography uses tenant CSS variables (`--primary`, `--border`, `--muted-foreground`, `--font-mono`) — no hardcoded colors.
5385
+ - `@tailwindcss/typography` is **not** required; the CSS blocks above replace it.
5386
+ - The toolbar is admin-only (studio mode). In visitor mode, content is rendered via `ReactMarkdown`.
5387
+ - Underline is intentionally excluded: `tiptap-markdown` with `html: false` cannot round-trip `<u>` tags.
5388
+
5389
+ END_OF_FILE_CONTENT
5390
+ echo "Creating src/components/tiptap/View.tsx..."
5391
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/View.tsx"
5392
+ import React from 'react';
5393
+ import ReactMarkdown from 'react-markdown';
5394
+ import remarkGfm from 'remark-gfm';
5395
+ import rehypeSanitize from 'rehype-sanitize';
5396
+ import { useEditor, EditorContent } from '@tiptap/react';
5397
+ import type { Editor } from '@tiptap/react';
5398
+ import StarterKit from '@tiptap/starter-kit';
5399
+ import Link from '@tiptap/extension-link';
5400
+ import Image from '@tiptap/extension-image';
5401
+ import { Markdown } from 'tiptap-markdown';
5402
+ import {
5403
+ Undo2, Redo2,
5404
+ List, ListOrdered,
5405
+ Bold, Italic, Strikethrough,
5406
+ Code2, Quote, SquareCode,
5407
+ Link2, Unlink2, ImagePlus, Eraser,
5408
+ } from 'lucide-react';
5409
+ import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
5410
+ import type { TiptapData, TiptapSettings } from './types';
5411
+
5412
+ // ── UI primitives ─────────────────────────────────────────────────
5413
+ const Btn: React.FC<{
5414
+ active?: boolean; title: string; onClick: () => void; children: React.ReactNode;
5415
+ }> = ({ active = false, title, onClick, children }) => (
5416
+ <button
5417
+ type="button" title={title}
5418
+ onMouseDown={(e) => e.preventDefault()} onClick={onClick}
5419
+ className={[
5420
+ 'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
5421
+ active ? 'bg-zinc-700/70 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
5422
+ ].join(' ')}
5423
+ >{children}</button>
5424
+ );
5425
+
5426
+ const Sep: React.FC = () => (
5427
+ <span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
5428
+ );
5429
+
5430
+ // ── Image extension with upload metadata ──────────────────────────
5431
+ const UploadableImage = Image.extend({
5432
+ addAttributes() {
5433
+ const bool = (attr: string) => ({
5434
+ default: false,
5435
+ parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
5436
+ renderHTML: (attrs: Record<string, unknown>) =>
5437
+ attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())]
5438
+ ? { [attr]: 'true' } : {},
5439
+ });
5440
+ return {
5441
+ ...this.parent?.(),
5442
+ uploadId: {
5443
+ default: null,
5444
+ parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
5445
+ renderHTML: (attrs: Record<string, unknown>) =>
5446
+ attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
5447
+ },
5448
+ uploading: bool('data-uploading'),
5449
+ uploadError: bool('data-upload-error'),
5450
+ awaitingUpload: bool('data-awaiting-upload'),
5451
+ };
5452
+ },
5453
+ });
5454
+
5455
+ // ── Helpers ───────────────────────────────────────────────────────
5456
+ const getMarkdown = (ed: Editor | null | undefined): string =>
5457
+ (ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
5458
+ ?.markdown?.getMarkdown?.() ?? '';
5459
+
5460
+ const svg = (body: string) =>
5461
+ 'data:image/svg+xml;utf8,' +
5462
+ encodeURIComponent(
5463
+ '<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'1200\' height=\'420\' viewBox=\'0 0 1200 420\'>' + body + '</svg>'
5464
+ );
5465
+
5466
+ const RECT = '<rect width=\'1200\' height=\'420\' fill=\'#090B14\' stroke=\'#3F3F46\' stroke-width=\'3\' stroke-dasharray=\'10 10\' rx=\'12\'/>';
5467
+
5468
+ const UPLOADING_SRC = svg(
5469
+ RECT + '<text x=\'600\' y=\'215\' font-family=\'Inter,Arial,sans-serif\' font-size=\'28\' font-weight=\'700\' fill=\'#A1A1AA\' text-anchor=\'middle\'>Uploading image\u2026</text>'
5470
+ );
5471
+
5472
+ const PICKER_SRC = svg(
5473
+ RECT +
5474
+ '<text x=\'600\' y=\'200\' font-family=\'Inter,Arial,sans-serif\' font-size=\'32\' font-weight=\'700\' fill=\'#E4E4E7\' text-anchor=\'middle\'>Click to upload or drag &amp; drop</text>' +
5475
+ '<text x=\'600\' y=\'248\' font-family=\'Inter,Arial,sans-serif\' font-size=\'22\' fill=\'#A1A1AA\' text-anchor=\'middle\'>Max 5 MB per file</text>'
5476
+ );
5477
+
5478
+ const patchImage = (ed: Editor, uploadId: string, patch: Record<string, unknown>): boolean => {
5479
+ let pos: number | null = null;
5480
+ ed.state.doc.descendants(
5481
+ (node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
5482
+ if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) { pos = p; return false; }
5483
+ return true;
5484
+ }
5485
+ );
5486
+ if (pos == null) return false;
5487
+ const cur = ed.state.doc.nodeAt(pos);
5488
+ if (!cur) return false;
5489
+ ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
5490
+ return true;
5491
+ };
5492
+
5493
+ const EXTENSIONS = [
5494
+ StarterKit,
5495
+ Link.configure({ openOnClick: false, autolink: true }),
5496
+ UploadableImage,
5497
+ Markdown.configure({ html: false }),
5498
+ ];
5499
+
5500
+ // ── Studio editor ─────────────────────────────────────────────────
5501
+ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
5502
+ const { assets } = useConfig();
5503
+ const hostRef = React.useRef<HTMLDivElement | null>(null);
5504
+ const sectionRef = React.useRef<HTMLElement | null>(null);
5505
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null);
5506
+ const editorRef = React.useRef<Editor | null>(null);
5507
+ const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
5508
+ const pendingPickerId = React.useRef<string | null>(null);
5509
+ const latestMd = React.useRef<string>(data.content ?? '');
5510
+ const emittedMd = React.useRef<string>(data.content ?? '');
5511
+ const [linkOpen, setLinkOpen] = React.useState(false);
5512
+ const [linkUrl, setLinkUrl] = React.useState('');
5513
+ const linkInputRef = React.useRef<HTMLInputElement | null>(null);
5514
+
5515
+ const getSectionId = React.useCallback((): string | null => {
5516
+ const el = sectionRef.current ?? (hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
5517
+ sectionRef.current = el;
5518
+ return el?.getAttribute('data-section-id') ?? null;
5519
+ }, []);
5520
+
5521
+ const emit = React.useCallback((markdown: string) => {
5522
+ latestMd.current = markdown;
5523
+ const sectionId = getSectionId();
5524
+ if (!sectionId) return;
5525
+ window.parent.postMessage({ type: STUDIO_EVENTS.INLINE_FIELD_UPDATE, sectionId, fieldKey: 'content', value: markdown }, window.location.origin);
5526
+ emittedMd.current = markdown;
5527
+ }, [getSectionId]);
5528
+
5529
+ const setFocusLock = React.useCallback((on: boolean) => {
5530
+ sectionRef.current?.classList.toggle('jp-editorial-focus', on);
5531
+ }, []);
5532
+
5533
+ const insertPlaceholder = React.useCallback((uploadId: string, src: string, awaitingUpload: boolean) => {
5534
+ const ed = editorRef.current;
5535
+ if (!ed) return;
5536
+ ed.chain().focus().setImage({ src, alt: 'upload-placeholder', title: awaitingUpload ? 'Click to upload' : 'Uploading\u2026', uploadId, uploading: !awaitingUpload, awaitingUpload, uploadError: false } as any).run();
5537
+ emit(getMarkdown(ed));
5538
+ }, [emit]);
5539
+
5540
+ const doUpload = React.useCallback(async (uploadId: string, file: File) => {
5541
+ const uploadFn = assets?.onAssetUpload;
5542
+ if (!uploadFn) return;
5543
+ const ed = editorRef.current;
5544
+ if (!ed) return;
5545
+ patchImage(ed, uploadId, { src: UPLOADING_SRC, alt: file.name, title: file.name, uploading: true, awaitingUpload: false, uploadError: false });
5546
+ const task = (async () => {
5547
+ try {
5548
+ const url = await uploadFn(file);
5549
+ const cur = editorRef.current;
5550
+ if (cur) { patchImage(cur, uploadId, { src: url, alt: file.name, title: file.name, uploadId: null, uploading: false, awaitingUpload: false, uploadError: false }); emit(getMarkdown(cur)); }
5551
+ } catch {
5552
+ const cur = editorRef.current;
5553
+ if (cur) { patchImage(cur, uploadId, { uploading: false, awaitingUpload: false, uploadError: true }); emit(getMarkdown(cur)); }
5554
+ } finally { pendingUploads.current.delete(uploadId); }
5555
+ })();
5556
+ pendingUploads.current.set(uploadId, task);
5557
+ }, [assets, emit]);
5558
+
5559
+ const uploadFile = React.useCallback(async (file: File) => {
5560
+ const id = crypto.randomUUID();
5561
+ insertPlaceholder(id, UPLOADING_SRC, false);
5562
+ await doUpload(id, file);
5563
+ }, [insertPlaceholder, doUpload]);
5564
+
5565
+ const editor = useEditor({
5566
+ extensions: EXTENSIONS,
5567
+ content: data.content ?? '',
5568
+ editorProps: { attributes: { class: 'min-h-[220px] p-4 outline-none' } },
5569
+ onUpdate({ editor: ed }) { emit(getMarkdown(ed)); },
5570
+ onFocus() { setFocusLock(true); },
5571
+ onBlur() {
5572
+ setTimeout(() => {
5573
+ if (!hostRef.current?.contains(document.activeElement)) setFocusLock(false);
5574
+ }, 100);
5575
+ },
5576
+ onCreate({ editor: ed }) {
5577
+ editorRef.current = ed;
5578
+ if (data.content) ed.commands.setContent(data.content);
5579
+ },
5580
+ onDestroy() { editorRef.current = null; },
5581
+ });
5582
+
5583
+ React.useEffect(() => {
5584
+ if (!editor || !data.content || data.content === latestMd.current) return;
5585
+ latestMd.current = data.content;
5586
+ emittedMd.current = data.content;
5587
+ editor.commands.setContent(data.content);
5588
+ }, [editor, data.content]);
5589
+
5590
+ React.useEffect(() => {
5591
+ const host = hostRef.current;
5592
+ if (!host) return;
5593
+ const onDrop = (e: DragEvent) => {
5594
+ e.preventDefault();
5595
+ const files = Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/'));
5596
+ files.forEach(f => void uploadFile(f));
5597
+ };
5598
+ const onPaste = (e: ClipboardEvent) => {
5599
+ const files = Array.from(e.clipboardData?.files ?? []).filter(f => f.type.startsWith('image/'));
5600
+ if (!files.length) return;
5601
+ e.preventDefault();
5602
+ files.forEach(f => void uploadFile(f));
5603
+ };
5604
+ host.addEventListener('drop', onDrop);
5605
+ host.addEventListener('paste', onPaste);
5606
+ return () => { host.removeEventListener('drop', onDrop); host.removeEventListener('paste', onPaste); };
5607
+ }, [uploadFile]);
5608
+
5609
+ const openLink = () => {
5610
+ const existing = editor?.getAttributes('link').href ?? '';
5611
+ setLinkUrl(existing);
5612
+ setLinkOpen(true);
5613
+ setTimeout(() => linkInputRef.current?.focus(), 50);
5614
+ };
5615
+
5616
+ const applyLink = () => {
5617
+ if (!editor) return;
5618
+ const url = linkUrl.trim();
5619
+ if (url) editor.chain().focus().setLink({ href: url }).run();
5620
+ else editor.chain().focus().unsetLink().run();
5621
+ setLinkOpen(false);
5622
+ };
5623
+
5624
+ const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
5625
+ const file = e.target.files?.[0];
5626
+ e.target.value = '';
5627
+ if (!file) return;
5628
+ const pickId = pendingPickerId.current;
5629
+ void (async () => {
5630
+ try {
5631
+ if (pickId) { await doUpload(pickId, file); pendingPickerId.current = null; }
5632
+ else { await uploadFile(file); }
5633
+ } catch { pendingPickerId.current = null; }
5634
+ })();
5635
+ };
5636
+
5637
+ const onPickImage = () => {
5638
+ if (pendingPickerId.current) return;
5639
+ const id = crypto.randomUUID();
5640
+ pendingPickerId.current = id;
5641
+ insertPlaceholder(id, PICKER_SRC, true);
5642
+ };
5643
+
5644
+ const isActive = (name: string, attrs?: Record<string, unknown>) => editor?.isActive(name, attrs) ?? false;
5645
+
5646
+ return (
5647
+ <div ref={hostRef} data-jp-field="content" className="space-y-2">
5648
+ {editor && (
5649
+ <div data-jp-ignore-select="true" className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950">
5650
+ <div className="flex flex-wrap items-center justify-center gap-1 p-2">
5651
+ <Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}><Undo2 size={13} /></Btn>
5652
+ <Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}><Redo2 size={13} /></Btn>
5653
+ <Sep />
5654
+ <Btn active={isActive('paragraph')} title="Paragraph" onClick={() => editor.chain().focus().setParagraph().run()}>P</Btn>
5655
+ <Btn active={isActive('heading', { level: 1 })} title="Heading 1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</Btn>
5656
+ <Btn active={isActive('heading', { level: 2 })} title="Heading 2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</Btn>
5657
+ <Btn active={isActive('heading', { level: 3 })} title="Heading 3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</Btn>
5658
+ <Sep />
5659
+ <Btn active={isActive('bold')} title="Bold (Ctrl+B)" onClick={() => editor.chain().focus().toggleBold().run()}><Bold size={13} /></Btn>
5660
+ <Btn active={isActive('italic')} title="Italic (Ctrl+I)" onClick={() => editor.chain().focus().toggleItalic().run()}><Italic size={13} /></Btn>
5661
+ <Btn active={isActive('strike')} title="Strikethrough" onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough size={13} /></Btn>
5662
+ <Btn active={isActive('code')} title="Inline code" onClick={() => editor.chain().focus().toggleCode().run()}><Code2 size={13} /></Btn>
5663
+ <Sep />
5664
+ <Btn active={isActive('bulletList')} title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}><List size={13} /></Btn>
5665
+ <Btn active={isActive('orderedList')} title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered size={13} /></Btn>
5666
+ <Btn active={isActive('blockquote')} title="Blockquote" onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></Btn>
5667
+ <Btn active={isActive('codeBlock')} title="Code block" onClick={() => editor.chain().focus().toggleCodeBlock().run()}><SquareCode size={13} /></Btn>
5668
+ <Sep />
5669
+ <Btn active={isActive('link') || linkOpen} title="Set link" onClick={openLink}><Link2 size={13} /></Btn>
5670
+ <Btn title="Remove link" onClick={() => editor.chain().focus().unsetLink().run()}><Unlink2 size={13} /></Btn>
5671
+ <Btn title="Insert image" onClick={onPickImage}><ImagePlus size={13} /></Btn>
5672
+ <Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser size={13} /></Btn>
5673
+ </div>
5674
+ {linkOpen && (
5675
+ <div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
5676
+ <Link2 size={12} className="shrink-0 text-zinc-500" />
5677
+ <input ref={linkInputRef} type="url" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
5678
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink(); } if (e.key === 'Escape') setLinkOpen(false); }}
5679
+ placeholder="https://example.com"
5680
+ className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none" />
5681
+ <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={applyLink} className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors">Set</button>
5682
+ <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => setLinkOpen(false)} className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors">Cancel</button>
5683
+ </div>
5684
+ )}
5685
+ </div>
5686
+ )}
5687
+ <EditorContent editor={editor} className="jp-simple-editor" />
5688
+ <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileSelected} />
5689
+ </div>
5690
+ );
5691
+ };
5692
+
5693
+ // ── Public view ───────────────────────────────────────────────────
5694
+ const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
5695
+ <article className="jp-tiptap-content" data-jp-field="content">
5696
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
5697
+ {content}
5698
+ </ReactMarkdown>
5699
+ </article>
5700
+ );
5701
+
5702
+ // ── Export ────────────────────────────────────────────────────────
5703
+ export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
5704
+ const { mode } = useStudio();
5705
+ return (
5706
+ <section
5707
+ style={{ '--local-bg': 'var(--background)', '--local-text': 'var(--foreground)' } as React.CSSProperties}
5708
+ className="relative z-0 w-full py-12 bg-[var(--local-bg)]"
5709
+ >
5710
+ <div className="max-w-3xl mx-auto px-6">
5711
+ {mode === 'studio' ? (
5712
+ <StudioTiptapEditor data={data} />
5713
+ ) : (
5714
+ <PublicTiptapContent content={data.content ?? ''} />
5715
+ )}
5716
+ </div>
5717
+ </section>
5718
+ );
5719
+ };
5720
+
5721
+ END_OF_FILE_CONTENT
5722
+ echo "Creating src/components/tiptap/index.ts..."
5723
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/index.ts"
5724
+ export * from './View';
5725
+ export * from './schema';
5726
+ export * from './types';
5727
+
5728
+ END_OF_FILE_CONTENT
5729
+ echo "Creating src/components/tiptap/schema.ts..."
5730
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/schema.ts"
5731
+ import { z } from 'zod';
5732
+ import { BaseSectionData } from '@olonjs/core';
5733
+
5734
+ export const TiptapSchema = BaseSectionData.extend({
5735
+ content: z.string().default('').describe('ui:editorial-markdown'),
5736
+ });
5737
+
5738
+ export const TiptapSettingsSchema = z.object({});
5739
+
5740
+ END_OF_FILE_CONTENT
5741
+ echo "Creating src/components/tiptap/types.ts..."
5742
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/types.ts"
5743
+ import { z } from 'zod';
5744
+ import { TiptapSchema, TiptapSettingsSchema } from './schema';
5745
+
5746
+ export type TiptapData = z.infer<typeof TiptapSchema>;
5747
+ export type TiptapSettings = z.infer<typeof TiptapSettingsSchema>;
5748
+
5147
5749
  END_OF_FILE_CONTENT
5148
5750
  mkdir -p "src/components/ui"
5149
5751
  echo "Creating src/components/ui/OlonMark.tsx..."
@@ -9880,6 +10482,17 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
9880
10482
  }
9881
10483
 
9882
10484
  END_OF_FILE_CONTENT
10485
+ echo "Creating src/lib/utils.ts..."
10486
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
10487
+ import { clsx, type ClassValue } from 'clsx';
10488
+ import { twMerge } from 'tailwind-merge';
10489
+
10490
+ export function cn(...inputs: ClassValue[]) {
10491
+ return twMerge(clsx(inputs));
10492
+ }
10493
+
10494
+ END_OF_FILE_CONTENT
10495
+ # SKIP: src/lib/utils.ts:Zone.Identifier is binary and cannot be embedded as text.
9883
10496
  echo "Creating src/main.tsx..."
9884
10497
  cat << 'END_OF_FILE_CONTENT' > "src/main.tsx"
9885
10498
  import '@/types'; // TBP: load type augmentation from capsule-driven types
@@ -1674,7 +1674,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
1674
1674
  "@tiptap/extension-link": "^2.11.5",
1675
1675
  "@tiptap/react": "^2.11.5",
1676
1676
  "@tiptap/starter-kit": "^2.11.5",
1677
- "@olonjs/core": "^1.0.122",
1677
+ "@olonjs/core": "^1.0.124",
1678
1678
  "class-variance-authority": "^0.7.1",
1679
1679
  "clsx": "^2.1.1",
1680
1680
  "lucide-react": "^0.474.0",
@@ -5144,6 +5144,608 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/save-drawer/saverStyle.css"
5144
5144
  }
5145
5145
 
5146
5146
 
5147
+ END_OF_FILE_CONTENT
5148
+ mkdir -p "src/components/tiptap"
5149
+ echo "Creating src/components/tiptap/INTEGRATION.md..."
5150
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/INTEGRATION.md"
5151
+ # Tiptap Editorial — Integration Guide
5152
+
5153
+ How to add the `tiptap` section to a new tenant.
5154
+
5155
+ ---
5156
+
5157
+ ## 1. Copy the component
5158
+
5159
+ Copy the entire folder into the new tenant:
5160
+
5161
+ ```
5162
+ src/components/tiptap/
5163
+ index.ts
5164
+ types.ts
5165
+ View.tsx
5166
+ ```
5167
+
5168
+ ---
5169
+
5170
+ ## 2. Install npm dependencies
5171
+
5172
+ Add to the tenant's `package.json` and run `npm install`:
5173
+
5174
+ ```json
5175
+ "@tiptap/extension-image": "^2.11.5",
5176
+ "@tiptap/extension-link": "^2.11.5",
5177
+ "@tiptap/react": "^2.11.5",
5178
+ "@tiptap/starter-kit": "^2.11.5",
5179
+ "react-markdown": "^9.0.1",
5180
+ "rehype-sanitize": "^6.0.0",
5181
+ "remark-gfm": "^4.0.1",
5182
+ "tiptap-markdown": "^0.8.10"
5183
+ ```
5184
+
5185
+ ---
5186
+
5187
+ ## 3. Add CSS to `src/index.css`
5188
+
5189
+ Two blocks are required — one for the public (visitor) view, one for the editor (studio) view.
5190
+
5191
+ ```css
5192
+ /* ==========================================================================
5193
+ TIPTAP — Public content typography (visitor view)
5194
+ ========================================================================== */
5195
+ .jp-tiptap-content > * + * { margin-top: 0.75em; }
5196
+
5197
+ .jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
5198
+ .jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
5199
+ .jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
5200
+ .jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
5201
+
5202
+ .jp-tiptap-content p { line-height: 1.7; }
5203
+ .jp-tiptap-content strong { font-weight: 700; }
5204
+ .jp-tiptap-content em { font-style: italic; }
5205
+ .jp-tiptap-content s { text-decoration: line-through; }
5206
+
5207
+ .jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
5208
+ .jp-tiptap-content a:hover { opacity: 0.8; }
5209
+
5210
+ .jp-tiptap-content code {
5211
+ font-family: var(--font-mono, ui-monospace, monospace);
5212
+ font-size: 0.875em;
5213
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
5214
+ border-radius: 0.25em;
5215
+ padding: 0.1em 0.35em;
5216
+ }
5217
+ .jp-tiptap-content pre {
5218
+ background: color-mix(in oklch, var(--background) 60%, black);
5219
+ border-radius: 0.5em;
5220
+ padding: 1em 1.25em;
5221
+ overflow-x: auto;
5222
+ }
5223
+ .jp-tiptap-content pre code { background: none; padding: 0; }
5224
+
5225
+ .jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
5226
+ .jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
5227
+ .jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
5228
+ .jp-tiptap-content li + li { margin-top: 0.25em; }
5229
+
5230
+ .jp-tiptap-content blockquote {
5231
+ border-left: 3px solid var(--border);
5232
+ padding-left: 1em;
5233
+ color: var(--muted-foreground);
5234
+ font-style: italic;
5235
+ }
5236
+ .jp-tiptap-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
5237
+ .jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
5238
+
5239
+ /* ==========================================================================
5240
+ TIPTAP / PROSEMIRROR — Editor typography (studio view)
5241
+ ========================================================================== */
5242
+ .jp-simple-editor .ProseMirror { outline: none; word-break: break-word; }
5243
+ .jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
5244
+
5245
+ .jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
5246
+ .jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
5247
+ .jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
5248
+ .jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
5249
+
5250
+ .jp-simple-editor .ProseMirror p { line-height: 1.7; }
5251
+ .jp-simple-editor .ProseMirror strong { font-weight: 700; }
5252
+ .jp-simple-editor .ProseMirror em { font-style: italic; }
5253
+ .jp-simple-editor .ProseMirror s { text-decoration: line-through; }
5254
+
5255
+ .jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
5256
+ .jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
5257
+
5258
+ .jp-simple-editor .ProseMirror code {
5259
+ font-family: var(--font-mono, ui-monospace, monospace);
5260
+ font-size: 0.875em;
5261
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
5262
+ border-radius: 0.25em;
5263
+ padding: 0.1em 0.35em;
5264
+ }
5265
+ .jp-simple-editor .ProseMirror pre {
5266
+ background: color-mix(in oklch, var(--background) 60%, black);
5267
+ border-radius: 0.5em;
5268
+ padding: 1em 1.25em;
5269
+ overflow-x: auto;
5270
+ }
5271
+ .jp-simple-editor .ProseMirror pre code { background: none; padding: 0; }
5272
+
5273
+ .jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
5274
+ .jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
5275
+ .jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
5276
+ .jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
5277
+
5278
+ .jp-simple-editor .ProseMirror blockquote {
5279
+ border-left: 3px solid var(--border);
5280
+ padding-left: 1em;
5281
+ color: var(--muted-foreground);
5282
+ font-style: italic;
5283
+ }
5284
+ .jp-simple-editor .ProseMirror hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
5285
+
5286
+ .jp-simple-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.5rem; }
5287
+ .jp-simple-editor .ProseMirror img[data-uploading="true"] {
5288
+ opacity: 0.6;
5289
+ filter: grayscale(0.25);
5290
+ outline: 2px dashed rgb(59 130 246 / 0.7);
5291
+ outline-offset: 2px;
5292
+ }
5293
+ .jp-simple-editor .ProseMirror img[data-upload-error="true"] {
5294
+ outline: 2px solid rgb(239 68 68 / 0.8);
5295
+ outline-offset: 2px;
5296
+ }
5297
+ .jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
5298
+ content: attr(data-placeholder);
5299
+ color: var(--muted-foreground);
5300
+ opacity: 0.5;
5301
+ pointer-events: none;
5302
+ float: left;
5303
+ height: 0;
5304
+ }
5305
+ ```
5306
+
5307
+ ---
5308
+
5309
+ ## 4. Register in `src/lib/schemas.ts`
5310
+
5311
+ ```ts
5312
+ import { TiptapSchema } from '@/components/tiptap';
5313
+
5314
+ export const SECTION_SCHEMAS = {
5315
+ // ... existing schemas
5316
+ 'tiptap': TiptapSchema,
5317
+ } as const;
5318
+ ```
5319
+
5320
+ ---
5321
+
5322
+ ## 5. Register in `src/lib/addSectionConfig.ts`
5323
+
5324
+ ```ts
5325
+ const addableSectionTypes = [
5326
+ // ... existing types
5327
+ 'tiptap',
5328
+ ] as const;
5329
+
5330
+ const sectionTypeLabels = {
5331
+ // ... existing labels
5332
+ 'tiptap': 'Tiptap Editorial',
5333
+ };
5334
+
5335
+ function getDefaultSectionData(type: string) {
5336
+ switch (type) {
5337
+ // ... existing cases
5338
+ case 'tiptap': return { content: '# Post title\n\nStart writing in Markdown...' };
5339
+ }
5340
+ }
5341
+ ```
5342
+
5343
+ ---
5344
+
5345
+ ## 6. Register in `src/lib/ComponentRegistry.tsx`
5346
+
5347
+ ```tsx
5348
+ import { Tiptap } from '@/components/tiptap';
5349
+
5350
+ export const ComponentRegistry = {
5351
+ // ... existing components
5352
+ 'tiptap': Tiptap,
5353
+ };
5354
+ ```
5355
+
5356
+ ---
5357
+
5358
+ ## 7. Register in `src/types.ts`
5359
+
5360
+ ```ts
5361
+ import type { TiptapData, TiptapSettings } from '@/components/tiptap';
5362
+
5363
+ export type SectionComponentPropsMap = {
5364
+ // ... existing entries
5365
+ 'tiptap': { data: TiptapData; settings?: TiptapSettings };
5366
+ };
5367
+
5368
+ declare module '@jsonpages/core' {
5369
+ export interface SectionDataRegistry {
5370
+ // ... existing entries
5371
+ 'tiptap': TiptapData;
5372
+ }
5373
+ export interface SectionSettingsRegistry {
5374
+ // ... existing entries
5375
+ 'tiptap': TiptapSettings;
5376
+ }
5377
+ }
5378
+ ```
5379
+
5380
+ ---
5381
+
5382
+ ## Notes
5383
+
5384
+ - Typography uses tenant CSS variables (`--primary`, `--border`, `--muted-foreground`, `--font-mono`) — no hardcoded colors.
5385
+ - `@tailwindcss/typography` is **not** required; the CSS blocks above replace it.
5386
+ - The toolbar is admin-only (studio mode). In visitor mode, content is rendered via `ReactMarkdown`.
5387
+ - Underline is intentionally excluded: `tiptap-markdown` with `html: false` cannot round-trip `<u>` tags.
5388
+
5389
+ END_OF_FILE_CONTENT
5390
+ echo "Creating src/components/tiptap/View.tsx..."
5391
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/View.tsx"
5392
+ import React from 'react';
5393
+ import ReactMarkdown from 'react-markdown';
5394
+ import remarkGfm from 'remark-gfm';
5395
+ import rehypeSanitize from 'rehype-sanitize';
5396
+ import { useEditor, EditorContent } from '@tiptap/react';
5397
+ import type { Editor } from '@tiptap/react';
5398
+ import StarterKit from '@tiptap/starter-kit';
5399
+ import Link from '@tiptap/extension-link';
5400
+ import Image from '@tiptap/extension-image';
5401
+ import { Markdown } from 'tiptap-markdown';
5402
+ import {
5403
+ Undo2, Redo2,
5404
+ List, ListOrdered,
5405
+ Bold, Italic, Strikethrough,
5406
+ Code2, Quote, SquareCode,
5407
+ Link2, Unlink2, ImagePlus, Eraser,
5408
+ } from 'lucide-react';
5409
+ import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
5410
+ import type { TiptapData, TiptapSettings } from './types';
5411
+
5412
+ // ── UI primitives ─────────────────────────────────────────────────
5413
+ const Btn: React.FC<{
5414
+ active?: boolean; title: string; onClick: () => void; children: React.ReactNode;
5415
+ }> = ({ active = false, title, onClick, children }) => (
5416
+ <button
5417
+ type="button" title={title}
5418
+ onMouseDown={(e) => e.preventDefault()} onClick={onClick}
5419
+ className={[
5420
+ 'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
5421
+ active ? 'bg-zinc-700/70 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
5422
+ ].join(' ')}
5423
+ >{children}</button>
5424
+ );
5425
+
5426
+ const Sep: React.FC = () => (
5427
+ <span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
5428
+ );
5429
+
5430
+ // ── Image extension with upload metadata ──────────────────────────
5431
+ const UploadableImage = Image.extend({
5432
+ addAttributes() {
5433
+ const bool = (attr: string) => ({
5434
+ default: false,
5435
+ parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
5436
+ renderHTML: (attrs: Record<string, unknown>) =>
5437
+ attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())]
5438
+ ? { [attr]: 'true' } : {},
5439
+ });
5440
+ return {
5441
+ ...this.parent?.(),
5442
+ uploadId: {
5443
+ default: null,
5444
+ parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
5445
+ renderHTML: (attrs: Record<string, unknown>) =>
5446
+ attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
5447
+ },
5448
+ uploading: bool('data-uploading'),
5449
+ uploadError: bool('data-upload-error'),
5450
+ awaitingUpload: bool('data-awaiting-upload'),
5451
+ };
5452
+ },
5453
+ });
5454
+
5455
+ // ── Helpers ───────────────────────────────────────────────────────
5456
+ const getMarkdown = (ed: Editor | null | undefined): string =>
5457
+ (ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
5458
+ ?.markdown?.getMarkdown?.() ?? '';
5459
+
5460
+ const svg = (body: string) =>
5461
+ 'data:image/svg+xml;utf8,' +
5462
+ encodeURIComponent(
5463
+ '<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'1200\' height=\'420\' viewBox=\'0 0 1200 420\'>' + body + '</svg>'
5464
+ );
5465
+
5466
+ const RECT = '<rect width=\'1200\' height=\'420\' fill=\'#090B14\' stroke=\'#3F3F46\' stroke-width=\'3\' stroke-dasharray=\'10 10\' rx=\'12\'/>';
5467
+
5468
+ const UPLOADING_SRC = svg(
5469
+ RECT + '<text x=\'600\' y=\'215\' font-family=\'Inter,Arial,sans-serif\' font-size=\'28\' font-weight=\'700\' fill=\'#A1A1AA\' text-anchor=\'middle\'>Uploading image\u2026</text>'
5470
+ );
5471
+
5472
+ const PICKER_SRC = svg(
5473
+ RECT +
5474
+ '<text x=\'600\' y=\'200\' font-family=\'Inter,Arial,sans-serif\' font-size=\'32\' font-weight=\'700\' fill=\'#E4E4E7\' text-anchor=\'middle\'>Click to upload or drag &amp; drop</text>' +
5475
+ '<text x=\'600\' y=\'248\' font-family=\'Inter,Arial,sans-serif\' font-size=\'22\' fill=\'#A1A1AA\' text-anchor=\'middle\'>Max 5 MB per file</text>'
5476
+ );
5477
+
5478
+ const patchImage = (ed: Editor, uploadId: string, patch: Record<string, unknown>): boolean => {
5479
+ let pos: number | null = null;
5480
+ ed.state.doc.descendants(
5481
+ (node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
5482
+ if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) { pos = p; return false; }
5483
+ return true;
5484
+ }
5485
+ );
5486
+ if (pos == null) return false;
5487
+ const cur = ed.state.doc.nodeAt(pos);
5488
+ if (!cur) return false;
5489
+ ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
5490
+ return true;
5491
+ };
5492
+
5493
+ const EXTENSIONS = [
5494
+ StarterKit,
5495
+ Link.configure({ openOnClick: false, autolink: true }),
5496
+ UploadableImage,
5497
+ Markdown.configure({ html: false }),
5498
+ ];
5499
+
5500
+ // ── Studio editor ─────────────────────────────────────────────────
5501
+ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
5502
+ const { assets } = useConfig();
5503
+ const hostRef = React.useRef<HTMLDivElement | null>(null);
5504
+ const sectionRef = React.useRef<HTMLElement | null>(null);
5505
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null);
5506
+ const editorRef = React.useRef<Editor | null>(null);
5507
+ const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
5508
+ const pendingPickerId = React.useRef<string | null>(null);
5509
+ const latestMd = React.useRef<string>(data.content ?? '');
5510
+ const emittedMd = React.useRef<string>(data.content ?? '');
5511
+ const [linkOpen, setLinkOpen] = React.useState(false);
5512
+ const [linkUrl, setLinkUrl] = React.useState('');
5513
+ const linkInputRef = React.useRef<HTMLInputElement | null>(null);
5514
+
5515
+ const getSectionId = React.useCallback((): string | null => {
5516
+ const el = sectionRef.current ?? (hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
5517
+ sectionRef.current = el;
5518
+ return el?.getAttribute('data-section-id') ?? null;
5519
+ }, []);
5520
+
5521
+ const emit = React.useCallback((markdown: string) => {
5522
+ latestMd.current = markdown;
5523
+ const sectionId = getSectionId();
5524
+ if (!sectionId) return;
5525
+ window.parent.postMessage({ type: STUDIO_EVENTS.INLINE_FIELD_UPDATE, sectionId, fieldKey: 'content', value: markdown }, window.location.origin);
5526
+ emittedMd.current = markdown;
5527
+ }, [getSectionId]);
5528
+
5529
+ const setFocusLock = React.useCallback((on: boolean) => {
5530
+ sectionRef.current?.classList.toggle('jp-editorial-focus', on);
5531
+ }, []);
5532
+
5533
+ const insertPlaceholder = React.useCallback((uploadId: string, src: string, awaitingUpload: boolean) => {
5534
+ const ed = editorRef.current;
5535
+ if (!ed) return;
5536
+ ed.chain().focus().setImage({ src, alt: 'upload-placeholder', title: awaitingUpload ? 'Click to upload' : 'Uploading\u2026', uploadId, uploading: !awaitingUpload, awaitingUpload, uploadError: false } as any).run();
5537
+ emit(getMarkdown(ed));
5538
+ }, [emit]);
5539
+
5540
+ const doUpload = React.useCallback(async (uploadId: string, file: File) => {
5541
+ const uploadFn = assets?.onAssetUpload;
5542
+ if (!uploadFn) return;
5543
+ const ed = editorRef.current;
5544
+ if (!ed) return;
5545
+ patchImage(ed, uploadId, { src: UPLOADING_SRC, alt: file.name, title: file.name, uploading: true, awaitingUpload: false, uploadError: false });
5546
+ const task = (async () => {
5547
+ try {
5548
+ const url = await uploadFn(file);
5549
+ const cur = editorRef.current;
5550
+ if (cur) { patchImage(cur, uploadId, { src: url, alt: file.name, title: file.name, uploadId: null, uploading: false, awaitingUpload: false, uploadError: false }); emit(getMarkdown(cur)); }
5551
+ } catch {
5552
+ const cur = editorRef.current;
5553
+ if (cur) { patchImage(cur, uploadId, { uploading: false, awaitingUpload: false, uploadError: true }); emit(getMarkdown(cur)); }
5554
+ } finally { pendingUploads.current.delete(uploadId); }
5555
+ })();
5556
+ pendingUploads.current.set(uploadId, task);
5557
+ }, [assets, emit]);
5558
+
5559
+ const uploadFile = React.useCallback(async (file: File) => {
5560
+ const id = crypto.randomUUID();
5561
+ insertPlaceholder(id, UPLOADING_SRC, false);
5562
+ await doUpload(id, file);
5563
+ }, [insertPlaceholder, doUpload]);
5564
+
5565
+ const editor = useEditor({
5566
+ extensions: EXTENSIONS,
5567
+ content: data.content ?? '',
5568
+ editorProps: { attributes: { class: 'min-h-[220px] p-4 outline-none' } },
5569
+ onUpdate({ editor: ed }) { emit(getMarkdown(ed)); },
5570
+ onFocus() { setFocusLock(true); },
5571
+ onBlur() {
5572
+ setTimeout(() => {
5573
+ if (!hostRef.current?.contains(document.activeElement)) setFocusLock(false);
5574
+ }, 100);
5575
+ },
5576
+ onCreate({ editor: ed }) {
5577
+ editorRef.current = ed;
5578
+ if (data.content) ed.commands.setContent(data.content);
5579
+ },
5580
+ onDestroy() { editorRef.current = null; },
5581
+ });
5582
+
5583
+ React.useEffect(() => {
5584
+ if (!editor || !data.content || data.content === latestMd.current) return;
5585
+ latestMd.current = data.content;
5586
+ emittedMd.current = data.content;
5587
+ editor.commands.setContent(data.content);
5588
+ }, [editor, data.content]);
5589
+
5590
+ React.useEffect(() => {
5591
+ const host = hostRef.current;
5592
+ if (!host) return;
5593
+ const onDrop = (e: DragEvent) => {
5594
+ e.preventDefault();
5595
+ const files = Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/'));
5596
+ files.forEach(f => void uploadFile(f));
5597
+ };
5598
+ const onPaste = (e: ClipboardEvent) => {
5599
+ const files = Array.from(e.clipboardData?.files ?? []).filter(f => f.type.startsWith('image/'));
5600
+ if (!files.length) return;
5601
+ e.preventDefault();
5602
+ files.forEach(f => void uploadFile(f));
5603
+ };
5604
+ host.addEventListener('drop', onDrop);
5605
+ host.addEventListener('paste', onPaste);
5606
+ return () => { host.removeEventListener('drop', onDrop); host.removeEventListener('paste', onPaste); };
5607
+ }, [uploadFile]);
5608
+
5609
+ const openLink = () => {
5610
+ const existing = editor?.getAttributes('link').href ?? '';
5611
+ setLinkUrl(existing);
5612
+ setLinkOpen(true);
5613
+ setTimeout(() => linkInputRef.current?.focus(), 50);
5614
+ };
5615
+
5616
+ const applyLink = () => {
5617
+ if (!editor) return;
5618
+ const url = linkUrl.trim();
5619
+ if (url) editor.chain().focus().setLink({ href: url }).run();
5620
+ else editor.chain().focus().unsetLink().run();
5621
+ setLinkOpen(false);
5622
+ };
5623
+
5624
+ const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
5625
+ const file = e.target.files?.[0];
5626
+ e.target.value = '';
5627
+ if (!file) return;
5628
+ const pickId = pendingPickerId.current;
5629
+ void (async () => {
5630
+ try {
5631
+ if (pickId) { await doUpload(pickId, file); pendingPickerId.current = null; }
5632
+ else { await uploadFile(file); }
5633
+ } catch { pendingPickerId.current = null; }
5634
+ })();
5635
+ };
5636
+
5637
+ const onPickImage = () => {
5638
+ if (pendingPickerId.current) return;
5639
+ const id = crypto.randomUUID();
5640
+ pendingPickerId.current = id;
5641
+ insertPlaceholder(id, PICKER_SRC, true);
5642
+ };
5643
+
5644
+ const isActive = (name: string, attrs?: Record<string, unknown>) => editor?.isActive(name, attrs) ?? false;
5645
+
5646
+ return (
5647
+ <div ref={hostRef} data-jp-field="content" className="space-y-2">
5648
+ {editor && (
5649
+ <div data-jp-ignore-select="true" className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950">
5650
+ <div className="flex flex-wrap items-center justify-center gap-1 p-2">
5651
+ <Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}><Undo2 size={13} /></Btn>
5652
+ <Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}><Redo2 size={13} /></Btn>
5653
+ <Sep />
5654
+ <Btn active={isActive('paragraph')} title="Paragraph" onClick={() => editor.chain().focus().setParagraph().run()}>P</Btn>
5655
+ <Btn active={isActive('heading', { level: 1 })} title="Heading 1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</Btn>
5656
+ <Btn active={isActive('heading', { level: 2 })} title="Heading 2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</Btn>
5657
+ <Btn active={isActive('heading', { level: 3 })} title="Heading 3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</Btn>
5658
+ <Sep />
5659
+ <Btn active={isActive('bold')} title="Bold (Ctrl+B)" onClick={() => editor.chain().focus().toggleBold().run()}><Bold size={13} /></Btn>
5660
+ <Btn active={isActive('italic')} title="Italic (Ctrl+I)" onClick={() => editor.chain().focus().toggleItalic().run()}><Italic size={13} /></Btn>
5661
+ <Btn active={isActive('strike')} title="Strikethrough" onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough size={13} /></Btn>
5662
+ <Btn active={isActive('code')} title="Inline code" onClick={() => editor.chain().focus().toggleCode().run()}><Code2 size={13} /></Btn>
5663
+ <Sep />
5664
+ <Btn active={isActive('bulletList')} title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}><List size={13} /></Btn>
5665
+ <Btn active={isActive('orderedList')} title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered size={13} /></Btn>
5666
+ <Btn active={isActive('blockquote')} title="Blockquote" onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></Btn>
5667
+ <Btn active={isActive('codeBlock')} title="Code block" onClick={() => editor.chain().focus().toggleCodeBlock().run()}><SquareCode size={13} /></Btn>
5668
+ <Sep />
5669
+ <Btn active={isActive('link') || linkOpen} title="Set link" onClick={openLink}><Link2 size={13} /></Btn>
5670
+ <Btn title="Remove link" onClick={() => editor.chain().focus().unsetLink().run()}><Unlink2 size={13} /></Btn>
5671
+ <Btn title="Insert image" onClick={onPickImage}><ImagePlus size={13} /></Btn>
5672
+ <Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser size={13} /></Btn>
5673
+ </div>
5674
+ {linkOpen && (
5675
+ <div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
5676
+ <Link2 size={12} className="shrink-0 text-zinc-500" />
5677
+ <input ref={linkInputRef} type="url" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
5678
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink(); } if (e.key === 'Escape') setLinkOpen(false); }}
5679
+ placeholder="https://example.com"
5680
+ className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none" />
5681
+ <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={applyLink} className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors">Set</button>
5682
+ <button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => setLinkOpen(false)} className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors">Cancel</button>
5683
+ </div>
5684
+ )}
5685
+ </div>
5686
+ )}
5687
+ <EditorContent editor={editor} className="jp-simple-editor" />
5688
+ <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileSelected} />
5689
+ </div>
5690
+ );
5691
+ };
5692
+
5693
+ // ── Public view ───────────────────────────────────────────────────
5694
+ const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
5695
+ <article className="jp-tiptap-content" data-jp-field="content">
5696
+ <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
5697
+ {content}
5698
+ </ReactMarkdown>
5699
+ </article>
5700
+ );
5701
+
5702
+ // ── Export ────────────────────────────────────────────────────────
5703
+ export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
5704
+ const { mode } = useStudio();
5705
+ return (
5706
+ <section
5707
+ style={{ '--local-bg': 'var(--background)', '--local-text': 'var(--foreground)' } as React.CSSProperties}
5708
+ className="relative z-0 w-full py-12 bg-[var(--local-bg)]"
5709
+ >
5710
+ <div className="max-w-3xl mx-auto px-6">
5711
+ {mode === 'studio' ? (
5712
+ <StudioTiptapEditor data={data} />
5713
+ ) : (
5714
+ <PublicTiptapContent content={data.content ?? ''} />
5715
+ )}
5716
+ </div>
5717
+ </section>
5718
+ );
5719
+ };
5720
+
5721
+ END_OF_FILE_CONTENT
5722
+ echo "Creating src/components/tiptap/index.ts..."
5723
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/index.ts"
5724
+ export * from './View';
5725
+ export * from './schema';
5726
+ export * from './types';
5727
+
5728
+ END_OF_FILE_CONTENT
5729
+ echo "Creating src/components/tiptap/schema.ts..."
5730
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/schema.ts"
5731
+ import { z } from 'zod';
5732
+ import { BaseSectionData } from '@olonjs/core';
5733
+
5734
+ export const TiptapSchema = BaseSectionData.extend({
5735
+ content: z.string().default('').describe('ui:editorial-markdown'),
5736
+ });
5737
+
5738
+ export const TiptapSettingsSchema = z.object({});
5739
+
5740
+ END_OF_FILE_CONTENT
5741
+ echo "Creating src/components/tiptap/types.ts..."
5742
+ cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/types.ts"
5743
+ import { z } from 'zod';
5744
+ import { TiptapSchema, TiptapSettingsSchema } from './schema';
5745
+
5746
+ export type TiptapData = z.infer<typeof TiptapSchema>;
5747
+ export type TiptapSettings = z.infer<typeof TiptapSettingsSchema>;
5748
+
5147
5749
  END_OF_FILE_CONTENT
5148
5750
  mkdir -p "src/components/ui"
5149
5751
  echo "Creating src/components/ui/OlonMark.tsx..."
@@ -9880,6 +10482,17 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
9880
10482
  }
9881
10483
 
9882
10484
  END_OF_FILE_CONTENT
10485
+ echo "Creating src/lib/utils.ts..."
10486
+ cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
10487
+ import { clsx, type ClassValue } from 'clsx';
10488
+ import { twMerge } from 'tailwind-merge';
10489
+
10490
+ export function cn(...inputs: ClassValue[]) {
10491
+ return twMerge(clsx(inputs));
10492
+ }
10493
+
10494
+ END_OF_FILE_CONTENT
10495
+ # SKIP: src/lib/utils.ts:Zone.Identifier is binary and cannot be embedded as text.
9883
10496
  echo "Creating src/main.tsx..."
9884
10497
  cat << 'END_OF_FILE_CONTENT' > "src/main.tsx"
9885
10498
  import '@/types'; // TBP: load type augmentation from capsule-driven types
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olonjs/cli",
3
- "version": "3.0.131",
3
+ "version": "3.0.133",
4
4
  "description": "The Sovereign CLI Engine for OlonJS.",
5
5
  "type": "module",
6
6
  "bin": {