From dc3a5eb598fcdb59be1929644ac88b6ae3953976 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 30 Oct 2026 10:01:35 +0100 Subject: [PATCH 1/7] feat(docs): share playground links that carry the Markdown in the URL --- docs/app/components/Playground.vue | 45 ++++++++++++++++++++++++++++-- docs/app/utils/share.ts | 26 +++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 docs/app/utils/share.ts diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index eb20cf7c7..2dcc0820f 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -19,9 +19,10 @@ import { renderMarkdown } from 'comark/render' import { Splitpanes, Pane } from 'splitpanes' import { generationErrorMessage, playgroundExamples } from '~/constants' import resolveComponent from '~/utils/components-manifest' +import { decodeShareHash, encodeShareHash } from '~/utils/share' import PromptInput from '~/components/playground/PromptInput.vue' import GeneratingIndicator from '~/components/playground/GeneratingIndicator.vue' -import { useLocalStorage, watchDebounced } from '@vueuse/core' +import { useClipboard, useLocalStorage, watchDebounced } from '@vueuse/core' import { useCompletion } from '@ai-sdk/vue' import type { MarkdownDocument, ComarkPlugin } from 'comark' import VueJsonPretty from 'vue-json-pretty' @@ -46,7 +47,7 @@ watch( () => route.query.example, (example) => { if (example !== selectedExample.value) { - router.replace({ query: { ...route.query, example: selectedExample.value } }) + router.replace({ query: { ...route.query, example: selectedExample.value }, hash: route.hash }) } }, { immediate: true } @@ -270,12 +271,39 @@ async function updatePreview(): Promise { } } +// Read before the router rewrites the query, since a shared link carries its Markdown in the hash. +const sharedHash = import.meta.client ? window.location.hash : '' + +async function writeShareHash(): Promise { + const hash = markdown.value === currentExample.value.content.trim() ? '' : await encodeShareHash(markdown.value) + const url = new URL(window.location.href) + url.hash = hash + // Keep the router's state, which replaceState would otherwise drop. + window.history.replaceState(window.history.state, '', url) + return url.href +} + watchDebounced(markdown, updatePreview, { debounce: 300 }) +watchDebounced(markdown, writeShareHash, { debounce: 300 }) watchDebounced([activePlugins, parseOptions], updatePreview, { deep: true, debounce: 300 }) -onMounted(() => { +onMounted(async () => { + const shared = await decodeShareHash(sharedHash) + if (shared !== undefined) markdown.value = shared nextTick(() => updatePreview()) }) +const { copy } = useClipboard({ legacy: true }) +const toast = useToast() + +async function shareComark(): Promise { + await copy(await writeShareHash()) + toast.add({ + title: 'Link copied', + description: 'The link holds the Markdown, so it opens as you see it.', + icon: 'i-lucide-link', + }) +} + watch(selectedExample, () => { markdown.value = currentExample.value.content.trim() }) @@ -387,6 +415,17 @@ async function handleGenerate(prompt: string) {
+ + + + ): Promise { + return new Uint8Array(await new Response(stream).arrayBuffer()) +} + +export async function encodeShareHash(markdown: string): Promise { + const deflated = await bytes(new Blob([markdown]).stream().pipeThrough(new CompressionStream('deflate-raw'))) + let binary = '' + for (const byte of deflated) binary += String.fromCharCode(byte) + return PREFIX + btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +export async function decodeShareHash(hash: string): Promise { + if (!hash.startsWith(PREFIX)) return undefined + try { + const binary = atob(hash.slice(PREFIX.length).replace(/-/g, '+').replace(/_/g, '/')) + const deflated = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + const inflated = await bytes(new Blob([deflated]).stream().pipeThrough(new DecompressionStream('deflate-raw'))) + return new TextDecoder().decode(inflated) + } catch { + return undefined + } +} From 8ee5f1eb6d728939c9b638619ab05d1c4600873d Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 30 Oct 2026 10:14:47 +0100 Subject: [PATCH 2/7] feat(docs): confirm a copied link on the Share button instead of a toast --- docs/app/components/Playground.vue | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index 2dcc0820f..73b5b25a4 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -292,16 +292,10 @@ onMounted(async () => { nextTick(() => updatePreview()) }) -const { copy } = useClipboard({ legacy: true }) -const toast = useToast() +const { copy, copied } = useClipboard({ legacy: true, copiedDuring: 1500 }) async function shareComark(): Promise { await copy(await writeShareHash()) - toast.add({ - title: 'Link copied', - description: 'The link holds the Markdown, so it opens as you see it.', - icon: 'i-lucide-link', - }) } watch(selectedExample, () => { @@ -420,11 +414,16 @@ async function handleGenerate(prompt: string) { size="xs" color="neutral" variant="ghost" - icon="i-lucide-share-2" - label="Share" + :icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'" + :label="copied ? 'Copied' : 'Share'" @click="shareComark" /> + {{ copied ? 'Link copied' : '' }} From 821d675fe68881b6661df75819d32a0aa2da36fe Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 30 Oct 2026 10:28:09 +0100 Subject: [PATCH 3/7] fix(docs): keep share hashes in router history and report unreadable links --- docs/app/components/Playground.vue | 45 ++++++++++++++++++++++-------- docs/app/utils/share.ts | 13 ++++----- 2 files changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index 73b5b25a4..b17a466b2 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -274,32 +274,55 @@ async function updatePreview(): Promise { // Read before the router rewrites the query, since a shared link carries its Markdown in the hash. const sharedHash = import.meta.client ? window.location.hash : '' +// The hash this playground wrote last, so the route watcher can tell its own writes from Back and Forward. +let writtenHash = sharedHash + async function writeShareHash(): Promise { - const hash = markdown.value === currentExample.value.content.trim() ? '' : await encodeShareHash(markdown.value) - const url = new URL(window.location.href) - url.hash = hash - // Keep the router's state, which replaceState would otherwise drop. - window.history.replaceState(window.history.state, '', url) - return url.href + const source = markdown.value + const hash = source === currentExample.value.content.trim() ? '' : await encodeShareHash(source) + // Text typed while this encoded gets its own write, and this older one mustn't replace it. + if (source !== markdown.value) return window.location.href + writtenHash = hash + // Through the router, so the history entry Back returns to keeps the hash. + await router.replace({ query: route.query, hash }) + return new URL(router.resolve({ query: route.query, hash }).href, window.location.origin).href +} + +const toast = useToast() + +async function restoreShareHash(hash: string): Promise { + try { + markdown.value = (await decodeShareHash(hash)) ?? currentExample.value.content.trim() + } catch { + markdown.value = currentExample.value.content.trim() + toast.add({ + title: "This link's Markdown couldn't be read", + description: 'The link may be cut off. Showing the example instead.', + color: 'error', + icon: 'i-lucide-link-2-off', + }) + } } watchDebounced(markdown, updatePreview, { debounce: 300 }) watchDebounced(markdown, writeShareHash, { debounce: 300 }) watchDebounced([activePlugins, parseOptions], updatePreview, { deep: true, debounce: 300 }) onMounted(async () => { - const shared = await decodeShareHash(sharedHash) - if (shared !== undefined) markdown.value = shared + if (sharedHash) await restoreShareHash(sharedHash) nextTick(() => updatePreview()) }) const { copy, copied } = useClipboard({ legacy: true, copiedDuring: 1500 }) async function shareComark(): Promise { - await copy(await writeShareHash()) + // A producer, so the clipboard write starts inside the click, which Safari requires, and gets the URL once encoded. + await copy(() => writeShareHash()) } -watch(selectedExample, () => { - markdown.value = currentExample.value.content.trim() +watch([selectedExample, () => route.hash], ([example, hash], [previousExample]) => { + if (example === previousExample && hash === writtenHash) return + writtenHash = hash + restoreShareHash(hash) }) function resetComark(): void { diff --git a/docs/app/utils/share.ts b/docs/app/utils/share.ts index a9d1aa7dd..e9ff79ab2 100644 --- a/docs/app/utils/share.ts +++ b/docs/app/utils/share.ts @@ -13,14 +13,11 @@ export async function encodeShareHash(markdown: string): Promise { return PREFIX + btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } +/** The Markdown in a share hash, or `undefined` for a hash that isn't one. Throws when the hash is cut off or mangled. */ export async function decodeShareHash(hash: string): Promise { if (!hash.startsWith(PREFIX)) return undefined - try { - const binary = atob(hash.slice(PREFIX.length).replace(/-/g, '+').replace(/_/g, '/')) - const deflated = Uint8Array.from(binary, (char) => char.charCodeAt(0)) - const inflated = await bytes(new Blob([deflated]).stream().pipeThrough(new DecompressionStream('deflate-raw'))) - return new TextDecoder().decode(inflated) - } catch { - return undefined - } + const binary = atob(hash.slice(PREFIX.length).replace(/-/g, '+').replace(/_/g, '/')) + const deflated = Uint8Array.from(binary, (char) => char.charCodeAt(0)) + const inflated = await bytes(new Blob([deflated]).stream().pipeThrough(new DecompressionStream('deflate-raw'))) + return new TextDecoder().decode(inflated) } From 3e14e8130d7c4916842c754a023b3fc353592042 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 30 Oct 2026 10:54:17 +0100 Subject: [PATCH 4/7] fix(docs): let only the latest navigation restore a shared link --- docs/app/components/Playground.vue | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index b17a466b2..6fe7e32be 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -290,10 +290,17 @@ async function writeShareHash(): Promise { const toast = useToast() +// Counts restores, so a slow decode from an earlier navigation can't overwrite a later one. +let restores = 0 + async function restoreShareHash(hash: string): Promise { + const restore = ++restores try { - markdown.value = (await decodeShareHash(hash)) ?? currentExample.value.content.trim() + const shared = await decodeShareHash(hash) + if (restore !== restores) return + markdown.value = shared ?? currentExample.value.content.trim() } catch { + if (restore !== restores) return markdown.value = currentExample.value.content.trim() toast.add({ title: "This link's Markdown couldn't be read", From 6f17073c94af8936e3a44d0478a9c12f85134c41 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 30 Oct 2026 11:19:21 +0100 Subject: [PATCH 5/7] fix(docs): keep text typed while a shared link decodes --- docs/app/components/Playground.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index 6fe7e32be..e4e59aee0 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -311,6 +311,8 @@ async function restoreShareHash(hash: string): Promise { } } +// Text typed while a restore decodes is newer than the link, so that restore mustn't replace it. +watch(markdown, () => restores++, { flush: 'sync' }) watchDebounced(markdown, updatePreview, { debounce: 300 }) watchDebounced(markdown, writeShareHash, { debounce: 300 }) watchDebounced([activePlugins, parseOptions], updatePreview, { deep: true, debounce: 300 }) From 4b85b4da55fb2fa9b8c886084e2be2b97116b828 Mon Sep 17 00:00:00 2001 From: onmax Date: Fri, 30 Oct 2026 11:40:45 +0100 Subject: [PATCH 6/7] fix(docs): size the Share icon to the label's cap height --- docs/app/components/Playground.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index e4e59aee0..4f3b6d005 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -448,6 +448,7 @@ async function handleGenerate(prompt: string) { variant="ghost" :icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'" :label="copied ? 'Copied' : 'Share'" + :ui="{ leadingIcon: 'size-[1.15cap]' }" @click="shareComark" /> From 90dabb3956df4e88cc3e8201ca0d1b1459c345ae Mon Sep 17 00:00:00 2001 From: onmax Date: Sat, 31 Oct 2026 08:47:15 +0100 Subject: [PATCH 7/7] fix(docs): copy the share link in Safari In Safari, Monaco starts its own clipboard write on every click that reaches the body, and that write cancels the Share button's. Stop the click there, and write the ClipboardItem directly so a failed copy shows an error instead of "Copied". Co-Authored-By: Claude Opus 5.5 --- docs/app/components/Playground.vue | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/docs/app/components/Playground.vue b/docs/app/components/Playground.vue index 4f3b6d005..fa2097389 100644 --- a/docs/app/components/Playground.vue +++ b/docs/app/components/Playground.vue @@ -22,7 +22,7 @@ import resolveComponent from '~/utils/components-manifest' import { decodeShareHash, encodeShareHash } from '~/utils/share' import PromptInput from '~/components/playground/PromptInput.vue' import GeneratingIndicator from '~/components/playground/GeneratingIndicator.vue' -import { useClipboard, useLocalStorage, watchDebounced } from '@vueuse/core' +import { useLocalStorage, useTimeoutFn, watchDebounced } from '@vueuse/core' import { useCompletion } from '@ai-sdk/vue' import type { MarkdownDocument, ComarkPlugin } from 'comark' import VueJsonPretty from 'vue-json-pretty' @@ -321,11 +321,27 @@ onMounted(async () => { nextTick(() => updatePreview()) }) -const { copy, copied } = useClipboard({ legacy: true, copiedDuring: 1500 }) +const copied = ref(false) +const { start: showCopied } = useTimeoutFn(() => (copied.value = false), 1500, { immediate: false }) async function shareComark(): Promise { - // A producer, so the clipboard write starts inside the click, which Safari requires, and gets the URL once encoded. - await copy(() => writeShareHash()) + // The ClipboardItem takes a promise, so the write starts inside the click, which Safari requires, and gets the URL + // once encoded. The click mustn't reach the body: in Safari, Monaco starts its own clipboard write on every click + // there, and that write cancels this one. + const url = writeShareHash().then((href) => new Blob([href], { type: 'text/plain' })) + try { + await navigator.clipboard.write([new ClipboardItem({ 'text/plain': url })]) + } catch { + toast.add({ + title: "The link couldn't be copied", + description: 'Copy it from the address bar instead.', + color: 'error', + icon: 'i-lucide-link-2-off', + }) + return + } + copied.value = true + showCopied() } watch([selectedExample, () => route.hash], ([example, hash], [previousExample]) => { @@ -449,7 +465,7 @@ async function handleGenerate(prompt: string) { :icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'" :label="copied ? 'Copied' : 'Share'" :ui="{ leadingIcon: 'size-[1.15cap]' }" - @click="shareComark" + @click.stop="shareComark" />