mdecks: Copy link puts the whole deck in the link
Copy link makes a link with the deck after "#d=": its text deflated (raw) and base64url-encoded, which browsers never send to a server, so nothing is stored. The page opens a deck it finds in its address, on load or when one is pasted in later, and also takes "#md=" with the text percent-encoded, for anything that can't compress. A link is kept ready while you type so a click copies it at once, the address bar shows it after copying, and notes say when a deck came from a link, when a link is broken, and when one is long enough that some apps may cut it short. The README documents the format with Python, Node and shell snippets for agents and scripts, and its live-page link now points at mdecks.yolopreneur.lol. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYHXFvWMCs1GUmRmpySqQ6
This commit is contained in:
1 parent
d2e4d755b6
commit
a02baa4aa6
3 files changed
+167
-24
No files matched your search
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Type slides in plain text and instantly preview a clean presentation layout — right in the browser.
|
Type slides in plain text and instantly preview a clean presentation layout — right in the browser.
|
||||||
|
|
||||||
**Live page:** https://teahubxyz.github.io/time/ventures/mdecks/
|
**Live page:** https://mdecks.yolopreneur.lol/
|
||||||
|
|
||||||
## Who it is for
|
## Who it is for
|
||||||
|
|
||||||
@@ -14,6 +14,7 @@ People who make a quick throwaway deck every week — sprint demos, status updat
|
|||||||
2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place.
|
2. Watch the preview on the right re-typeset live. Navigate with the arrow keys (or swipe on touch screens); a progress bar tracks your place.
|
||||||
3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more.
|
3. A slide with too much text shrinks to fit, down to 70% of the normal size. Past that it's cut into two or more slides, between lines and never right after a heading, and each new slide repeats its title with "(cont.)". A note under the preview says which slides were cut; put in a `---` yourself to choose the break. Cuts are worked out for a 1280x720 screen, so the copied deck has the same slides on any screen, and on a smaller one they shrink a little more.
|
||||||
4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies.
|
4. Press **Copy deck as HTML** to copy the whole deck as one self-contained HTML file. Open it in any browser and present with the arrow keys — offline, no dependencies.
|
||||||
|
5. Or press **Copy link** for a link with the whole deck inside it. Whoever opens it gets the deck in the editor, ready to present or change; changes make a new link. The deck rides after the `#`, which browsers never send to a server, so nothing is stored anywhere.
|
||||||
|
|
||||||
The syntax:
|
The syntax:
|
||||||
|
|
||||||
@@ -24,11 +25,41 @@ The syntax:
|
|||||||
- `[text](https://…)` for a link
|
- `[text](https://…)` for a link
|
||||||
- Any other line becomes a paragraph
|
- Any other line becomes a paragraph
|
||||||
|
|
||||||
Everything runs in the page. Nothing is saved, nothing is sent anywhere, no account is needed, and the page is free.
|
Everything runs in the page. Nothing is stored, nothing is sent anywhere, no account is needed, and the page is free.
|
||||||
|
|
||||||
|
## Links, for agents and scripts
|
||||||
|
|
||||||
|
A link is the page's address, then `#d=`, then the deck's text (UTF-8) compressed with raw deflate and written in base64url without padding. Anything that can't compress can use `#md=` and the text percent-encoded instead; those links are just longer. The page opens either kind.
|
||||||
|
|
||||||
|
In Python:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import base64, zlib
|
||||||
|
|
||||||
|
def mdeck_link(text):
|
||||||
|
deflate = zlib.compressobj(9, zlib.DEFLATED, -15)
|
||||||
|
data = deflate.compress(text.encode()) + deflate.flush()
|
||||||
|
return "https://mdecks.yolopreneur.lol/#d=" + base64.urlsafe_b64encode(data).decode().rstrip("=")
|
||||||
|
```
|
||||||
|
|
||||||
|
In Node:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const link = "https://mdecks.yolopreneur.lol/#d=" + require("zlib").deflateRawSync(text).toString("base64url");
|
||||||
|
```
|
||||||
|
|
||||||
|
From a shell, for a deck in `deck.md`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -c 'import base64,sys,zlib; c=zlib.compressobj(9,zlib.DEFLATED,-15); d=c.compress(sys.stdin.buffer.read())+c.flush(); print("https://mdecks.yolopreneur.lol/#d="+base64.urlsafe_b64encode(d).decode().rstrip("="))' < deck.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Without compression: `"https://mdecks.yolopreneur.lol/#md=" + encodeURIComponent(text)`.
|
||||||
|
|
||||||
## Honest limits
|
## Honest limits
|
||||||
|
|
||||||
- Nothing is saved — close the tab and your text is gone.
|
- Nothing is stored — close the tab without copying a link or the HTML and your text is gone.
|
||||||
|
- Anyone with a link can read the deck in it, and a very long deck makes a link some apps cut short.
|
||||||
- One layout and one typeface; no themes or branding.
|
- One layout and one typeface; no themes or branding.
|
||||||
- No images, tables, or nested lists yet.
|
- No images, tables, or nested lists yet.
|
||||||
- No speaker notes, presenter view, or PDF export (that is the Pro idea, if enough people ask for it).
|
- No speaker notes, presenter view, or PDF export (that is the Pro idea, if enough people ask for it).
|
||||||
@@ -36,7 +67,7 @@ Everything runs in the page. Nothing is saved, nothing is sent anywhere, no acco
|
|||||||
|
|
||||||
## .mdeck Pro (not built yet)
|
## .mdeck Pro (not built yet)
|
||||||
|
|
||||||
The paid idea: saved decks, themes, speaker notes, PDF export, and shareable links — still plain text in. Early access is collected via a [public GitHub issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro); no email, no payment, nothing is charged today. A 👍 on the [signal issue](https://teahub.xyz/teahubxyz/time/issues/13) counts as interest too.
|
The paid idea: saved decks, themes, speaker notes, and PDF export — still plain text in. Early access is collected via a [public GitHub issue form](https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro); no email, no payment, nothing is charged today. A 👍 on the [signal issue](https://teahub.xyz/teahubxyz/time/issues/13) counts as interest too.
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
|
|||||||
+124
-15
@@ -40,6 +40,7 @@
|
|||||||
"- This editor and the live preview",
|
"- This editor and the live preview",
|
||||||
"- Arrow keys or swipe to present",
|
"- Arrow keys or swipe to present",
|
||||||
"- **Copy deck as HTML**: one file that opens in any browser",
|
"- **Copy deck as HTML**: one file that opens in any browser",
|
||||||
|
"- **Copy link**: the whole deck rides inside the link",
|
||||||
"- Nothing is saved or sent: your deck stays in this tab",
|
"- Nothing is saved or sent: your deck stays in this tab",
|
||||||
"",
|
"",
|
||||||
"---",
|
"---",
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
"",
|
"",
|
||||||
"- Saved decks and themes",
|
"- Saved decks and themes",
|
||||||
"- Speaker notes",
|
"- Speaker notes",
|
||||||
"- PDF export and a shareable link",
|
"- PDF export",
|
||||||
"- **$5/mo**, the founding price",
|
"- **$5/mo**, the founding price",
|
||||||
"",
|
"",
|
||||||
"Pro isn't built yet, and nothing is charged today.",
|
"Pro isn't built yet, and nothing is charged today.",
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
if (current < 0) current = 0;
|
if (current < 0) current = 0;
|
||||||
render();
|
render();
|
||||||
note(cuts, over);
|
note(cuts, over);
|
||||||
|
scheduleLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -403,6 +405,101 @@
|
|||||||
touchX = null;
|
touchX = null;
|
||||||
}, { passive: true });
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Links: the deck's text travels in the link after "#", which browsers never send to a server, so
|
||||||
|
// nothing is stored. #d= is the text deflated (raw) and base64url-encoded; #md= is the text
|
||||||
|
// percent-encoded, for anything that can't compress. The README shows how to make either.
|
||||||
|
var linkBtn = document.getElementById("link");
|
||||||
|
var linkNote = document.getElementById("linknote");
|
||||||
|
var linkFor = null, link = null, linkTimer = null;
|
||||||
|
|
||||||
|
function say(text, warn) {
|
||||||
|
linkNote.hidden = !text;
|
||||||
|
linkNote.textContent = text || "";
|
||||||
|
linkNote.classList.toggle("warn", !!warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64url(bytes) {
|
||||||
|
var s = "";
|
||||||
|
for (var i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
||||||
|
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function unbase64url(text) {
|
||||||
|
var s = atob(text.replace(/-/g, "+").replace(/_/g, "/"));
|
||||||
|
var bytes = new Uint8Array(s.length);
|
||||||
|
for (var i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function through(data, transform) {
|
||||||
|
return new Response(new Blob([data]).stream().pipeThrough(transform));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link to this text, as a promise.
|
||||||
|
function makeLink(text) {
|
||||||
|
var base = location.href.split("#")[0];
|
||||||
|
if (typeof CompressionStream !== "function") return Promise.resolve(base + "#md=" + encodeURIComponent(text));
|
||||||
|
return through(text, new CompressionStream("deflate-raw")).arrayBuffer().then(function (buf) {
|
||||||
|
return base + "#d=" + base64url(new Uint8Array(buf));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link to the deck is kept ready, so a click copies it at once: some browsers only let a page
|
||||||
|
// copy while it is still answering the click.
|
||||||
|
function scheduleLink() {
|
||||||
|
clearTimeout(linkTimer);
|
||||||
|
linkTimer = setTimeout(function () {
|
||||||
|
var text = src.value;
|
||||||
|
makeLink(text).then(function (url) { linkFor = text; link = url; });
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deck in a "#", as a promise of its text, or null when there is none.
|
||||||
|
function readLink(hash) {
|
||||||
|
var params = new URLSearchParams(hash.replace(/^#/, ""));
|
||||||
|
if (params.has("md")) return Promise.resolve(params.get("md"));
|
||||||
|
if (!params.has("d")) return null;
|
||||||
|
return Promise.resolve(params.get("d")).then(function (data) {
|
||||||
|
if (typeof DecompressionStream !== "function") throw new Error("no DecompressionStream");
|
||||||
|
return through(unbase64url(data), new DecompressionStream("deflate-raw")).text();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink() {
|
||||||
|
var reading = readLink(location.hash);
|
||||||
|
if (!reading) return;
|
||||||
|
reading.then(function (text) {
|
||||||
|
load(text);
|
||||||
|
current = 0;
|
||||||
|
parse();
|
||||||
|
say("Opened from a link. Edits stay in this tab; copy a new link to share them.");
|
||||||
|
}, function () {
|
||||||
|
if (!src.value) { load(SAMPLE); parse(); }
|
||||||
|
say("This link's deck couldn't be opened. It may have been cut short on the way.", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put text in the editor, showing its start.
|
||||||
|
function load(text) {
|
||||||
|
src.value = text;
|
||||||
|
src.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function share(url) {
|
||||||
|
history.replaceState(null, "", url);
|
||||||
|
say(url.length > 8000 ? "This link is " + url.length.toLocaleString("en") + " characters long. Some apps " +
|
||||||
|
"cut links that long short; the HTML copy travels anywhere." : "");
|
||||||
|
copyText(url, linkBtn, "Link copied!", function () {
|
||||||
|
say("Couldn't copy here, but the link is in the address bar.", true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
linkBtn.addEventListener("click", function () {
|
||||||
|
var text = src.value;
|
||||||
|
if (linkFor === text) share(link);
|
||||||
|
else makeLink(text).then(share);
|
||||||
|
});
|
||||||
|
|
||||||
// Build the self-contained export deck: the slides as shown, cuts and all.
|
// Build the self-contained export deck: the slides as shown, cuts and all.
|
||||||
function buildExport() {
|
function buildExport() {
|
||||||
var slidesHtml = deck.map(function (s) {
|
var slidesHtml = deck.map(function (s) {
|
||||||
@@ -427,31 +524,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
copyBtn.addEventListener("click", function () {
|
copyBtn.addEventListener("click", function () {
|
||||||
var html = buildExport();
|
copyText(buildExport(), copyBtn, "Copied!");
|
||||||
var done = function () {
|
|
||||||
var old = copyBtn.textContent;
|
|
||||||
copyBtn.textContent = "Copied!";
|
|
||||||
setTimeout(function () { copyBtn.textContent = old; }, 1500);
|
|
||||||
};
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
navigator.clipboard.writeText(html).then(done, function () { fallbackCopy(html, done); });
|
|
||||||
} else {
|
|
||||||
fallbackCopy(html, done);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function fallbackCopy(text, done) {
|
// Copy, then say so on the button for a moment; failed (optional) runs when the browser won't copy.
|
||||||
|
function copyText(text, button, label, failed) {
|
||||||
|
var done = function () {
|
||||||
|
if (!button.dataset.label) button.dataset.label = button.textContent;
|
||||||
|
button.textContent = label;
|
||||||
|
clearTimeout(button.labelTimer);
|
||||||
|
button.labelTimer = setTimeout(function () { button.textContent = button.dataset.label; }, 1500);
|
||||||
|
};
|
||||||
|
var fallback = function () { fallbackCopy(text, done, failed || function () {}); };
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(text).then(done, fallback);
|
||||||
|
} else {
|
||||||
|
fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopy(text, done, failed) {
|
||||||
var ta = document.createElement("textarea");
|
var ta = document.createElement("textarea");
|
||||||
ta.value = text;
|
ta.value = text;
|
||||||
ta.style.position = "fixed";
|
ta.style.position = "fixed";
|
||||||
ta.style.opacity = "0";
|
ta.style.opacity = "0";
|
||||||
document.body.appendChild(ta);
|
document.body.appendChild(ta);
|
||||||
ta.select();
|
ta.select();
|
||||||
try { document.execCommand("copy"); done(); } catch (e) { /* give up quietly */ }
|
var copied = false;
|
||||||
|
try { copied = document.execCommand("copy"); } catch (e) { copied = false; }
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
|
if (copied) done(); else failed();
|
||||||
}
|
}
|
||||||
|
|
||||||
src.value = SAMPLE;
|
// A deck in the address opens in place of the sample, and so does one pasted into it later.
|
||||||
|
var linked = /(^#|&)(d|md)=/.test(location.hash);
|
||||||
|
load(linked ? "" : SAMPLE);
|
||||||
makeFrame();
|
makeFrame();
|
||||||
parse();
|
parse();
|
||||||
|
if (linked) openLink();
|
||||||
|
window.addEventListener("hashchange", openLink);
|
||||||
})();
|
})();
|
||||||
@@ -411,11 +411,12 @@
|
|||||||
<button id="prev" aria-label="Previous slide">← Prev</button>
|
<button id="prev" aria-label="Previous slide">← Prev</button>
|
||||||
<button id="next" aria-label="Next slide">Next →</button>
|
<button id="next" aria-label="Next slide">Next →</button>
|
||||||
<button id="copy" class="primary">Copy deck as HTML</button>
|
<button id="copy" class="primary">Copy deck as HTML</button>
|
||||||
|
<button id="link">Copy link</button>
|
||||||
<span class="counter" id="counter">1 / 1</span>
|
<span class="counter" id="counter">1 / 1</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" role="progressbar" aria-label="Deck progress"><div id="bar"></div></div>
|
<div class="progress" role="progressbar" aria-label="Deck progress"><div id="bar"></div></div>
|
||||||
<div class="slide-wrap" id="wrap"><article class="slide" id="slide"></article></div><p class="fit-note" id="fitnote" role="status" hidden></p>
|
<div class="slide-wrap" id="wrap"><article class="slide" id="slide"></article></div><p class="fit-note" id="fitnote" role="status" hidden></p><p class="fit-note" id="linknote" role="status" hidden></p>
|
||||||
<p class="export-note">The copied file is a self-contained deck: open it in any browser and use ← / → to present.</p>
|
<p class="export-note">The copied file is a self-contained deck: open it in any browser and use ← / → to present. A copied link carries the deck inside it, after the #, so it opens here with nothing stored.</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -432,7 +433,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="step">
|
<div class="step">
|
||||||
<h3>Take the file with you</h3>
|
<h3>Take the file with you</h3>
|
||||||
<p>One button copies the deck as a single self-contained HTML file. Open it anywhere, present it offline, email it, done.</p>
|
<p>One button copies the deck as a single self-contained HTML file. Open it anywhere, present it offline, email it, done. Or copy a link with the whole deck inside it.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -469,6 +470,7 @@
|
|||||||
<li>Full plain-text editor</li>
|
<li>Full plain-text editor</li>
|
||||||
<li>Live navigable preview with arrow keys and progress bar</li>
|
<li>Live navigable preview with arrow keys and progress bar</li>
|
||||||
<li>One-click copy of the deck as self-contained HTML</li>
|
<li>One-click copy of the deck as self-contained HTML</li>
|
||||||
|
<li>Links with the deck inside them, nothing stored</li>
|
||||||
<li>Runs entirely in the browser; nothing is saved or sent.</li>
|
<li>Runs entirely in the browser; nothing is saved or sent.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a class="cta btn" href="#try">Use it free</a>
|
<a class="cta btn" href="#try">Use it free</a>
|
||||||
@@ -479,7 +481,7 @@
|
|||||||
<p>For people who make a deck every week and want it saved and polished.</p>
|
<p>For people who make a deck every week and want it saved and polished.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Saved decks, themes, speaker notes</li>
|
<li>Saved decks, themes, speaker notes</li>
|
||||||
<li>PDF export, shareable links</li>
|
<li>PDF export</li>
|
||||||
<li>Plain text in, always.</li>
|
<li>Plain text in, always.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a class="cta btn" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro" target="_blank" rel="noopener">Join early access</a>
|
<a class="cta btn" href="https://teahub.xyz/teahubxyz/time/issues/new?template=early-access.yml&title=Early+access%3A+.mdeck&venture=mdecks&plan=Pro" target="_blank" rel="noopener">Join early access</a>
|
||||||
@@ -491,7 +493,8 @@
|
|||||||
<section id="limits">
|
<section id="limits">
|
||||||
<h2>Honest limits</h2>
|
<h2>Honest limits</h2>
|
||||||
<ul class="limits">
|
<ul class="limits">
|
||||||
<li>Nothing is saved — close the tab and your text is gone.</li>
|
<li>Nothing is stored — close the tab without copying a link or the HTML and your text is gone.</li>
|
||||||
|
<li>Anyone with a link can read the deck in it, and a very long deck makes a link some apps cut short.</li>
|
||||||
<li>One layout and one typeface; no themes or branding.</li>
|
<li>One layout and one typeface; no themes or branding.</li>
|
||||||
<li>No images, tables, or nested lists yet.</li>
|
<li>No images, tables, or nested lists yet.</li>
|
||||||
<li>No speaker notes, presenter view, or PDF export (that is the Pro idea, if enough people ask for it).</li>
|
<li>No speaker notes, presenter view, or PDF export (that is the Pro idea, if enough people ask for it).</li>
|
||||||
|
|||||||
Reference in new issue
Block a user