mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 21:53:40 +02:00
Add Documents app: Tiptap+Yjs editor, autosave, NIP-23 publish
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
a3a245a7a5
commit
af15e6a9a7
22
docs/apps.md
22
docs/apps.md
@@ -89,7 +89,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
}
|
||||
```
|
||||
|
||||
## The eleven apps
|
||||
## The twelve apps
|
||||
|
||||
| App | `id` | Params | Notes |
|
||||
|---|---|---|---|
|
||||
@@ -97,6 +97,7 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
| Profile | `profile` | `pubkey`, `relays?` | kind 0 metadata, the author's notes, follow/unfollow |
|
||||
| Note | `notes` | `id?`, `relays?` | One note and its replies, or a blank local draft when `id` is absent. **Not** a singleton |
|
||||
| Reader | `articles` | `pubkey?`, `identifier?`, `kind?`, `relays?` | NIP-23 long-form, `react-markdown`, NIP-84 highlights |
|
||||
| Documents | `documents` | `doc?` | Tiptap + Yjs rich-text editor, autosaves to IndexedDB, explicit NIP-23 publish. Shows a login prompt when signed out |
|
||||
| Bookmarks | `bookmarks` | — | NIP-51 kind 10003 list — bookmarked notes and articles |
|
||||
| Web Bookmarks | `web-bookmarks` | — | NIP-B0 kind 39701 — one addressable event per saved URL |
|
||||
| Live | `live` | `pubkey?`, `identifier?` | NIP-53 kind 30311 live events + kind 1311 chat |
|
||||
@@ -105,6 +106,25 @@ export default function ExampleApp({ setTitle }: AppProps) {
|
||||
| Settings | `settings` | — | Theme, relay list, Blossom servers, account, session |
|
||||
| About | `about` | — | What this is, the app list, the shortcuts |
|
||||
|
||||
### Documents keeps the live draft local, publishes a snapshot
|
||||
|
||||
The Documents app (`src/apps/documents`, `src/lib/documents`) is the solo-editing phase of a
|
||||
larger collaborative-editor plan. The working draft is a **Yjs document** persisted to
|
||||
IndexedDB (`openDocumentSession` in `src/lib/documents/ydoc.ts`) and edited through Tiptap's
|
||||
`Collaboration` extension (`useDocumentEditor`). That extension is the exact seam where a
|
||||
Hocuspocus-style WebSocket provider attaches later — the live draft never touches Nostr
|
||||
relays, and no custom event kinds are introduced.
|
||||
|
||||
Publishing is a deliberate owner action that serializes the document to portable Markdown
|
||||
(`src/lib/documents/markdown.ts`) and emits a NIP-23 kind 30023 snapshot
|
||||
(`buildPublishTemplate` in `src/lib/documents/publish.ts`). The snapshot is a one-way release
|
||||
that opens in the Reader; it never replaces or interrupts the live draft. Blossom attachments
|
||||
ride along as validated NIP-94 `imeta` metadata (`src/lib/documents/attachments.ts`).
|
||||
|
||||
Pasted and imported HTML passes an allowlist sanitizer (`src/lib/documents/sanitizeHtml.ts`)
|
||||
before the constrained Tiptap schema parses it, and link hrefs are restricted to the shared
|
||||
`sanitizeUrl` protocol allowlist — the editor never uses `dangerouslySetInnerHTML`.
|
||||
|
||||
### Spells are a third-party kind, adopted for interop
|
||||
|
||||
Kind `777` ("Spell") isn't in the official nostr-protocol/nips registry — it comes from
|
||||
|
||||
74
package-lock.json
generated
74
package-lock.json
generated
@@ -13,6 +13,8 @@
|
||||
"@nostrify/react": "^0.6.8",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@tiptap/extension-collaboration": "^3.31.3",
|
||||
"@tiptap/extension-table": "^3.31.3",
|
||||
"@tiptap/extensions": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
@@ -3719,6 +3721,22 @@
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-collaboration": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.31.3.tgz",
|
||||
"integrity": "sha512-JAwOXjeeDYghrPcK57dtvxwn06zcz50mxSSk4PaSdLxM8tkw8+udx+51ewqKKd3WlFof4zyMwUEbO/DGaUdEvw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3",
|
||||
"@tiptap/y-tiptap": "^3.0.7",
|
||||
"yjs": "^13"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz",
|
||||
@@ -3923,6 +3941,20 @@
|
||||
"@tiptap/core": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz",
|
||||
"integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.3",
|
||||
"@tiptap/pm": "3.31.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.3",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz",
|
||||
@@ -4051,6 +4083,27 @@
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/y-tiptap": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.9.tgz",
|
||||
"integrity": "sha512-7/El8NQ8R5V5MkdrOUdfj9IgZacpt0H071xNimX7B0AnYiWiKefQnMKd41neQYzo2MOXbWdN3iZ+7Z7BruzOSA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.100"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prosemirror-model": "^1.7.1",
|
||||
"prosemirror-state": "^1.2.3",
|
||||
"prosemirror-view": "^1.9.10",
|
||||
"y-protocols": "^1.0.1",
|
||||
"yjs": "^13.5.38"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
@@ -10647,6 +10700,27 @@
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y-protocols": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
|
||||
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.85"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"@nostrify/react": "^0.6.8",
|
||||
"@radix-ui/react-toast": "^1.2.1",
|
||||
"@tanstack/react-query": "^5.100.5",
|
||||
"@tiptap/extension-collaboration": "^3.31.3",
|
||||
"@tiptap/extension-table": "^3.31.3",
|
||||
"@tiptap/extensions": "^3.31.3",
|
||||
"@tiptap/react": "^3.31.3",
|
||||
"@tiptap/starter-kit": "^3.31.3",
|
||||
|
||||
90
src/apps/documents/DocumentEditor.test.tsx
Normal file
90
src/apps/documents/DocumentEditor.test.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { DocumentEditor } from './DocumentEditor';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import type { DocumentMeta } from '@/lib/documents/types';
|
||||
|
||||
function meta(overrides: Partial<DocumentMeta> = {}): DocumentMeta {
|
||||
return {
|
||||
id: 'doc-test-1',
|
||||
title: 'Test document',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
savedAt: 0,
|
||||
role: 'owner',
|
||||
archived: false,
|
||||
attachments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocumentEditor', () => {
|
||||
it('mounts the editor under Strict Mode without crashing', async () => {
|
||||
render(
|
||||
<StrictMode>
|
||||
<TestApp>
|
||||
<DocumentEditor
|
||||
meta={meta()}
|
||||
onRename={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUpdateMeta={() => {}}
|
||||
/>
|
||||
</TestApp>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.doc-editor')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the title, role and formatting toolbar', async () => {
|
||||
render(
|
||||
<TestApp>
|
||||
<DocumentEditor
|
||||
meta={meta()}
|
||||
onRename={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUpdateMeta={() => {}}
|
||||
/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle('Rename document')).toHaveTextContent('Test document');
|
||||
});
|
||||
expect(screen.getByRole('toolbar', { name: 'Formatting', hidden: true })).toBeInTheDocument();
|
||||
expect(screen.getByText('owner')).toBeInTheDocument();
|
||||
|
||||
// The editor surface is the labelled, editable document body.
|
||||
await waitFor(() => {
|
||||
const region = document.querySelector('.doc-editor');
|
||||
expect(region).toHaveAttribute('contenteditable', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
it('disables editing affordances for a viewer', async () => {
|
||||
render(
|
||||
<TestApp>
|
||||
<DocumentEditor
|
||||
meta={meta({ role: 'viewer' })}
|
||||
onRename={() => {}}
|
||||
onDelete={() => {}}
|
||||
onUpdateMeta={() => {}}
|
||||
/>
|
||||
</TestApp>,
|
||||
);
|
||||
|
||||
// The editor mounts read-only for a viewer.
|
||||
await waitFor(() => {
|
||||
const region = document.querySelector('.doc-editor');
|
||||
expect(region).toHaveAttribute('contenteditable', 'false');
|
||||
});
|
||||
|
||||
// Viewers cannot publish or rename.
|
||||
expect(screen.queryByRole('button', { name: /publish/i, hidden: true })).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle(/renaming is unavailable/i)).toBeDisabled();
|
||||
expect(screen.getByTitle(/bold/i)).toBeDisabled();
|
||||
});
|
||||
});
|
||||
448
src/apps/documents/DocumentEditor.tsx
Normal file
448
src/apps/documents/DocumentEditor.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { EditorContent } from '@tiptap/react';
|
||||
import {
|
||||
Check,
|
||||
CloudOff,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Send,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDocumentEditor } from '@/hooks/useDocumentEditor';
|
||||
import { useDocumentPublish } from '@/hooks/useDocumentPublish';
|
||||
import { useRelayHints } from '@/hooks/useRelayHints';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { deriveSummary } from '@/lib/documents/publish';
|
||||
import { ROLE_CAPABILITIES } from '@/lib/documents/types';
|
||||
import type { DocumentMeta, PublicationRecord, SaveState } from '@/lib/documents/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FormatToolbar } from './FormatToolbar';
|
||||
|
||||
interface DocumentEditorProps {
|
||||
meta: DocumentMeta;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onUpdateMeta: (id: string, patch: Partial<Omit<DocumentMeta, 'id'>>) => void;
|
||||
}
|
||||
|
||||
export function DocumentEditor({ meta, onRename, onDelete, onUpdateMeta }: DocumentEditorProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleAutosaved = useCallback(() => {
|
||||
onUpdateMeta(meta.id, { savedAt: Date.now(), updatedAt: Date.now() });
|
||||
}, [meta.id, onUpdateMeta]);
|
||||
|
||||
const { editor, saveState, words, canEdit, getMarkdown } = useDocumentEditor({
|
||||
documentId: meta.id,
|
||||
role: meta.role,
|
||||
onAutosaved: handleAutosaved,
|
||||
});
|
||||
|
||||
const capabilities = ROLE_CAPABILITIES[meta.role];
|
||||
|
||||
const [renameOpen, setRenameOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Document toolbar: title, save state, actions. */}
|
||||
<div className="flex h-11 shrink-0 items-center gap-2 border-b border-border px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setRenameOpen(true)}
|
||||
disabled={!canEdit}
|
||||
aria-label={canEdit ? 'Rename document' : `Document title (renaming requires editor access; you are a ${meta.role})`}
|
||||
title={canEdit ? 'Rename document' : `You have ${meta.role} access — renaming is unavailable`}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
canEdit ? 'hover:bg-muted' : 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-[13px] font-medium">{meta.title}</span>
|
||||
{canEdit && <Pencil className="size-3 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
</button>
|
||||
|
||||
<SaveIndicator state={saveState} />
|
||||
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{capabilities.publish && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1.5 px-2 text-xs"
|
||||
onClick={() => setPublishOpen(true)}
|
||||
>
|
||||
<Send className="size-3.5" aria-hidden />
|
||||
<span className="hidden sm:inline">Publish</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 p-0"
|
||||
aria-label="Document actions"
|
||||
>
|
||||
<MoreHorizontal className="size-4" aria-hidden />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onClick={() => setRenameOpen(true)}>
|
||||
<Pencil className="size-4" aria-hidden />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{meta.publication && <CopyPublicationLink meta={meta} />}
|
||||
{meta.role === 'owner' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormatToolbar editor={editor} canEdit={canEdit} />
|
||||
|
||||
{/* Editing surface. */}
|
||||
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{editor ? (
|
||||
<EditorContent editor={editor} aria-label={meta.title} />
|
||||
) : (
|
||||
<div className="space-y-4 p-8" aria-busy="true" aria-label="Loading document">
|
||||
<div className="h-9 w-2/3 animate-pulse rounded-md bg-muted" />
|
||||
<div className="h-4 w-full animate-pulse rounded-md bg-muted" />
|
||||
<div className="h-4 w-4/5 animate-pulse rounded-md bg-muted" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar. */}
|
||||
<div className="flex h-8 shrink-0 items-center gap-3 border-t border-border px-3 text-[11px] text-muted-foreground">
|
||||
<span>{words} {words === 1 ? 'word' : 'words'}</span>
|
||||
<span className="capitalize">{meta.role}</span>
|
||||
{meta.publication && (
|
||||
<span className="truncate">
|
||||
Published {new Date(meta.publication.publishedAt * 1000).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renameOpen && (
|
||||
<RenameDialog
|
||||
open={renameOpen}
|
||||
onOpenChange={setRenameOpen}
|
||||
title={meta.title}
|
||||
onRename={(title) => {
|
||||
onRename(meta.id, title);
|
||||
setRenameOpen(false);
|
||||
toast({ title: 'Document renamed' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
title={meta.title}
|
||||
onDelete={() => {
|
||||
setDeleteOpen(false);
|
||||
onDelete(meta.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
{capabilities.publish && publishOpen && (
|
||||
<PublishDialog
|
||||
open={publishOpen}
|
||||
onOpenChange={setPublishOpen}
|
||||
meta={meta}
|
||||
getMarkdown={getMarkdown}
|
||||
onPublished={(publication) => {
|
||||
onUpdateMeta(meta.id, { publication });
|
||||
setPublishOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveIndicator({ state }: { state: SaveState }) {
|
||||
const content: Record<SaveState, { icon: React.ReactNode; label: string }> = {
|
||||
loading: { icon: <Loader2 className="size-3 animate-spin motion-reduce:animate-none" aria-hidden />, label: 'Loading' },
|
||||
saving: { icon: <Loader2 className="size-3 animate-spin motion-reduce:animate-none" aria-hidden />, label: 'Saving…' },
|
||||
saved: { icon: <Check className="size-3" aria-hidden />, label: 'Saved' },
|
||||
offline: { icon: <CloudOff className="size-3" aria-hidden />, label: 'Offline — saved on this device' },
|
||||
error: { icon: <CloudOff className="size-3" aria-hidden />, label: 'Save failed — will retry' },
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1 text-[11px]',
|
||||
state === 'offline' || state === 'error' ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{content[state].icon}
|
||||
<span className="hidden sm:inline">{content[state].label}</span>
|
||||
<span className="sr-only sm:hidden">{content[state].label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function RenameDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
onRename,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
onRename: (title: string) => void;
|
||||
}) {
|
||||
// The field initializes from the title on mount; the dialog body only
|
||||
// renders while open (see usage), so every open starts from the current
|
||||
// title without an effect.
|
||||
const [value, setValue] = useState(title);
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename document</DialogTitle>
|
||||
<DialogDescription>The new name shows up in your document library.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (trimmed) onRename(trimmed);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-2 py-2">
|
||||
<Label htmlFor="doc-rename">Title</Label>
|
||||
<Input
|
||||
id="doc-rename"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!trimmed}>
|
||||
Rename
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
onDelete,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete “{title}”?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This removes the document from this device. A published snapshot, if you made one,
|
||||
stays on your relays — delete it separately if needed.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Keep document
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyPublicationLink({ meta }: { meta: DocumentMeta }) {
|
||||
const { toast } = useToast();
|
||||
const hints = useRelayHints();
|
||||
const { user } = useCurrentUser();
|
||||
const publication = meta.publication;
|
||||
|
||||
if (!publication || !user) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
pubkey: user.pubkey,
|
||||
kind: 30023,
|
||||
identifier: publication.identifier,
|
||||
relays: hints,
|
||||
});
|
||||
await navigator.clipboard.writeText(`${window.location.origin}/${naddr}`);
|
||||
toast({ title: 'Published article link copied' });
|
||||
} catch {
|
||||
toast({ title: 'Could not copy the link', variant: 'destructive' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy published link
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function PublishDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
meta,
|
||||
getMarkdown,
|
||||
onPublished,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
meta: DocumentMeta;
|
||||
getMarkdown: () => string;
|
||||
onPublished: (publication: PublicationRecord) => void;
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const publish = useDocumentPublish();
|
||||
// The snapshot is captured the moment the dialog body mounts (the dialog
|
||||
// only renders it while open): what the owner reviews and confirms is
|
||||
// exactly what gets published, even if typing continues in the background.
|
||||
// The live document is never touched.
|
||||
const [snapshot] = useState(() => {
|
||||
const markdown = getMarkdown();
|
||||
return { markdown, summary: deriveSummary(markdown) };
|
||||
});
|
||||
const [summary, setSummary] = useState(snapshot.summary);
|
||||
|
||||
const markdown = snapshot.markdown;
|
||||
const empty = markdown.trim().length === 0;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handlePublish = () => {
|
||||
publish.mutate(
|
||||
{
|
||||
meta,
|
||||
markdown,
|
||||
summary: summary.trim(),
|
||||
attachments: meta.attachments,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ publication }) => {
|
||||
onPublished(publication);
|
||||
toast({ title: 'Document published', description: 'The snapshot is live on your relays.' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: 'Publishing failed',
|
||||
description: 'None of your write relays accepted the article. Try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Publish “{meta.title}”</DialogTitle>
|
||||
<DialogDescription>
|
||||
Publishes a portable Markdown snapshot as a Nostr long-form article (kind 30023).
|
||||
This is a one-way release: the live document stays here and keeps autosaving —
|
||||
publishing never replaces or interrupts it.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="doc-summary">Summary</Label>
|
||||
<Textarea
|
||||
id="doc-summary"
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
rows={2}
|
||||
maxLength={280}
|
||||
placeholder="What is this document about?"
|
||||
/>
|
||||
</div>
|
||||
{meta.publication && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This document was published before. Publishing again updates the article at the same
|
||||
address.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={publish.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={publish.isPending || empty}>
|
||||
{publish.isPending && (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" aria-hidden />
|
||||
)}
|
||||
{publish.isPending ? 'Publishing…' : 'Publish snapshot'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
326
src/apps/documents/FormatToolbar.tsx
Normal file
326
src/apps/documents/FormatToolbar.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useState } from 'react';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import {
|
||||
Bold,
|
||||
Code,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
Link2,
|
||||
List,
|
||||
ListChecks,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Quote,
|
||||
Redo2,
|
||||
Strikethrough,
|
||||
Table,
|
||||
Underline,
|
||||
Undo2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { sanitizeUrl } from '@/lib/nostrUtils';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormatToolbarProps {
|
||||
editor: Editor | null;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The compact formatting toolbar. It scrolls horizontally on narrow windows
|
||||
* rather than wrapping, so every control stays one row-tap away at ~360px.
|
||||
*/
|
||||
export function FormatToolbar({ editor, canEdit }: FormatToolbarProps) {
|
||||
const disabled = !editor || !canEdit;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Formatting"
|
||||
className="os-scroll flex h-10 shrink-0 items-center gap-0.5 overflow-x-auto overflow-y-hidden border-b border-border px-2"
|
||||
>
|
||||
<ToolbarButton
|
||||
label="Undo"
|
||||
shortcut="Ctrl+Z"
|
||||
disabled={disabled || !editor.can().undo()}
|
||||
onClick={() => editor?.chain().focus().undo().run()}
|
||||
icon={<Undo2 className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Redo"
|
||||
shortcut="Ctrl+Shift+Z"
|
||||
disabled={disabled || !editor.can().redo()}
|
||||
onClick={() => editor?.chain().focus().redo().run()}
|
||||
icon={<Redo2 className="size-4" aria-hidden />}
|
||||
/>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
label="Bold"
|
||||
shortcut="Ctrl+B"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('bold')}
|
||||
onClick={() => editor?.chain().focus().toggleBold().run()}
|
||||
icon={<Bold className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Italic"
|
||||
shortcut="Ctrl+I"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('italic')}
|
||||
onClick={() => editor?.chain().focus().toggleItalic().run()}
|
||||
icon={<Italic className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Underline"
|
||||
shortcut="Ctrl+U"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('underline')}
|
||||
onClick={() => editor?.chain().focus().toggleUnderline().run()}
|
||||
icon={<Underline className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Strikethrough"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('strike')}
|
||||
onClick={() => editor?.chain().focus().toggleStrike().run()}
|
||||
icon={<Strikethrough className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Inline code"
|
||||
shortcut="Ctrl+E"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('code')}
|
||||
onClick={() => editor?.chain().focus().toggleCode().run()}
|
||||
icon={<Code className="size-4" aria-hidden />}
|
||||
/>
|
||||
<LinkControl editor={editor} disabled={disabled} />
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
label="Heading 1"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('heading', { level: 1 })}
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
icon={<Heading1 className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Heading 2"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('heading', { level: 2 })}
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
icon={<Heading2 className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Heading 3"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('heading', { level: 3 })}
|
||||
onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
icon={<Heading3 className="size-4" aria-hidden />}
|
||||
/>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
label="Bullet list"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('bulletList')}
|
||||
onClick={() => editor?.chain().focus().toggleBulletList().run()}
|
||||
icon={<List className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Numbered list"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('orderedList')}
|
||||
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
|
||||
icon={<ListOrdered className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Checklist"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('taskList')}
|
||||
onClick={() => editor?.chain().focus().toggleTaskList().run()}
|
||||
icon={<ListChecks className="size-4" aria-hidden />}
|
||||
/>
|
||||
|
||||
<ToolbarDivider />
|
||||
|
||||
<ToolbarButton
|
||||
label="Quote"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('blockquote')}
|
||||
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
|
||||
icon={<Quote className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Code block"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('codeBlock')}
|
||||
onClick={() => editor?.chain().focus().toggleCodeBlock().run()}
|
||||
icon={<Code2 className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Table"
|
||||
disabled={disabled}
|
||||
active={editor?.isActive('table')}
|
||||
onClick={() => {
|
||||
if (!editor) return;
|
||||
if (editor.isActive('table')) editor.chain().focus().deleteTable().run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
}}
|
||||
icon={<Table className="size-4" aria-hidden />}
|
||||
/>
|
||||
<ToolbarButton
|
||||
label="Horizontal rule"
|
||||
disabled={disabled}
|
||||
onClick={() => editor?.chain().focus().setHorizontalRule().run()}
|
||||
icon={<Minus className="size-4" aria-hidden />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
label,
|
||||
shortcut,
|
||||
disabled,
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
}: {
|
||||
label: string;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={shortcut ? `${label} (${shortcut})` : label}
|
||||
aria-pressed={active}
|
||||
title={shortcut ? `${label} (${shortcut})` : label}
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <div className="mx-1 h-5 w-px shrink-0 bg-border" aria-hidden />;
|
||||
}
|
||||
|
||||
function LinkControl({ editor, disabled }: { editor: Editor | null; disabled: boolean }) {
|
||||
const { toast } = useToast();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const active = editor?.isActive('link') ?? false;
|
||||
|
||||
// Prefill with the current link's href when the popover opens on one. This
|
||||
// is event-driven (not an effect), so it runs once per open gesture.
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
setOpen(next);
|
||||
if (next) setValue((editor?.getAttributes('link').href as string | undefined) ?? '');
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
if (!editor) return;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// The href is untrusted input: only allowlisted protocols survive.
|
||||
const safe = sanitizeUrl(trimmed);
|
||||
if (!safe) {
|
||||
toast({
|
||||
title: 'That link is not allowed',
|
||||
description: 'Only https, http, mailto and nostr links work here.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: safe }).run();
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-label="Link"
|
||||
aria-pressed={active}
|
||||
title="Link"
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-40',
|
||||
active ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Link2 className="size-4" aria-hidden />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 space-y-2" align="start">
|
||||
<label htmlFor="doc-link-href" className="text-xs font-medium">
|
||||
Link URL
|
||||
</label>
|
||||
<Input
|
||||
id="doc-link-href"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
placeholder="https://…"
|
||||
inputMode="url"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
apply();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
{active && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().unsetLink().run();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={apply}>
|
||||
{active ? 'Update' : 'Add link'}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
226
src/apps/documents/index.tsx
Normal file
226
src/apps/documents/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { ChevronLeft, FilePlus2, FileText } from 'lucide-react';
|
||||
import {
|
||||
AppBody,
|
||||
AppLayout,
|
||||
AppSectionTitle,
|
||||
AppSplit,
|
||||
AppSidebar,
|
||||
AppToolbar,
|
||||
EmptyState,
|
||||
} from '@/components/os/AppChrome';
|
||||
import { LoginRequired } from '@/components/nostr/LoginRequired';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useDocumentIndex } from '@/lib/documents/store';
|
||||
import { deletePersistedDocument } from '@/lib/documents/ydoc';
|
||||
import { relativeTime } from '@/lib/nostrUtils';
|
||||
import type { DocumentMeta } from '@/lib/documents/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AppProps } from '@/os/types';
|
||||
import { DocumentEditor } from './DocumentEditor';
|
||||
|
||||
export default function DocumentsApp({ params, setTitle, setParams }: AppProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { documents, createDocument, updateDocument, removeDocument } = useDocumentIndex();
|
||||
|
||||
// The open document lives in the window's params so a reload, a deep link
|
||||
// and the desktop/mobile shell switch all agree on what is open.
|
||||
const selectedId = params.doc ?? null;
|
||||
const selected = documents.find((doc) => doc.id === selectedId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(selected ? `Documents — ${selected.title}` : 'Documents');
|
||||
}, [selected, setTitle]);
|
||||
|
||||
const openDocument = useCallback((id: string) => setParams({ doc: id }), [setParams]);
|
||||
const closeDocument = useCallback(() => setParams({}), [setParams]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
const meta = createDocument('Untitled document');
|
||||
openDocument(meta.id);
|
||||
}, [createDocument, openDocument]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, title: string) => {
|
||||
updateDocument(id, { title, updatedAt: Date.now() });
|
||||
},
|
||||
[updateDocument],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(id: string) => {
|
||||
removeDocument(id);
|
||||
// The body lives in IndexedDB; removing the index entry alone would
|
||||
// leak it. Failures are non-fatal: the entry is already gone and an
|
||||
// orphaned IndexedDB database is inert.
|
||||
void deletePersistedDocument(id).catch(() => {});
|
||||
toast({ title: 'Document deleted' });
|
||||
if (selectedId === id) closeDocument();
|
||||
},
|
||||
[removeDocument, toast, selectedId, closeDocument],
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppToolbar>
|
||||
<span className="text-[13px] font-medium">Documents</span>
|
||||
</AppToolbar>
|
||||
<AppBody>
|
||||
<LoginRequired action="write documents" />
|
||||
</AppBody>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const listPane = (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="shrink-0 border-b border-border p-2">
|
||||
<Button size="sm" className="w-full gap-1.5" onClick={handleCreate}>
|
||||
<FilePlus2 className="size-3.5" aria-hidden />
|
||||
New document
|
||||
</Button>
|
||||
</div>
|
||||
<div className="os-scroll min-h-0 flex-1 overflow-y-auto">
|
||||
<DocumentList
|
||||
documents={documents}
|
||||
selectedId={selectedId}
|
||||
onSelect={openDocument}
|
||||
onCreate={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editorPane = !selected ? (
|
||||
<EmptyState
|
||||
title={documents.length === 0 ? 'No documents yet' : 'Pick a document'}
|
||||
hint={
|
||||
documents.length === 0
|
||||
? 'Create your first document — it stays on this device and autosaves as you type.'
|
||||
: 'Choose one from the list, or start a new one.'
|
||||
}
|
||||
action={
|
||||
documents.length === 0 ? (
|
||||
<Button size="sm" className="gap-1.5" onClick={handleCreate}>
|
||||
<FilePlus2 className="size-3.5" aria-hidden />
|
||||
New document
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DocumentEditor
|
||||
key={selected.id}
|
||||
meta={selected}
|
||||
onRename={handleRename}
|
||||
onDelete={handleDelete}
|
||||
onUpdateMeta={updateDocument}
|
||||
/>
|
||||
);
|
||||
|
||||
// Narrow windows have no room for a sidebar, so list and editor take turns.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AppLayout>
|
||||
{!selected && (
|
||||
<AppToolbar>
|
||||
<span className="text-[13px] font-medium">Documents</span>
|
||||
</AppToolbar>
|
||||
)}
|
||||
{selected ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<AppToolbar className="h-9">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDocument}
|
||||
className="-ml-1 flex items-center gap-1 rounded px-1 py-0.5 text-[13px] font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
All documents
|
||||
</button>
|
||||
</AppToolbar>
|
||||
{editorPane}
|
||||
</div>
|
||||
) : (
|
||||
<AppBody className="flex flex-col">{listPane}</AppBody>
|
||||
)}
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<AppSplit>
|
||||
<AppSidebar className="w-60 p-0">{listPane}</AppSidebar>
|
||||
<AppBody className="flex flex-col">{editorPane}</AppBody>
|
||||
</AppSplit>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentList({
|
||||
documents,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
documents: DocumentMeta[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
if (documents.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Nothing here yet.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
className="font-medium text-primary hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
|
||||
>
|
||||
Create a document
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSectionTitle>Your documents</AppSectionTitle>
|
||||
<ul className="pb-2">
|
||||
{documents.map((doc) => (
|
||||
<li key={doc.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(doc.id)}
|
||||
aria-current={doc.id === selectedId ? 'true' : undefined}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left transition-colors',
|
||||
'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring',
|
||||
doc.id === selectedId ? 'bg-accent text-accent-foreground' : 'hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<FileText className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="truncate text-[13px] font-medium">{doc.title}</span>
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate pl-5 text-[11px] text-muted-foreground">
|
||||
{doc.publication ? 'Published · ' : ''}
|
||||
{relativeTime(Math.floor(doc.updatedAt / 1000))}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
198
src/hooks/useDocumentEditor.ts
Normal file
198
src/hooks/useDocumentEditor.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Collaboration from '@tiptap/extension-collaboration';
|
||||
import { TableKit } from '@tiptap/extension-table';
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type * as Y from 'yjs';
|
||||
import { sanitizeUrl } from '@/lib/nostrUtils';
|
||||
import { sanitizeHtml } from '@/lib/documents/sanitizeHtml';
|
||||
import { docToMarkdown } from '@/lib/documents/markdown';
|
||||
import { openDocumentSession } from '@/lib/documents/ydoc';
|
||||
import { ROLE_CAPABILITIES } from '@/lib/documents/types';
|
||||
import type { DocumentRole, SaveState } from '@/lib/documents/types';
|
||||
|
||||
const AUTOSAVE_DELAY = 1200;
|
||||
|
||||
export interface DocumentEditorOptions {
|
||||
documentId: string;
|
||||
role: DocumentRole;
|
||||
/** Called after every debounced autosave (stamps the metadata index). */
|
||||
onAutosaved?: () => void;
|
||||
}
|
||||
|
||||
export interface DocumentEditor {
|
||||
editor: Editor | null;
|
||||
saveState: SaveState;
|
||||
/** Word count of the current document. */
|
||||
words: number;
|
||||
canEdit: boolean;
|
||||
/** Serialize the current document to portable Markdown. */
|
||||
getMarkdown: () => string;
|
||||
/** Replace the document with imported Markdown. */
|
||||
replaceWithMarkdown: (markdown: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires a Tiptap editor to the Yjs document behind `documentId`.
|
||||
*
|
||||
* - The `Y.Doc` + IndexedDB session lives in a ref, created and destroyed
|
||||
* inside a `useEffect`: React Strict Mode's double-mount opens, closes and
|
||||
* reopens it predictably — no duplicate persistence handles, no stale
|
||||
* listener, and the closed first session cannot write over the second.
|
||||
* - The editor binds to the Yjs document through the Collaboration
|
||||
* extension, which is the exact seam where the Phase 2 Hocuspocus provider
|
||||
* attaches (`Collaboration.configure({ document, provider })`). Nothing
|
||||
* else about the app changes when that lands.
|
||||
* - Local undo/redo stays enabled: Collaboration swaps in the Yjs undo
|
||||
* manager only when a provider connects, so solo Phase 1 editing keeps the
|
||||
* expected Word-like per-user history.
|
||||
* - Pasted/dropped HTML passes through the allowlist sanitizer before the
|
||||
* editor's schema parse; the schema itself drops anything else.
|
||||
*/
|
||||
export function useDocumentEditor({
|
||||
documentId,
|
||||
role,
|
||||
onAutosaved,
|
||||
}: DocumentEditorOptions): DocumentEditor {
|
||||
const canEdit = ROLE_CAPABILITIES[role].edit;
|
||||
|
||||
// The session doc is set asynchronously once the session confirms it is
|
||||
// alive, which recreates the editor bound to that document. Strict Mode's
|
||||
// double-mount opens, closes and reopens the session predictably — the
|
||||
// closed first session's `then` is guarded off before it can set state.
|
||||
const [sessionDoc, setSessionDoc] = useState<Y.Doc | null>(null);
|
||||
const [saveState, setSaveState] = useState<SaveState>('loading');
|
||||
const [words, setWords] = useState(0);
|
||||
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dirty = useRef(false);
|
||||
const onAutosavedRef = useRef(onAutosaved);
|
||||
|
||||
useEffect(() => {
|
||||
onAutosavedRef.current = onAutosaved;
|
||||
}, [onAutosaved]);
|
||||
|
||||
const flushSave = useCallback(() => {
|
||||
if (!dirty.current) return;
|
||||
dirty.current = false;
|
||||
// The Yjs update was already written to IndexedDB by the persistence
|
||||
// layer; here we surface the save and stamp the metadata index.
|
||||
setSaveState(navigator.onLine ? 'saved' : 'offline');
|
||||
onAutosavedRef.current?.();
|
||||
}, []);
|
||||
|
||||
const scheduleSave = useCallback(() => {
|
||||
dirty.current = true;
|
||||
setSaveState('saving');
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(flushSave, AUTOSAVE_DELAY);
|
||||
}, [flushSave]);
|
||||
|
||||
// Open the Yjs session per document. The state write happens inside the
|
||||
// session's promise, never synchronously in the effect body.
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const session = openDocumentSession(documentId, {
|
||||
onSynced: () => {
|
||||
if (active) setSaveState(navigator.onLine ? 'saved' : 'offline');
|
||||
},
|
||||
// IndexedDB persists every update immediately; the debounced React
|
||||
// affordance is driven by the editor's own onUpdate.
|
||||
onUpdate: () => {},
|
||||
});
|
||||
session.whenSynced.then(() => {
|
||||
if (active) setSessionDoc(session.doc);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
setSessionDoc(null);
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
dirty.current = false;
|
||||
session.destroy();
|
||||
};
|
||||
}, [documentId]);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
immediatelyRender: false,
|
||||
editable: canEdit,
|
||||
shouldRerenderOnTransaction: true,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [1, 2, 3] },
|
||||
link: {
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
// Only allowlisted protocols may become links. Everything else
|
||||
// (javascript:, data:, vbscript:) is dropped by the extension.
|
||||
isAllowedUri: (url, ctx) => {
|
||||
if (ctx.defaultValidate(url)) return true;
|
||||
return sanitizeUrl(url) !== undefined;
|
||||
},
|
||||
},
|
||||
}),
|
||||
...(sessionDoc ? [Collaboration.configure({ document: sessionDoc })] : []),
|
||||
TableKit.configure({ table: { resizable: false } }),
|
||||
Placeholder.configure({ placeholder: 'Start writing…' }),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'doc-editor',
|
||||
// The editable region is the labelled document body.
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
},
|
||||
transformPastedHTML: (html) => sanitizeHtml(html),
|
||||
},
|
||||
onUpdate: ({ editor: instance }) => {
|
||||
setWords(countWords(instance.getText()));
|
||||
scheduleSave();
|
||||
},
|
||||
},
|
||||
[documentId, sessionDoc, canEdit],
|
||||
);
|
||||
|
||||
// Coming back online updates the autosave affordance.
|
||||
useEffect(() => {
|
||||
const onOnline = () => setSaveState((s) => (s === 'offline' ? 'saved' : s));
|
||||
const onOffline = () => setSaveState((s) => (s === 'saved' ? 'offline' : s));
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('offline', onOffline);
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('offline', onOffline);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Keep editability in sync if the role changes while the editor is open.
|
||||
useEffect(() => {
|
||||
editor?.setEditable(canEdit);
|
||||
}, [editor, canEdit]);
|
||||
|
||||
const getMarkdown = useCallback(
|
||||
() => (editor ? docToMarkdown(editor.getJSON()) : ''),
|
||||
[editor],
|
||||
);
|
||||
|
||||
const replaceWithMarkdown = useCallback(
|
||||
(markdown: string) => {
|
||||
if (!editor || !canEdit) return;
|
||||
// Lazy import keeps the parser out of the first-paint path.
|
||||
import('@/lib/documents/markdown').then(({ markdownToDoc }) => {
|
||||
const doc = markdownToDoc(markdown);
|
||||
editor.chain().focus().setContent(doc.content ?? []).run();
|
||||
});
|
||||
},
|
||||
[editor, canEdit],
|
||||
);
|
||||
|
||||
return { editor, saveState, words, canEdit, getMarkdown, replaceWithMarkdown };
|
||||
}
|
||||
|
||||
function countWords(text: string): number {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
return trimmed.split(/\s+/).length;
|
||||
}
|
||||
34
src/hooks/useDocumentPublish.ts
Normal file
34
src/hooks/useDocumentPublish.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { buildPublishTemplate, publicationFromEvent } from '@/lib/documents/publish';
|
||||
import type { DocumentAttachment, DocumentMeta, PublicationRecord } from '@/lib/documents/types';
|
||||
|
||||
export interface PublishInput {
|
||||
meta: DocumentMeta;
|
||||
markdown: string;
|
||||
summary: string;
|
||||
attachments: DocumentAttachment[];
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
event: NostrEvent;
|
||||
publication: PublicationRecord;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the owner-approved Markdown snapshot as a NIP-23 kind 30023
|
||||
* event. The mutation stays idle until the owner explicitly confirms, so a
|
||||
* published snapshot can never silently replace live collaborative work.
|
||||
*/
|
||||
export function useDocumentPublish() {
|
||||
const { mutateAsync } = useNostrPublish();
|
||||
|
||||
return useMutation<PublishResult, Error, PublishInput>({
|
||||
mutationFn: async ({ meta, markdown, summary, attachments }) => {
|
||||
const template = buildPublishTemplate(meta, markdown, summary, attachments);
|
||||
const event = await mutateAsync(template);
|
||||
return { event, publication: publicationFromEvent(event, meta.title) };
|
||||
},
|
||||
});
|
||||
}
|
||||
168
src/index.css
168
src/index.css
@@ -348,3 +348,171 @@ body.os-dragging .os-window-content {
|
||||
animation: os-fade-in 120ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Documents editor */
|
||||
|
||||
/*
|
||||
* The Tiptap surface. Styles target the schema's rendered elements only;
|
||||
* colors come from theme variables so light/dark just work. All selectors
|
||||
* are element/class based — no untrusted value is ever interpolated here.
|
||||
*/
|
||||
.doc-editor {
|
||||
min-height: 100%;
|
||||
outline: none;
|
||||
padding: 1.25rem 1rem 40vh;
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-editor {
|
||||
padding: 2rem 2.5rem 40vh;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-editor > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-editor h1 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.doc-editor h2 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.3;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.doc-editor h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
.doc-editor ul,
|
||||
.doc-editor ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.doc-editor ul:not([data-type='taskList']) {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.doc-editor ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.doc-editor li > ul,
|
||||
.doc-editor li > ol {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-editor blockquote {
|
||||
border-left: 2px solid var(--primary);
|
||||
padding-left: 1rem;
|
||||
color: var(--muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.doc-editor code {
|
||||
background: var(--muted);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.doc-editor pre {
|
||||
background: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.doc-editor pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.doc-editor a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: color-mix(in oklab, var(--primary) 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.doc-editor a:hover {
|
||||
text-decoration-color: var(--primary);
|
||||
}
|
||||
|
||||
.doc-editor hr {
|
||||
border-color: var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.doc-editor table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.doc-editor th,
|
||||
.doc-editor td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.doc-editor th {
|
||||
background: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-editor .selectedCell {
|
||||
background: color-mix(in oklab, var(--accent) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Checklists render without a bullet and align the box with the first line. */
|
||||
.doc-editor ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding-left: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-editor ul[data-type='taskList'] li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.doc-editor ul[data-type='taskList'] li > label {
|
||||
margin-top: 0.3rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.doc-editor ul[data-type='taskList'] li > div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-editor ul[data-type='taskList'] li[data-checked='true'] > div {
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* Placeholder: shown only on the first empty node of an empty document. */
|
||||
.doc-editor p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--muted-foreground);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
65
src/lib/documents/attachments.test.ts
Normal file
65
src/lib/documents/attachments.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { attachmentFromImetaTags, attachmentToImetaTags } from './attachments';
|
||||
|
||||
const SHA = 'a'.repeat(64);
|
||||
|
||||
describe('attachmentFromImetaTags', () => {
|
||||
it('parses Blossom uploader tags', () => {
|
||||
const result = attachmentFromImetaTags([
|
||||
['url', 'https://blossom.example/file.png'],
|
||||
['m', 'image/png'],
|
||||
['x', SHA],
|
||||
['size', '2048'],
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
url: 'https://blossom.example/file.png',
|
||||
mimeType: 'image/png',
|
||||
sha256: SHA,
|
||||
size: 2048,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a combined imeta tag', () => {
|
||||
const result = attachmentFromImetaTags([
|
||||
['imeta', `url https://blossom.example/file.pdf`, 'm application/pdf', `x ${SHA}`],
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
url: 'https://blossom.example/file.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
sha256: SHA,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-https and unsafe URLs', () => {
|
||||
expect(attachmentFromImetaTags([['url', 'javascript:alert(1)']])).toBeNull();
|
||||
expect(attachmentFromImetaTags([['url', 'not a url']])).toBeNull();
|
||||
expect(attachmentFromImetaTags([['m', 'image/png']])).toBeNull();
|
||||
});
|
||||
|
||||
it('drops malformed hashes and sizes', () => {
|
||||
const result = attachmentFromImetaTags([
|
||||
['url', 'https://blossom.example/file.png'],
|
||||
['x', 'not-a-hash'],
|
||||
['size', '-5'],
|
||||
]);
|
||||
expect(result).toEqual({ url: 'https://blossom.example/file.png' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachmentToImetaTags', () => {
|
||||
it('round-trips through attachmentFromImetaTags', () => {
|
||||
const original = {
|
||||
url: 'https://blossom.example/file.png',
|
||||
mimeType: 'image/png',
|
||||
sha256: SHA,
|
||||
size: 2048,
|
||||
};
|
||||
const tags = attachmentToImetaTags(original);
|
||||
expect(attachmentFromImetaTags(tags)).toEqual(original);
|
||||
});
|
||||
|
||||
it('includes the bare x tag for NIP-94 addressability', () => {
|
||||
const tags = attachmentToImetaTags({ url: 'https://blossom.example/f', sha256: SHA });
|
||||
expect(tags).toContainEqual(['x', SHA]);
|
||||
});
|
||||
});
|
||||
60
src/lib/documents/attachments.ts
Normal file
60
src/lib/documents/attachments.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { sanitizeUrl } from '@/lib/nostrUtils';
|
||||
import type { DocumentAttachment } from './types';
|
||||
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
||||
|
||||
/**
|
||||
* Turn raw Blossom uploader tags (NIP-94 `imeta` triplets) into validated
|
||||
* attachment metadata. Anything untrusted — non-https URLs, malformed hashes,
|
||||
* negative sizes — is dropped rather than passed through.
|
||||
*/
|
||||
export function attachmentFromImetaTags(tags: string[][]): DocumentAttachment | null {
|
||||
const flat = new Map<string, string>();
|
||||
for (const tag of tags) {
|
||||
const [name, ...values] = tag;
|
||||
if (!name || values.length === 0) continue;
|
||||
if (name === 'imeta') {
|
||||
// Each remaining entry is one `key value` pair.
|
||||
for (const entry of values) {
|
||||
const space = entry.indexOf(' ');
|
||||
if (space <= 0) continue;
|
||||
const key = entry.slice(0, space);
|
||||
const val = entry.slice(space + 1);
|
||||
if (!flat.has(key)) flat.set(key, val);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Bare NIP-94 tags: `url`, `m`, `x`, `size`.
|
||||
if (!flat.has(name)) flat.set(name, values[0]);
|
||||
}
|
||||
|
||||
const url = sanitizeUrl(flat.get('url'));
|
||||
if (!url || !url.startsWith('https:')) return null;
|
||||
|
||||
const attachment: DocumentAttachment = { url };
|
||||
|
||||
const mimeType = flat.get('m');
|
||||
if (mimeType && /^[\w.+-]+\/[\w.+-]+$/.test(mimeType)) attachment.mimeType = mimeType;
|
||||
|
||||
const sha256 = flat.get('x');
|
||||
if (sha256 && SHA256_PATTERN.test(sha256)) attachment.sha256 = sha256;
|
||||
|
||||
const size = Number(flat.get('size'));
|
||||
if (Number.isFinite(size) && size >= 0) attachment.size = size;
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
/** Serialize an attachment back into NIP-94 tags for the publish event. */
|
||||
export function attachmentToImetaTags(attachment: DocumentAttachment): string[][] {
|
||||
const imeta = [
|
||||
`url ${attachment.url}`,
|
||||
attachment.mimeType ? `m ${attachment.mimeType}` : undefined,
|
||||
attachment.sha256 ? `x ${attachment.sha256}` : undefined,
|
||||
attachment.size !== undefined ? `size ${attachment.size}` : undefined,
|
||||
].filter((part): part is string => typeof part === 'string');
|
||||
|
||||
const tags: string[][] = [['imeta', ...imeta]];
|
||||
if (attachment.sha256) tags.push(['x', attachment.sha256]);
|
||||
return tags;
|
||||
}
|
||||
305
src/lib/documents/markdown.test.ts
Normal file
305
src/lib/documents/markdown.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||
|
||||
function doc(...content: JSONContent[]): JSONContent {
|
||||
return { type: 'doc', content };
|
||||
}
|
||||
|
||||
function para(...content: JSONContent[]): JSONContent {
|
||||
return { type: 'paragraph', content };
|
||||
}
|
||||
|
||||
function text(text: string, marks?: JSONContent['marks']): JSONContent {
|
||||
const node: JSONContent = { type: 'text', text };
|
||||
if (marks) node.marks = marks;
|
||||
return node;
|
||||
}
|
||||
|
||||
describe('docToMarkdown', () => {
|
||||
it('serializes headings and paragraphs', () => {
|
||||
const input = doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Title')] },
|
||||
para(text('Hello world')),
|
||||
);
|
||||
expect(docToMarkdown(input)).toBe('# Title\n\nHello world');
|
||||
});
|
||||
|
||||
it('serializes inline marks', () => {
|
||||
const input = para(
|
||||
text('a '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' and '),
|
||||
text('italic', [{ type: 'italic' }]),
|
||||
text(' and '),
|
||||
text('struck', [{ type: 'strike' }]),
|
||||
text(' and '),
|
||||
text('code', [{ type: 'code' }]),
|
||||
);
|
||||
expect(docToMarkdown(doc(input))).toBe('a **bold** and *italic* and ~~struck~~ and `code`');
|
||||
});
|
||||
|
||||
it('serializes links and drops unsafe hrefs', () => {
|
||||
const input = para(
|
||||
text('safe', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
text(' '),
|
||||
text('evil', [{ type: 'link', attrs: { href: 'javascript:alert(1)' } }]),
|
||||
);
|
||||
expect(docToMarkdown(doc(input))).toBe('[safe](https://example.com) evil');
|
||||
});
|
||||
|
||||
it('serializes bullet, ordered and task lists', () => {
|
||||
const input = doc(
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('two')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [para(text('nested'))] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('first'))] },
|
||||
{ type: 'listItem', content: [para(text('second'))] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
|
||||
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(docToMarkdown(input)).toBe(
|
||||
'- one\n- two\n - nested\n\n1. first\n2. second\n\n- [x] done\n- [ ] todo',
|
||||
);
|
||||
});
|
||||
|
||||
it('serializes quotes, code blocks and rules', () => {
|
||||
const input = doc(
|
||||
{ type: 'blockquote', content: [para(text('wise words'))] },
|
||||
{
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'ts' },
|
||||
content: [text('const a = 1;')],
|
||||
},
|
||||
{ type: 'horizontalRule' },
|
||||
);
|
||||
expect(docToMarkdown(input)).toBe('> wise words\n\n```ts\nconst a = 1;\n```\n\n---');
|
||||
});
|
||||
|
||||
it('serializes tables as GFM', () => {
|
||||
const input = doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableHeader', content: [para(text('Name'))] },
|
||||
{ type: 'tableHeader', content: [para(text('Role'))] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [para(text('Ada'))] },
|
||||
{ type: 'tableCell', content: [para(text('owner'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(docToMarkdown(input)).toBe(
|
||||
'| Name | Role |\n| --- | --- |\n| Ada | owner |',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes characters that would change meaning', () => {
|
||||
const input = para(text('1. not a list # not a heading *not italic*'));
|
||||
expect(docToMarkdown(doc(input))).toBe('1\\. not a list \\# not a heading \\*not italic\\*');
|
||||
});
|
||||
|
||||
it('does not mangle text that is already a plain sentence', () => {
|
||||
const input = para(text('Hello world'));
|
||||
expect(docToMarkdown(doc(input))).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns an empty string for an empty document', () => {
|
||||
expect(docToMarkdown(doc(para()))).toBe('');
|
||||
expect(docToMarkdown(undefined)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdownToDoc', () => {
|
||||
it('parses headings and paragraphs', () => {
|
||||
const result = markdownToDoc('# Title\n\nHello world');
|
||||
expect(result).toEqual(
|
||||
doc(
|
||||
{ type: 'heading', attrs: { level: 1 }, content: [text('Title')] },
|
||||
para(text('Hello world')),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses inline marks', () => {
|
||||
const result = markdownToDoc('a **bold** and *italic* and ~~struck~~ and `code`');
|
||||
expect(result).toEqual(
|
||||
doc(
|
||||
para(
|
||||
text('a '),
|
||||
text('bold', [{ type: 'bold' }]),
|
||||
text(' and '),
|
||||
text('italic', [{ type: 'italic' }]),
|
||||
text(' and '),
|
||||
text('struck', [{ type: 'strike' }]),
|
||||
text(' and '),
|
||||
text('code', [{ type: 'code' }]),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses links and keeps unsafe hrefs as literal text', () => {
|
||||
const result = markdownToDoc('[safe](https://example.com) [evil](javascript:alert(1))');
|
||||
expect(result).toEqual(
|
||||
doc(
|
||||
para(
|
||||
text('safe', [{ type: 'link', attrs: { href: 'https://example.com' } }]),
|
||||
text(' [evil](javascript:alert(1))'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses bullet, ordered and task lists with nesting', () => {
|
||||
const result = markdownToDoc('- one\n- two\n - nested\n\n1. first\n2. second\n\n- [x] done\n- [ ] todo');
|
||||
expect(result).toEqual(
|
||||
doc(
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('one'))] },
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
para(text('two')),
|
||||
{
|
||||
type: 'bulletList',
|
||||
content: [{ type: 'listItem', content: [para(text('nested'))] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'orderedList',
|
||||
content: [
|
||||
{ type: 'listItem', content: [para(text('first'))] },
|
||||
{ type: 'listItem', content: [para(text('second'))] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'taskList',
|
||||
content: [
|
||||
{ type: 'taskItem', attrs: { checked: true }, content: [para(text('done'))] },
|
||||
{ type: 'taskItem', attrs: { checked: false }, content: [para(text('todo'))] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses quotes, code blocks and rules', () => {
|
||||
const result = markdownToDoc('> wise words\n\n```ts\nconst a = 1;\n```\n\n---');
|
||||
expect(result).toEqual(
|
||||
doc(
|
||||
{ type: 'blockquote', content: [para(text('wise words'))] },
|
||||
{
|
||||
type: 'codeBlock',
|
||||
attrs: { language: 'ts' },
|
||||
content: [text('const a = 1;')],
|
||||
},
|
||||
{ type: 'horizontalRule' },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses GFM tables', () => {
|
||||
const result = markdownToDoc('| Name | Role |\n| --- | --- |\n| Ada | owner |');
|
||||
expect(result).toEqual(
|
||||
doc({
|
||||
type: 'table',
|
||||
content: [
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableHeader', content: [para(text('Name'))] },
|
||||
{ type: 'tableHeader', content: [para(text('Role'))] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'tableRow',
|
||||
content: [
|
||||
{ type: 'tableCell', content: [para(text('Ada'))] },
|
||||
{ type: 'tableCell', content: [para(text('owner'))] },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unescapes escaped characters', () => {
|
||||
const result = markdownToDoc('1\\. not a list \\# not a heading \\*not italic\\*');
|
||||
expect(result).toEqual(doc(para(text('1. not a list # not a heading *not italic*'))));
|
||||
});
|
||||
});
|
||||
|
||||
describe('round trip', () => {
|
||||
it('serialize(parse(x)) preserves the constructs the schema supports', () => {
|
||||
const source = [
|
||||
'# Title',
|
||||
'',
|
||||
'Some **bold** and *italic* and ~~struck~~ and `code` and a [link](https://example.com).',
|
||||
'',
|
||||
'- one',
|
||||
'- two',
|
||||
' - nested',
|
||||
'',
|
||||
'1. first',
|
||||
'2. second',
|
||||
'',
|
||||
'- [x] done',
|
||||
'- [ ] todo',
|
||||
'',
|
||||
'> wise words',
|
||||
'',
|
||||
'```ts',
|
||||
'const a = 1;',
|
||||
'```',
|
||||
'',
|
||||
'| Name | Role |',
|
||||
'| --- | --- |',
|
||||
'| Ada | owner |',
|
||||
'',
|
||||
'---',
|
||||
].join('\n');
|
||||
|
||||
// The serializer escapes trailing punctuation aggressively; that output
|
||||
// must parse back to the identical document, and the second serialization
|
||||
// must be stable.
|
||||
const once = docToMarkdown(markdownToDoc(source));
|
||||
expect(markdownToDoc(once)).toEqual(markdownToDoc(source));
|
||||
expect(docToMarkdown(markdownToDoc(once))).toBe(once);
|
||||
});
|
||||
});
|
||||
559
src/lib/documents/markdown.ts
Normal file
559
src/lib/documents/markdown.ts
Normal file
@@ -0,0 +1,559 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
|
||||
/**
|
||||
* ProseMirror JSON ↔ portable Markdown.
|
||||
*
|
||||
* The serializer produces the NIP-23 snapshot body. It is a small, fully
|
||||
* understood walk over the constrained editor schema: the only HTML that
|
||||
* ever reaches the editor has already passed `sanitizeHtml`, and the output
|
||||
* here is plain text, so no untrusted HTML survives a publish.
|
||||
*
|
||||
* The parser covers the same constructs so a published snapshot can be
|
||||
* imported back and tests can round-trip. It is not a general Markdown
|
||||
* implementation — anything unrecognised degrades to a paragraph instead of
|
||||
* dropping content.
|
||||
*/
|
||||
|
||||
const MENTION_PATTERN = /^nostr:((npub|nprofile|note|nevent|naddr)1[02-9ac-hj-np-z]+)$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serializer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function escapeText(text: string): string {
|
||||
// Escape every ASCII punctuation character. CommonMark defines a backslash
|
||||
// before ASCII punctuation as the literal character, so this is uniform,
|
||||
// always round-trips, and needs no positional special cases.
|
||||
return text.replace(/([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g, '\\$1');
|
||||
}
|
||||
|
||||
function isSafeHref(href: string | undefined | null): href is string {
|
||||
if (!href) return false;
|
||||
if (MENTION_PATTERN.test(href)) return true;
|
||||
try {
|
||||
const parsed = new URL(href, 'https://layer.invalid');
|
||||
return ['https:', 'http:', 'mailto:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeInline(nodes: JSONContent[] | undefined): string {
|
||||
if (!nodes) return '';
|
||||
let out = '';
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'hardBreak') {
|
||||
out += '\\\n';
|
||||
continue;
|
||||
}
|
||||
if (node.type !== 'text' || typeof node.text !== 'string') continue;
|
||||
|
||||
const prefix: string[] = [];
|
||||
const suffix: string[] = [];
|
||||
let code = false;
|
||||
|
||||
for (const mark of node.marks ?? []) {
|
||||
switch (mark.type) {
|
||||
case 'code':
|
||||
code = true;
|
||||
break;
|
||||
case 'bold': prefix.push('**'); suffix.unshift('**'); break;
|
||||
case 'italic': prefix.push('*'); suffix.unshift('*'); break;
|
||||
case 'strike': prefix.push('~~'); suffix.unshift('~~'); break;
|
||||
case 'underline': prefix.push('<u>'); suffix.unshift('</u>'); break;
|
||||
case 'link': {
|
||||
const href = (mark.attrs as { href?: string } | undefined)?.href;
|
||||
if (isSafeHref(href)) {
|
||||
prefix.push('[');
|
||||
suffix.unshift(`](${href})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if (code) {
|
||||
// Inline code is never escaped; pick a fence that cannot collide.
|
||||
const longest = Math.max(0, ...[...node.text.matchAll(/`+/g)].map((m) => m[0].length));
|
||||
const fence = '`'.repeat(longest + 1);
|
||||
const pad = node.text.startsWith('`') || node.text.endsWith('`') ? ' ' : '';
|
||||
text = `${fence}${pad}${node.text}${pad}${fence}`;
|
||||
} else {
|
||||
text = escapeText(node.text);
|
||||
}
|
||||
|
||||
out += `${prefix.join('')}${text}${suffix.join('')}`;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function serializeList(
|
||||
node: JSONContent,
|
||||
depth: number,
|
||||
ordered: boolean,
|
||||
task: boolean,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
for (const item of node.content ?? []) {
|
||||
index += 1;
|
||||
const indent = ' '.repeat(depth);
|
||||
const marker = task ? '-' : ordered ? `${index}.` : '-';
|
||||
const checkbox = task
|
||||
? ` [${(item.attrs as { checked?: boolean } | undefined)?.checked ? 'x' : ' '}]`
|
||||
: '';
|
||||
|
||||
let first = true;
|
||||
for (const block of item.content ?? []) {
|
||||
if (block.type === 'paragraph') {
|
||||
const text = serializeInline(block.content);
|
||||
lines.push(first ? `${indent}${marker}${checkbox} ${text}` : `${indent} ${text}`);
|
||||
first = false;
|
||||
} else if (
|
||||
block.type === 'bulletList' ||
|
||||
block.type === 'orderedList' ||
|
||||
block.type === 'taskList'
|
||||
) {
|
||||
lines.push(
|
||||
...serializeList(
|
||||
block,
|
||||
depth + 1,
|
||||
block.type === 'orderedList',
|
||||
block.type === 'taskList',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const nested = serializeBlock(block);
|
||||
if (nested) {
|
||||
lines.push(...nested.split('\n').map((line) => `${indent} ${line}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (first) lines.push(`${indent}${marker}${checkbox}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function serializeTable(node: JSONContent): string[] {
|
||||
const rows: string[][] = [];
|
||||
for (const row of node.content ?? []) {
|
||||
if (row.type !== 'tableRow') continue;
|
||||
const cells: string[] = [];
|
||||
for (const cell of row.content ?? []) {
|
||||
if (cell.type !== 'tableCell' && cell.type !== 'tableHeader') continue;
|
||||
const text = (cell.content ?? [])
|
||||
.map((block) => serializeInline(block.content))
|
||||
.join(' ')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\n/g, ' ')
|
||||
.trim();
|
||||
cells.push(text);
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const width = Math.max(...rows.map((row) => row.length));
|
||||
const padded = rows.map((row) => [...row, ...Array<string>(width - row.length).fill('')]);
|
||||
const lines = padded.map((row) => `| ${row.join(' | ')} |`);
|
||||
// GFM needs a header row; the first row always plays that role.
|
||||
const separator = `| ${Array<string>(width).fill('---').join(' | ')} |`;
|
||||
return [lines[0], separator, ...lines.slice(1)];
|
||||
}
|
||||
|
||||
function serializeBlock(node: JSONContent): string {
|
||||
switch (node.type) {
|
||||
case 'paragraph':
|
||||
return serializeInline(node.content);
|
||||
case 'heading': {
|
||||
const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 6);
|
||||
const text = serializeInline(node.content);
|
||||
return text ? `${'#'.repeat(level)} ${text}` : '';
|
||||
}
|
||||
case 'blockquote': {
|
||||
const inner = serializeBlocks(node.content);
|
||||
return inner
|
||||
.split('\n')
|
||||
.map((line) => (line ? `> ${line}` : '>'))
|
||||
.join('\n');
|
||||
}
|
||||
case 'codeBlock': {
|
||||
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : '';
|
||||
const body = (node.content ?? [])
|
||||
.filter((child) => child.type === 'text' && typeof child.text === 'string')
|
||||
.map((child) => child.text)
|
||||
.join('');
|
||||
const longest = Math.max(2, ...[...body.matchAll(/`+/g)].map((m) => m[0].length));
|
||||
const fence = '`'.repeat(longest + 1);
|
||||
return `${fence}${language}\n${body.replace(/\n$/, '')}\n${fence}`;
|
||||
}
|
||||
case 'horizontalRule':
|
||||
return '---';
|
||||
case 'bulletList':
|
||||
case 'orderedList':
|
||||
case 'taskList':
|
||||
return serializeList(
|
||||
node,
|
||||
0,
|
||||
node.type === 'orderedList',
|
||||
node.type === 'taskList',
|
||||
).join('\n');
|
||||
case 'table':
|
||||
return serializeTable(node).join('\n');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function serializeBlocks(nodes: JSONContent[] | undefined): string {
|
||||
return (nodes ?? [])
|
||||
.map((node) => serializeBlock(node))
|
||||
.filter((block) => block.trim().length > 0)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/** Serialize the editor document to portable Markdown. */
|
||||
export function docToMarkdown(doc: JSONContent | null | undefined): string {
|
||||
if (!doc) return '';
|
||||
return serializeBlocks(doc.content).replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Mark = NonNullable<JSONContent['marks']>[number];
|
||||
|
||||
function textNode(text: string, marks?: Mark[]): JSONContent {
|
||||
const node: JSONContent = { type: 'text', text };
|
||||
if (marks && marks.length > 0) node.marks = marks;
|
||||
return node;
|
||||
}
|
||||
|
||||
const UNESCAPE = /\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/g;
|
||||
const UNESCAPE_SINGLE = /^[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]$/;
|
||||
|
||||
/** Inline Markdown → text nodes: code, bold, italic, strike, links, mentions. */
|
||||
function parseInline(source: string): JSONContent[] {
|
||||
const nodes: JSONContent[] = [];
|
||||
let rest = source;
|
||||
|
||||
const pushText = (text: string) => {
|
||||
if (text) nodes.push(textNode(text.replace(UNESCAPE, '$1')));
|
||||
};
|
||||
|
||||
while (rest.length > 0) {
|
||||
// Backslash escape: the punctuation after it is always literal. This must
|
||||
// win over every construct below (e.g. `\*` must not open emphasis).
|
||||
if (rest[0] === '\\') {
|
||||
if (rest.length > 1 && UNESCAPE_SINGLE.test(rest[1])) {
|
||||
pushText(rest[1]);
|
||||
rest = rest.slice(2);
|
||||
} else {
|
||||
pushText(rest[0]);
|
||||
rest = rest.slice(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline code: `code` with any fence length.
|
||||
const code = rest.match(/^(`+)(.+?)\1(?!`)/s);
|
||||
if (code) {
|
||||
nodes.push(textNode(code[2], [{ type: 'code' }]));
|
||||
rest = rest.slice(code[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// [label](href)
|
||||
const link = rest.match(/^\[([^\]]*)\]\(([^)\s]+)\)/);
|
||||
if (link) {
|
||||
const [, label, href] = link;
|
||||
if (isSafeHref(href)) {
|
||||
for (const inner of parseInline(label)) {
|
||||
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'link', attrs: { href } }] });
|
||||
}
|
||||
} else {
|
||||
pushText(link[0]);
|
||||
}
|
||||
rest = rest.slice(link[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bare nostr: mention becomes a link.
|
||||
const mention = rest.match(/^nostr:((npub|nprofile|note|nevent|naddr)1[02-9ac-hj-np-z]+)/);
|
||||
if (mention) {
|
||||
nodes.push(textNode(`${mention[1].slice(0, 16)}…`, [{ type: 'link', attrs: { href: mention[0] } }]));
|
||||
rest = rest.slice(mention[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bold, italic, strike — longest token first.
|
||||
const bold = rest.match(/^\*\*([^*]+)\*\*/) ?? rest.match(/^__([^_]+)__/);
|
||||
if (bold) {
|
||||
for (const inner of parseInline(bold[1])) {
|
||||
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'bold' } satisfies Mark] });
|
||||
}
|
||||
rest = rest.slice(bold[0].length);
|
||||
continue;
|
||||
}
|
||||
const italic = rest.match(/^\*([^*]+)\*/) ?? rest.match(/^_([^_]+)_/);
|
||||
if (italic) {
|
||||
for (const inner of parseInline(italic[1])) {
|
||||
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'italic' } satisfies Mark] });
|
||||
}
|
||||
rest = rest.slice(italic[0].length);
|
||||
continue;
|
||||
}
|
||||
const strike = rest.match(/^~~([^~]+)~~/);
|
||||
if (strike) {
|
||||
for (const inner of parseInline(strike[1])) {
|
||||
nodes.push({ ...inner, marks: [...(inner.marks ?? []), { type: 'strike' } satisfies Mark] });
|
||||
}
|
||||
rest = rest.slice(strike[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain text up to the next possible construct.
|
||||
const next = rest.search(/[`*_~[\]!]|\\(?=[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])|nostr:/);
|
||||
if (next === -1) {
|
||||
pushText(rest);
|
||||
break;
|
||||
}
|
||||
if (next === 0) {
|
||||
pushText(rest[0]);
|
||||
rest = rest.slice(1);
|
||||
continue;
|
||||
}
|
||||
pushText(rest.slice(0, next));
|
||||
rest = rest.slice(next);
|
||||
}
|
||||
|
||||
// Merge adjacent text nodes carrying identical marks.
|
||||
const merged: JSONContent[] = [];
|
||||
for (const node of nodes) {
|
||||
const prev = merged[merged.length - 1];
|
||||
if (
|
||||
prev &&
|
||||
prev.type === 'text' &&
|
||||
node.type === 'text' &&
|
||||
JSON.stringify(prev.marks ?? []) === JSON.stringify(node.marks ?? [])
|
||||
) {
|
||||
prev.text = (prev.text ?? '') + (node.text ?? '');
|
||||
} else {
|
||||
merged.push(node);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
const LIST_ITEM = /^(\s*)([-+*]|\d+\.)\s+(?:\[( |x|X)\][ \t]+)?(.*)$/;
|
||||
|
||||
interface ParsedListItem {
|
||||
indent: number;
|
||||
ordered: boolean;
|
||||
task: boolean;
|
||||
checked: boolean;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Turn a flat run of list-item lines into nested list nodes. */
|
||||
function buildList(items: ParsedListItem[]): JSONContent | null {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
interface Frame {
|
||||
indent: number;
|
||||
type: 'bulletList' | 'orderedList' | 'taskList';
|
||||
node: JSONContent;
|
||||
/** Last item added, so a deeper list nests underneath it. */
|
||||
lastItem: JSONContent | null;
|
||||
}
|
||||
|
||||
const first = items[0];
|
||||
const root: Frame = {
|
||||
indent: first.indent,
|
||||
type: first.task ? 'taskList' : first.ordered ? 'orderedList' : 'bulletList',
|
||||
node: { type: first.task ? 'taskList' : first.ordered ? 'orderedList' : 'bulletList', content: [] },
|
||||
lastItem: null,
|
||||
};
|
||||
const stack: Frame[] = [root];
|
||||
|
||||
for (const item of items) {
|
||||
const type = item.task ? 'taskList' : item.ordered ? 'orderedList' : 'bulletList';
|
||||
|
||||
// Pop frames this line no longer belongs to.
|
||||
while (stack.length > 1 && item.indent <= stack[stack.length - 1].indent) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
let frame = stack[stack.length - 1];
|
||||
if (item.indent > frame.indent) {
|
||||
// Deeper: nest a new list inside the previous item.
|
||||
frame = { indent: item.indent, type, node: { type, content: [] }, lastItem: null };
|
||||
const parent = stack[stack.length - 1].lastItem;
|
||||
if (parent) {
|
||||
parent.content = [...(parent.content ?? []), frame.node];
|
||||
}
|
||||
stack.push(frame);
|
||||
} else if (frame.type !== type && item.indent === frame.indent) {
|
||||
// Marker flavor changed at the same depth: switch this list's type.
|
||||
frame.type = type;
|
||||
frame.node.type = type;
|
||||
}
|
||||
|
||||
const node: JSONContent = {
|
||||
type: item.task ? 'taskItem' : 'listItem',
|
||||
...(item.task ? { attrs: { checked: item.checked } } : {}),
|
||||
content: [{ type: 'paragraph', content: parseInline(item.text) }],
|
||||
};
|
||||
frame.node.content = [...(frame.node.content ?? []), node];
|
||||
frame.lastItem = node;
|
||||
}
|
||||
|
||||
return root.node;
|
||||
}
|
||||
|
||||
/** Parse Markdown into a ProseMirror-compatible JSON document. */
|
||||
export function markdownToDoc(markdown: string): JSONContent {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
||||
const blocks: JSONContent[] = [];
|
||||
|
||||
let paragraph: string[] = [];
|
||||
const flushParagraph = () => {
|
||||
const text = paragraph.join(' ').trim();
|
||||
paragraph = [];
|
||||
if (!text) return;
|
||||
const content = parseInline(text);
|
||||
blocks.push({ type: 'paragraph', ...(content.length > 0 ? { content } : {}) });
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Fenced code block.
|
||||
const fence = line.match(/^(`{3,}|~{3,})([\w-]*)\s*$/);
|
||||
if (fence) {
|
||||
flushParagraph();
|
||||
const marker = fence[1][0];
|
||||
const length = fence[1].length;
|
||||
const language = fence[2] ?? '';
|
||||
const body: string[] = [];
|
||||
i += 1;
|
||||
const closing = new RegExp(`^\\${marker}{${length},}\\s*$`);
|
||||
while (i < lines.length && !closing.test(lines[i])) {
|
||||
body.push(lines[i]);
|
||||
i += 1;
|
||||
}
|
||||
i += 1; // consume the closing fence (or run off the end)
|
||||
blocks.push({
|
||||
type: 'codeBlock',
|
||||
attrs: { language: language || null },
|
||||
...(body.length > 0 ? { content: [{ type: 'text', text: body.join('\n') }] } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: header row, | --- | separator, then body rows.
|
||||
if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
|
||||
flushParagraph();
|
||||
const parseRow = (row: string, header: boolean): JSONContent => ({
|
||||
type: 'tableRow',
|
||||
content: row
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split(/(?<!\\)\|/)
|
||||
.map((cell) => ({
|
||||
type: header ? 'tableHeader' : 'tableCell',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: parseInline(cell.replace(/\\\|/g, '|').trim()),
|
||||
},
|
||||
],
|
||||
})),
|
||||
});
|
||||
const rows: JSONContent[] = [parseRow(line, true)];
|
||||
i += 2;
|
||||
while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
|
||||
rows.push(parseRow(lines[i], false));
|
||||
i += 1;
|
||||
}
|
||||
blocks.push({ type: 'table', content: rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Heading.
|
||||
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
const content = parseInline(heading[2].trim());
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
attrs: { level: heading[1].length },
|
||||
...(content.length > 0 ? { content } : {}),
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule.
|
||||
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
blocks.push({ type: 'horizontalRule' });
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote: collect the run and parse the inner text recursively.
|
||||
if (/^\s*>/.test(line)) {
|
||||
flushParagraph();
|
||||
const inner: string[] = [];
|
||||
while (i < lines.length && /^\s*>/.test(lines[i])) {
|
||||
inner.push(lines[i].replace(/^\s*>\s?/, ''));
|
||||
i += 1;
|
||||
}
|
||||
const doc = markdownToDoc(inner.join('\n'));
|
||||
blocks.push({ type: 'blockquote', content: doc.content ?? [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
// List run (bullets, ordered, tasks), nested by two-space indentation.
|
||||
if (LIST_ITEM.test(line)) {
|
||||
flushParagraph();
|
||||
const items: ParsedListItem[] = [];
|
||||
while (i < lines.length && LIST_ITEM.test(lines[i])) {
|
||||
const match = lines[i].match(LIST_ITEM)!;
|
||||
items.push({
|
||||
indent: match[1].replace(/\t/g, ' ').length,
|
||||
ordered: /^\d+\.$/.test(match[2]),
|
||||
task: match[3] !== undefined,
|
||||
checked: (match[3] ?? '').toLowerCase() === 'x',
|
||||
text: match[4] ?? '',
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
const list = buildList(items);
|
||||
if (list) blocks.push(list);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
flushParagraph();
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line.trim());
|
||||
i += 1;
|
||||
}
|
||||
flushParagraph();
|
||||
|
||||
return { type: 'doc', content: blocks };
|
||||
}
|
||||
100
src/lib/documents/publish.test.ts
Normal file
100
src/lib/documents/publish.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { buildPublishTemplate, deriveSummary, documentIdentifier, publicationFromEvent } from './publish';
|
||||
import type { DocumentMeta } from './types';
|
||||
|
||||
function meta(overrides: Partial<DocumentMeta> = {}): DocumentMeta {
|
||||
return {
|
||||
id: 'abcd1234-0000-0000-0000-000000000000',
|
||||
title: 'My Notes',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
savedAt: 0,
|
||||
role: 'owner',
|
||||
archived: false,
|
||||
attachments: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('documentIdentifier', () => {
|
||||
it('slugifies the title and suffixes the document id', () => {
|
||||
expect(documentIdentifier('My Notes!', 'abcd1234-rest')).toBe('my-notes-abcd1234');
|
||||
});
|
||||
|
||||
it('falls back to a generic slug for untitled documents', () => {
|
||||
expect(documentIdentifier('!!!', 'abcd1234-rest')).toBe('document-abcd1234');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPublishTemplate', () => {
|
||||
it('builds a NIP-23 kind 30023 template', () => {
|
||||
const template = buildPublishTemplate(meta(), '# Hello\n\nBody', 'A summary', []);
|
||||
expect(template.kind).toBe(30023);
|
||||
expect(template.content).toBe('# Hello\n\nBody');
|
||||
expect(template.tags).toContainEqual(['d', 'my-notes-abcd1234']);
|
||||
expect(template.tags).toContainEqual(['title', 'My Notes']);
|
||||
expect(template.tags).toContainEqual(['summary', 'A summary']);
|
||||
expect(template.tags.some(([name]) => name === 'published_at')).toBe(true);
|
||||
});
|
||||
|
||||
it('reuses the existing identifier on republish', () => {
|
||||
const existing = meta({
|
||||
publication: {
|
||||
eventId: 'e',
|
||||
identifier: 'kept-slug',
|
||||
address: '30023:pk:kept-slug',
|
||||
publishedAt: 1,
|
||||
title: 'Old',
|
||||
},
|
||||
});
|
||||
const template = buildPublishTemplate(existing, 'body', '', []);
|
||||
expect(template.tags).toContainEqual(['d', 'kept-slug']);
|
||||
});
|
||||
|
||||
it('attaches NIP-94 imeta metadata', () => {
|
||||
const attachment = { url: 'https://blossom.example/f.png', sha256: 'a'.repeat(64) };
|
||||
const template = buildPublishTemplate(meta(), 'body', '', [attachment]);
|
||||
expect(template.tags.some(([name]) => name === 'imeta')).toBe(true);
|
||||
expect(template.tags).toContainEqual(['x', 'a'.repeat(64)]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicationFromEvent', () => {
|
||||
it('extracts the address and timestamp', () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'event-id',
|
||||
pubkey: 'pk',
|
||||
kind: 30023,
|
||||
created_at: 100,
|
||||
content: '',
|
||||
sig: '',
|
||||
tags: [
|
||||
['d', 'slug'],
|
||||
['published_at', '99'],
|
||||
],
|
||||
};
|
||||
expect(publicationFromEvent(event, 'My Notes')).toEqual({
|
||||
eventId: 'event-id',
|
||||
identifier: 'slug',
|
||||
address: '30023:pk:slug',
|
||||
publishedAt: 99,
|
||||
title: 'My Notes',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveSummary', () => {
|
||||
it('strips Markdown syntax into plain text', () => {
|
||||
expect(deriveSummary('# Title\n\nSome **bold** [link](https://example.com) text')).toBe(
|
||||
'Title Some bold link text',
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates long bodies', () => {
|
||||
const long = 'word '.repeat(100);
|
||||
const summary = deriveSummary(long, 40);
|
||||
expect(summary.length).toBeLessThanOrEqual(40);
|
||||
expect(summary.endsWith('…')).toBe(true);
|
||||
});
|
||||
});
|
||||
78
src/lib/documents/publish.ts
Normal file
78
src/lib/documents/publish.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { tagValue } from '@/lib/nostrUtils';
|
||||
import type { DocumentAttachment, DocumentMeta, PublicationRecord } from './types';
|
||||
import { attachmentToImetaTags } from './attachments';
|
||||
|
||||
export const ARTICLE_KIND = 30023;
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[a-z0-9-]+$/;
|
||||
|
||||
/**
|
||||
* The NIP-23 `d` identifier is part of the article's permanent address, so it
|
||||
* must be a plain slug: lowercase, URL-safe, and — per NIP-01 — free of the
|
||||
* characters that would break address parsing.
|
||||
*/
|
||||
export function documentIdentifier(title: string, id: string): string {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
const base = IDENTIFIER_PATTERN.test(slug) && slug.length > 0 ? slug : 'document';
|
||||
return `${base}-${id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
export interface PublishTemplate {
|
||||
kind: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the kind 30023 snapshot event template. The snapshot is a portable,
|
||||
* owner-approved release: it carries the Markdown body, title, summary and
|
||||
* NIP-94 attachment metadata, and never replaces the live document.
|
||||
*/
|
||||
export function buildPublishTemplate(
|
||||
meta: DocumentMeta,
|
||||
markdown: string,
|
||||
summary: string,
|
||||
attachments: DocumentAttachment[],
|
||||
): PublishTemplate {
|
||||
const identifier = meta.publication?.identifier ?? documentIdentifier(meta.title, meta.id);
|
||||
const tags: string[][] = [
|
||||
['d', identifier],
|
||||
['title', meta.title],
|
||||
['published_at', String(Math.floor(Date.now() / 1000))],
|
||||
];
|
||||
if (summary) tags.push(['summary', summary]);
|
||||
for (const attachment of attachments) {
|
||||
tags.push(...attachmentToImetaTags(attachment));
|
||||
}
|
||||
return { kind: ARTICLE_KIND, content: markdown, tags };
|
||||
}
|
||||
|
||||
/** Extract the publication record from the signed, relay-acknowledged event. */
|
||||
export function publicationFromEvent(event: NostrEvent, title: string): PublicationRecord {
|
||||
const identifier = tagValue(event, 'd') ?? '';
|
||||
return {
|
||||
eventId: event.id,
|
||||
identifier,
|
||||
address: `${event.kind}:${event.pubkey}:${identifier}`,
|
||||
publishedAt: Number(tagValue(event, 'published_at')) || event.created_at,
|
||||
title,
|
||||
};
|
||||
}
|
||||
|
||||
/** A short plain-text summary derived from the Markdown body. */
|
||||
export function deriveSummary(markdown: string, maxLength = 160): string {
|
||||
const plain = markdown
|
||||
.replace(/^[#>\s`-]+/gm, '')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[*_~`]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (plain.length <= maxLength) return plain;
|
||||
return `${plain.slice(0, maxLength - 1).trimEnd()}…`;
|
||||
}
|
||||
57
src/lib/documents/sanitizeHtml.test.ts
Normal file
57
src/lib/documents/sanitizeHtml.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeHtml } from './sanitizeHtml';
|
||||
|
||||
describe('sanitizeHtml', () => {
|
||||
it('keeps allowlisted formatting elements', () => {
|
||||
const input = '<h1>Title</h1><p>Some <strong>bold</strong> and <em>italic</em> and <s>struck</s> and <u>under</u>.</p>';
|
||||
expect(sanitizeHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('removes scripts and event handlers', () => {
|
||||
const input = '<p onclick="alert(1)">hi</p><script>alert(2)</script>';
|
||||
const result = sanitizeHtml(input);
|
||||
expect(result).not.toContain('onclick');
|
||||
expect(result).not.toContain('<script');
|
||||
expect(result).toContain('<p>hi</p>');
|
||||
});
|
||||
|
||||
it('drops javascript: hrefs but keeps safe protocols', () => {
|
||||
const input =
|
||||
'<a href="javascript:alert(1)">evil</a><a href="https://example.com">safe</a><a href="nostr:npub1abc">mention</a>';
|
||||
const result = sanitizeHtml(input);
|
||||
expect(result).not.toContain('javascript:');
|
||||
expect(result).toContain('<a>evil</a>');
|
||||
expect(result).toContain('href="https://example.com/"');
|
||||
expect(result).toContain('rel="noopener noreferrer nofollow"');
|
||||
expect(result).toContain('nostr:npub1abc');
|
||||
});
|
||||
|
||||
it('unwraps disallowed elements but keeps their text', () => {
|
||||
const input = '<div><span>kept</span></div><iframe src="https://evil.example"></iframe>';
|
||||
const result = sanitizeHtml(input);
|
||||
expect(result).toContain('kept');
|
||||
expect(result).not.toContain('<div');
|
||||
expect(result).not.toContain('<iframe');
|
||||
expect(result).not.toContain('evil.example');
|
||||
});
|
||||
|
||||
it('strips style attributes and srcdoc', () => {
|
||||
const input = '<p style="background:url(javascript:alert(1))">styled</p>';
|
||||
expect(sanitizeHtml(input)).toBe('<p>styled</p>');
|
||||
});
|
||||
|
||||
it('keeps checklist classes and data-checked', () => {
|
||||
const input =
|
||||
'<ul class="task-list"><li class="task-item" data-checked="true">done</li></ul>';
|
||||
expect(sanitizeHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('keeps tables', () => {
|
||||
const input = '<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table>';
|
||||
expect(sanitizeHtml(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('returns an empty string for empty input', () => {
|
||||
expect(sanitizeHtml('')).toBe('');
|
||||
});
|
||||
});
|
||||
121
src/lib/documents/sanitizeHtml.ts
Normal file
121
src/lib/documents/sanitizeHtml.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Allowlist-based sanitizer for HTML that is about to enter the editor
|
||||
* (pasted or imported content). It never touches `innerHTML` on a live node
|
||||
* and never returns markup for `dangerouslySetInnerHTML` — the returned
|
||||
* string feeds Tiptap's paste parser only.
|
||||
*
|
||||
* The allowlist mirrors the constrained editor schema: headings, paragraphs,
|
||||
* lists, checklists, quotes, code, tables, links and inline formatting.
|
||||
* Everything else — scripts, styles, iframes, forms, event handlers,
|
||||
* `javascript:` URLs — is dropped, not escaped.
|
||||
*/
|
||||
|
||||
const ALLOWED_ELEMENTS = new Set([
|
||||
'a',
|
||||
'blockquote',
|
||||
'br',
|
||||
'code',
|
||||
'em',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'hr',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
'pre',
|
||||
's',
|
||||
'strong',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
'u',
|
||||
'ul',
|
||||
]);
|
||||
|
||||
/** class="task-list" / "task-item" and data-checked carry checklist state. */
|
||||
const ALLOWED_CLASSES = new Set(['task-list', 'task-item']);
|
||||
|
||||
const SAFE_PROTOCOLS = new Set(['https:', 'http:', 'mailto:', 'nostr:']);
|
||||
|
||||
function sanitizeHref(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed = new URL(value, window.location.origin);
|
||||
return SAFE_PROTOCOLS.has(parsed.protocol) ? parsed.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeAttributes(element: Element): void {
|
||||
for (const attribute of [...element.attributes]) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
|
||||
// Event handlers, styles and unknown data attributes never survive.
|
||||
if (name.startsWith('on') || name === 'style' || name === 'srcdoc') {
|
||||
element.removeAttribute(attribute.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (element.tagName === 'A' && name === 'href') {
|
||||
const safe = sanitizeHref(attribute.value);
|
||||
if (safe) {
|
||||
element.setAttribute('href', safe);
|
||||
element.setAttribute('rel', 'noopener noreferrer nofollow');
|
||||
} else {
|
||||
element.removeAttribute(attribute.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'class') {
|
||||
const kept = attribute.value
|
||||
.split(/\s+/)
|
||||
.filter((cls) => ALLOWED_CLASSES.has(cls))
|
||||
.join(' ');
|
||||
if (kept) element.setAttribute('class', kept);
|
||||
else element.removeAttribute(attribute.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'data-checked' && (attribute.value === 'true' || attribute.value === 'false')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
element.removeAttribute(attribute.name);
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeElement(element: Element): void {
|
||||
// Children first: a disallowed child is unwrapped before we look up again.
|
||||
for (const child of [...element.children]) {
|
||||
sanitizeElement(child);
|
||||
}
|
||||
|
||||
const tag = element.tagName.toLowerCase();
|
||||
if (!ALLOWED_ELEMENTS.has(tag)) {
|
||||
// Unwrap: keep the (already sanitized) children, drop the wrapper. This
|
||||
// keeps the text of a <div> or <span> instead of deleting it.
|
||||
element.replaceWith(...element.childNodes);
|
||||
return;
|
||||
}
|
||||
|
||||
sanitizeAttributes(element);
|
||||
}
|
||||
|
||||
/** Sanitize an HTML string into editor-safe markup. */
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (typeof html !== 'string' || html.length === 0) return '';
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
for (const child of [...doc.body.children]) {
|
||||
sanitizeElement(child);
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
95
src/lib/documents/store.ts
Normal file
95
src/lib/documents/store.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||
import type { DocumentMeta } from './types';
|
||||
|
||||
const INDEX_KEY = 'layer:documents:index';
|
||||
const CURRENT_VERSION = 1;
|
||||
|
||||
interface DocumentIndex {
|
||||
version: number;
|
||||
documents: DocumentMeta[];
|
||||
}
|
||||
|
||||
function emptyIndex(): DocumentIndex {
|
||||
return { version: CURRENT_VERSION, documents: [] };
|
||||
}
|
||||
|
||||
function isValidMeta(value: unknown): value is DocumentMeta {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const meta = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof meta.id === 'string' &&
|
||||
meta.id.length > 0 &&
|
||||
typeof meta.title === 'string' &&
|
||||
typeof meta.createdAt === 'number' &&
|
||||
typeof meta.updatedAt === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
function parseIndex(raw: string): DocumentIndex {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== 'object' || parsed === null) return emptyIndex();
|
||||
const documents = (parsed as Record<string, unknown>).documents;
|
||||
if (!Array.isArray(documents)) return emptyIndex();
|
||||
return { version: CURRENT_VERSION, documents: documents.filter(isValidMeta) };
|
||||
}
|
||||
|
||||
function mintId(): string {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
return `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The document library index: one localStorage entry holding the metadata of
|
||||
* every document. Bodies live in IndexedDB (see `ydoc.ts`), which keeps this
|
||||
* index tiny and keeps per-keystroke writes out of synchronous storage.
|
||||
*
|
||||
* `useLocalStorage` broadcasts writes on `window`, so a rename in one window
|
||||
* shows up in an open library without a reload.
|
||||
*/
|
||||
export function useDocumentIndex() {
|
||||
const [index, setIndex] = useLocalStorage<DocumentIndex>(INDEX_KEY, emptyIndex(), {
|
||||
serialize: JSON.stringify,
|
||||
deserialize: parseIndex,
|
||||
});
|
||||
|
||||
const documents = [...index.documents]
|
||||
.filter((doc) => !doc.archived)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
const createDocument = (title: string): DocumentMeta => {
|
||||
const now = Date.now();
|
||||
const meta: DocumentMeta = {
|
||||
id: mintId(),
|
||||
title,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
savedAt: now,
|
||||
role: 'owner',
|
||||
archived: false,
|
||||
attachments: [],
|
||||
};
|
||||
setIndex((prev) => ({ ...prev, documents: [...prev.documents, meta] }));
|
||||
return meta;
|
||||
};
|
||||
|
||||
const updateDocument = (id: string, patch: Partial<Omit<DocumentMeta, 'id'>>) => {
|
||||
setIndex((prev) => ({
|
||||
...prev,
|
||||
documents: prev.documents.map((doc) =>
|
||||
doc.id === id ? { ...doc, ...patch, id: doc.id } : doc,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const removeDocument = (id: string) => {
|
||||
setIndex((prev) => ({
|
||||
...prev,
|
||||
documents: prev.documents.filter((doc) => doc.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
return { documents, createDocument, updateDocument, removeDocument };
|
||||
}
|
||||
63
src/lib/documents/types.ts
Normal file
63
src/lib/documents/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Shared types for the Documents app.
|
||||
*
|
||||
* Phase 1 stores documents locally (metadata in localStorage, the rich-text
|
||||
* body as a Yjs document persisted to IndexedDB) and releases portable
|
||||
* Markdown snapshots as NIP-23 kind 30023 events. Phase 2 moves the live
|
||||
* document behind a Hocuspocus-style collaboration service that owns the
|
||||
* ACL — the roles below already model that boundary.
|
||||
*/
|
||||
|
||||
/** Document access roles, ordered from most to least privileged. */
|
||||
export type DocumentRole = 'owner' | 'editor' | 'commenter' | 'viewer';
|
||||
|
||||
/** What each role may do. Server-side enforcement arrives with Phase 2. */
|
||||
export const ROLE_CAPABILITIES: Record<
|
||||
DocumentRole,
|
||||
{ edit: boolean; comment: boolean; manageAccess: boolean; publish: boolean }
|
||||
> = {
|
||||
owner: { edit: true, comment: true, manageAccess: true, publish: true },
|
||||
editor: { edit: true, comment: true, manageAccess: false, publish: false },
|
||||
commenter: { edit: false, comment: true, manageAccess: false, publish: false },
|
||||
viewer: { edit: false, comment: false, manageAccess: false, publish: false },
|
||||
};
|
||||
|
||||
export interface PublicationRecord {
|
||||
/** Kind 30023 event id of the published snapshot. */
|
||||
eventId: string;
|
||||
/** NIP-23 `d` identifier of the snapshot. */
|
||||
identifier: string;
|
||||
/** Addressable coordinate (`30023:<pubkey>:<identifier>`). */
|
||||
address: string;
|
||||
publishedAt: number;
|
||||
/** Title the snapshot was published under. */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** Validated NIP-94-style metadata for a Blossom attachment. */
|
||||
export interface DocumentAttachment {
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
/** Lowercase hex SHA-256 of the blob, when the uploader reported it. */
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export interface DocumentMeta {
|
||||
/** Stable id, also the IndexedDB/Yjs document name. */
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
/** Milliseconds timestamp of the last successful autosave. */
|
||||
savedAt: number;
|
||||
role: DocumentRole;
|
||||
/** True once the owner has archived the document (kept, but read-only). */
|
||||
archived: boolean;
|
||||
/** Most recent published snapshot, if any. Never the live source of truth. */
|
||||
publication?: PublicationRecord;
|
||||
attachments: DocumentAttachment[];
|
||||
}
|
||||
|
||||
/** Autosave surface state, shown in the editor toolbar. */
|
||||
export type SaveState = 'loading' | 'saving' | 'saved' | 'offline' | 'error';
|
||||
37
src/lib/documents/ydoc.test.ts
Normal file
37
src/lib/documents/ydoc.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as Y from 'yjs';
|
||||
import { openDocumentSession } from './ydoc';
|
||||
|
||||
// jsdom has no IndexedDB; the session must degrade to in-memory gracefully.
|
||||
|
||||
describe('openDocumentSession', () => {
|
||||
it('opens a document and fires onSynced', async () => {
|
||||
const onSynced = vi.fn();
|
||||
const session = openDocumentSession('test-doc', { onUpdate: () => {}, onSynced });
|
||||
await session.whenSynced;
|
||||
expect(onSynced).toHaveBeenCalledOnce();
|
||||
expect(session.doc).toBeInstanceOf(Y.Doc);
|
||||
session.destroy();
|
||||
});
|
||||
|
||||
it('forwards updates', async () => {
|
||||
const onUpdate = vi.fn();
|
||||
const session = openDocumentSession('test-doc', { onUpdate, onSynced: () => {} });
|
||||
session.doc.getXmlFragment('default');
|
||||
session.doc.transact(() => {
|
||||
session.doc.getMap('meta').set('key', 'value');
|
||||
});
|
||||
expect(onUpdate).toHaveBeenCalled();
|
||||
session.destroy();
|
||||
});
|
||||
|
||||
it('destroy is idempotent and stops callbacks', async () => {
|
||||
const onSynced = vi.fn();
|
||||
const session = openDocumentSession('test-doc', { onUpdate: () => {}, onSynced });
|
||||
session.destroy();
|
||||
session.destroy();
|
||||
await session.whenSynced;
|
||||
// A destroyed session must not fire onSynced afterwards.
|
||||
expect(onSynced).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
103
src/lib/documents/ydoc.ts
Normal file
103
src/lib/documents/ydoc.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
|
||||
const DB_PREFIX = 'layer-doc-';
|
||||
|
||||
/** Callbacks a session reports through. */
|
||||
export interface DocumentSessionEvents {
|
||||
/** Fired on every Yjs update (local typing and, later, remote peers). */
|
||||
onUpdate: () => void;
|
||||
/** Fired once the local copy has been loaded into the document. */
|
||||
onSynced: () => void;
|
||||
}
|
||||
|
||||
export interface DocumentSession {
|
||||
doc: Y.Doc;
|
||||
/** Resolved when the local snapshot has been applied. */
|
||||
whenSynced: Promise<void>;
|
||||
/** Tears down persistence and frees the document. Safe to call twice. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** IndexedDB is unavailable in some environments (tests, locked-down modes). */
|
||||
function indexedDbAvailable(): boolean {
|
||||
try {
|
||||
return typeof indexedDB !== 'undefined';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Yjs document for `documentId` with offline persistence behind it.
|
||||
*
|
||||
* The session is created and torn down in a `useEffect`, so React Strict
|
||||
* Mode's mount → unmount → mount cycle simply opens, closes and reopens it:
|
||||
* each mount gets its own `Y.Doc`, no provider or subscription outlives its
|
||||
* effect, and the IndexedDB write behind an update is idempotent, so the
|
||||
* double-mount cannot corrupt or duplicate state.
|
||||
*
|
||||
* Without IndexedDB the session degrades to an in-memory document: editing
|
||||
* still works, it just does not survive a reload until the Phase 2 service
|
||||
* provides persistence.
|
||||
*
|
||||
* Phase 2 attaches a Hocuspocus WebSocket provider here; `ydoc.on('update')`
|
||||
* is the single fan-in point, so nothing else changes.
|
||||
*/
|
||||
export function openDocumentSession(
|
||||
documentId: string,
|
||||
events: DocumentSessionEvents,
|
||||
): DocumentSession {
|
||||
const doc = new Y.Doc();
|
||||
const persistence = indexedDbAvailable()
|
||||
? new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc)
|
||||
: null;
|
||||
|
||||
const onUpdate = () => events.onUpdate();
|
||||
doc.on('update', onUpdate);
|
||||
|
||||
let destroyed = false;
|
||||
|
||||
// `whenSynced` resolves with the persistence instance; callers only care
|
||||
// that the snapshot landed. Without persistence the doc starts empty and is
|
||||
// "synced" immediately — asynchronously, matching the IndexedDB contract.
|
||||
let whenSynced: Promise<void>;
|
||||
let cleanup: () => void;
|
||||
if (persistence) {
|
||||
const onSynced = () => events.onSynced();
|
||||
persistence.on('synced', onSynced);
|
||||
whenSynced = persistence.whenSynced.then(() => undefined);
|
||||
cleanup = () => persistence.off('synced', onSynced);
|
||||
} else {
|
||||
whenSynced = Promise.resolve().then(() => {
|
||||
if (!destroyed) events.onSynced();
|
||||
});
|
||||
cleanup = () => {};
|
||||
}
|
||||
|
||||
return {
|
||||
doc,
|
||||
whenSynced,
|
||||
destroy: () => {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
doc.off('update', onUpdate);
|
||||
cleanup();
|
||||
persistence?.destroy();
|
||||
doc.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes the persisted body of a document that was deleted from the index. */
|
||||
export async function deletePersistedDocument(documentId: string): Promise<void> {
|
||||
if (!indexedDbAvailable()) return;
|
||||
const doc = new Y.Doc();
|
||||
const persistence = new IndexeddbPersistence(`${DB_PREFIX}${documentId}`, doc);
|
||||
try {
|
||||
await persistence.clearData();
|
||||
} finally {
|
||||
persistence.destroy();
|
||||
doc.destroy();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { lazy } from 'react';
|
||||
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, Image, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react';
|
||||
import { Activity, Bookmark, BookOpen, CalendarDays, FileText, FilePenLine, Image, Info, Link2, Radio, Rss, Search, Settings, Sparkles, User } from 'lucide-react';
|
||||
import type { AppDefinition } from './types';
|
||||
|
||||
/**
|
||||
@@ -109,6 +109,18 @@ export const APPS: AppDefinition[] = [
|
||||
defaultSize: { width: 900, height: 700 },
|
||||
minSize: { width: 420, height: 420 },
|
||||
},
|
||||
{
|
||||
id: 'documents',
|
||||
title: 'Documents',
|
||||
description: 'Write rich-text documents and publish them as Nostr articles',
|
||||
icon: FilePenLine,
|
||||
category: 'tools',
|
||||
component: lazy(() => import('@/apps/documents')),
|
||||
defaultSize: { width: 820, height: 720 },
|
||||
minSize: { width: 360, height: 360 },
|
||||
// Auth is enforced inside the app: writing documents needs a signer, so
|
||||
// the app renders a login prompt when signed out (same as Bookmarks).
|
||||
},
|
||||
{
|
||||
id: 'spells',
|
||||
title: 'Spells',
|
||||
|
||||
Reference in New Issue
Block a user