concept-collection / mdshare
mdshare: markdown editor with the document stored in the URL
Split-pane editor (CodeMirror 6) and renderer (react-markdown) with KaTeX math, highlight.js code blocks, GFM tables/task lists/footnotes, and lazy mermaid diagrams. The document is deflate-compressed and base64url-encoded into the hash fragment, so the URL is the share mechanism; no server.
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 3e0fff38ed7a Browse files
22 changed files+5476−0
.github/workflows/deploy.ymladded+35−0View file
@@ -0,0 +1,35 @@
1+name: deploy
2+on:
3+ push:
4+ branches: [main]
5+ workflow_dispatch:
6+
7+permissions:
8+ contents: read
9+ pages: write
10+ id-token: write
11+
12+concurrency:
13+ group: pages
14+ cancel-in-progress: true
15+
16+jobs:
17+ build-deploy:
18+ runs-on: ubuntu-latest
19+ environment:
20+ name: github-pages
21+ url: ${{ steps.deployment.outputs.page_url }}
22+ steps:
23+ - uses: actions/checkout@v4
24+ - uses: actions/setup-node@v4
25+ with:
26+ node-version: 24
27+ cache: npm
28+ - run: npm ci
29+ - run: npm run build # tsc -b first, so this is the typecheck too
30+ - uses: actions/configure-pages@v5
31+ - uses: actions/upload-pages-artifact@v3
32+ with:
33+ path: dist
34+ - id: deployment
35+ uses: actions/deploy-pages@v4
.gitignoreadded+24−0View file
@@ -0,0 +1,24 @@
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
.oxlintrc.jsonadded+8−0View file
@@ -0,0 +1,8 @@
1+{
2+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3+ "plugins": ["react", "typescript", "oxc"],
4+ "rules": {
5+ "react/rules-of-hooks": "error",
6+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
7+ }
8+}
README.mdadded+36−0View file
@@ -0,0 +1,36 @@
1+# mdshare
2+
3+A markdown editor whose document lives entirely in the URL. Type markdown on the left, see it rendered on the right, and share the page by copying the link: the text is compressed and encoded into the URL fragment, so there is no server, no account, and nothing is uploaded.
4+
5+**Live: <https://concept-collection.github.io/mdshare/>**
6+
7+## What it renders
8+
9+- LaTeX math, inline (`$...$`) and display (`$$...$$`), via remark-math and KaTeX
10+- Fenced code blocks with per-language syntax highlighting (highlight.js) and a copy button
11+- GitHub-flavored markdown: tables, task lists, strikethrough, footnotes, autolinks
12+- Mermaid diagrams in ` ```mermaid ` fences (the mermaid library is loaded lazily, only when a document uses one)
13+
14+Raw HTML in the document is deliberately rendered as text rather than passed through. Since any document can arrive via a shared link, HTML passthrough would let a link author run script in the viewer's browser.
15+
16+## How the URL encoding works
17+
18+The document is stored in the hash fragment as
19+
20+```
21+#base64url( version byte + DEFLATE(utf8 text) )
22+```
23+
24+The fragment is never sent to any server, and base64url needs no percent-escaping, so links survive copy and paste through chat clients and email. A one-byte version header leaves room to change the encoding later without breaking old links. Editing updates the URL in place (via `replaceState`, debounced) so the address bar always holds a shareable link; the length of the current URL is shown in the top bar.
25+
26+A typical page of prose with a few equations compresses to a URL of roughly one to two thousand characters. Browsers handle URLs far longer than that, but some other software truncates very long links, so the length indicator is worth a glance before sharing a large document.
27+
28+## Development
29+
30+```bash
31+npm install
32+npm run dev # local dev server
33+npm run build # typecheck + production build to dist/
34+```
35+
36+Deployment to GitHub Pages is automatic on push to `main` via `.github/workflows/deploy.yml`.
index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <title>mdshare</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
package-lock.jsonadded+4564−0View file
This diff is 4,569 lines long and is not shown.
package.jsonadded+40−0View file
@@ -0,0 +1,40 @@
1+{
2+ "name": "mdshare",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "oxlint",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "@codemirror/commands": "^6.10.4",
14+ "@codemirror/lang-markdown": "^6.5.2",
15+ "@codemirror/language": "^6.12.4",
16+ "@codemirror/state": "^6.7.1",
17+ "@codemirror/view": "^6.43.8",
18+ "codemirror": "^6.0.2",
19+ "fflate": "^0.8.3",
20+ "highlight.js": "^11.12.0",
21+ "katex": "^0.18.4",
22+ "mermaid": "^11.16.1",
23+ "react": "^19.2.8",
24+ "react-dom": "^19.2.8",
25+ "react-markdown": "^10.1.0",
26+ "rehype-highlight": "^7.0.2",
27+ "rehype-katex": "^7.0.1",
28+ "remark-gfm": "^4.0.1",
29+ "remark-math": "^6.0.0"
30+ },
31+ "devDependencies": {
32+ "@types/node": "^24.13.3",
33+ "@types/react": "^19.2.17",
34+ "@types/react-dom": "^19.2.3",
35+ "@vitejs/plugin-react": "^6.0.4",
36+ "oxlint": "^1.75.0",
37+ "typescript": "~6.0.2",
38+ "vite": "^8.2.0"
39+ }
40+}
public/favicon.svgadded+5−0View file
@@ -0,0 +1,5 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2+ <rect x="1" y="5" width="30" height="22" rx="4" fill="#2563eb"/>
3+ <path d="M5 21v-10h3l3 4 3-4h3v10h-3v-5.5l-3 4-3-4V21z" fill="#fff"/>
4+ <path d="M22 11h3v5h3l-4.5 5.5L19 16h3z" fill="#fff"/>
5+</svg>
src/App.cssadded+255−0View file
@@ -0,0 +1,255 @@
1+.app {
2+ display: flex;
3+ flex-direction: column;
4+ height: 100vh;
5+}
6+
7+.topbar {
8+ display: flex;
9+ align-items: center;
10+ gap: 10px;
11+ padding: 8px 14px;
12+ border-bottom: 1px solid #ddd;
13+ background: #fafafa;
14+ flex: 0 0 auto;
15+}
16+
17+.topbar .title {
18+ font-weight: 600;
19+ font-size: 15px;
20+}
21+
22+.topbar .subtitle {
23+ color: #777;
24+ font-size: 13px;
25+}
26+
27+.topbar .spacer {
28+ flex: 1;
29+}
30+
31+.topbar .url-size {
32+ color: #777;
33+ font-size: 12px;
34+ font-variant-numeric: tabular-nums;
35+}
36+
37+.topbar button {
38+ font-size: 13px;
39+ padding: 4px 12px;
40+ border: 1px solid #ccc;
41+ border-radius: 5px;
42+ background: #fff;
43+ cursor: pointer;
44+}
45+
46+.topbar button:hover {
47+ background: #f0f0f0;
48+}
49+
50+.topbar button.primary {
51+ background: #2563eb;
52+ border-color: #2563eb;
53+ color: #fff;
54+}
55+
56+.topbar button.primary:hover {
57+ background: #1d4ed8;
58+}
59+
60+.panes {
61+ display: flex;
62+ flex: 1;
63+ min-height: 0;
64+}
65+
66+.pane {
67+ min-width: 0;
68+ overflow: hidden;
69+ display: flex;
70+ flex-direction: column;
71+}
72+
73+.pane-preview {
74+ flex: 1;
75+ overflow-y: auto;
76+}
77+
78+.splitter {
79+ flex: 0 0 6px;
80+ cursor: col-resize;
81+ background: #eee;
82+ border-left: 1px solid #ddd;
83+ border-right: 1px solid #ddd;
84+}
85+
86+.splitter:hover {
87+ background: #d5e3fb;
88+}
89+
90+.editor-container {
91+ height: 100%;
92+ overflow: hidden;
93+}
94+
95+.editor-container .cm-editor {
96+ height: 100%;
97+}
98+
99+/* --- rendered markdown --- */
100+
101+.preview {
102+ padding: 18px 28px 60px;
103+ max-width: 820px;
104+ font-size: 15px;
105+ line-height: 1.6;
106+}
107+
108+.preview h1,
109+.preview h2,
110+.preview h3,
111+.preview h4 {
112+ line-height: 1.25;
113+ margin-top: 1.4em;
114+ margin-bottom: 0.5em;
115+}
116+
117+.preview h1:first-child {
118+ margin-top: 0.3em;
119+}
120+
121+.preview h1 {
122+ font-size: 1.7em;
123+ border-bottom: 1px solid #eee;
124+ padding-bottom: 0.2em;
125+}
126+
127+.preview h2 {
128+ font-size: 1.35em;
129+ border-bottom: 1px solid #f0f0f0;
130+ padding-bottom: 0.15em;
131+}
132+
133+.preview h3 {
134+ font-size: 1.15em;
135+}
136+
137+.preview a {
138+ color: #2563eb;
139+}
140+
141+.preview blockquote {
142+ margin: 0.8em 0;
143+ padding: 0.1em 1em;
144+ border-left: 4px solid #ddd;
145+ color: #555;
146+}
147+
148+.preview code {
149+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
150+ font-size: 0.9em;
151+ background: #f3f3f3;
152+ border-radius: 4px;
153+ padding: 0.1em 0.35em;
154+}
155+
156+.preview pre {
157+ background: #f8f8f8;
158+ border: 1px solid #eee;
159+ border-radius: 6px;
160+ padding: 12px 14px;
161+ overflow-x: auto;
162+ line-height: 1.45;
163+}
164+
165+.preview pre code {
166+ background: none;
167+ padding: 0;
168+ font-size: 0.87em;
169+}
170+
171+.code-block {
172+ position: relative;
173+}
174+
175+.code-block .copy-button {
176+ position: absolute;
177+ top: 6px;
178+ right: 6px;
179+ font-size: 11px;
180+ padding: 2px 8px;
181+ border: 1px solid #ddd;
182+ border-radius: 4px;
183+ background: #fff;
184+ color: #666;
185+ cursor: pointer;
186+ opacity: 0;
187+ transition: opacity 0.15s;
188+}
189+
190+.code-block:hover .copy-button {
191+ opacity: 1;
192+}
193+
194+.code-block .copy-button:hover {
195+ background: #f0f0f0;
196+}
197+
198+.preview table {
199+ border-collapse: collapse;
200+ margin: 0.8em 0;
201+ display: block;
202+ overflow-x: auto;
203+}
204+
205+.preview th,
206+.preview td {
207+ border: 1px solid #ddd;
208+ padding: 5px 12px;
209+}
210+
211+.preview th {
212+ background: #f7f7f7;
213+}
214+
215+.preview tr:nth-child(2n) {
216+ background: #fbfbfb;
217+}
218+
219+.preview img {
220+ max-width: 100%;
221+}
222+
223+.preview hr {
224+ border: none;
225+ border-top: 1px solid #ddd;
226+ margin: 1.5em 0;
227+}
228+
229+.preview .katex-display {
230+ overflow-x: auto;
231+ overflow-y: hidden;
232+ padding: 2px 0;
233+}
234+
235+.preview input[type='checkbox'] {
236+ margin-right: 0.45em;
237+}
238+
239+.mermaid-diagram {
240+ margin: 0.8em 0;
241+ overflow-x: auto;
242+}
243+
244+.mermaid-loading {
245+ color: #999;
246+ font-size: 13px;
247+ margin: 0.8em 0;
248+}
249+
250+.mermaid-error {
251+ color: #b91c1c;
252+ background: #fef2f2;
253+ border: 1px solid #fecaca;
254+ white-space: pre-wrap;
255+}
src/App.tsxadded+117−0View file
@@ -0,0 +1,117 @@
1+import { useCallback, useEffect, useRef, useState } from 'react'
2+import CodeMirrorEditor from './CodeMirrorEditor'
3+import Preview from './Preview'
4+import { encodeDocument, decodeDocument } from './url'
5+import sampleDoc from './sample.md?raw'
6+import './App.css'
7+
8+function readHash(): string | null {
9+ const h = window.location.hash.replace(/^#/, '')
10+ if (!h) return null
11+ try {
12+ return decodeDocument(h)
13+ } catch {
14+ return null
15+ }
16+}
17+
18+export default function App() {
19+ const [text, setText] = useState<string>(() => readHash() ?? sampleDoc)
20+ const [urlLength, setUrlLength] = useState<number>(() => window.location.href.length)
21+ const [copied, setCopied] = useState(false)
22+
23+ // Debounced text -> URL. replaceState (not assignment) so typing doesn't
24+ // flood the browser history; lastWrittenRef lets the hashchange listener
25+ // tell our own writes apart from external navigation.
26+ const lastWrittenRef = useRef<string | null>(null)
27+ useEffect(() => {
28+ const t = setTimeout(() => {
29+ const encoded = text ? encodeDocument(text) : ''
30+ lastWrittenRef.current = encoded
31+ const url = new URL(window.location.href)
32+ url.hash = encoded
33+ window.history.replaceState(null, '', url)
34+ setUrlLength(url.href.length)
35+ }, 300)
36+ return () => clearTimeout(t)
37+ }, [text])
38+
39+ // URL -> text, when the user pastes a different mdshare link into the
40+ // address bar or navigates back/forward.
41+ useEffect(() => {
42+ const onHashChange = () => {
43+ const h = window.location.hash.replace(/^#/, '')
44+ if (h === lastWrittenRef.current) return
45+ const decoded = readHash()
46+ if (decoded !== null) setText(decoded)
47+ }
48+ window.addEventListener('hashchange', onHashChange)
49+ return () => window.removeEventListener('hashchange', onHashChange)
50+ }, [])
51+
52+ const copyLink = useCallback(async () => {
53+ // Encode the current text directly so a fast type-then-copy can't race
54+ // the debounced URL write.
55+ const url = new URL(window.location.href)
56+ url.hash = text ? encodeDocument(text) : ''
57+ lastWrittenRef.current = url.hash.replace(/^#/, '')
58+ window.history.replaceState(null, '', url)
59+ setUrlLength(url.href.length)
60+ await navigator.clipboard.writeText(url.href)
61+ setCopied(true)
62+ setTimeout(() => setCopied(false), 1500)
63+ }, [text])
64+
65+ const newDocument = useCallback(() => {
66+ if (text.trim() && text !== sampleDoc) {
67+ if (!window.confirm('Discard the current document? (The old link keeps working.)')) return
68+ }
69+ setText('')
70+ }, [text])
71+
72+ // Simple draggable splitter
73+ const [split, setSplit] = useState(0.5)
74+ const mainRef = useRef<HTMLDivElement>(null)
75+ const onSplitterDown = useCallback((e: React.PointerEvent) => {
76+ e.preventDefault()
77+ const main = mainRef.current
78+ if (!main) return
79+ const rect = main.getBoundingClientRect()
80+ const onMove = (ev: PointerEvent) => {
81+ const f = (ev.clientX - rect.left) / rect.width
82+ setSplit(Math.min(0.8, Math.max(0.2, f)))
83+ }
84+ const onUp = () => {
85+ window.removeEventListener('pointermove', onMove)
86+ window.removeEventListener('pointerup', onUp)
87+ }
88+ window.addEventListener('pointermove', onMove)
89+ window.addEventListener('pointerup', onUp)
90+ }, [])
91+
92+ return (
93+ <div className="app">
94+ <header className="topbar">
95+ <span className="title">mdshare</span>
96+ <span className="subtitle">markdown that lives in the URL</span>
97+ <span className="spacer" />
98+ <span className="url-size" title="Length of the shareable URL">
99+ URL: {urlLength.toLocaleString()} chars
100+ </span>
101+ <button onClick={newDocument}>New</button>
102+ <button className="primary" onClick={copyLink}>
103+ {copied ? 'Copied!' : 'Copy link'}
104+ </button>
105+ </header>
106+ <main className="panes" ref={mainRef}>
107+ <section className="pane" style={{ flexBasis: `${split * 100}%` }}>
108+ <CodeMirrorEditor value={text} onChange={setText} />
109+ </section>
110+ <div className="splitter" onPointerDown={onSplitterDown} />
111+ <section className="pane pane-preview">
112+ <Preview markdown={text} />
113+ </section>
114+ </main>
115+ </div>
116+ )
117+}
src/CodeMirrorEditor.tsxadded+69−0View file
@@ -0,0 +1,69 @@
1+import { useEffect, useRef } from 'react'
2+import { EditorView, keymap } from '@codemirror/view'
3+import { EditorState } from '@codemirror/state'
4+import { indentWithTab } from '@codemirror/commands'
5+import { markdown } from '@codemirror/lang-markdown'
6+import { basicSetup } from 'codemirror'
7+
8+interface Props {
9+ value: string
10+ onChange: (value: string) => void
11+}
12+
13+// Thin React wrapper around a CodeMirror 6 editor. The editor owns the text
14+// while the user types; `value` is only pushed in when it differs from the
15+// editor's contents (i.e. on external changes like a hash edit).
16+export default function CodeMirrorEditor({ value, onChange }: Props) {
17+ const containerRef = useRef<HTMLDivElement>(null)
18+ const viewRef = useRef<EditorView | null>(null)
19+ const onChangeRef = useRef(onChange)
20+ onChangeRef.current = onChange
21+
22+ useEffect(() => {
23+ if (!containerRef.current) return
24+ const view = new EditorView({
25+ state: EditorState.create({
26+ doc: value,
27+ extensions: [
28+ basicSetup,
29+ keymap.of([indentWithTab]),
30+ markdown(),
31+ EditorView.lineWrapping,
32+ EditorView.updateListener.of((update) => {
33+ if (update.docChanged) {
34+ onChangeRef.current(update.state.doc.toString())
35+ }
36+ }),
37+ EditorView.theme({
38+ '&': { height: '100%', fontSize: '13px' },
39+ '.cm-scroller': {
40+ fontFamily:
41+ "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
42+ },
43+ }),
44+ ],
45+ }),
46+ parent: containerRef.current,
47+ })
48+ viewRef.current = view
49+ return () => {
50+ view.destroy()
51+ viewRef.current = null
52+ }
53+ // The editor is created once; `value` afterwards flows through the effect below.
54+ // eslint-disable-next-line react-hooks/exhaustive-deps
55+ }, [])
56+
57+ useEffect(() => {
58+ const view = viewRef.current
59+ if (!view) return
60+ const current = view.state.doc.toString()
61+ if (current !== value) {
62+ view.dispatch({
63+ changes: { from: 0, to: current.length, insert: value },
64+ })
65+ }
66+ }, [value])
67+
68+ return <div className="editor-container" ref={containerRef} />
69+}
src/Mermaid.tsxadded+37−0View file
@@ -0,0 +1,37 @@
1+import { useEffect, useRef, useState } from 'react'
2+
3+let idCounter = 0
4+
5+// Renders a ```mermaid fence. The mermaid library (~1 MB) is imported lazily
6+// the first time a diagram appears, so documents without diagrams never load
7+// it. securityLevel 'strict' because documents arrive from shared links.
8+export default function Mermaid({ code }: { code: string }) {
9+ const [svg, setSvg] = useState<string | null>(null)
10+ const [error, setError] = useState<string | null>(null)
11+ const idRef = useRef(`mermaid-${idCounter++}`)
12+
13+ useEffect(() => {
14+ let cancelled = false
15+ setSvg(null)
16+ setError(null)
17+ ;(async () => {
18+ try {
19+ const mermaid = (await import('mermaid')).default
20+ mermaid.initialize({ startOnLoad: false, securityLevel: 'strict' })
21+ const { svg } = await mermaid.render(idRef.current, code)
22+ if (!cancelled) setSvg(svg)
23+ } catch (e) {
24+ if (!cancelled) setError(e instanceof Error ? e.message : String(e))
25+ // mermaid.render leaves an error element in the body on parse failure
26+ document.getElementById(`d${idRef.current}`)?.remove()
27+ }
28+ })()
29+ return () => {
30+ cancelled = true
31+ }
32+ }, [code])
33+
34+ if (error) return <pre className="mermaid-error">mermaid: {error}</pre>
35+ if (svg === null) return <div className="mermaid-loading">rendering diagram…</div>
36+ return <div className="mermaid-diagram" dangerouslySetInnerHTML={{ __html: svg }} />
37+}
src/Preview.tsxadded+66−0View file
@@ -0,0 +1,66 @@
1+import { memo, useState, type ReactNode } from 'react'
2+import ReactMarkdown from 'react-markdown'
3+import remarkGfm from 'remark-gfm'
4+import remarkMath from 'remark-math'
5+import rehypeKatex from 'rehype-katex'
6+import rehypeHighlight from 'rehype-highlight'
7+import Mermaid from './Mermaid'
8+
9+// Note: no rehype-raw — raw HTML in the markdown is rendered as text. Since
10+// any document can arrive via a shared link, HTML passthrough would let a
11+// link author run script in the viewer's browser.
12+
13+function extractText(node: ReactNode): string {
14+ if (typeof node === 'string') return node
15+ if (Array.isArray(node)) return node.map(extractText).join('')
16+ if (node && typeof node === 'object' && 'props' in node) {
17+ return extractText((node.props as { children?: ReactNode }).children)
18+ }
19+ return ''
20+}
21+
22+function Pre({ children, ...props }: React.HTMLAttributes<HTMLPreElement>) {
23+ const [copied, setCopied] = useState(false)
24+
25+ // ```mermaid fences render as a diagram instead of a code block
26+ const child = Array.isArray(children) ? children[0] : children
27+ if (
28+ child &&
29+ typeof child === 'object' &&
30+ 'props' in child &&
31+ /\blanguage-mermaid\b/.test(
32+ String((child.props as { className?: string }).className ?? ''),
33+ )
34+ ) {
35+ return <Mermaid code={extractText(child).replace(/\n$/, '')} />
36+ }
37+
38+ const copy = () => {
39+ navigator.clipboard.writeText(extractText(children).replace(/\n$/, ''))
40+ setCopied(true)
41+ setTimeout(() => setCopied(false), 1500)
42+ }
43+
44+ return (
45+ <div className="code-block">
46+ <button className="copy-button" onClick={copy} title="Copy code">
47+ {copied ? 'copied' : 'copy'}
48+ </button>
49+ <pre {...props}>{children}</pre>
50+ </div>
51+ )
52+}
53+
54+export default memo(function Preview({ markdown }: { markdown: string }) {
55+ return (
56+ <div className="preview">
57+ <ReactMarkdown
58+ remarkPlugins={[remarkGfm, remarkMath]}
59+ rehypePlugins={[rehypeKatex, rehypeHighlight]}
60+ components={{ pre: Pre }}
61+ >
62+ {markdown}
63+ </ReactMarkdown>
64+ </div>
65+ )
66+})
src/index.cssadded+17−0View file
@@ -0,0 +1,17 @@
1+* {
2+ box-sizing: border-box;
3+}
4+
5+html,
6+body,
7+#root {
8+ margin: 0;
9+ height: 100%;
10+}
11+
12+body {
13+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial,
14+ sans-serif;
15+ color: #1a1a1a;
16+ background: #fff;
17+}
src/main.tsxadded+12−0View file
@@ -0,0 +1,12 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import 'katex/dist/katex.min.css'
4+import 'highlight.js/styles/github.css'
5+import './index.css'
6+import App from './App.tsx'
7+
8+createRoot(document.getElementById('root')!).render(
9+ <StrictMode>
10+ <App />
11+ </StrictMode>,
12+)
src/sample.mdadded+71−0View file
@@ -0,0 +1,71 @@
1+# mdshare
2+
3+Type markdown on the left, see it rendered on the right. The document is
4+compressed and stored in the URL itself, so sharing the page is just copying
5+the link: there is no server and nothing is uploaded.
6+
7+## Math
8+
9+Inline math like $e^{i\pi} + 1 = 0$ works, and so do display equations:
10+
11+$$
12+\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
13+$$
14+
15+$$
16+\begin{pmatrix} a & b \\ c & d \end{pmatrix}
17+\begin{pmatrix} x \\ y \end{pmatrix}
18+=
19+\begin{pmatrix} ax + by \\ cx + dy \end{pmatrix}
20+$$
21+
22+## Code
23+
24+```python
25+import numpy as np
26+
27+def gaussian(x, sigma=1.0):
28+ """The integrand from the equation above."""
29+ return np.exp(-x**2 / sigma**2)
30+
31+x = np.linspace(-4, 4, 1000)
32+print(np.trapezoid(gaussian(x), x)) # ~ sqrt(pi)
33+```
34+
35+```javascript
36+// The URL encoding used by this page
37+const payload = deflate(new TextEncoder().encode(text))
38+location.hash = base64url(payload)
39+```
40+
41+## Tables and task lists
42+
43+| Feature | Plugin | Status |
44+| --- | --- | --- |
45+| Math | remark-math + KaTeX | works |
46+| Code highlighting | highlight.js | works |
47+| Tables, footnotes | remark-gfm | works |
48+| Diagrams | mermaid | works |
49+
50+- [x] render markdown
51+- [x] store the document in the URL
52+- [ ] your ideas here
53+
54+## Diagrams
55+
56+```mermaid
57+graph LR
58+ A[edit markdown] --> B[compress]
59+ B --> C[base64url]
60+ C --> D[URL hash]
61+ D --> E[share the link]
62+ E --> A
63+```
64+
65+## Notes
66+
67+Footnotes[^1], ~~strikethrough~~, and block quotes:
68+
69+> The URL *is* the document.
70+
71+[^1]: Like this one.
src/url.tsadded+41−0View file
@@ -0,0 +1,41 @@
1+import { deflateSync, inflateSync, strToU8, strFromU8 } from 'fflate'
2+
3+// The document lives in the URL hash as: '#' + base64url(version byte + raw
4+// DEFLATE of the UTF-8 text). The hash fragment is never sent to the server,
5+// and base64url needs no percent-escaping, so the link survives copy/paste
6+// through chat clients and email intact.
7+
8+const VERSION = 1
9+
10+function toBase64Url(bytes: Uint8Array): string {
11+ let bin = ''
12+ const chunk = 0x8000
13+ for (let i = 0; i < bytes.length; i += chunk) {
14+ bin += String.fromCharCode(...bytes.subarray(i, i + chunk))
15+ }
16+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
17+}
18+
19+function fromBase64Url(s: string): Uint8Array {
20+ const b64 = s.replace(/-/g, '+').replace(/_/g, '/')
21+ const bin = atob(b64)
22+ const bytes = new Uint8Array(bin.length)
23+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
24+ return bytes
25+}
26+
27+export function encodeDocument(text: string): string {
28+ const compressed = deflateSync(strToU8(text), { level: 9 })
29+ const payload = new Uint8Array(compressed.length + 1)
30+ payload[0] = VERSION
31+ payload.set(compressed, 1)
32+ return toBase64Url(payload)
33+}
34+
35+export function decodeDocument(encoded: string): string {
36+ const payload = fromBase64Url(encoded)
37+ if (payload.length === 0 || payload[0] !== VERSION) {
38+ throw new Error(`unsupported document encoding (version ${payload[0]})`)
39+ }
40+ return strFromU8(inflateSync(payload.subarray(1)))
41+}
src/vite-env.d.tsadded+1−0View file
@@ -0,0 +1 @@
1+/// <reference types="vite/client" />
tsconfig.app.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023", "DOM"],
6+ "module": "esnext",
7+ "types": ["vite/client"],
8+ "allowArbitraryExtensions": true,
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "noUnusedLocals": true,
21+ "noUnusedParameters": true,
22+ "erasableSyntaxOnly": true,
23+ "noFallthroughCasesInSwitch": true
24+ },
25+ "include": ["src"]
26+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
tsconfig.node.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023"],
6+ "types": ["node"],
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "module": "nodenext",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+
16+ /* Linting */
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true,
19+ "erasableSyntaxOnly": true,
20+ "noFallthroughCasesInSwitch": true
21+ },
22+ "include": ["vite.config.ts"]
23+}
vite.config.tsadded+9−0View file
@@ -0,0 +1,9 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// Relative base so the built site works from any path (GitHub Pages project
5+// site, a subdirectory, or the filesystem).
6+export default defineConfig({
7+ base: './',
8+ plugins: [react()],
9+})