Skip to content

Commit 9d9cd22

Browse files
committed
feat: supoort md export
1 parent 51c24e0 commit 9d9cd22

6 files changed

Lines changed: 328 additions & 5 deletions

File tree

frontend/src/lib/markdown.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,3 +400,13 @@ export function renderMarkdown(source) {
400400
if (!source) return ''
401401
return singletonMd.render(source)
402402
}
403+
404+
// Milkdown preserves pasted `<br>` as raw HTML inline nodes, which then round-
405+
// trip back into serialized markdown verbatim. Users reasonably expect clean
406+
// markdown when copying/exporting, so we normalize those to CommonMark hard
407+
// breaks (two trailing spaces + newline). Any following newline is absorbed
408+
// to avoid turning `<br />\n` into an accidental paragraph break.
409+
export function stripBrTags(source) {
410+
if (!source) return source
411+
return source.replace(/<br\s*\/?>[ \t]*\n?/gi, ' \n')
412+
}

frontend/src/lib/markdown.test.js

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { renderMarkdown } from './markdown'
2+
import { renderMarkdown, stripBrTags } from './markdown'
33

44
describe('renderMarkdown', () => {
55
it('returns empty string for null/undefined/empty', () => {
@@ -275,3 +275,35 @@ describe('renderMarkdown', () => {
275275
expect(html).toContain('visible')
276276
})
277277
})
278+
279+
describe('stripBrTags', () => {
280+
it('passes through null/undefined/empty unchanged', () => {
281+
expect(stripBrTags('')).toBe('')
282+
expect(stripBrTags(null)).toBeNull()
283+
expect(stripBrTags(undefined)).toBeUndefined()
284+
})
285+
286+
it('converts <br /> followed by newline to a markdown hard break', () => {
287+
expect(stripBrTags('Line 1<br />\nLine 2')).toBe('Line 1 \nLine 2')
288+
})
289+
290+
it('handles <br>, <br/>, <br /> and case variants', () => {
291+
expect(stripBrTags('a<br>b')).toBe('a \nb')
292+
expect(stripBrTags('a<br/>b')).toBe('a \nb')
293+
expect(stripBrTags('a<BR />b')).toBe('a \nb')
294+
expect(stripBrTags('a<Br/>b')).toBe('a \nb')
295+
})
296+
297+
it('strips trailing horizontal whitespace after the tag', () => {
298+
expect(stripBrTags('foo<br /> \nbar')).toBe('foo \nbar')
299+
})
300+
301+
it('replaces multiple occurrences', () => {
302+
expect(stripBrTags('a<br />b<br />c')).toBe('a \nb \nc')
303+
})
304+
305+
it('leaves markdown without <br> untouched', () => {
306+
const md = '# Heading\n\nHello world\n\n- item'
307+
expect(stripBrTags(md)).toBe(md)
308+
})
309+
})

frontend/src/pages/NewPage.jsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Editor from '../components/Editor/Editor'
77
import MediaPickerModal from '../components/Editor/MediaPickerModal'
88
import DrawioModal from '../components/DrawioModal'
99
import useUnsavedWarning from '../hooks/useUnsavedWarning'
10+
import { stripBrTags } from '../lib/markdown'
1011

1112
function findNodeById(nodes, id) {
1213
for (const node of nodes) {
@@ -128,7 +129,7 @@ export default function NewPage() {
128129
try {
129130
const page = await createPage({
130131
title,
131-
content_md: content,
132+
content_md: stripBrTags(content),
132133
template_id: selectedTemplate?.id,
133134
parent_id: parentMissing ? null : parentId,
134135
})

frontend/src/pages/PageEdit.jsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import MediaPickerModal from '../components/Editor/MediaPickerModal'
99
import MarkdownViewer from '../components/Viewer/MarkdownViewer'
1010
import DrawioModal from '../components/DrawioModal'
1111
import useUnsavedWarning from '../hooks/useUnsavedWarning'
12+
import { stripBrTags } from '../lib/markdown'
1213
import api from '../api/client'
1314

1415
export default function PageEdit() {
@@ -152,14 +153,17 @@ export default function PageEdit() {
152153
setError('')
153154
setConflict(null)
154155
try {
156+
// Milkdown round-trips pasted <br> tags as raw HTML; normalize to
157+
// markdown hard breaks before persisting so new content stays clean.
158+
const cleanContent = stripBrTags(content)
155159
const updated = await updatePage(slug, {
156160
title,
157-
content_md: content,
161+
content_md: cleanContent,
158162
base_version: baseVersionRef.current,
159163
})
160164
baseVersionRef.current = updated.version
161165
await fetchTree()
162-
setOriginal({ title, content })
166+
setOriginal({ title, content: cleanContent })
163167
setSaving(false)
164168
navigate(`/page/${slug}`)
165169
} catch (err) {

frontend/src/pages/PageView.jsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, useRef, useCallback } from 'react'
1+
import React, { useEffect, useState, useRef, useCallback } from 'react'
22
import { useParams, useNavigate, Link } from 'react-router-dom'
33
import usePages from '../store/usePages'
44
import useTags from '../store/useTags'
@@ -10,6 +10,7 @@ import TableOfContents from '../components/Viewer/TableOfContents'
1010
import Comments from '../components/Comments'
1111
import ConfirmDialog from '../components/ConfirmDialog'
1212
import AclManager from '../components/AclManager'
13+
import { stripBrTags } from '../lib/markdown'
1314
import api from '../api/client'
1415

1516
export default function PageView() {
@@ -141,6 +142,31 @@ export default function PageView() {
141142
setMenuOpen(false)
142143
}
143144

145+
const handleCopyMarkdown = async () => {
146+
try {
147+
await navigator.clipboard.writeText(stripBrTags(page.content_md ?? ''))
148+
setToast('Markdown copied')
149+
} catch {
150+
setToast('Copy failed — clipboard requires HTTPS or localhost')
151+
}
152+
setMenuOpen(false)
153+
}
154+
155+
const handleDownloadMarkdown = () => {
156+
const blob = new Blob([stripBrTags(page.content_md ?? '')], { type: 'text/markdown;charset=utf-8' })
157+
const url = URL.createObjectURL(blob)
158+
const a = document.createElement('a')
159+
a.href = url
160+
a.download = `${page.slug}.md`
161+
document.body.appendChild(a)
162+
a.click()
163+
document.body.removeChild(a)
164+
// Defer revoke: some browsers start the download asynchronously after
165+
// click(), and revoking the blob URL too early can cancel it.
166+
setTimeout(() => URL.revokeObjectURL(url), 0)
167+
setMenuOpen(false)
168+
}
169+
144170
const handleMakePrivate = async () => {
145171
try {
146172
await api.put(`/pages/${slug}`, { is_public: false })
@@ -281,6 +307,27 @@ export default function PageView() {
281307
</>
282308
)}
283309
<div className="border-t border-border my-1" />
310+
<button
311+
onClick={handleCopyMarkdown}
312+
className="w-full text-left px-3 py-2 text-sm text-text hover:bg-surface-hover flex items-center gap-2"
313+
>
314+
<svg className="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
315+
<rect x="9" y="9" width="11" height="11" rx="2" />
316+
<path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1" />
317+
</svg>
318+
Copy Markdown
319+
</button>
320+
<button
321+
onClick={handleDownloadMarkdown}
322+
className="w-full text-left px-3 py-2 text-sm text-text hover:bg-surface-hover flex items-center gap-2"
323+
>
324+
<svg className="w-4 h-4 text-text-secondary" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" viewBox="0 0 24 24">
325+
<path d="M12 3v12" />
326+
<path d="M7 10l5 5 5-5" />
327+
<path d="M5 21h14" />
328+
</svg>
329+
Download .md
330+
</button>
284331
<button
285332
onClick={() => {
286333
setMenuOpen(false)

0 commit comments

Comments
 (0)