-
Privacy Policy
+
+
+
Privacy Policy
+
- We only collect the content of your Claude AI conversations to generate shareable URLs.
- No analytics or tracking tools are used, and no additional data is collected. The extension is open source for transparency. You can review the code to see how it works.
+ We only collect the content of your Claude AI conversations to generate
+ shareable URLs. No analytics or tracking tools are used, and no additional
+ data is collected. The extension is open source for transparency. You can
+ review the code to see how it works.
-
How It Works
-
- When you share a conversation, the extension saves it to ShareClaude's database (not Claude's). Each conversation is assigned a unique URL, similar to an unlisted YouTube video. This URL can be shared with others, but it won’t appear in search results on Google. Subsequent conversations are served from ShareClaude's database, not directly from Claude.
+
+
How It Works
+
+
+ When you share a conversation, the extension saves it to ShareClaude's
+ database, not Claude's. Each conversation is assigned a unique URL,
+ similar to an unlisted YouTube video. This URL can be shared with others,
+ but it won't appear in search results on Google. Subsequent conversations
+ are served from ShareClaude's database, not directly from Claude.
-
+
+
Important: While the URL is private and not searchable, anyone with the URL can still view the conversation. Please avoid sharing sensitive or personal information.
);
-};
+}
export default PrivacyPolicy;
diff --git a/app/tailwind.config.js b/app/tailwind.config.js
index 93a340c..64e96b2 100644
--- a/app/tailwind.config.js
+++ b/app/tailwind.config.js
@@ -1,4 +1,6 @@
/** @type {import('tailwindcss').Config} */
+import typography from '@tailwindcss/typography';
+
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
@@ -15,5 +17,5 @@ export default {
}
}
},
- plugins: [require('@tailwindcss/typography')]
+ plugins: [typography]
}
diff --git a/app/temp/claude.json b/app/temp/claude.json
deleted file mode 100644
index 1afc18c..0000000
--- a/app/temp/claude.json
+++ /dev/null
@@ -1,102 +0,0 @@
-{
- "uuid": "c38d16f5-945f-46f0-bd9c-dbc8955a4981",
- "name": "Friendly Greeting",
- "chat_messages": [
- {
- "uuid": "0b297740-98a4-4187-8faa-afa7b11b8d96",
- "text": "Hi",
- "sender": "human",
- "index": 0,
- "created_at": "2024-08-20T06:45:43.346831+00:00",
- "updated_at": "2024-08-20T06:45:43.346831+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "00000000-0000-4000-8000-000000000000"
- },
- {
- "uuid": "93965369-f563-44df-86b0-ed0635af7cb8",
- "text": " Hello! How can I assist you today?",
- "sender": "assistant",
- "index": 1,
- "created_at": "2024-08-20T06:45:43.346831+00:00",
- "updated_at": "2024-08-20T06:45:43.346831+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "0b297740-98a4-4187-8faa-afa7b11b8d96"
- },
- {
- "uuid": "3d6a9178-8a2b-47f8-93cf-9c125122bc6f",
- "text": "Hi",
- "sender": "human",
- "index": 2,
- "created_at": "2024-08-20T06:45:54.295692+00:00",
- "updated_at": "2024-08-20T06:45:54.295692+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "93965369-f563-44df-86b0-ed0635af7cb8"
- },
- {
- "uuid": "e5c3c3f5-c094-4939-bf32-d5a446f29197",
- "text": " Hello again. Is there anything specific you'd like help with or discuss today?",
- "sender": "assistant",
- "index": 3,
- "created_at": "2024-08-20T06:45:54.295692+00:00",
- "updated_at": "2024-08-20T06:45:54.295692+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "3d6a9178-8a2b-47f8-93cf-9c125122bc6f"
- },
- {
- "uuid": "44cc7177-341d-4c76-8f31-5332d1baedda",
- "text": "Can u write a small express server",
- "sender": "human",
- "index": 4,
- "created_at": "2024-08-20T06:46:17.016705+00:00",
- "updated_at": "2024-08-20T06:46:17.016705+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "e5c3c3f5-c094-4939-bf32-d5a446f29197"
- },
- {
- "uuid": "e9c6eb64-d153-4a51-8379-50ebe757af13",
- "text": " Certainly. I'll write a small Express server for you. Here's a basic example:\n\n```javascript\nconst express = require('express');\nconst app = express();\nconst port = 3000;\n\n// Middleware to parse JSON bodies\napp.use(express.json());\n\n// Root route\napp.get('/', (req, res) => {\n res.send('Hello, World!');\n});\n\n// Sample POST route\napp.post('/api/data', (req, res) => {\n const data = req.body;\n res.json({ message: 'Data received', data: data });\n});\n\n// Start the server\napp.listen(port, () => {\n console.log(`Server running at http://localhost:${port}`);\n});\n```\n\nWould you like me to explain or break down this code?",
- "sender": "assistant",
- "index": 5,
- "created_at": "2024-08-20T06:46:17.016705+00:00",
- "updated_at": "2024-08-20T06:46:17.016705+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "44cc7177-341d-4c76-8f31-5332d1baedda"
- },
- {
- "uuid": "f6bf9349-a9e6-471d-896d-0d9d50f272c5",
- "text": "how to take query",
- "sender": "human",
- "index": 6,
- "created_at": "2024-08-20T06:50:10.934255+00:00",
- "updated_at": "2024-08-20T06:50:10.934255+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "e9c6eb64-d153-4a51-8379-50ebe757af13"
- },
- {
- "uuid": "16f54142-a0d1-4631-ac89-a881c2325eb8",
- "text": " To handle query parameters in an Express server, you can use the `req.query` object. Here's an example of how to modify the previous server to include a route that handles query parameters:\n\n```javascript\nconst express = require('express');\nconst app = express();\nconst port = 3000;\n\napp.use(express.json());\n\n// Root route\napp.get('/', (req, res) => {\n res.send('Hello, World!');\n});\n\n// Route with query parameters\napp.get('/api/search', (req, res) => {\n const { q, limit } = req.query;\n res.json({\n message: 'Search request received',\n searchTerm: q,\n limit: limit ? parseInt(limit) : 10\n });\n});\n\napp.listen(port, () => {\n console.log(`Server running at http://localhost:${port}`);\n});\n```\n\nWould you like me to explain how this code handles query parameters?",
- "sender": "assistant",
- "index": 7,
- "created_at": "2024-08-20T06:50:10.934255+00:00",
- "updated_at": "2024-08-20T06:50:10.934255+00:00",
- "truncated": false,
- "attachments": [],
- "files": [],
- "parent_message_uuid": "f6bf9349-a9e6-471d-896d-0d9d50f272c5"
- }
- ]
-}
\ No newline at end of file
diff --git a/app/temp/scraped.json b/app/temp/scraped.json
deleted file mode 100644
index 57df504..0000000
--- a/app/temp/scraped.json
+++ /dev/null
@@ -1,38 +0,0 @@
-{
- "id": 6,
- "title": "Friendly Greeting",
- "contents": [
- {
- "source": "user",
- "message": "Hi"
- },
- {
- "source": "claude",
- "message": "Hello! How can I assist you today?"
- },
- {
- "source": "user",
- "message": "Hi"
- },
- {
- "source": "claude",
- "message": "Hello again. Is there anything specific you'd like help with or discuss today?"
- },
- {
- "source": "user",
- "message": "Can u write a small express server"
- },
- {
- "source": "claude",
- "message": "Certainly. I'll write a small Express server for you. Here's a basic example:```javascript const express = require('express');\nconst app = express();\nconst port = 3000;\n\n// Middleware to parse JSON bodies\napp.use(express.json());\n\n// Root route\napp.get('/', (req, res) => {\n res.send('Hello, World!');\n});\n\n// Sample POST route\napp.post('/api/data', (req, res) => {\n const data = req.body;\n res.json({ message: 'Data received', data: data });\n});\n\n// Start the server\napp.listen(port, () => {\n console.log(`Server running at http://localhost:${port}`);\n});\n``` \nWould you like me to explain or break down this code?"
- },
- {
- "source": "user",
- "message": "how to take query"
- },
- {
- "source": "claude",
- "message": "To handle query parameters in an Express server, you can use the req.query object. Here's an example of how to modify the previous server to include a route that handles query parameters:\njavascript const express = require('express');\nconst app = express();\nconst port = 3000;\n\napp.use(express.json());\n\n// Root route\napp.get('/', (req, res) => {\n res.send('Hello, World!');\n});\n\n// Route with query parameters\napp.get('/api/search', (req, res) => {\n const { q, limit } = req.query;\n res.json({\n message: 'Search request received',\n searchTerm: q,\n limit: limit ? parseInt(limit) : 10\n });\n});\n\napp.listen(port, () => {\n console.log(`Server running at http://localhost:${port}`);\n});\nWould you like me to explain how this code handles query parameters?"
- }
- ]
-}
\ No newline at end of file
diff --git a/app/vite.config.js b/app/vite.config.js
index 5a33944..9ffcc67 100644
--- a/app/vite.config.js
+++ b/app/vite.config.js
@@ -1,7 +1,6 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
-// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})
diff --git a/extension/content.js b/extension/content.js
index 97400fa..3856aa4 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -2,14 +2,9 @@ const PAGE_URL = 'https://shareclaude.pages.dev'
const CLAUDE_API_URL = 'https://claude.ai/api/organizations'
let organizationId = ''
-const shareIconSVG = `
`
-
-const loaderSVG = `
`
-// Store artifacts content globally for updates
const artifactsCache = new Map()
async function getOrganizationId() {
- // If organizationId is already set, return it
if (organizationId) return organizationId
try {
const response = await fetch(CLAUDE_API_URL, {
@@ -68,7 +63,6 @@ function processArtifact(item) {
if (!id) return ''
- // Build artifact properties
const artifactProps = {
identifier: id,
type: type || artifactsCache.get(id)?.artifactType,
@@ -81,7 +75,7 @@ function processArtifact(item) {
.map(([key, value]) => `${key}="${value}"`)
.join(' ')
- // Handle content updates
+ // handle content updates
if (command === 'update' && old_str && new_str) {
const artifactData = artifactsCache.get(id)
if (!artifactData?.content) return ''
@@ -91,7 +85,7 @@ function processArtifact(item) {
return formatArtifactOutput(propString, artifactData.content)
}
- // Handle content creation/rewrite or otherwise
+ // handle content creation/rewrite
if (content) {
const artifactData =
command === 'rewrite'
@@ -123,7 +117,7 @@ function processContentItem(item) {
} else if (item.name === 'repl') {
return processREPL(item)
}
- //handle other tool_use items
+ // handle other tool_use items
return ''
default:
return ''
@@ -196,7 +190,6 @@ async function getShareURL(messages) {
}
const { id } = await response.json()
- // Clear artifacts content after successful share
artifactsCache.clear()
return `${PAGE_URL}/c/${id}`
} catch (error) {
@@ -205,77 +198,612 @@ async function getShareURL(messages) {
}
}
-function addShareButton() {
- const button = document.createElement('button')
- button.innerHTML = shareIconSVG
+// --- export conversion functions ---
+const transformExcerptBlocks = (message, transformExcerpt) =>
+ globalThis.ShareClaudeExcerptUtils.transformExcerptBlocks(
+ message,
+ transformExcerpt
+ )
- button.className =
- 'inline-flex items-center justify-center relative shrink-0 ring-offset-2 ring-offset-bg-300 ring-accent-main-100 focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none text-text-200 border-transparent transition-colors font-styrene active:bg-bg-400 hover:bg-bg-500/40 hover:text-text-100 h-8 w-8 rounded-md active:scale-95 !rounded-lg'
- button.type = 'button'
- button.title = 'Share conversation'
- button.ariaLabel = 'Share conversation'
+function normalizeMessageMarkdown(message) {
+ return transformExcerptBlocks(message, (excerptContent) => {
+ if (!excerptContent) return ''
+ const quotedLines = excerptContent
+ .split(/\r?\n/)
+ .map((line) => `> ${line}`)
+ .join('\n')
+ return `> **Quoted excerpt**\n>\n${quotedLines}`
+ })
+}
- button.addEventListener('click', async () => {
- const conversationId = getConversationId()
- if (!conversationId) {
- alert('You need to go to a conversation to share it')
- return
- }
+function markdownToPlainText(message) {
+ return normalizeMessageMarkdown(message)
+ .replace(/```[\w-]*\r?\n([\s\S]*?)\r?\n```/g, (_, code) => code.trim())
+ .replace(/^>\s?/gm, '')
+ .replace(/^#{1,6}\s+/gm, '')
+ .replace(/\*\*\*(.+?)\*\*\*/g, '$1')
+ .replace(/\*\*(.+?)\*\*/g, '$1')
+ .replace(/\*(.+?)\*/g, '$1')
+ .replace(/~~(.+?)~~/g, '$1')
+ .replace(/`([^`]+)`/g, '$1')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)')
+ .replace(/^\|\s*:?-+:?\s*(\|\s*:?-+:?\s*)+$/gm, '')
+ .replace(/\|/g, ' | ')
+ .replace(/\n{3,}/g, '\n\n')
+ .trim()
+}
- button.innerHTML = loaderSVG
- button.disabled = true
+function convertToMarkdown(title, messages) {
+ let md = `# ${title}\n\n`
+ messages.forEach(({ source, message }) => {
+ const role = source === 'user' ? 'You' : 'Claude'
+ md += `## ${role}\n\n${normalizeMessageMarkdown(message)}\n\n---\n\n`
+ })
+ return md
+}
+
+function convertToText(title, messages) {
+ const result = [`${title}\n${'='.repeat(title.length)}\n\n`]
+ messages.forEach(({ source, message }) => {
+ const role = source === 'user' ? 'You' : 'Claude'
+ const plain = markdownToPlainText(message)
+ result.push(`${role}:\n${plain}\n\n`)
+ })
+ return result.join('')
+}
- const messages = await getConversationMessages({
- organizationId,
- conversationId
+function convertToHTML(title, messages) {
+ const esc = (str) =>
+ str.replace(/&/g, '&').replace(//g, '>')
+
+ function markdownToHTML(text) {
+ text = normalizeMessageMarkdown(text)
+
+ // 1. save fenced code blocks
+ const codeBlocks = []
+ text = text.replace(/```([\w-]*)\r?\n([\s\S]*?)\r?\n```/g, (_, lang, code) => {
+ const idx = codeBlocks.push({ lang, code: esc(code) }) - 1
+ return `\x00CODE${idx}\x00`
})
- if (!messages) {
- alert('Failed to get conversation messages')
- button.innerHTML = shareIconSVG
- button.disabled = false
- return
- }
- const shareURL = await getShareURL(messages)
- if (!shareURL) {
- alert('Failed to generate share URL')
- button.innerHTML = shareIconSVG
- button.disabled = false
- return
+ // 2. save inline code
+ const inlineCodes = []
+ text = text.replace(/`([^`\n]+)`/g, (_, code) => {
+ const idx = inlineCodes.push(esc(code)) - 1
+ return `\x00IC${idx}\x00`
+ })
+
+ // 3. tables
+ text = text.replace(
+ /((?:[^\n]*\|[^\n]*\n)+)/g,
+ (block) => {
+ const lines = block.trim().split('\n')
+ if (lines.length < 2) return block
+ const sep = lines[1]
+ if (!/^[\s|:\-]+$/.test(sep)) return block
+
+ const parseRegex = /[^|\s](?:[^|]*[^|\s])?/g
+ const headers = lines[0].match(parseRegex) || []
+ let theadInner = ''
+ for (let i = 0; i < headers.length; i++) {
+ theadInner += `
${applyInline(esc(headers[i]))} `
+ }
+ const thead = `
${theadInner} `
+
+ let tbodyInner = ''
+ for (let i = 2; i < lines.length; i++) {
+ const rowMatch = lines[i].match(parseRegex)
+ if (rowMatch) {
+ let trInner = ''
+ for (let j = 0; j < rowMatch.length; j++) {
+ trInner += `
${applyInline(esc(rowMatch[j]))} `
+ }
+ tbodyInner += `
${trInner} \n`
+ } else {
+ tbodyInner += `
\n`
+ }
+ }
+ const tbody = tbodyInner ? `
${tbodyInner.slice(0, -1)} ` : ''
+ return `
`
+ }
+ )
+
+ // 4. headings
+ text = text.replace(/^(#{1,6})\s+(.+)$/gm, (_, h, content) =>
+ `
${applyInline(content)} `
+ )
+
+ // 5. blockquotes
+ text = text.replace(/^((?:>.*\n?)+)/gm, (match) => {
+ const inner = match.replace(/^>\s?/gm, '').trim()
+ return `
${applyInline(inner)} `
+ })
+
+ // 6. unordered lists
+ text = text.replace(/^((?:[*\-]\s.+\n?)+)/gm, (block) => {
+ const items = block.trim().split('\n').map(l => l.replace(/^[*\-]\s/, ''))
+ return `
${items.map(i => `${applyInline(i)} `).join('')} `
+ })
+
+ // 7. ordered lists
+ text = text.replace(/^((?:\d+\.\s.+\n?)+)/gm, (block) => {
+ const items = block.trim().split('\n').map(l => l.replace(/^\d+\.\s/, ''))
+ return `
${items.map(i => `${applyInline(i)} `).join('')} `
+ })
+
+ // 8. horizontal rules
+ text = text.replace(/^[-*_]{3,}$/gm, '
')
+
+ // 9. paragraphs: wrap non-empty, non-block lines
+ text = text
+ .split(/\n{2,}/)
+ .map((para) => {
+ const t = para.trim()
+ if (!t) return ''
+ if (/^<(h[1-6]|ul|ol|li|table|blockquote|pre|hr|div)/.test(t)) return t
+ if (t.startsWith('\x00CODE')) return t
+ return `
${applyInline(t.replace(/\n/g, ' '))}
`
+ })
+ .join('\n')
+
+ // 10. restore code blocks
+ text = text.replace(/\x00CODE(\d+)\x00/g, (_, idx) => {
+ const b = codeBlocks[parseInt(idx)]
+ if (!b) return ''
+ return `
${b.code}`
+ })
+
+ function applyInline(s) {
+ return s
+ .replace(/\*\*\*(.+?)\*\*\*/g, '
$1 ')
+ .replace(/\*\*(.+?)\*\*/g, '
$1 ')
+ .replace(/\*(.+?)\*/g, '
$1 ')
+ .replace(/~~(.+?)~~/g, '
$1')
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '
$1 ')
+ .replace(/\x00IC(\d+)\x00/g, (_, i) => `
${inlineCodes[parseInt(i)]}`)
}
- navigator.clipboard.writeText(shareURL)
- window.open(shareURL, '_blank')
+ return text
+ }
- // Reset button after the action
- button.innerHTML = shareIconSVG
- button.disabled = false
+ let html = `
+
+
+
+
+
${esc(title)}
+
+
+
+
${esc(title)} \n`
+
+ messages.forEach(({ source, message }) => {
+ const role = source === 'user' ? 'You' : 'Claude'
+ const cls = source === 'user' ? 'human' : 'claude'
+ html += `
\n${role}
\n${markdownToHTML(message)}
\n \n`
})
+ html += `\n`
+ return html
+}
- const uploadButton = document.querySelector(
- 'button[aria-label="Upload content"]'
+function convertToJSON(title, messages) {
+ const normalizedMessages = messages.map(({ source, message }) => ({
+ source,
+ message: normalizeMessageMarkdown(message)
+ }))
+
+ return JSON.stringify(
+ {
+ title,
+ exportedAt: new Date().toISOString(),
+ messages: normalizedMessages
+ },
+ null,
+ 2
)
- if (uploadButton && !document.querySelector('.share-button')) {
- button.classList.add('share-button')
- uploadButton.parentElement.appendChild(button)
+}
+
+// --- minimal zip builder for docx generation ---
+
+let _crc32Table = null
+function getCRC32Table() {
+ if (_crc32Table) return _crc32Table
+ _crc32Table = new Uint32Array(256)
+ for (let i = 0; i < 256; i++) {
+ let c = i
+ for (let j = 0; j < 8; j++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
+ }
+ _crc32Table[i] = c
}
+ return _crc32Table
}
-function monitorPageChanges() {
- const observer = new MutationObserver(() => {
- if (!document.querySelector('.share-button')) {
- addShareButton()
+function crc32(data) {
+ let crc = 0xffffffff
+ const table = getCRC32Table()
+ for (let i = 0; i < data.length; i++) {
+ crc = (crc >>> 8) ^ table[(crc ^ data[i]) & 0xff]
+ }
+ return (crc ^ 0xffffffff) >>> 0
+}
+
+function createMinimalZip(files) {
+ const encoder = new TextEncoder()
+ const parts = []
+ const centralHeaders = []
+ let offset = 0
+
+ files.forEach((file) => {
+ const nameBytes = encoder.encode(file.name)
+ const data = file.content
+ const crc = crc32(data)
+
+ // local file header (30 bytes + filename)
+ const local = new Uint8Array(30 + nameBytes.length)
+ const lv = new DataView(local.buffer)
+ lv.setUint32(0, 0x04034b50, true)
+ lv.setUint16(4, 20, true)
+ lv.setUint16(6, 0, true)
+ lv.setUint16(8, 0, true) // store
+ lv.setUint16(10, 0, true)
+ lv.setUint16(12, 0, true)
+ lv.setUint32(14, crc, true)
+ lv.setUint32(18, data.length, true)
+ lv.setUint32(22, data.length, true)
+ lv.setUint16(26, nameBytes.length, true)
+ lv.setUint16(28, 0, true)
+ local.set(nameBytes, 30)
+
+ // central directory entry (46 bytes + filename)
+ const central = new Uint8Array(46 + nameBytes.length)
+ const cv = new DataView(central.buffer)
+ cv.setUint32(0, 0x02014b50, true)
+ cv.setUint16(4, 20, true)
+ cv.setUint16(6, 20, true)
+ cv.setUint16(8, 0, true)
+ cv.setUint16(10, 0, true)
+ cv.setUint16(12, 0, true)
+ cv.setUint16(14, 0, true)
+ cv.setUint32(16, crc, true)
+ cv.setUint32(20, data.length, true)
+ cv.setUint32(24, data.length, true)
+ cv.setUint16(28, nameBytes.length, true)
+ cv.setUint16(30, 0, true)
+ cv.setUint16(32, 0, true)
+ cv.setUint16(34, 0, true)
+ cv.setUint16(36, 0, true)
+ cv.setUint32(38, 0, true)
+ cv.setUint32(42, offset, true)
+ central.set(nameBytes, 46)
+
+ centralHeaders.push(central)
+ parts.push(local, data)
+ offset += local.length + data.length
+ })
+
+ const centralStart = offset
+ let centralSize = 0
+ centralHeaders.forEach((h) => (centralSize += h.length))
+
+ // end of central directory (22 bytes)
+ const eocd = new Uint8Array(22)
+ const ev = new DataView(eocd.buffer)
+ ev.setUint32(0, 0x06054b50, true)
+ ev.setUint16(4, 0, true)
+ ev.setUint16(6, 0, true)
+ ev.setUint16(8, files.length, true)
+ ev.setUint16(10, files.length, true)
+ ev.setUint32(12, centralSize, true)
+ ev.setUint32(16, centralStart, true)
+ ev.setUint16(20, 0, true)
+
+ const totalLength = offset + centralSize + 22
+ const result = new Uint8Array(totalLength)
+ let pos = 0
+ parts.forEach((p) => {
+ result.set(p, pos)
+ pos += p.length
+ })
+ centralHeaders.forEach((h) => {
+ result.set(h, pos)
+ pos += h.length
+ })
+ result.set(eocd, pos)
+
+ return result
+}
+
+function convertToDOCX(title, messages) {
+ const encoder = new TextEncoder()
+
+ function escapeXML(str) {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ }
+
+ let paragraphs = ''
+
+ // title
+ paragraphs += `
${escapeXML(title)} `
+
+ messages.forEach(({ source, message }) => {
+ const role = source === 'user' ? 'You' : 'Claude'
+ const color = source === 'user' ? '666666' : 'D97757'
+
+ // role header
+ paragraphs += `
${escapeXML(role)} `
+
+ // message lines
+ const lines = markdownToPlainText(message).split('\n')
+ paragraphs += lines.map(line => `
${escapeXML(line)} `).join('')
+
+ // separator
+ paragraphs += `
`
+ })
+
+ const contentTypes = `
`
+
+ const rels = `
`
+
+ const documentXml = `
${paragraphs} `
+
+ const wordRels = `
`
+
+ const files = [
+ { name: '[Content_Types].xml', content: encoder.encode(contentTypes) },
+ { name: '_rels/.rels', content: encoder.encode(rels) },
+ { name: 'word/document.xml', content: encoder.encode(documentXml) },
+ {
+ name: 'word/_rels/document.xml.rels',
+ content: encoder.encode(wordRels)
}
+ ]
+
+ return createMinimalZip(files)
+}
+
+function sanitizeFilename(name) {
+ return (
+ name
+ .replace(/[^a-z0-9_\-\s]/gi, '')
+ .replace(/\s+/g, '_')
+ .substring(0, 100) || 'conversation'
+ )
+}
+
+function downloadFile(content, filename, mimeType) {
+ const blob = new Blob([content], { type: mimeType })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = filename
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+}
+
+// --- ui injection ---
+
+function injectStyles() {
+ if (document.getElementById('sc-styles')) return
+ const s = document.createElement('style')
+ s.id = 'sc-styles'
+ s.textContent = `
+.sc-controls{display:inline-flex;align-items:center;flex-shrink:0}
+.sc-divider{width:2px;height:22px;margin:0 8px 0 6px;background:currentColor;opacity:0.22;border-radius:999px;align-self:center;flex-shrink:0}
+.sc-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border:none;border-radius:6px;background:transparent;color:inherit;cursor:pointer;transition:background 0.1s}
+.sc-icon-btn:hover{background:var(--bg-200, rgba(128,128,128,0.1))}
+.sc-icon-btn:active{transform:scale(0.95)}
+.sc-icon-btn svg{width:16px;height:16px;opacity:0.65}
+.sc-icon-btn:hover svg{opacity:1}
+.sc-icon-btn.sc-loading{opacity:0.4;pointer-events:none}
+.sc-export-wrap{position:relative;display:inline-flex;align-items:center}
+.sc-menu{position:absolute;top:calc(100% + 6px);right:0;border-radius:12px;padding:4px;z-index:50001;min-width:200px;display:none;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)}
+.sc-menu.sc-open{display:block}
+.sc-menu[data-theme="light"]{background:rgba(255,255,255,0.95);border:1px solid rgba(0,0,0,0.1);box-shadow:0 8px 30px rgba(0,0,0,0.12);color:#333}
+.sc-menu[data-theme="dark"]{background:rgba(40,40,40,0.95);border:1px solid rgba(255,255,255,0.1);box-shadow:0 8px 30px rgba(0,0,0,0.4);color:#e0e0e0}
+.sc-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 12px;background:none;border:none;color:inherit;font-size:13px;text-align:left;cursor:pointer;border-radius:8px;font-family:inherit;line-height:1.4}
+.sc-menu[data-theme="light"] .sc-item:hover{background:rgba(0,0,0,0.06)}
+.sc-menu[data-theme="dark"] .sc-item:hover{background:rgba(255,255,255,0.08)}
+.sc-item svg{width:16px;height:16px;flex-shrink:0;opacity:0.55}
+`
+ document.head.appendChild(s)
+}
+
+function detectTheme() {
+ const bg = getComputedStyle(document.body).backgroundColor
+ const m = bg.match(/(\d+),\s*(\d+),\s*(\d+)/)
+ if (!m) return 'light'
+ const lum = (parseInt(m[1]) * 299 + parseInt(m[2]) * 587 + parseInt(m[3]) * 114) / 1000
+ return lum < 128 ? 'dark' : 'light'
+}
+
+function findActionsBar() {
+ return (
+ document.querySelector('[data-testid="wiggle-controls-actions"]') ||
+ document.querySelector('[data-testid$="-controls-actions"]') ||
+ document.querySelector('[data-testid*="controls-actions"]') ||
+ document.querySelector('[data-testid*="message-actions"]') ||
+ document.querySelector('[data-testid*="action-bar"]') ||
+ null
+ )
+}
+
+function ensureControlsPosition(actionsBar, controlsWrap) {
+ if (!actionsBar || !controlsWrap) return
+ if (controlsWrap.parentElement !== actionsBar) {
+ actionsBar.appendChild(controlsWrap)
+ return
+ }
+ if (actionsBar.lastElementChild !== controlsWrap) {
+ actionsBar.appendChild(controlsWrap)
+ }
+}
+
+function injectButtons() {
+ const actionsBar = findActionsBar()
+ if (!actionsBar) return
+
+ injectStyles()
+
+ const existingControls = document.querySelector('.sc-controls')
+ if (existingControls) {
+ ensureControlsPosition(actionsBar, existingControls)
+ return
+ }
+
+ const shareSVG = '
'
+ const downloadSVG = '
'
+
+ const controlsWrap = document.createElement('div')
+ controlsWrap.className = 'sc-controls'
+
+ const divider = document.createElement('div')
+ divider.className = 'sc-divider'
+
+ // --- share button ---
+ const shareBtn = document.createElement('button')
+ shareBtn.type = 'button'
+ shareBtn.className = 'sc-icon-btn'
+ shareBtn.title = 'Share to ShareClaude'
+ shareBtn.innerHTML = shareSVG
+
+ shareBtn.addEventListener('click', async () => {
+ const conversationId = getConversationId()
+ if (!conversationId) { alert('Open a conversation first'); return }
+
+ shareBtn.classList.add('sc-loading')
+ const messages = await getConversationMessages({ organizationId, conversationId })
+ if (!messages) { alert('Failed to get conversation messages'); shareBtn.classList.remove('sc-loading'); return }
+
+ const shareURL = await getShareURL(messages)
+ if (!shareURL) { alert('Failed to generate share URL'); shareBtn.classList.remove('sc-loading'); return }
+
+ navigator.clipboard.writeText(shareURL)
+ window.open(shareURL, '_blank')
+ shareBtn.classList.remove('sc-loading')
})
- observer.observe(document.body, {
- childList: true,
- subtree: true
+ // --- download button ---
+ const exportWrap = document.createElement('div')
+ exportWrap.className = 'sc-export-wrap'
+
+ const dlBtn = document.createElement('button')
+ dlBtn.type = 'button'
+ dlBtn.className = 'sc-icon-btn'
+ dlBtn.title = 'Export conversation'
+ dlBtn.innerHTML = downloadSVG
+ exportWrap.appendChild(dlBtn)
+
+ const menu = document.createElement('div')
+ menu.className = 'sc-menu'
+ menu.dataset.theme = detectTheme()
+ exportWrap.appendChild(menu)
+
+ const formats = [
+ { label: 'Markdown (.md)', ext: 'md', convert: convertToMarkdown, mime: 'text/markdown' },
+ { label: 'Plain Text (.txt)', ext: 'txt', convert: convertToText, mime: 'text/plain' },
+ { label: 'JSON (.json)', ext: 'json', convert: convertToJSON, mime: 'application/json' },
+ { label: 'HTML (.html)', ext: 'html', convert: convertToHTML, mime: 'text/html' },
+ { label: 'Word (.docx)', ext: 'docx', convert: convertToDOCX, mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }
+ ]
+
+ formats.forEach(({ label, ext, convert, mime }) => {
+ const item = document.createElement('button')
+ item.type = 'button'
+ item.className = 'sc-item'
+ item.innerHTML = downloadSVG + '
' + label + ' '
+ item.addEventListener('click', async (e) => {
+ e.stopPropagation()
+ menu.classList.remove('sc-open')
+
+ const conversationId = getConversationId()
+ if (!conversationId) { alert('Open a conversation first'); return }
+
+ dlBtn.classList.add('sc-loading')
+ const messages = await getConversationMessages({ organizationId, conversationId })
+ if (!messages) { alert('Failed to get conversation messages'); dlBtn.classList.remove('sc-loading'); return }
+
+ const filename = sanitizeFilename(messages.title) + '.' + ext
+ const content = convert(messages.title || 'Conversation', messages.content)
+ downloadFile(content, filename, mime)
+ dlBtn.classList.remove('sc-loading')
+ })
+ menu.appendChild(item)
+ })
+
+ dlBtn.addEventListener('click', (e) => {
+ e.stopPropagation()
+ menu.dataset.theme = detectTheme()
+ menu.classList.toggle('sc-open')
+ })
+
+ document.addEventListener('click', (e) => {
+ if (!exportWrap.contains(e.target)) menu.classList.remove('sc-open')
+ })
+
+ controlsWrap.appendChild(divider)
+ controlsWrap.appendChild(shareBtn)
+ controlsWrap.appendChild(exportWrap)
+ ensureControlsPosition(actionsBar, controlsWrap)
+}
+
+function monitorPageChanges() {
+ let debounceTimer = null
+ const observer = new MutationObserver(() => {
+ clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(injectButtons, 300)
})
+ observer.observe(document.body, { childList: true, subtree: true })
}
-window.addEventListener('load', async () => {
+async function init() {
organizationId = await getOrganizationId()
- addShareButton()
+ injectButtons()
monitorPageChanges()
-})
+}
+
+if (document.readyState === 'complete') {
+ init()
+} else {
+ window.addEventListener('load', init)
+}
diff --git a/extension/excerpt-utils.js b/extension/excerpt-utils.js
new file mode 100644
index 0000000..6856c5d
--- /dev/null
+++ b/extension/excerpt-utils.js
@@ -0,0 +1,72 @@
+(function initShareClaudeExcerptUtils(root) {
+ const EXCERPT_HEADER_RE = /excerpt_from_previous_claude_message\.txt:\s*(?:\r?\n){2}/g
+ const EXCERPT_MARKER = 'excerpt_from_previous_claude_message.txt:'
+ const FENCED_EXCERPT_RE = /^```[^\n\r]*\r?\n([\s\S]*?)\r?\n```/
+
+ function splitTextOnExcerpts(text) {
+ const parts = []
+ let cursor = 0
+ const headerRe = new RegExp(EXCERPT_HEADER_RE.source, 'g')
+ let headerMatch
+
+ while ((headerMatch = headerRe.exec(text)) !== null) {
+ const excerptStart = headerMatch.index
+ const bodyStart = headerRe.lastIndex
+
+ if (excerptStart > cursor) {
+ parts.push({ type: 'markdown', content: text.slice(cursor, excerptStart) })
+ }
+
+ const remaining = text.slice(bodyStart)
+ const fencedMatch = remaining.match(FENCED_EXCERPT_RE)
+
+ let excerptContent = ''
+ let blockEnd = bodyStart
+
+ if (fencedMatch) {
+ excerptContent = (fencedMatch[1] || '').trim()
+ blockEnd = bodyStart + fencedMatch[0].length
+ } else {
+ // Plain excerpt content ends at the next paragraph break, preserving
+ // any following non-quoted message text.
+ const nextParagraphBreak = text.slice(bodyStart).search(/\r?\n\r?\n/)
+ blockEnd =
+ nextParagraphBreak === -1
+ ? text.length
+ : bodyStart + nextParagraphBreak
+ excerptContent = text.slice(bodyStart, blockEnd).trim()
+ }
+
+ if (excerptContent) {
+ parts.push({ type: 'excerpt', content: excerptContent })
+ }
+
+ cursor = blockEnd
+ headerRe.lastIndex = blockEnd
+ }
+
+ if (cursor < text.length) {
+ parts.push({ type: 'markdown', content: text.slice(cursor) })
+ }
+
+ return parts.length > 0 ? parts : [{ type: 'markdown', content: text }]
+ }
+
+ function transformExcerptBlocks(text, transformExcerpt) {
+ const parts = splitTextOnExcerpts(text)
+ return parts
+ .map((part) => {
+ if (part.type !== 'excerpt') return part.content
+ return transformExcerpt(part.content)
+ })
+ .join('')
+ }
+
+ root.ShareClaudeExcerptUtils = {
+ EXCERPT_HEADER_RE,
+ EXCERPT_MARKER,
+ FENCED_EXCERPT_RE,
+ splitTextOnExcerpts,
+ transformExcerptBlocks
+ }
+})(globalThis)
diff --git a/extension/excerpt-utils.test.js b/extension/excerpt-utils.test.js
new file mode 100644
index 0000000..9f69a94
--- /dev/null
+++ b/extension/excerpt-utils.test.js
@@ -0,0 +1,111 @@
+const test = require('node:test');
+const assert = require('node:assert');
+
+// Load the script. It registers ShareClaudeExcerptUtils on globalThis.
+require('./excerpt-utils.js');
+const { splitTextOnExcerpts, transformExcerptBlocks } = globalThis.ShareClaudeExcerptUtils;
+
+test('splitTextOnExcerpts', async (t) => {
+ await t.test('empty string', () => {
+ const result = splitTextOnExcerpts('');
+ assert.deepStrictEqual(result, [{ type: 'markdown', content: '' }]);
+ });
+
+ await t.test('text without excerpts', () => {
+ const text = 'Hello world\n\nThis is just normal text.';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [{ type: 'markdown', content: text }]);
+ });
+
+ await t.test('single plain excerpt', () => {
+ const text = 'Before excerpt\n\nexcerpt_from_previous_claude_message.txt:\n\nThis is the excerpt content\n\nAfter excerpt';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'markdown', content: 'Before excerpt\n\n' },
+ { type: 'excerpt', content: 'This is the excerpt content' },
+ { type: 'markdown', content: '\n\nAfter excerpt' }
+ ]);
+ });
+
+ await t.test('single plain excerpt ending at EOF', () => {
+ const text = 'Before excerpt\n\nexcerpt_from_previous_claude_message.txt:\n\nThis is the excerpt content';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'markdown', content: 'Before excerpt\n\n' },
+ { type: 'excerpt', content: 'This is the excerpt content' }
+ ]);
+ });
+
+ await t.test('single fenced excerpt', () => {
+ const text = 'Before excerpt\n\nexcerpt_from_previous_claude_message.txt:\n\n```js\nconst x = 1;\n```\nAfter excerpt';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'markdown', content: 'Before excerpt\n\n' },
+ { type: 'excerpt', content: 'const x = 1;' },
+ { type: 'markdown', content: '\nAfter excerpt' }
+ ]);
+ });
+
+ await t.test('fenced excerpt with no content inside', () => {
+ const text = 'excerpt_from_previous_claude_message.txt:\n\n```\n\n```\nAfter';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'markdown', content: '\nAfter' }
+ ]);
+ });
+
+ await t.test('multiple excerpts', () => {
+ const text = 'First.\n\nexcerpt_from_previous_claude_message.txt:\n\nExcerpt 1\n\nMiddle.\n\nexcerpt_from_previous_claude_message.txt:\n\n```\nExcerpt 2\n```\n\nEnd.';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'markdown', content: 'First.\n\n' },
+ { type: 'excerpt', content: 'Excerpt 1' },
+ { type: 'markdown', content: '\n\nMiddle.\n\n' },
+ { type: 'excerpt', content: 'Excerpt 2' },
+ { type: 'markdown', content: '\n\nEnd.' }
+ ]);
+ });
+
+ await t.test('excerpt at the very beginning', () => {
+ const text = 'excerpt_from_previous_claude_message.txt:\n\nPlain excerpt here\n\nFollow up text.';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'excerpt', content: 'Plain excerpt here' },
+ { type: 'markdown', content: '\n\nFollow up text.' }
+ ]);
+ });
+
+ await t.test('windows style line endings', () => {
+ const text = 'Before\r\n\r\nexcerpt_from_previous_claude_message.txt:\r\n\r\n```js\r\nconsole.log();\r\n```\r\nAfter';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'markdown', content: 'Before\r\n\r\n' },
+ { type: 'excerpt', content: 'console.log();' },
+ { type: 'markdown', content: '\r\nAfter' }
+ ]);
+ });
+
+ await t.test('adjacent excerpts', () => {
+ const text = 'excerpt_from_previous_claude_message.txt:\n\nExcerpt 1\n\nexcerpt_from_previous_claude_message.txt:\n\nExcerpt 2';
+ const result = splitTextOnExcerpts(text);
+ assert.deepStrictEqual(result, [
+ { type: 'excerpt', content: 'Excerpt 1' },
+ { type: 'markdown', content: '\n\n' },
+ { type: 'excerpt', content: 'Excerpt 2' }
+ ]);
+ });
+});
+
+test('transformExcerptBlocks', async (t) => {
+ await t.test('transforms only excerpt blocks', () => {
+ const text = 'Before\n\nexcerpt_from_previous_claude_message.txt:\n\nThis is excerpt\n\nAfter';
+ const result = transformExcerptBlocks(text, (content) => `> ${content}`);
+ assert.strictEqual(result, 'Before\n\n> This is excerpt\n\nAfter');
+ });
+
+ await t.test('handles text without excerpts', () => {
+ const text = 'Just some text';
+ const result = transformExcerptBlocks(text, () => 'should not be called');
+ assert.strictEqual(result, text);
+ });
+});
diff --git a/extension/manifest.json b/extension/manifest.json
index deb197b..6e9e23e 100644
--- a/extension/manifest.json
+++ b/extension/manifest.json
@@ -3,21 +3,34 @@
"name": "ShareClaude",
"description": "Share your Claude chats with one click, preserving syntax highlighting for conversations",
"version": "0.3.3",
- "permissions": [],
+ "permissions": ["clipboardWrite"],
"host_permissions": [
"https://claude.ai/*"
],
"icons": {
"128": "images/icon-128.png"
},
+ "action": {
+ "default_icon": {
+ "128": "images/icon-128.png"
+ },
+ "default_popup": "popup.html"
+ },
"content_scripts": [
{
"matches": [
"https://claude.ai/*"
],
"js": [
+ "excerpt-utils.js",
"content.js"
]
}
- ]
+ ],
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "shareclaude@shareclaude.pages.dev",
+ "strict_min_version": "109.0"
+ }
+ }
}
\ No newline at end of file
diff --git a/extension/popup.html b/extension/popup.html
new file mode 100644
index 0000000..023a03e
--- /dev/null
+++ b/extension/popup.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+
ShareClaude
+
+
+
+
+
Share & export your Claude conversations
+
+
Share your Claude AI conversations with a unique URL, or export them locally as Markdown, Text, JSON, HTML, or Word (.docx).
+
+
+
+
1
+
Go to claude.ai and open a conversation
+
+
+
2
+
Click the share icon to share online, or the download icon to export locally
+
+
+
3
+
Pick a format (MD, TXT, JSON, HTML, DOCX) and the file downloads instantly
+
+
+
+
+
+