From ce065c4a5132479c210d9fb281f94fddbcd07d80 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Fri, 31 Jul 2026 06:14:06 -0700 Subject: [PATCH 1/3] Point og:image rasterizing at the shared og-svg renderer The old call built '/.netlify/functions/rasterize/' + svg as a path, but rasterize.js only ever read the query string, so every request 502'd. It also put unescaped SVG markup straight into a URL path. decodeURL has already base64-decoded the value by this point, so what we hold is SVG markup; it is re-encoded as base64url, which stays URL-safe with no percent-encoding and so fits more into Cloudflare's 16KB URL cap. Removes functions/rasterize.js and the sharp dependency it needed, plus the [functions] block that existed only to mark sharp external. Renderer: https://github.com/arfct/og-svg Co-Authored-By: Claude Opus 5 --- functions/rasterize.js | 18 ------------------ netlify.toml | 6 ------ netlify/edge-functions/metadata.js | 21 ++++++++++++++++++++- package.json | 3 +-- 4 files changed, 21 insertions(+), 27 deletions(-) delete mode 100644 functions/rasterize.js diff --git a/functions/rasterize.js b/functions/rasterize.js deleted file mode 100644 index 7f44bad..0000000 --- a/functions/rasterize.js +++ /dev/null @@ -1,18 +0,0 @@ -const sharp = require("sharp") - - exports.handler = async function (event, context) { - console.log("event", event, context, event.rawQuery); - let data = event.rawQuery.replace(/\=/g,'') - let svg = atob(data) || decodeURIComponent(event.rawQuery); - if (!svg.startsWith("${svg}` - console.log("svg", svg) - const img = sharp(Buffer.from(svg)) - .resize(1200) - const jpeg = img.jpeg().toBuffer(); - return { - statusCode: 200, - headers: {"content-type": "image/jpeg"}, - isBase64Encoded: true, - body: (await jpeg).toString('base64') - }; - }; \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index 292421d..473dad1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,9 +1,3 @@ -[functions] - external_node_modules = [ - "sharp" - ] - directory = "functions/" - [[edge_functions]] path = "/*" function = "metadata" diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index d688840..2ed9c4d 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -11,6 +11,25 @@ function decodeURL(s) { function atou(b64) { return decodeURIComponent(escape(atob(b64))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } +// Shared SVG->PNG renderer: https://github.com/arfct/og-svg +const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev"; + +// Builds a render URL from SVG markup. +// +// This replaces "/.netlify/functions/rasterize/" + svg, which never worked: +// rasterize.js read the query string, not the path, so every call 502'd. It +// also put unescaped markup straight into a URL path. +// +// base64url keeps the payload URL-safe with no percent-encoding, which matters +// because Cloudflare caps URLs at 16KB. +function renderUrl(svg) { + const payload = utoa(svg) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${RENDER_ORIGIN}/png?s=${payload}`; +} + function pathToMetadata(path) { let components = path.substring(1).split("/"); let info = {title: decodePrettyComponent(components.shift())} @@ -56,7 +75,7 @@ export default async (request, context) => { if (info.i) { info.i = decodeURL(info.i) - if (!info.i.startsWith("http")) info.i = "/.netlify/functions/rasterize/" + info.i + if (!info.i.startsWith("http")) info.i = renderUrl(info.i) content.push(mProp("og:image", info.i)); if (info.iw) content.push(mProp("og:image:width", info.iw)); if (info.ih) content.push(mProp("og:image:width", info.ih)); diff --git a/package.json b/package.json index a08e6da..130fec3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,6 @@ { "dependencies": { - "brotli-wasm": "^1.1.0", - "sharp": "^0.31.3" + "brotli-wasm": "^1.1.0" }, "devDependencies": { "netlify-cli": "^11.5.1" From 0fde9b37576a7b0e86e636a86533a44e17c446b6 Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Fri, 31 Jul 2026 06:14:06 -0700 Subject: [PATCH 2/3] Point og:image rasterizing at the shared og-svg renderer The old call built '/.netlify/functions/rasterize/' + svg as a path, but rasterize.js only ever read the query string, so every request 502'd. It also put unescaped SVG markup straight into a URL path. decodeURL has already base64-decoded the value by this point, so what we hold is SVG markup; it is re-encoded as base64url, which stays URL-safe with no percent-encoding and so fits more into Cloudflare's 16KB URL cap. Removes functions/rasterize.js and the sharp dependency it needed, plus the [functions] block that existed only to mark sharp external. Renderer: https://github.com/arfct/og-svg Co-Authored-By: Claude Opus 5 --- functions/rasterize.js | 18 ------------------ netlify.toml | 6 ------ netlify/edge-functions/metadata.js | 21 ++++++++++++++++++++- package.json | 3 +-- 4 files changed, 21 insertions(+), 27 deletions(-) delete mode 100644 functions/rasterize.js diff --git a/functions/rasterize.js b/functions/rasterize.js deleted file mode 100644 index 7f44bad..0000000 --- a/functions/rasterize.js +++ /dev/null @@ -1,18 +0,0 @@ -const sharp = require("sharp") - - exports.handler = async function (event, context) { - console.log("event", event, context, event.rawQuery); - let data = event.rawQuery.replace(/\=/g,'') - let svg = atob(data) || decodeURIComponent(event.rawQuery); - if (!svg.startsWith("${svg}` - console.log("svg", svg) - const img = sharp(Buffer.from(svg)) - .resize(1200) - const jpeg = img.jpeg().toBuffer(); - return { - statusCode: 200, - headers: {"content-type": "image/jpeg"}, - isBase64Encoded: true, - body: (await jpeg).toString('base64') - }; - }; \ No newline at end of file diff --git a/netlify.toml b/netlify.toml index 292421d..473dad1 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,9 +1,3 @@ -[functions] - external_node_modules = [ - "sharp" - ] - directory = "functions/" - [[edge_functions]] path = "/*" function = "metadata" diff --git a/netlify/edge-functions/metadata.js b/netlify/edge-functions/metadata.js index d688840..2ed9c4d 100644 --- a/netlify/edge-functions/metadata.js +++ b/netlify/edge-functions/metadata.js @@ -11,6 +11,25 @@ function decodeURL(s) { function atou(b64) { return decodeURIComponent(escape(atob(b64))); } function utoa(data) { return btoa(unescape(encodeURIComponent(data))); } +// Shared SVG->PNG renderer: https://github.com/arfct/og-svg +const RENDER_ORIGIN = "https://og-svg.arfct.workers.dev"; + +// Builds a render URL from SVG markup. +// +// This replaces "/.netlify/functions/rasterize/" + svg, which never worked: +// rasterize.js read the query string, not the path, so every call 502'd. It +// also put unescaped markup straight into a URL path. +// +// base64url keeps the payload URL-safe with no percent-encoding, which matters +// because Cloudflare caps URLs at 16KB. +function renderUrl(svg) { + const payload = utoa(svg) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + return `${RENDER_ORIGIN}/png?s=${payload}`; +} + function pathToMetadata(path) { let components = path.substring(1).split("/"); let info = {title: decodePrettyComponent(components.shift())} @@ -56,7 +75,7 @@ export default async (request, context) => { if (info.i) { info.i = decodeURL(info.i) - if (!info.i.startsWith("http")) info.i = "/.netlify/functions/rasterize/" + info.i + if (!info.i.startsWith("http")) info.i = renderUrl(info.i) content.push(mProp("og:image", info.i)); if (info.iw) content.push(mProp("og:image:width", info.iw)); if (info.ih) content.push(mProp("og:image:width", info.ih)); diff --git a/package.json b/package.json index ab1ee23..0b79118 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,5 @@ { "dependencies": { - "brotli-wasm": "^1.1.0", - "sharp": "^0.31.3" + "brotli-wasm": "^1.1.0" } } From 722462ee0e22f28a4a2fe86c9b54051cd2c4629d Mon Sep 17 00:00:00 2001 From: Nicholas Jitkoff Date: Tue, 4 Aug 2026 05:46:08 -0700 Subject: [PATCH 3/3] WIP: move doc-title into the format bar, add url preview and share form In-progress editor chrome rework on the og-svg-renderer branch: - Relocate the doc-title metadata form from the page body into #formatbar, and add a params field plus a contenteditable #url-preview. - Turn 'show preview' from a menu link into a PREVIEW button in the toolbar. - Sketch a ShareForm class in bitty-menu.js for redirect.app share links. Incomplete: parse() still references undefined 'object' and 'dashspaces'. Committed to preserve work ahead of moving this repo out of Dropbox. --- docs/bitty-menu.js | 83 ++++++++++++++++++++++++++++------------- docs/edit.css | 15 ++++++-- docs/edit.html | 29 ++++++++------ docs/edit.js | 44 +++++++++++++++------- docs/index.css | 4 +- docs/index.js | 1 + docs/render/contact.css | 15 ++++++-- docs/render/contact.js | 1 + 8 files changed, 133 insertions(+), 59 deletions(-) diff --git a/docs/bitty-menu.js b/docs/bitty-menu.js index 8d593db..c715ff7 100644 --- a/docs/bitty-menu.js +++ b/docs/bitty-menu.js @@ -1,4 +1,57 @@ import * as bitty from '/bitty.js'; + +class ShareForm { + constructor() { + + // element created + } + + parse(form) { + + let formData = new FormData(form); + let formObj = Object.fromEntries(formData); + console.log("DATA", formObj) + + console.log("object", formObj); + let url = formData.get("url"); + formData.delete("url") + let title = formData.get("t"); + formData.delete("t") + + if (!title.length) return; + let path = `/${dashspaces(title)}/` + let encodedFields = ["i", "v"]; + formData.forEach((value, key) => { + if (!value.length) return; + if (encodedFields.includes(key)) value = btoa(key) + path += `/${key}/${value}` + }); + formData.forEach((value, key) => {if (value.length) object[key] = value}); + + path += "#" + url; + console.log(path); + document.getElementById("rurl").value = `https://redirect.app${path}`; + // history.replaceState(null, path, path); + } + + render() { + return el("form#share-form.flex-column", + el("input", {type: "image", target: "i", class: "thumbnail", onclick: "getURL(this)", src: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='600' viewBox='0 0 1200 600'%3E%%3C/svg%3E%0A"}), + el("div.flex-row", + el("div.flex-column", + el("input", {name: "t", oninput: this.parse, placeholder: "Title"}), + el("input", {name: "d", oninput: this.parse, placeholder: "Description"}), + el("input", {id: "i", name: "i", type: "hidden", oninput: this.parse}), + el("input", {id: "f", name: "i", type: "hidden", oninput: this.parse}), + el("input", {name: "url", oninput: this.parse, placeholder: "example.com"}), + el("button", {type: "sumbit", style: "display:none"}, "submit") + ), + el("input", {type: "image", src: "", id: "favicon", target: "f", onclick: "getURL(this)", oninput: this.parse, placeholder: "icon"}) + ) + ) + } +} + class Menu { /** * @constructor @@ -18,7 +71,6 @@ class Menu { } makeQR() { - bitty.loadScript('/js/qrious.min.js', null, "").then(() => { console.log("qrious loaded", location.href); let qrDialog = el("dialog#qr-dialog.dialog", @@ -91,14 +143,10 @@ class Menu { icons = { "qr": ``, - "text": ` - `, - "email": ` - `, - "mastodon": ` - `, - "twitter": ` - `, + "text": ``, + "email": ``, + "mastodon": ``, + "twitter": ``, } selectField(target) { @@ -164,22 +212,7 @@ class Menu { menu.style.top = y + "px"; - menu.style.removeProperty("display") - - // if (window.innerWidth - info.offset.left > 300) { - // menu.style.left = info?.offset.left + "px"; - // } else { - // menu.style.right = (window.innerWidth - info?.offset.right) + "px"; - // } - - // if (window.innerHeight - info.offset.bottom > 300) { - // menu.style.top = info?.offset.bottom + "px"; - // } else { - // menu.style.top = "auto"; - - // menu.style.bottom = (window.innerHeight - info?.offset.top) + "px"; - // } - // console.log("info", info, menu, window.innerWidth, window.innerHeight); + menu.style.removeProperty("display") } menu.showModal() this.selectField(urlField) diff --git a/docs/edit.css b/docs/edit.css index 85e0c81..7f7faa4 100644 --- a/docs/edit.css +++ b/docs/edit.css @@ -117,19 +117,20 @@ body.drag #content { outline: none; /* min-width: 180px; */ /* top: 0; */ - position: absolute; + /* position: absolute; */ padding: 0 1em; font-weight: bold; /* opacity:0.33; */ user-select: none; cursor: default; - margin-left: -1em; + /* margin-left: -1em; */ line-height: 2.5em; display: inline-block; - margin-top: -2.7em; + /* margin-top: -2.7em; */ z-index: 2000; float: left; + font-size: 16px; } #doc-title.open { @@ -480,6 +481,14 @@ div.menu-contents { color:var(--background-color) !important; } +#url-preview { + font-weight:normal; + max-width: 460px; + overflow:hidden; + text-overflow: ellipsis; + font-size: 13px; +} + .menu hr { border: none; background-color: transparent; diff --git a/docs/edit.html b/docs/edit.html index e4caf60..f609375 100644 --- a/docs/edit.html +++ b/docs/edit.html @@ -33,16 +33,7 @@ -
-
- -
+
 itty bitty things
 can be conveyed in a link.
 type here – and then share!

 about itty bitty

@@ -57,7 +48,7 @@ diff --git a/docs/edit.js b/docs/edit.js index 26fc216..965af42 100644 --- a/docs/edit.js +++ b/docs/edit.js @@ -1,6 +1,8 @@ import * as bitty from './bitty.js'; +import * as bitty_menu from '/bitty-menu.js'; window.bitty = bitty; +window.el = bitty.el; var QS = document.querySelector.bind(document); var QSS = document.querySelectorAll.bind(document); @@ -87,7 +89,9 @@ window.onload = async function() { QS("#qrcode").onclick = makeQRCode; QS("#upload").onclick = upload; QS("#share").onclick = share; - if (!navigator.share) QS("#share").style.display = "none" + QS("#url-preview").onclick = () => {document.execCommand('selectAll',false,null)}; + + // if (!navigator.share) QS("#share").style.display = "none" QS("#twitter").onclick = tweetLink; QS("#copy").onclick = copyLink; QS("#preview").onclick = togglePreview; @@ -299,27 +303,34 @@ async function handleContentChange() { text = editor.innerHTML; } + console.log("text", text, rawHTML); if (text.trim().length) { let url = `data:text/html;charset=utf-8,${encodeURIComponent(text)}`; let durl = new bitty.DataURL(url) + if (metadata.params) { + console.log("parameters", metadata.params) + metadata.params.split(";").forEach(p => { + let ps = p.split('='); + durl.params[ps.shift()] = ps.pop() + }) + } if (metadata.password) { durl.params.cipher = "aes-gcm" durl.params.style = "default" durl.params._password = metadata.password; } + durl = await durl.compress(bitty.GZIP_MARKER); let ratio = durl.href.length / url.length; console.debug(`Compressed from ${url.length} to ${durl.href.length} bytes (${Math.round(ratio * 100)}%)`); - - - if (ratio <= 0.95) url = durl.href; + if (ratio >= 1.0) url = durl.href; if (rawHTML) { updateLink(url, metadata); - } else if (metadata.password) { + } else if (metadata.password || metadata.params) { updateLink(durl.href, metadata); } else { updateLink("?" + durl.data, metadata); @@ -366,12 +377,14 @@ function updateLink(url, metadata, push) { } var hash = location.hash; - if (true) { + if (false) { if (push || !hash || !hash.length) { window.history.pushState(null, null, bittyLink); } else { window.history.replaceState(null, null, bittyLink); } + } else { + document.getElementById("url-preview").innerText = bittyLink } var length = bittyLink.length; @@ -390,14 +403,17 @@ function updateLink(url, metadata, push) { } } -function share() { - navigator.share({ - title: 'itty.bitty', - url: bittyLink - }).then(() => { - console.log('Shared!'); - }) - .catch(console.error); +function share(e) { + let menu = new bitty_menu.Menu(e.target); + console.log("men", menu) + menu.show(); + // navigator.share({ + // title: 'itty.bitty', + // url: bittyLink + // }).then(() => { + // console.log('Shared!'); + // }) + // .catch(console.error); } function makeQRCode() { var url = diff --git a/docs/index.css b/docs/index.css index 9e17468..81e4a86 100644 --- a/docs/index.css +++ b/docs/index.css @@ -45,13 +45,15 @@ body { opacity: .8; border-bottom-left-radius: 0; font-size: 18px; - transition: font-size 100ms cubic-bezier(0.4, 0.0, 0.2, 1);; + transition: font-size 100ms cubic-bezier(0.4, 0.0, 0.2, 1); text-align: right; + mix-blend-mode: exclusion; } #menu svg { width: 1em; height: 1em; + /* background-blend-mode: difference; */ } #menu.open, diff --git a/docs/index.js b/docs/index.js index a65ed12..a257969 100644 --- a/docs/index.js +++ b/docs/index.js @@ -333,6 +333,7 @@ // fragment = fragment.replace("text/plain,", "text/html").replace(",", "text/html"); renderMode = "data"; + } else if (durl.mediatype == "multipart/related") { } else if (durl.type == "text") { } else if (durl.type == "image") { } else if (durl.type == undefined) { diff --git a/docs/render/contact.css b/docs/render/contact.css index be17999..ca92624 100644 --- a/docs/render/contact.css +++ b/docs/render/contact.css @@ -22,6 +22,7 @@ body { justify-content:center; align-items: center; min-height:100vh; + margin:0; } #arc-theme { @@ -40,11 +41,11 @@ body { .contact { background-color: rgba(255,255,255,0.5); - width:320px; + width:360px; min-height:518px; box-shadow: var(--shadow-elevation-medium); margin-bottom:10vh; - border-radius:10px; + border-radius:4px; display:flex; flex-direction: column; justify-content: space-between; @@ -58,12 +59,18 @@ body { width:320px; } -/* @media only screen and (min-width: 640px) { +@media only screen and (min-width: 640px) { .contact { flex-direction: row; width:640px; } -} */ +} + +@media only screen and (max-width: 400px) { + .contact { + width:100%; + } +} .header { flex: 1 1 auto; diff --git a/docs/render/contact.js b/docs/render/contact.js index 23c57dc..9b64186 100644 --- a/docs/render/contact.js +++ b/docs/render/contact.js @@ -27,6 +27,7 @@ let siteIcons = { "github.com": {icon: "github", reformat:(a) => a.replace(/github.com\/(.*)/, "@$1")}, "medium.com": {icon: "medium"}, "twitch.tv": {icon: "twitch"}, + "discordapp.com": {icon: "discord", reformat:(a) => "Discord"}, } let spriteString = `