Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions docs/frontend/rich-text-editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { RichTextEditor } from '@/components/markdown/RichTextEditor';
| `className` | `string` | Extra container classes |
| `maxLength` | `number` | Shows a counter and truncates input |
| `allowImages` | `boolean` | Toggle image insertion (default `true`) |
| `showHtmlToggle` | `boolean` | Show the *HTML* view toggle (default `true`, issue #90) |
| `onBlur` | `() => void` | Forwarded to the textarea (react-hook-form support) |
| `aria-invalid` / `aria-describedby` | — | Forwarded for validation messaging |

Expand All @@ -71,8 +72,49 @@ the server-side `POST /api/v1/uploads` endpoint and reference the returned URL.
The *Preview* toggle renders the markdown through `MarkdownContent` with
sanitization enabled, so raw HTML and unsafe links are stripped before display.

## HTML view and "save as HTML" (issue #90)

The *HTML* toggle shows the sanitized HTML rendition of the current description
with a **Copy HTML** button. The conversion lives in
`frontend/lib/markdown/to-html.ts` (`markdownToHtml`), and the persisted payload
is built by `frontend/lib/projects/work-description.ts` — both
`/dashboard/projects/new` and its localized variant call
`serializeWorkDescription`, so the on-chain shape stays consistent:

```ts
serializeWorkDescription({ title, description, repo });
// => {
// title,
// description, // markdown source of truth (issue #795)
// descriptionHtml, // sanitized HTML rendition (issue #90)
// repo,
// }
```

### Why a local serializer

The preview renders with `react-markdown`, but the project does not depend on a
markdown *stringifier* (`rehype-stringify`), and adding one purely for this would
be a heavier change than the feature warrants. `to-html.ts` therefore covers the
subset the editor can produce — headings, emphasis, strikethrough, inline and
fenced code, links, images, lists, quotes and rules — and is dependency-free.

Both paths share the same security posture:

- raw HTML is never passed through — every text run is escaped, so `<script>`
becomes `&lt;script&gt;`;
- only `http:`, `https:` and `mailto:` URLs are emitted (plus anything passed via
`allowedProtocols`); `javascript:`, `data:`, `vbscript:` and protocol-relative
URLs are dropped, and external links get `rel="noopener noreferrer"`;
- images additionally use `loading="lazy"`.

## Tests

`frontend/components/markdown/__tests__/RichTextEditor.test.tsx` covers
formatting, list prefixes, image insertion/validation, preview rendering, the
character limit, and the disabled state.

`frontend/lib/markdown/__tests__/to-html.test.ts` and
`frontend/lib/projects/__tests__/work-description.test.ts` cover the HTML
rendition, including injection attempts and URL-scheme filtering, plus the
"save as HTML" payload.
8 changes: 6 additions & 2 deletions frontend/app/[locale]/dashboard/projects/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useAgenticPay } from '@/lib/hooks/useAgenticPay';
import { useAccount } from 'wagmi';
import { ConfirmModal } from '@/components/transaction/ConfirmModal';
import { RichTextEditor } from '@/components/markdown/RichTextEditor';
import { serializeWorkDescription } from '@/lib/projects/work-description';
import { parseEther } from 'viem';

type PendingTransaction = {
Expand Down Expand Up @@ -197,10 +198,13 @@ export default function CreateProjectPage() {
// or just use description. The contract takes `_workDescription`.
// Let's use JSON format for better structure if we want to include milestones later,
// but for now just a string.
const workDesc = JSON.stringify({
// Descriptions are authored as markdown (issue #795) and persisted as
// both markdown and HTML (issue #90) so consumers that cannot render
// markdown can display the description directly.
const workDesc = serializeWorkDescription({
title: data.title,
description: data.description,
repo: data.githubRepo
repo: data.githubRepo,
});

const prepared = await prepareTransaction('createProject', [
Expand Down
8 changes: 6 additions & 2 deletions frontend/app/dashboard/projects/new/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useAgenticPay } from '@/lib/hooks/useAgenticPay';
import { useAccount } from 'wagmi';
import { ConfirmModal } from '@/components/transaction/ConfirmModal';
import { RichTextEditor } from '@/components/markdown/RichTextEditor';
import { serializeWorkDescription } from '@/lib/projects/work-description';
import { parseEther } from 'viem';

type PendingTransaction = {
Expand Down Expand Up @@ -197,10 +198,13 @@ export default function CreateProjectPage() {
// or just use description. The contract takes `_workDescription`.
// Let's use JSON format for better structure if we want to include milestones later,
// but for now just a string.
const workDesc = JSON.stringify({
// Descriptions are authored as markdown (issue #795) and persisted as
// both markdown and HTML (issue #90) so consumers that cannot render
// markdown can display the description directly.
const workDesc = serializeWorkDescription({
title: data.title,
description: data.description,
repo: data.githubRepo
repo: data.githubRepo,
});

const prepared = await prepareTransaction('createProject', [
Expand Down
64 changes: 63 additions & 1 deletion frontend/components/markdown/RichTextEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import {
useCallback,
useMemo,
useRef,
useState,
type ChangeEvent,
Expand Down Expand Up @@ -40,6 +41,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { MarkdownContent } from '@/components/markdown/MarkdownContent';
import { markdownToHtml } from '@/lib/markdown/to-html';
import { cn } from '@/lib/utils';

const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5MB
Expand Down Expand Up @@ -86,6 +88,8 @@ export interface RichTextEditorProps {
maxLength?: number;
/** Allow inserting images (URL or drag/drop). Defaults to true. */
allowImages?: boolean;
/** Also surface the description as HTML (see the HTML view toggle, issue #90). */
showHtmlToggle?: boolean;
onBlur?: () => void;
'aria-invalid'?: boolean;
'aria-describedby'?: string;
Expand Down Expand Up @@ -146,12 +150,15 @@ export function RichTextEditor({
className,
maxLength,
allowImages = true,
showHtmlToggle = true,
onBlur,
'aria-invalid': ariaInvalid,
'aria-describedby': ariaDescribedBy,
}: RichTextEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const [showPreview, setShowPreview] = useState(false);
const [showHtml, setShowHtml] = useState(false);
const [copiedHtml, setCopiedHtml] = useState(false);
const [showImageForm, setShowImageForm] = useState(false);
const [imageUrl, setImageUrl] = useState('');
const [imageAlt, setImageAlt] = useState('');
Expand Down Expand Up @@ -305,6 +312,18 @@ export function RichTextEditor({
}
};

// The HTML rendition of the description, i.e. what "save as HTML" persists.
const html = useMemo(() => markdownToHtml(value, { allowImages }), [value, allowImages]);

const handleCopyHtml = () => {
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
if (!clipboard) return;
void clipboard.writeText(html).then(
() => setCopiedHtml(true),
() => setCopiedHtml(false),
);
};

const sourceLength = value.length;

return (
Expand Down Expand Up @@ -354,6 +373,27 @@ export function RichTextEditor({
{showPreview ? <Pencil className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
{showPreview ? 'Edit' : 'Preview'}
</Button>

{showHtmlToggle && (
<Button
type="button"
size="sm"
variant={showHtml ? 'default' : 'ghost'}
className="h-8 gap-1 px-2 text-xs"
aria-pressed={showHtml}
aria-label="View HTML"
title="View the generated HTML"
disabled={disabled}
onClick={() => {
setShowHtml((open) => !open);
setShowPreview(false);
setCopiedHtml(false);
}}
>
<Code className="h-3.5 w-3.5" />
HTML
</Button>
)}
</div>
</div>

Expand Down Expand Up @@ -405,7 +445,29 @@ export function RichTextEditor({
</div>
)}

{showPreview ? (
{showHtml ? (
<div className="space-y-2 p-3" data-testid="rich-text-html">
{value.trim() ? (
<>
<textarea
readOnly
rows={8}
aria-label="HTML output"
className="w-full resize-y rounded-md border bg-muted/40 p-2 font-mono text-xs"
value={html}
/>
<div className="flex items-center gap-2">
<Button type="button" size="sm" variant="outline" onClick={handleCopyHtml}>
Copy HTML
</Button>
{copiedHtml && <span className="text-xs text-muted-foreground">Copied</span>}
</div>
</>
) : (
<p className="text-sm text-muted-foreground">Nothing to convert yet.</p>
)}
</div>
) : showPreview ? (
<div className="min-h-[120px] p-3" data-testid="rich-text-preview">
{value.trim() ? (
<MarkdownContent content={value} previewMode={false} />
Expand Down
166 changes: 166 additions & 0 deletions frontend/lib/markdown/__tests__/to-html.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* markdownToHtml tests — Issue #90 ("Save as HTML")
*/
import { describe, expect, it } from 'vitest';

import { escapeHtml, markdownToHtml } from '@/lib/markdown/to-html';

describe('markdownToHtml — block structure', () => {
it('returns an empty string for empty input', () => {
expect(markdownToHtml('')).toBe('');
expect(markdownToHtml(' \n\n ')).toBe('');
});

it('wraps plain text in a paragraph', () => {
expect(markdownToHtml('Hello world')).toBe('<p>Hello world</p>');
});

it('joins consecutive lines into one paragraph', () => {
expect(markdownToHtml('line one\nline two')).toBe('<p>line one\nline two</p>');
});

it('separates paragraphs on blank lines', () => {
expect(markdownToHtml('first\n\nsecond')).toBe('<p>first</p>\n<p>second</p>');
});

it('renders headings h1–h6', () => {
expect(markdownToHtml('# One')).toBe('<h1>One</h1>');
expect(markdownToHtml('## Two')).toBe('<h2>Two</h2>');
expect(markdownToHtml('### Three')).toBe('<h3>Three</h3>');
expect(markdownToHtml('###### Six')).toBe('<h6>Six</h6>');
});

it('renders bullet lists', () => {
expect(markdownToHtml('- one\n- two')).toBe('<ul><li>one</li><li>two</li></ul>');
});

it('renders ordered lists', () => {
expect(markdownToHtml('1. first\n2. second')).toBe('<ol><li>first</li><li>second</li></ol>');
});

it('renders blockquotes', () => {
expect(markdownToHtml('> quoted')).toBe('<blockquote>quoted</blockquote>');
});

it('renders horizontal rules', () => {
expect(markdownToHtml('---')).toBe('<hr />');
});

it('renders fenced code with a language class and escapes the body', () => {
const html = markdownToHtml('```ts\nconst a = "<b>";\n```');
expect(html).toBe('<pre><code class="language-ts">const a = &quot;&lt;b&gt;&quot;;</code></pre>');
});

it('treats a fenced block as code rather than markdown', () => {
expect(markdownToHtml('```\n**not bold**\n```')).toContain('**not bold**');
});
});

describe('markdownToHtml — inline formatting', () => {
it('renders bold with ** and __', () => {
expect(markdownToHtml('**bold**')).toBe('<p><strong>bold</strong></p>');
expect(markdownToHtml('__bold__')).toBe('<p><strong>bold</strong></p>');
});

it('renders italic with * and _', () => {
expect(markdownToHtml('*italic*')).toBe('<p><em>italic</em></p>');
expect(markdownToHtml('some _italic_ text')).toBe('<p>some <em>italic</em> text</p>');
});

it('renders strikethrough', () => {
expect(markdownToHtml('~~gone~~')).toBe('<p><del>gone</del></p>');
});

it('renders inline code without applying formatting inside it', () => {
expect(markdownToHtml('use `**not bold**` here')).toBe(
'<p>use <code>**not bold**</code> here</p>'
);
});

it('escapes code span contents', () => {
expect(markdownToHtml('`<script>`')).toBe('<p><code>&lt;script&gt;</code></p>');
});

it('combines formatting with surrounding text', () => {
expect(markdownToHtml('a **b** and *c*')).toBe('<p>a <strong>b</strong> and <em>c</em></p>');
});
});

describe('markdownToHtml — links and images', () => {
it('renders links', () => {
expect(markdownToHtml('[docs](https://example.com/docs)')).toBe(
'<p><a href="https://example.com/docs" rel="noopener noreferrer">docs</a></p>'
);
});

it('renders relative links without a rel attribute', () => {
expect(markdownToHtml('[home](/dashboard)')).toBe('<p><a href="/dashboard">home</a></p>');
});

it('allows mailto links', () => {
expect(markdownToHtml('[mail](mailto:hi@example.com)')).toContain('href="mailto:hi@example.com"');
});

it('renders images with alt text and lazy loading', () => {
expect(markdownToHtml('![shot](/img/a.png)')).toBe(
'<p><img src="/img/a.png" alt="shot" loading="lazy" /></p>'
);
});

it('omits images when they are disabled', () => {
const html = markdownToHtml('![shot](/img/a.png)', { allowImages: false });
expect(html).toBe('<p>![shot](/img/a.png)</p>');
});

it('leaves a rejected-protocol link as inert literal text', () => {
const html = markdownToHtml('[click](javascript:alert(1))');
// No anchor is emitted; the syntax survives only as escaped text.
expect(html).not.toContain('<a ');
expect(html).toBe('<p>[click](javascript:alert(1))</p>');
});
});

describe('markdownToHtml — injection defence', () => {
it('escapes raw HTML instead of passing it through', () => {
const html = markdownToHtml('<script>alert(1)</script>');
expect(html).toBe('<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>');
expect(html).not.toContain('<script>');
});

it('escapes HTML inside emphasis', () => {
expect(markdownToHtml('**<img src=x onerror=alert(1)>**')).toContain(
'&lt;img src=x onerror=alert(1)&gt;'
);
});

it('drops event-handler style attributes smuggled through a link', () => {
const html = markdownToHtml('[x](https://example.com" onmouseover="alert(1))');
// The payload may survive as escaped text, but never as a real attribute.
expect(html).not.toContain('<a ');
expect(html).not.toMatch(/<[a-z]+[^>]*onmouseover=/i);
});

it('rejects data: and vbscript: URLs', () => {
const dataUrl = markdownToHtml('[x](data:text/html;base64,PHNjcmlwdD4=)');
expect(dataUrl).not.toContain('<a ');

const vbscript = markdownToHtml('![x](vbscript:msgbox)');
expect(vbscript).not.toContain('<img ');
});

it('rejects protocol-relative URLs', () => {
expect(markdownToHtml('[x](//evil.example.com)')).not.toContain('href="//');
});

it('honours extra allowlisted protocols', () => {
expect(markdownToHtml('[call](tel:+15551234)', { allowedProtocols: ['tel:'] })).toContain(
'href="tel:+15551234"'
);
});
});

describe('escapeHtml', () => {
it('escapes the five HTML-significant characters', () => {
expect(escapeHtml(`<a href="x">&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&amp;&#39;&lt;/a&gt;');
});
});
Loading
Loading