diff --git a/.gitignore b/.gitignore index 02c20f5..4387ac0 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,8 @@ /target /build +/.deps /.claude/settings.local.json /.claude.local/ -/playground/node_modules/ -/playground/src/placeholder.wasm diff --git a/CLAUDE.md b/CLAUDE.md index f228875..1612f7c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,37 +5,48 @@ All dependency versions are declared in one place: - **Rust crates**: `Cargo.toml` `[dependencies]` -- **Asset dependencies** (e.g. playground artifact): `Cargo.toml` `[package.metadata.assets.]` +- **Asset dependencies** (e.g. playground, brand): `Cargo.toml` `[package.metadata.assets.]` ### Updating dependencies Run `cargo run -- update-deps` to update all dependencies: 1. Runs `cargo update` (Rust crates) -2. Downloads asset dependencies from GitHub releases into `build/assets/` +2. Downloads asset dependencies from GitHub releases into `.deps/` ### Build vs fetch -- `cargo run` (or `cargo run -- build`) builds with what is already in `build/assets/`. It will error if a referenced asset is missing. +- `cargo run` (or `cargo run -- build`) builds with what is already in `.deps/`. It will error if a referenced asset is missing. - `cargo run -- update-deps` fetches/updates dependencies. It does not build. - A clean build should never fetch dependencies. -## Build output +## Project layout ``` +.deps/ # downloaded dependencies — NOT wiped by build, gitignored + playground/ # playground artifact (JS/WASM/CSS) + brand/ # brand assets (logo SVGs, favicon PNGs, social image) build/ - assets/ # downloaded dependencies — NOT wiped by build - site/ # generated output — wiped on each build + site/ # generated output — wiped on each build +static/ # static files copied verbatim to build/site/ + # (brand assets here are fallbacks, overridden by .deps/brand/) ``` -`build/` is gitignored. The dev server (`cargo run -- serve`) serves from `build/site/`. +The dev server (`cargo run -- serve`) serves from `build/site/`. + +## Brand assets + +Brand assets (logo, favicons, social image) come from the `brand` dependency. +The build copies them from `.deps/brand/assets/` into `build/site/`, overriding +any fallback files in `static/`. Once the brand release is live, the fallback +copies in `static/` can be removed. ## Fragment injection -Content pages can inject HTML fragments from `build/assets/` using frontmatter: +Content pages can inject HTML fragments from `.deps/` using frontmatter: ```yaml -fragment: playground/fragment.html # path relative to build/assets/ +fragment: playground/fragment.html # path relative to .deps/ asset_dir: playground # copies runtime files to build/site/ ``` diff --git a/Cargo.lock b/Cargo.lock index 1c4e439..c6e6b25 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" @@ -119,6 +125,18 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cc" version = "1.2.57" @@ -507,6 +525,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -515,8 +542,8 @@ checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fink" -version = "0.1.0" -source = "git+https://github.com/fink-lang/fink.git?branch=main#c311109ecd6f92540a3094d6215b6fa4fe97d9b7" +version = "0.0.0" +source = "git+https://github.com/fink-lang/fink.git?tag=v0.1.0#1a61a6b21aa0ad51c2f03e4062df64f774a17ae1" dependencies = [ "dap", "tokio", @@ -533,6 +560,8 @@ version = "0.1.0" dependencies = [ "anyhow", "fink", + "flate2", + "image", "pulldown-cmark", "tera", "tiny_http", @@ -545,6 +574,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" @@ -844,6 +883,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -980,6 +1032,26 @@ 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 = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1125,6 +1197,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "postcard" version = "1.1.3" @@ -1207,6 +1292,12 @@ dependencies = [ "syn", ] +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + [[package]] name = "quote" version = "1.0.45" @@ -1479,6 +1570,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..3d9554f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,12 +6,19 @@ edition = "2024" [package.metadata.assets.playground] url = "https://github.com/fink-lang/playground/releases/download/{version}/playground.tar.gz" version = "v1.9.0" -dest = "build/assets/playground" +dest = ".deps/playground" + +[package.metadata.assets.brand] +url = "https://github.com/fink-lang/brand/releases/download/{version}/brand-assets.tar.gz" +version = "v1.0.0" +dest = ".deps/brand" [dependencies] -fink = { git = "https://github.com/fink-lang/fink.git", branch = "main" } +fink = { git = "https://github.com/fink-lang/fink.git", tag = "v0.1.0" } pulldown-cmark = "0.13" tera = "1" walkdir = "2" anyhow = "1" tiny_http = "0.12" +flate2 = "1" +image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/src/main.rs b/src/main.rs index 1990bbd..eea0339 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,17 +5,18 @@ // build/site/index.html for the root page). Static files in static/ are copied // verbatim. // -// Downloaded dependencies (e.g. playground artifact) live in build/assets/. -// Only build/site/ is wiped on each build; build/assets/ is preserved. +// Downloaded dependencies (e.g. playground, brand) live in .deps/. +// Only build/site/ is wiped on each build; .deps/ is preserved. // // Content file frontmatter (YAML between --- markers) supports: // title: string — page and heading // template: string — Tera template name without extension (default: "page") -// fragment: path — HTML fragment to inject (relative to ASSETS_DIR) -// asset_dir: path — copies runtime files from ASSETS_DIR/<path> to output +// fragment: path — HTML fragment to inject (relative to .deps/) +// asset_dir: path — copies runtime files from .deps/<path> to output mod highlight; mod markdown; +mod playground; mod serve; mod svg; @@ -31,7 +32,7 @@ const CONTENT_DIR: &str = "content"; const TEMPLATE_DIR: &str = "templates"; const STATIC_DIR: &str = "static"; const SITE_DIR: &str = "build/site"; -const ASSETS_DIR: &str = "build/assets"; +const DEPS_DIR: &str = ".deps"; const DEFAULT_PORT: u16 = 8080; fn main() -> Result<()> { @@ -62,10 +63,14 @@ fn main() -> Result<()> { update_deps()?; return Ok(()); } + Some("check-deps") => { + check_deps()?; + return Ok(()); + } Some("build") | None => {} Some(other) => { eprintln!("Unknown command: {other}"); - eprintln!("Usage: fink-site [build | serve [port] | stop | update-deps | svg <file>]"); + eprintln!("Usage: fink-site [build | serve [port] | stop | update-deps | check-deps | svg <file>]"); std::process::exit(1); } } @@ -96,6 +101,9 @@ fn build() -> Result<()> { // Copy static assets copy_static()?; + // Copy brand assets from .deps/brand/ into build/site/ + copy_brand()?; + println!("Build complete → {}/", SITE_DIR); Ok(()) } @@ -167,7 +175,7 @@ fn render_page(md_path: &Path, tera: &Tera) -> Result<()> { // Load HTML fragment from assets if requested let fragment = if let Some(frag_path) = &fragment_path { - let full = Path::new(ASSETS_DIR).join(frag_path); + let full = Path::new(DEPS_DIR).join(frag_path); let html = fs::read_to_string(&full) .with_context(|| format!("failed to read fragment {}", full.display()))?; Some(html) @@ -177,7 +185,7 @@ fn render_page(md_path: &Path, tera: &Tera) -> Result<()> { // Copy runtime asset directory if requested (e.g. JS/WASM/CSS files) if let Some(dir) = &asset_dir { - let src = Path::new(ASSETS_DIR).join(dir); + let src = Path::new(DEPS_DIR).join(dir); let dest_dir = Path::new(SITE_DIR).join(dir); if src.is_dir() { copy_dir(&src, &dest_dir) @@ -264,29 +272,72 @@ fn copy_dir(src: &Path, dest: &Path) -> Result<()> { Ok(()) } -// ---- dependency management ------------------------------------------------- +// ---- brand assets ---------------------------------------------------------- -/// Run `cargo update` and download asset dependencies listed in Cargo.toml -/// under [package.metadata.assets.<name>]. -fn update_deps() -> Result<()> { - // 1. Run cargo update for crate dependencies - println!("Running cargo update…"); - let status = std::process::Command::new("cargo") - .arg("update") - .status() - .context("failed to run cargo update")?; - if !status.success() { - anyhow::bail!("cargo update failed"); +const BRAND_DIR: &str = ".deps/brand"; + +/// Copy brand assets from .deps/brand/ into the site output directory. +/// SVGs are copied directly; PNGs are downscaled to the sizes needed for +/// favicons, apple-touch-icon, and social image. +fn copy_brand() -> Result<()> { + let brand = Path::new(BRAND_DIR); + if !brand.exists() { + // Brand dep not fetched — static/ fallbacks will be used + return Ok(()); } - // 2. Download asset dependencies from Cargo.toml metadata + let site = Path::new(SITE_DIR); + + // Logo SVG (nav, hero, SVG favicon) + let logo_src = brand.join("assets/fink-rect-no-bg.svg"); + anyhow::ensure!(logo_src.exists(), "required brand asset missing: {}", logo_src.display()); + fs::copy(&logo_src, site.join("logo.svg"))?; + + // Favicons downscaled from the no-bg PNG (transparent) + let icon_src = brand.join("assets/fink-rect-no-bg.png"); + anyhow::ensure!(icon_src.exists(), "required brand asset missing: {}", icon_src.display()); + downscale_png(&icon_src, &site.join("favicon-32.png"), 32)?; + downscale_png(&icon_src, &site.join("favicon-192.png"), 192)?; + + // Apple touch icon from rounded variant (iOS masks transparent icons to black) + let apple_src = brand.join("assets/fink-rounded.png"); + anyhow::ensure!(apple_src.exists(), "required brand asset missing: {}", apple_src.display()); + downscale_png(&apple_src, &site.join("apple-touch-icon.png"), 180)?; + + // Social / og:image from wordmark (already 1200×630) + let social_src = brand.join("assets/fink-wordmark.png"); + anyhow::ensure!(social_src.exists(), "required brand asset missing: {}", social_src.display()); + fs::copy(&social_src, site.join("social.png"))?; + + Ok(()) +} + +/// Downscale a PNG to a square of the given size using Lanczos3 filtering. +fn downscale_png(src: &Path, dest: &Path, size: u32) -> Result<()> { + let img = image::open(src) + .with_context(|| format!("failed to open {}", src.display()))?; + let resized = img.resize_exact(size, size, image::imageops::FilterType::Lanczos3); + resized.save(dest) + .with_context(|| format!("failed to write {}", dest.display()))?; + Ok(()) +} + +// ---- dependency management ------------------------------------------------- + +struct AssetDep { + name: String, + url: String, + version: String, + dest: String, +} + +/// Parse [package.metadata.assets.*] entries from Cargo.toml. +fn parse_asset_deps() -> Result<Vec<AssetDep>> { let toml_str = fs::read_to_string("Cargo.toml") .context("failed to read Cargo.toml")?; - // Parse [package.metadata.assets.*] entries. - // Each has: url (with {version} placeholder), version, dest let mut in_asset: Option<String> = None; - let mut assets: Vec<(String, String, String, String)> = vec![]; // (name, url, version, dest) + let mut assets: Vec<AssetDep> = vec![]; let mut url = String::new(); let mut version = String::new(); let mut dest = String::new(); @@ -294,10 +345,9 @@ fn update_deps() -> Result<()> { for line in toml_str.lines() { let trimmed = line.trim(); if let Some(rest) = trimmed.strip_prefix("[package.metadata.assets.") { - // Save previous asset if any if let Some(name) = in_asset.take() { if !url.is_empty() && !version.is_empty() && !dest.is_empty() { - assets.push((name, url.clone(), version.clone(), dest.clone())); + assets.push(AssetDep { name, url: url.clone(), version: version.clone(), dest: dest.clone() }); } } in_asset = Some(rest.trim_end_matches(']').to_string()); @@ -319,21 +369,47 @@ fn update_deps() -> Result<()> { } if let Some(name) = in_asset { if !url.is_empty() && !version.is_empty() && !dest.is_empty() { - assets.push((name, url, version, dest)); + assets.push(AssetDep { name, url, version, dest }); } } - for (name, url_template, ver, dest_dir) in &assets { - let resolved_url = url_template.replace("{version}", ver); - println!("Fetching {name} {ver}…"); + Ok(assets) +} + +/// Extract "owner/repo" from a GitHub release URL. +fn github_repo_from_url(url: &str) -> Option<String> { + // https://github.com/{owner}/{repo}/releases/download/... + let rest = url.strip_prefix("https://github.com/")?; + let parts: Vec<&str> = rest.splitn(4, '/').collect(); + if parts.len() >= 3 && parts[2] == "releases" { + Some(format!("{}/{}", parts[0], parts[1])) + } else { + None + } +} + +/// Run `cargo update` and download asset dependencies. +fn update_deps() -> Result<()> { + println!("Running cargo update…"); + let status = std::process::Command::new("cargo") + .arg("update") + .status() + .context("failed to run cargo update")?; + if !status.success() { + anyhow::bail!("cargo update failed"); + } + + let assets = parse_asset_deps()?; - // Clean and recreate dest - if Path::new(dest_dir).exists() { - fs::remove_dir_all(dest_dir)?; + for dep in &assets { + let resolved_url = dep.url.replace("{version}", &dep.version); + println!("Fetching {} {}…", dep.name, dep.version); + + if Path::new(&dep.dest).exists() { + fs::remove_dir_all(&dep.dest)?; } - fs::create_dir_all(dest_dir)?; + fs::create_dir_all(&dep.dest)?; - // Download and extract tarball let status = std::process::Command::new("curl") .args(["-sL", &resolved_url]) .stdout(std::process::Stdio::piped()) @@ -341,15 +417,15 @@ fn update_deps() -> Result<()> { .context("failed to run curl")?; let tar_status = std::process::Command::new("tar") - .args(["-xzf", "-", "-C", dest_dir]) + .args(["-xzf", "-", "-C", &dep.dest]) .stdin(status.stdout.unwrap()) .status() .context("failed to run tar")?; if !tar_status.success() { - anyhow::bail!("failed to download/extract {name} from {resolved_url}"); + anyhow::bail!("failed to download/extract {} from {resolved_url}", dep.name); } - println!(" → {dest_dir}/"); + println!(" → {}/", dep.dest); } if assets.is_empty() { @@ -358,3 +434,130 @@ fn update_deps() -> Result<()> { Ok(()) } + +/// Query the latest release tag for a GitHub repo. Returns None on failure. +fn github_latest_tag(repo: &str) -> Option<String> { + let output = std::process::Command::new("curl") + .args(["-sf", &format!("https://api.github.com/repos/{repo}/releases/latest")]) + .output() + .ok()?; + + if !output.status.success() { return None; } + + // Extract tag_name from JSON (simple string search to avoid a JSON dep) + // Looks for: "tag_name": "v1.2.3" + let body = String::from_utf8_lossy(&output.stdout); + body.find("\"tag_name\"").and_then(|i| { + let after_key = &body[i + "\"tag_name\"".len()..]; + let val_start = after_key.find('"')? + 1; + let val_end = after_key[val_start..].find('"')?; + Some(after_key[val_start..val_start + val_end].to_string()) + }) +} + +/// Parse git dependencies with tag pins from Cargo.toml. +/// Returns (name, repo "owner/repo", pinned tag). +fn parse_git_deps() -> Result<Vec<(String, String, String)>> { + let toml_str = fs::read_to_string("Cargo.toml") + .context("failed to read Cargo.toml")?; + + let mut deps = vec![]; + for line in toml_str.lines() { + let trimmed = line.trim(); + // Match: name = { git = "https://github.com/owner/repo.git", tag = "v1.0.0" } + if let Some((key, value)) = trimmed.split_once('=') { + let name = key.trim(); + let val = value.trim(); + if val.contains("git =") && val.contains("tag =") { + let git_url = extract_field(val, "git"); + let tag = extract_field(val, "tag"); + if let (Some(url), Some(tag)) = (git_url, tag) { + if let Some(repo) = github_repo_from_git_url(&url) { + deps.push((name.to_string(), repo, tag)); + } + } + } + } + } + Ok(deps) +} + +/// Extract a quoted field value from an inline TOML table, e.g. `git` from `{ git = "..." }`. +fn extract_field(s: &str, field: &str) -> Option<String> { + let pattern = format!("{field} = \""); + let start = s.find(&pattern)? + pattern.len(); + let end = s[start..].find('"')?; + Some(s[start..start + end].to_string()) +} + +/// Extract "owner/repo" from a GitHub .git URL. +fn github_repo_from_git_url(url: &str) -> Option<String> { + // https://github.com/owner/repo.git + let rest = url.strip_prefix("https://github.com/")?; + let rest = rest.strip_suffix(".git").unwrap_or(rest); + let parts: Vec<&str> = rest.splitn(3, '/').collect(); + if parts.len() >= 2 { + Some(format!("{}/{}", parts[0], parts[1])) + } else { + None + } +} + +/// Check for newer releases of all dependencies via the GitHub API. +fn check_deps() -> Result<()> { + let assets = parse_asset_deps()?; + let git_deps = parse_git_deps()?; + + if assets.is_empty() && git_deps.is_empty() { + println!("No dependencies found in Cargo.toml"); + return Ok(()); + } + + let mut all_current = true; + + // Check git dependencies (crates pinned to tags) + for (name, repo, tag) in &git_deps { + match github_latest_tag(repo) { + Some(ref latest) if latest == tag => { + println!(" {name} {tag} ✓"); + } + Some(ref latest) => { + println!(" {name} {tag} → {latest} available"); + all_current = false; + } + None => { + println!(" {name} {tag} (failed to query GitHub API)"); + } + } + } + + // Check asset dependencies + for dep in &assets { + let repo = match github_repo_from_url(&dep.url) { + Some(r) => r, + None => { + println!(" {} {} (skipped — not a GitHub release URL)", dep.name, dep.version); + continue; + } + }; + + match github_latest_tag(&repo) { + Some(ref tag) if tag == &dep.version => { + println!(" {} {} ✓", dep.name, dep.version); + } + Some(ref tag) => { + println!(" {} {} → {tag} available", dep.name, dep.version); + all_current = false; + } + None => { + println!(" {} {} (failed to query GitHub API)", dep.name, dep.version); + } + } + } + + if all_current { + println!("All dependencies are up to date."); + } + + Ok(()) +} diff --git a/src/markdown.rs b/src/markdown.rs index 7c71f48..6d6c6c2 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,20 @@ 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!( - "<pre class=\"code-block\"><code class=\"language-fink\">{}</code></pre>\n", - highlighted + "<div class=\"code-block-wrap\">\ + <a class=\"playground-link\" href=\"/playground/#{hash}\" \ + title=\"Open in playground\" aria-label=\"Open in playground\">\ + <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 135 135\">\ + <g transform=\"translate(0,3)\">\ + <path fill=\"currentColor\" d=\"M 7.6,102.4 C 34.3,81.4 46.3,56 57.8,36.3 70.8,14.2 90.6,8.3 111.9,25.6 103.8,25.3 98.5,37.4 84.1,53.9 69.7,70.5 44.6,92.5 7.6,102.4Z\"/>\ + <path fill=\"currentColor\" d=\"M 97.9,42.1 C 105.6,70.6 101.8,110.8 5.7,115.6 64.5,89.8 93.6,50 97.9,42.1Z\"/>\ + <path fill=\"#faa343\" d=\"m 102.5,50.9 c -4.6,-22.7 10.2,-26.9 26.5,-21.5 -8.2,2.8 -20.2,8 -26.5,21.5z\"/>\ + </g></svg></a>\ + <pre class=\"code-block\"><code class=\"language-fink\">{highlighted}</code></pre>\ + </div>\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<u32> limbs (least-significant first) for big integer arithmetic. +fn to_bigint(bytes: &[u8]) -> Vec<u32> { + let mut limbs: Vec<u32> = 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<u32> limbs, LSB first) by 62, return (quotient, remainder). +fn div_mod_62(limbs: &[u32]) -> (Vec<u32>, 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/apple-touch-icon.png b/static/apple-touch-icon.png deleted file mode 100644 index 705a7dc..0000000 Binary files a/static/apple-touch-icon.png and /dev/null differ diff --git a/static/favicon-192.png b/static/favicon-192.png deleted file mode 100644 index 1459f62..0000000 Binary files a/static/favicon-192.png and /dev/null differ diff --git a/static/favicon-32.png b/static/favicon-32.png deleted file mode 100644 index 73ba8b1..0000000 Binary files a/static/favicon-32.png and /dev/null differ diff --git a/static/logo.svg b/static/logo.svg deleted file mode 100644 index bc3e8a2..0000000 --- a/static/logo.svg +++ /dev/null @@ -1,31 +0,0 @@ -<?xml version="1.0" encoding="UTF-8" standalone="no"?> -<svg - width="500" - height="500" - viewBox="0 0 135 135" - version="1.1" - xml:space="preserve" - xmlns="http://www.w3.org/2000/svg" - xmlns:svg="http://www.w3.org/2000/svg" -> - <g transform="translate(0, 3)"> - <path - style="fill:#345e65" - d="M 7.5991585,102.41306 C 34.300533,81.435463 46.318437,55.988057 57.811472,36.346974 70.752567,14.231234 90.593352,8.2969824 111.93705,25.616702 103.82049,25.276458 98.49317,37.354838 84.148101,53.872282 69.700471,70.507823 44.56227,92.481987 7.5991585,102.41306 Z" - id="body" /> - <path - style="fill:#345e65" - d="M 97.852904,42.119144 C 105.64423,70.635971 101.79353,110.79478 5.6643805,115.55219 64.46,89.799518 93.579124,49.95442 97.852904,42.119144 Z" - id="belly" /> - <path - style="fill:#faa343;" - d="m 102.50472,50.913801 c -4.584599,-22.650221 10.16165,-26.894507 26.5183,-21.47238 -8.23959,2.793409 -20.16491,7.988834 -26.5183,21.47238 z" - id="beak"/> - <circle - style="fill:#ffffff;" - id="eye" - cx="90.480888" - cy="28.07902" - r="3.5513237" /> - </g> -</svg> diff --git a/static/social.png b/static/social.png deleted file mode 100644 index 77d2fb3..0000000 Binary files a/static/social.png and /dev/null differ diff --git a/static/style.css b/static/style.css index 93c9662..ee09856 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.2; + 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 @@ <nav> <a class="wordmark" href="{{ root }}"><img src="{{ root }}logo.svg" alt="" class="wordmark-logo">ƒink</a> <a href="{{ root }}docs/">docs</a> + <a href="{{ root }}playground/">playground</a> <a href="https://github.com/fink-lang" class="nav-icon" aria-label="GitHub"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/></svg></a> <div class="theme-picker"> <button class="theme-toggle" aria-label="Toggle theme" title="Theme">