From 078b85294cc043b3d805822a0f5f9cd015059e54 Mon Sep 17 00:00:00 2001 From: Jan Klaas Kollhof Date: Tue, 24 Mar 2026 06:57:10 +0000 Subject: [PATCH 1/3] feat: add playground to nav and open-in-playground buttons on code blocks Add playground link to the navigation bar alongside docs. Fink code blocks now have a subtle icon in the top-right corner that opens the snippet in the playground. Source is encoded at build time using the same deflate-raw + base62 scheme the playground uses for URL hashes. --- Cargo.lock | 33 ++++++++++++++++ Cargo.toml | 1 + src/main.rs | 1 + src/markdown.rs | 17 ++++++++- src/playground.rs | 91 +++++++++++++++++++++++++++++++++++++++++++++ static/style.css | 18 +++++++++ templates/base.html | 1 + 7 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/playground.rs diff --git a/Cargo.lock b/Cargo.lock index 1c4e439..25d3425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "gimli 0.33.0", ] +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -533,6 +539,7 @@ version = "0.1.0" dependencies = [ "anyhow", "fink", + "flate2", "pulldown-cmark", "tera", "tiny_http", @@ -545,6 +552,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -980,6 +997,16 @@ dependencies = [ "rustix", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1479,6 +1506,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "siphasher" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 0a91568..bc5460c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,4 @@ tera = "1" walkdir = "2" anyhow = "1" tiny_http = "0.12" +flate2 = "1" diff --git a/src/main.rs b/src/main.rs index 1990bbd..90ef8a2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ mod highlight; mod markdown; +mod playground; mod serve; mod svg; diff --git a/src/markdown.rs b/src/markdown.rs index 7c71f48..ce62740 100644 --- a/src/markdown.rs +++ b/src/markdown.rs @@ -11,6 +11,7 @@ use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd, html}; use crate::highlight; +use crate::playground; /// A single TOC entry extracted from a ## heading. pub struct TocEntry { @@ -97,9 +98,21 @@ pub fn render(md: &str) -> String { in_fink_block = false; let src = fink_buf.trim_end_matches('\n'); let highlighted = highlight::highlight(src); + let hash = playground::encode_source(src); + // Wrap in container with playground link button (external-link icon) let html = format!( - "
{}
\n", - highlighted + "
\ + \ + \ + \ + \ + \ + \ +
{highlighted}
\ +
\n" ); events.push(Event::Html(html.into())); } diff --git a/src/playground.rs b/src/playground.rs new file mode 100644 index 0000000..bfaf3dc --- /dev/null +++ b/src/playground.rs @@ -0,0 +1,91 @@ +// Playground URL encoder. +// +// Encodes source code into a URL hash fragment compatible with the playground's +// decodeSource(). Pipeline: UTF-8 → deflate-raw → base62. + +use std::io::Write; +use flate2::Compression; +use flate2::write::DeflateEncoder; + +const BASE62: &[u8; 62] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/// Encode source code into a base62 string suitable for a playground URL hash. +pub fn encode_source(src: &str) -> String { + let bytes = src.as_bytes(); + + // Deflate-raw compress + let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default()); + encoder.write_all(bytes).expect("deflate write"); + let compressed = encoder.finish().expect("deflate finish"); + + if compressed.is_empty() { + return String::from("0"); + } + + // Count leading zero bytes (preserved as leading '0' digits) + let leading_zeros = compressed.iter().take_while(|&&b| b == 0).count(); + + // Treat compressed bytes as big-endian unsigned integer + let mut n = to_bigint(compressed.as_slice()); + + // Convert to base62 + let mut out = Vec::new(); + if is_zero(&n) { + out.push(BASE62[0]); + } else { + while !is_zero(&n) { + let (quotient, remainder) = div_mod_62(&n); + out.push(BASE62[remainder]); + n = quotient; + } + } + out.reverse(); + + // Prepend leading '0' digits for preserved zero bytes + let mut result = String::with_capacity(leading_zeros + out.len()); + for _ in 0..leading_zeros { + result.push('0'); + } + for &b in &out { + result.push(b as char); + } + result +} + +// Big-endian bytes → Vec limbs (least-significant first) for big integer arithmetic. +fn to_bigint(bytes: &[u8]) -> Vec { + let mut limbs: Vec = vec![0]; + for &b in bytes { + // limbs = limbs * 256 + b + let mut carry = b as u64; + for limb in limbs.iter_mut() { + let v = (*limb as u64) * 256 + carry; + *limb = v as u32; + carry = v >> 32; + } + if carry > 0 { + limbs.push(carry as u32); + } + } + limbs +} + +// Divide big integer (Vec limbs, LSB first) by 62, return (quotient, remainder). +fn div_mod_62(limbs: &[u32]) -> (Vec, usize) { + let mut result = vec![0u32; limbs.len()]; + let mut remainder: u64 = 0; + for i in (0..limbs.len()).rev() { + let cur = (remainder << 32) | limbs[i] as u64; + result[i] = (cur / 62) as u32; + remainder = cur % 62; + } + // Trim leading zero limbs + while result.len() > 1 && *result.last().unwrap() == 0 { + result.pop(); + } + (result, remainder as usize) +} + +fn is_zero(limbs: &[u32]) -> bool { + limbs.iter().all(|&l| l == 0) +} diff --git a/static/style.css b/static/style.css index 93c9662..dfc9da7 100644 --- a/static/style.css +++ b/static/style.css @@ -327,6 +327,24 @@ footer { .prose th { background: var(--surface); font-weight: 600; } /* --- Code blocks -------------------------------------------- */ +.code-block-wrap { + position: relative; +} +.playground-link { + position: absolute; + top: 0.5rem; + right: 0.5rem; + width: 1.25rem; + height: 1.25rem; + color: var(--text-muted); + opacity: 0.1; + transition: opacity 0.15s; + z-index: 1; +} +.code-block-wrap:hover .playground-link { opacity: 0.5; } +.playground-link:hover { opacity: 1 !important; color: var(--accent); } +.playground-link svg { width: 100%; height: 100%; display: block; } + pre.code-block { background: var(--code-bg); color: var(--code-text); diff --git a/templates/base.html b/templates/base.html index 3e89e98..c8f949d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -44,6 +44,7 @@