From 70f5bf3b034a2abc5339cfff88ac27a316454b93 Mon Sep 17 00:00:00 2001 From: Thomas <28439359+thoda-dev@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:54:39 +0200 Subject: [PATCH] feat(paste): write a paste in markdown, off by default --- ROADMAP.md | 7 - apps/app/app/assets/css/rich-text.css | 2 +- .../{LegalDocument.vue => RichText.vue} | 4 +- .../{LegalTable.vue => RichTextTable.vue} | 0 apps/app/app/components/TextEditor.client.vue | 2 +- apps/app/app/pages/admin/legal.vue | 13 + apps/app/app/pages/index.vue | 55 +- apps/app/app/pages/legal/[slug].vue | 2 +- apps/app/app/pages/p/[id].vue | 46 +- apps/app/i18n/locales/en.json | 9 +- apps/app/i18n/locales/fr.json | 9 +- apps/app/server/api/admin/legal/index.put.ts | 2 +- apps/app/server/api/legal/[slug].get.ts | 2 +- apps/app/server/api/pastes/[id]/meta.get.ts | 4 +- .../app/server/api/pastes/[id]/reveal.post.ts | 1 + apps/app/server/api/pastes/index.post.ts | 3 + .../database/migrations/0005_naive_tigra.sql | 3 + .../migrations/meta/0005_snapshot.json | 1357 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + apps/app/server/database/schema/paste.ts | 8 +- apps/app/shared/utils/legal-markdown.ts | 31 - apps/app/shared/utils/markdown.ts | 73 + apps/app/tests/editor-roundtrip.test.ts | 4 +- apps/app/tests/legal-markdown.test.ts | 83 - apps/app/tests/markdown.test.ts | 154 ++ .../content/2.self-hosting/5.legal-pages.md | 2 +- .../3.using-shhh/1.creating-a-paste.md | 27 + 27 files changed, 1757 insertions(+), 153 deletions(-) rename apps/app/app/components/{LegalDocument.vue => RichText.vue} (78%) rename apps/app/app/components/{LegalTable.vue => RichTextTable.vue} (100%) create mode 100644 apps/app/server/database/migrations/0005_naive_tigra.sql create mode 100644 apps/app/server/database/migrations/meta/0005_snapshot.json delete mode 100644 apps/app/shared/utils/legal-markdown.ts create mode 100644 apps/app/shared/utils/markdown.ts delete mode 100644 apps/app/tests/legal-markdown.test.ts create mode 100644 apps/app/tests/markdown.test.ts diff --git a/ROADMAP.md b/ROADMAP.md index 123e960..fab366b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,13 +38,6 @@ Anything not listed here is either done or deliberately out of scope (see the bo provider stub. Assert on `paste_email_recipients` rows rather than on delivered mail, to avoid depending on a mail server in CI. -## Waiting on upstream - -- **Rich text editing and markdown rendering.** Pastes are plain text today. Nuxt UI's `UEditor` - handles both editing and read-only rendering, so the two arrive together rather than shipping a - separate renderer first — waiting on the component to stabilise (open bugs in v4.x around external - `modelValue` breaking markdown rendering, plugin conflicts, no table support). - ## Later 1. **Outgoing webhooks** — notify on key events (paste created, paste read), disabled by default. diff --git a/apps/app/app/assets/css/rich-text.css b/apps/app/app/assets/css/rich-text.css index 1e85947..ac9c8da 100644 --- a/apps/app/app/assets/css/rich-text.css +++ b/apps/app/app/assets/css/rich-text.css @@ -97,7 +97,7 @@ line-height: inherit; } -/* Tables scroll rather than squeezing their columns on a phone. The frame lives on the wrapper because that is the box that scrolls; Tiptap draws one around every table and LegalTable.vue matches it on the published page. */ +/* Tables scroll rather than squeezing their columns on a phone. The frame lives on the wrapper because that is the box that scrolls; Tiptap draws one around every table and RichTextTable.vue matches it on the published page. */ .rich-text.rich-text .tableWrapper { margin-top: 0; margin-bottom: 1.25rem; diff --git a/apps/app/app/components/LegalDocument.vue b/apps/app/app/components/RichText.vue similarity index 78% rename from apps/app/app/components/LegalDocument.vue rename to apps/app/app/components/RichText.vue index aea4dac..bf97af9 100644 --- a/apps/app/app/components/LegalDocument.vue +++ b/apps/app/app/components/RichText.vue @@ -1,7 +1,7 @@ @@ -10,7 +10,7 @@ defineProps<{ value: MarkdownDocumentType }>()
diff --git a/apps/app/app/components/LegalTable.vue b/apps/app/app/components/RichTextTable.vue similarity index 100% rename from apps/app/app/components/LegalTable.vue rename to apps/app/app/components/RichTextTable.vue diff --git a/apps/app/app/components/TextEditor.client.vue b/apps/app/app/components/TextEditor.client.vue index 11f157a..2fbed92 100644 --- a/apps/app/app/components/TextEditor.client.vue +++ b/apps/app/app/components/TextEditor.client.vue @@ -107,7 +107,7 @@ const tableItems = computed(() => [ :extensions="extensions" :handlers="handlers" :editor-props="editorProps" - :ui="{ base: 'rich-text p-4', content: 'relative w-full' }" + :ui="{ base: 'rich-text p-4 min-h-40', content: 'relative w-full' }" class="overflow-y-auto rounded-md border border-default" > (drafts.value[draftKey.value] = value) }) +// The parser drops images and the CSP would refuse a remote one anyway, so an operator who writes one sees it vanish with nothing said. Same warning as the paste form. +const hasImage = computed(() => containsMarkdownImage(content.value)) + const localeLabels = computed(() => Object.fromEntries(LOCALES.map(code => [code, t(`admin.legal.locales.${code}`)])) ) @@ -251,6 +254,16 @@ function formatDate(value: string) { /> + +

(() => [ const kind = ref<'text' | 'file'>('text') const textContent = ref('') +// Off by default, and deliberately so: a paste is a secret to be copied verbatim far more often than it is a document to be read. See `create.markdownHint`. +const markdownMode = ref(false) const file = ref(null) +// An image cannot be displayed — see `containsMarkdownImage`. The markdown is kept anyway, since the content is often on its way somewhere else, so this warns rather than blocks. +const hasImage = computed(() => markdownMode.value && containsMarkdownImage(textContent.value)) + const passwordProtected = ref(false) const password = ref('') @@ -125,6 +130,7 @@ async function submit() { payload.kind = 'text' payload.ciphertext = bytesToBase64(ciphertext) payload.iv = bytesToBase64(iv) + payload.format = markdownMode.value ? 'markdown' : 'plain' } else { const selectedFile = file.value! const { ciphertext: fileBlob, iv: fileIv } = await encryptBytes(aesKey, new Uint8Array(await selectedFile.arrayBuffer())) @@ -173,6 +179,7 @@ const mailtoHref = computed(() => { function reset() { resultUrl.value = '' textContent.value = '' + markdownMode.value = false file.value = null passwordProtected.value = false password.value = '' @@ -190,7 +197,7 @@ function reset() {