use std::fs;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, UNIX_EPOCH};
use tiny_http::{Header, Request, Response, Server};
use crate::link_handler::is_external_link;
use crate::markdown_converter::{document_to_html, markdown_to_document};
use crate::nonprintable::printable;
use crate::section_link::{heading_anchors, normalize_link_target, split_target};
use piki_core::ensure_md_extension;
use tdoc::{ChecklistItem, Document, InlineStyle, Paragraph, Span};
const POLL_TIMEOUT: Duration = Duration::from_millis(250);
#[derive(Clone, PartialEq, Eq, Hash, Debug, Default)]
pub struct HighlightTarget {
pub block: usize,
pub li: Option<usize>,
}
struct ShareState {
dir: PathBuf,
current_note: String,
current_markdown: String,
highlight: Vec<HighlightTarget>,
generation: u64,
}
pub struct LiveShare {
state: Arc<Mutex<ShareState>>,
port: u16,
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
}
impl LiveShare {
pub fn start(dir: PathBuf, note: String, markdown: String) -> std::io::Result<LiveShare> {
let server =
Server::http("127.0.0.1:0").map_err(|e| std::io::Error::other(e.to_string()))?;
let port = server
.server_addr()
.to_ip()
.map(|addr| addr.port())
.unwrap_or(0);
let state = Arc::new(Mutex::new(ShareState {
dir,
current_note: note,
current_markdown: markdown,
highlight: Vec::new(),
generation: 1,
}));
let stop = Arc::new(AtomicBool::new(false));
let handle = {
let state = Arc::clone(&state);
let stop = Arc::clone(&stop);
thread::spawn(move || serve_loop(server, state, stop))
};
Ok(LiveShare {
state,
port,
stop,
handle: Some(handle),
})
}
pub fn port(&self) -> u16 {
self.port
}
pub fn url_for(&self, note: &str) -> String {
format!("http://localhost:{}/{}", self.port, encode_path(note))
}
pub fn set_current(&self, note: &str, markdown: &str) {
if let Ok(mut st) = self.state.lock() {
let note_changed = st.current_note != note;
if note_changed || st.current_markdown != markdown {
st.current_note = note.to_string();
st.current_markdown = markdown.to_string();
if note_changed {
st.highlight.clear();
}
st.generation = st.generation.wrapping_add(1);
}
}
}
pub fn set_highlight(&self, targets: Vec<HighlightTarget>) {
if let Ok(mut st) = self.state.lock()
&& st.highlight != targets
{
st.highlight = targets;
st.generation = st.generation.wrapping_add(1);
}
}
pub fn stop(&mut self) {
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}
impl Drop for LiveShare {
fn drop(&mut self) {
self.stop();
}
}
fn serve_loop(server: Server, state: Arc<Mutex<ShareState>>, stop: Arc<AtomicBool>) {
while !stop.load(Ordering::Relaxed) {
match server.recv_timeout(POLL_TIMEOUT) {
Ok(Some(request)) => handle_request(request, &state),
Ok(None) => {} Err(_) => break,
}
}
}
fn handle_request(request: Request, state: &Arc<Mutex<ShareState>>) {
let raw_url = request.url().to_string();
let (path_part, query_part) = match raw_url.split_once('?') {
Some((p, q)) => (p, q),
None => (raw_url.as_str(), ""),
};
let path = percent_decode(path_part);
let (dir, current_note, current_markdown, highlight, generation) = match state.lock() {
Ok(st) => (
st.dir.clone(),
st.current_note.clone(),
st.current_markdown.clone(),
st.highlight.clone(),
st.generation,
),
Err(_) => {
let _ = request.respond(html_response("<p>Internal error.</p>", 500));
return;
}
};
if path == "/" {
let location = format!("/{}", encode_path(¤t_note));
let response = Response::empty(302).with_header(ascii_header("Location", &location));
let _ = request.respond(response);
return;
}
if path == "/favicon.ico" {
let _ = request.respond(Response::empty(204));
return;
}
if path == "/__piki/version" {
let note = query_param(query_part, "note").unwrap_or_default();
let token = version_token(¬e, ¤t_note, generation, &dir);
let _ = request.respond(text_response(&token));
return;
}
let note = path.trim_start_matches('/');
let is_current = note == current_note;
let markdown = if is_current {
Some(current_markdown)
} else if is_valid_note_name(note) {
load_note_markdown(&dir, note)
} else {
None
};
let Some(markdown) = markdown else {
let _ = request.respond(html_response(¬_found_page(note), 404));
return;
};
let highlight: &[HighlightTarget] = if is_current { &highlight } else { &[] };
if query_param(query_part, "raw").is_some() {
let body = render_fragment(&markdown, highlight);
let _ = request.respond(html_response(&body, 200));
} else {
let token = version_token(note, ¤t_note, generation, &dir);
let page = render_page(note, &markdown, &token, highlight);
let _ = request.respond(html_response(&page, 200));
}
}
fn version_token(note: &str, current_note: &str, generation: u64, dir: &Path) -> String {
if note == current_note {
return format!("g{generation}");
}
if !is_valid_note_name(note) {
return "invalid".to_string();
}
let path = dir.join(ensure_md_extension(note));
let nanos = fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("m{nanos}")
}
fn is_valid_note_name(name: &str) -> bool {
if name.is_empty() || name.starts_with('/') || name.contains('\\') {
return false;
}
if name.chars().any(|c| c.is_control()) {
return false;
}
name.split('/')
.all(|component| !(component.is_empty() || component == "." || component == ".."))
}
fn load_note_markdown(dir: &Path, note: &str) -> Option<String> {
fs::read_to_string(dir.join(ensure_md_extension(note))).ok()
}
fn render_fragment(markdown: &str, highlight: &[HighlightTarget]) -> String {
let markdown = printable(markdown);
let mut doc = markdown_to_document(&markdown);
rewrite_links_in_document(&mut doc);
let anchors = collect_heading_anchors(&doc);
let sectioned = render_sectioned_html(&doc, highlight);
inject_heading_ids(§ioned, &anchors)
}
fn render_sectioned_html(doc: &Document, highlight: &[HighlightTarget]) -> String {
let is_heading = |p: &Paragraph| {
matches!(
p,
Paragraph::Header1 { .. } | Paragraph::Header2 { .. } | Paragraph::Header3 { .. }
)
};
let lead = highlight
.iter()
.min_by_key(|t| (t.block, t.li.map(|k| k + 1).unwrap_or(0)))
.cloned();
let mut out = String::new();
let mut section_open = false;
for (index, paragraph) in doc.paragraphs.iter().enumerate() {
if is_heading(paragraph) {
if section_open {
out.push_str("</section>\n");
}
out.push_str("<section class=\"piki-sec\">\n");
section_open = true;
}
let single = Document::new().with_paragraphs(vec![paragraph.clone()]);
let mut block_html = document_to_html(&single);
if highlight.iter().any(|t| t.block == index && t.li.is_none()) {
let is_lead = lead.as_ref()
== Some(&HighlightTarget {
block: index,
li: None,
});
block_html = mark_block_root(&block_html, is_lead);
}
let mut lis: Vec<usize> = highlight
.iter()
.filter(|t| t.block == index)
.filter_map(|t| t.li)
.collect();
if !lis.is_empty() {
lis.sort_unstable();
lis.dedup();
let lead_li = lead
.as_ref()
.filter(|t| t.block == index)
.and_then(|t| t.li);
block_html = mark_lis(&block_html, &lis, lead_li);
}
out.push_str(&block_html);
out.push('\n');
}
if section_open {
out.push_str("</section>\n");
}
out
}
fn active_class(is_lead: bool) -> &'static str {
if is_lead {
"class=\"piki-active piki-lead\""
} else {
"class=\"piki-active\""
}
}
fn mark_block_root(html: &str, is_lead: bool) -> String {
match html.find('>') {
Some(pos) => {
let mut out = String::with_capacity(html.len() + 32);
out.push_str(&html[..pos]);
out.push(' ');
out.push_str(active_class(is_lead));
out.push_str(&html[pos..]);
out
}
None => html.to_string(),
}
}
fn mark_lis(html: &str, indices: &[usize], lead: Option<usize>) -> String {
if indices.is_empty() {
return html.to_string();
}
let mut out = String::with_capacity(html.len() + indices.len() * 32);
let mut count = 0;
let mut from = 0;
while let Some(rel) = html[from..].find("<li>") {
let pos = from + rel;
out.push_str(&html[from..pos]);
if indices.contains(&count) {
out.push_str("<li ");
out.push_str(active_class(lead == Some(count)));
out.push('>');
} else {
out.push_str("<li>");
}
count += 1;
from = pos + "<li>".len();
}
out.push_str(&html[from..]);
out
}
fn render_page(note: &str, markdown: &str, version: &str, highlight: &[HighlightTarget]) -> String {
let body = render_fragment(markdown, highlight);
let mut page = String::with_capacity(
body.len() + STYLESHEET.len() + RELOAD_SCRIPT.len() + COLUMN_SCRIPT.len() + 512,
);
page.push_str("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n");
page.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n");
page.push_str("<meta name=\"color-scheme\" content=\"light dark\" />\n");
page.push_str("<title>");
page.push_str(&html_escape_text(note));
page.push_str("</title>\n<style>");
page.push_str(STYLESHEET);
page.push_str("</style>\n</head>\n<body>\n");
page.push_str("<div id=\"piki-doc\">\n");
page.push_str(&body);
page.push_str("\n</div>\n");
page.push_str("<div id=\"piki-status\" hidden>Live sharing has ended.</div>\n");
page.push_str("<footer id=\"piki-footer\">Shared by Piki v");
page.push_str(env!("CARGO_PKG_VERSION"));
page.push_str(FOOTER_CONTROLS);
page.push_str("</footer>\n");
page.push_str("<script>window.__pikiInitialVersion = ");
page.push_str(&json_string(version));
page.push_str(";</script>\n<script>");
page.push_str(RELOAD_SCRIPT);
page.push_str("</script>\n<script>");
page.push_str(COLUMN_SCRIPT);
page.push_str("</script>\n<script>");
page.push_str(SPACING_SCRIPT);
page.push_str("</script>\n<script>");
page.push_str(FOOTER_FADE_SCRIPT);
page.push_str("</script>\n</body>\n</html>\n");
page
}
fn not_found_page(note: &str) -> String {
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\" />\
<meta name=\"color-scheme\" content=\"light dark\" />\
<title>Not found</title><style>{STYLESHEET}</style></head>\
<body><h1>Note not available</h1><p>The note <code>{}</code> is not \
being shared.</p></body></html>",
html_escape_text(note)
)
}
fn rewrite_links_in_document(doc: &mut Document) {
for paragraph in &mut doc.paragraphs {
rewrite_links_in_paragraph(paragraph);
}
}
fn rewrite_links_in_paragraph(paragraph: &mut Paragraph) {
match paragraph {
Paragraph::Text { content }
| Paragraph::Header1 { content }
| Paragraph::Header2 { content }
| Paragraph::Header3 { content }
| Paragraph::CodeBlock { content } => {
for span in content.iter_mut() {
rewrite_links_in_span(span);
}
}
Paragraph::OrderedList { entries } | Paragraph::UnorderedList { entries } => {
for entry in entries.iter_mut() {
for child in entry.iter_mut() {
rewrite_links_in_paragraph(child);
}
}
}
Paragraph::Checklist { items } => {
for item in items.iter_mut() {
rewrite_links_in_checklist_item(item);
}
}
Paragraph::Quote { children } => {
for child in children.iter_mut() {
rewrite_links_in_paragraph(child);
}
}
Paragraph::Table { rows } => {
for row in rows.iter_mut() {
for cell in row.cells.iter_mut() {
for span in cell.content.iter_mut() {
rewrite_links_in_span(span);
}
}
}
}
}
}
fn rewrite_links_in_checklist_item(item: &mut ChecklistItem) {
for span in item.content.iter_mut() {
rewrite_links_in_span(span);
}
for child in item.children.iter_mut() {
rewrite_links_in_checklist_item(child);
}
}
fn rewrite_links_in_span(span: &mut Span) {
if span.style == InlineStyle::Link
&& let Some(target) = &span.link_target
&& let Some(rewritten) = rewrite_link_target(target)
{
span.link_target = Some(rewritten);
}
for child in span.children.iter_mut() {
rewrite_links_in_span(child);
}
}
fn rewrite_link_target(target: &str) -> Option<String> {
let trimmed = target.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let normalized = normalize_link_target(trimmed);
if is_external_link(&normalized) {
return None;
}
let (note, fragment) = split_target(&normalized);
if note.is_empty() {
return None;
}
let mut out = format!("/{}", encode_path(note));
if let Some(fragment) = fragment.filter(|f| !f.is_empty()) {
out.push('#');
out.push_str(&encode_fragment(fragment));
}
Some(out)
}
fn collect_heading_anchors(doc: &Document) -> Vec<String> {
let mut texts = Vec::new();
collect_heading_texts(&doc.paragraphs, &mut texts);
heading_anchors(&texts)
}
fn collect_heading_texts(paragraphs: &[Paragraph], out: &mut Vec<String>) {
for paragraph in paragraphs {
match paragraph {
Paragraph::Header1 { content }
| Paragraph::Header2 { content }
| Paragraph::Header3 { content } => out.push(spans_plain_text(content)),
Paragraph::Quote { children } => collect_heading_texts(children, out),
Paragraph::OrderedList { entries } | Paragraph::UnorderedList { entries } => {
for entry in entries {
collect_heading_texts(entry, out);
}
}
_ => {}
}
}
}
fn spans_plain_text(spans: &[Span]) -> String {
let mut text = String::new();
for span in spans {
collect_span_text(span, &mut text);
}
text
}
fn collect_span_text(span: &Span, out: &mut String) {
out.push_str(&span.text);
for child in &span.children {
collect_span_text(child, out);
}
}
fn inject_heading_ids(html: &str, anchors: &[String]) -> String {
if anchors.is_empty() {
return html.to_string();
}
let mut out = String::with_capacity(html.len() + anchors.len() * 20);
let mut rest = html;
let mut idx = 0;
while idx < anchors.len() {
let next = ["<h1", "<h2", "<h3"]
.iter()
.filter_map(|tag| rest.find(tag).map(|pos| (pos, *tag)))
.min_by_key(|(pos, _)| *pos);
let Some((pos, tag)) = next else { break };
let after = pos + tag.len();
out.push_str(&rest[..after]);
out.push_str(&format!(" id=\"{}\"", html_escape_attr(&anchors[idx])));
rest = &rest[after..];
idx += 1;
}
out.push_str(rest);
out
}
fn html_response(body: &str, code: u16) -> Response<Cursor<Vec<u8>>> {
Response::from_string(body.to_string())
.with_status_code(code)
.with_header(ascii_header("Content-Type", "text/html; charset=utf-8"))
}
fn text_response(body: &str) -> Response<Cursor<Vec<u8>>> {
Response::from_string(body.to_string())
.with_header(ascii_header("Content-Type", "text/plain; charset=utf-8"))
}
fn ascii_header(name: &str, value: &str) -> Header {
Header::from_bytes(name.as_bytes(), value.as_bytes())
.unwrap_or_else(|_| Header::from_bytes(&b"X-Piki"[..], &b"1"[..]).unwrap())
}
fn query_param(query: &str, key: &str) -> Option<String> {
query.split('&').find_map(|pair| {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
(k == key).then(|| percent_decode(&v.replace('+', " ")))
})
}
fn is_unreserved(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~')
}
fn encode_path(s: &str) -> String {
encode_with(s, |b| is_unreserved(b) || b == b'/')
}
fn encode_fragment(s: &str) -> String {
encode_with(s, is_unreserved)
}
fn encode_with(s: &str, keep: impl Fn(u8) -> bool) -> String {
let mut out = String::with_capacity(s.len());
for &b in s.as_bytes() {
if keep(b) {
out.push(b as char);
} else {
out.push('%');
out.push_str(&format!("{b:02X}"));
}
}
out
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let (Some(hi), Some(lo)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
{
out.push(hi * 16 + lo);
i += 3;
continue;
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn hex_val(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn html_escape_text(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
fn html_escape_attr(s: &str) -> String {
html_escape_text(s).replace('"', """)
}
fn json_string(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
const RELOAD_SCRIPT: &str = r#"(function () {
var pathNote = decodeURIComponent(location.pathname.replace(/^\//, ""));
var version = window.__pikiInitialVersion;
var statusEl = document.getElementById("piki-status");
function poll() {
fetch("/__piki/version?note=" + encodeURIComponent(pathNote))
.then(function (r) { if (!r.ok) throw new Error("bad"); return r.text(); })
.then(function (v) {
if (statusEl) statusEl.hidden = true;
if (v === version) return;
version = v;
return fetch(location.pathname + "?raw=1")
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = document.getElementById("piki-doc");
if (doc) {
doc.innerHTML = html;
// Bring the spotlighted paragraph/item into view for the audience.
// `nearest` leaves it alone when already visible, so ordinary edits
// don't yank the page around.
var active = doc.querySelector(".piki-active");
if (active) active.scrollIntoView({ block: "nearest" });
}
});
})
.catch(function () { if (statusEl) statusEl.hidden = false; });
}
setInterval(poll, 1000);
poll();
})();"#;
const COLUMN_SCRIPT: &str = r#"(function () {
function apply(n) {
document.body.classList.toggle("cols-2", n === 2);
try { localStorage.setItem("pikiCols", String(n)); } catch (e) {}
var links = document.querySelectorAll(".piki-col");
for (var i = 0; i < links.length; i++) {
links[i].classList.toggle("active", links[i].getAttribute("data-cols") === String(n));
}
}
var saved = 1;
try { if (localStorage.getItem("pikiCols") === "2") saved = 2; } catch (e) {}
apply(saved);
var links = document.querySelectorAll(".piki-col");
for (var i = 0; i < links.length; i++) {
links[i].addEventListener("click", function (e) {
e.preventDefault();
apply(parseInt(this.getAttribute("data-cols"), 10));
});
}
})();"#;
const FOOTER_CONTROLS: &str = r##" •
<a href="#" class="piki-spacing active" data-spacing="wide" title="Wide line spacing" aria-label="Wide line spacing"><svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="4" x2="13.5" y2="4"/><line x1="2.5" y1="12" x2="13.5" y2="12"/></svg></a>
<a href="#" class="piki-spacing" data-spacing="compact" title="Compact line spacing" aria-label="Compact line spacing"><svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><line x1="2.5" y1="6" x2="13.5" y2="6"/><line x1="2.5" y1="10" x2="13.5" y2="10"/></svg></a>
•
<a href="#" class="piki-col active" data-cols="1">1 col</a>
<a href="#" class="piki-col" data-cols="2">2 cols</a>"##;
const SPACING_SCRIPT: &str = r#"(function () {
function apply(mode) {
document.body.classList.toggle("compact", mode === "compact");
try { localStorage.setItem("pikiSpacing", mode); } catch (e) {}
var links = document.querySelectorAll(".piki-spacing");
for (var i = 0; i < links.length; i++) {
links[i].classList.toggle("active", links[i].getAttribute("data-spacing") === mode);
}
}
var saved = "wide";
try { if (localStorage.getItem("pikiSpacing") === "compact") saved = "compact"; } catch (e) {}
apply(saved);
var links = document.querySelectorAll(".piki-spacing");
for (var i = 0; i < links.length; i++) {
links[i].addEventListener("click", function (e) {
e.preventDefault();
apply(this.getAttribute("data-spacing"));
});
}
})();"#;
const FOOTER_FADE_SCRIPT: &str = r#"(function () {
var footer = document.getElementById("piki-footer");
if (!footer) return;
var timer = null;
function fade() { footer.classList.add("piki-faded"); }
function wake() {
footer.classList.remove("piki-faded");
if (timer) clearTimeout(timer);
timer = setTimeout(fade, 3000);
}
function nearCorner(x, y) {
var r = footer.getBoundingClientRect();
return x >= r.left - 40 && y >= r.top - 40;
}
document.addEventListener("mousemove", function (e) {
if (nearCorner(e.clientX, e.clientY)) wake();
});
document.addEventListener("touchstart", function (e) {
var t = e.touches[0];
if (t && nearCorner(t.clientX, t.clientY)) wake();
}, { passive: true });
footer.addEventListener("focusin", wake);
// Visible on load, then fade after ~3s.
wake();
})();"#;
const STYLESHEET: &str = r##"
:root { color-scheme: light dark; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI",
system-ui, "Ubuntu", "Droid Sans", sans-serif;
font-size: 14px;
line-height: 1.6;
color: #1f2328;
background-color: #ffffff;
max-width: 760px;
margin: 0 auto;
padding: 24px 26px 48px;
word-wrap: break-word;
}
a { color: #0969da; text-decoration: none; }
a:hover { text-decoration: underline; }
h1, h2, h3, h4, h5, h6 {
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
line-height: 1.25;
}
h1 { font-size: 2em; padding-bottom: 0.3em; border-bottom: 1px solid #d8dee4; }
h2 { font-size: 1.5em; padding-bottom: 0.3em; border-bottom: 1px solid #d8dee4; }
h3 { font-size: 1.25em; }
#piki-doc > :first-child,
#piki-doc > .piki-sec:first-child > :first-child { margin-top: 0; }
p { margin-top: 0; margin-bottom: 16px; }
ul, ol { margin-top: 0; margin-bottom: 16px; padding-left: 2em; }
li + li { margin-top: 0.25em; }
li > ul, li > ol { margin-top: 0.25em; margin-bottom: 0; }
li:has(> input[type="checkbox"]) { list-style: none; }
li > input[type="checkbox"] { margin: 0 0.4em 0 -1.4em; vertical-align: middle; }
blockquote {
margin: 0 0 16px 0;
padding: 0 1em;
color: #656d76;
border-left: 0.25em solid #d0d7de;
}
blockquote > :first-child { margin-top: 0; }
blockquote > :last-child { margin-bottom: 0; }
code, tt {
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 0.9em;
padding: 0.2em 0.4em;
background-color: rgba(175, 184, 193, 0.2);
border-radius: 6px;
}
pre {
margin-top: 0;
margin-bottom: 16px;
padding: 16px;
overflow: auto;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
"Liberation Mono", monospace;
font-size: 0.9em;
line-height: 1.45;
background-color: #f6f8fa;
border-radius: 6px;
}
pre code, pre tt {
padding: 0;
font-size: inherit;
background-color: transparent;
border-radius: 0;
}
table {
margin-top: 0;
margin-bottom: 16px;
border-collapse: collapse;
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
}
th, td { padding: 6px 13px; border: 1px solid #d0d7de; }
th { font-weight: 600; }
tr:nth-child(2n) { background-color: #f6f8fa; }
mark { background-color: #fff8c5; color: inherit; }
/* Live selection spotlight: the paragraph or list item the presenter has
selected in the app (see `HighlightTarget`). Every selected paragraph/item
gets a warm tint (`piki-active`) that persists; the first one (`piki-lead`)
also gets a large arrow in the left gutter that sweeps in to draw the eye and
then fades out, leaving just the tint. A multi-paragraph selection thus reads
as one region with a single, momentary pointer. The classes are added
server-side; they appear only while the editor has a selection and clear when
the caret moves. */
.piki-active {
position: relative;
background-color: #fff3bf;
border-radius: 3px;
/* Extend the tint a little past the text box so it reads as a highlight band
rather than a background stuck to the glyphs. */
box-shadow: 0 0 0 0.3em #fff3bf;
/* Play the entrance each time the spotlight lands: the live-reload swap
re-inserts this element on every selection change, so the animation
re-triggers precisely then (see the reload script). */
animation: piki-active-band 0.28s ease-out;
}
.piki-lead::before {
content: "";
position: absolute;
left: -1.85em;
top: 50%;
/* A solid right-pointing triangle built from borders (no assets). */
width: 0;
height: 0;
border-style: solid;
border-width: 0.6em 0 0.6em 0.85em;
border-color: transparent transparent transparent #f08c00;
/* Transient pointer: hidden at rest, it sweeps in, holds briefly, then fades
back out (the animation ends where the resting style is, so `fill-mode`
stays the default `none`). */
opacity: 0;
transform: translateY(-50%);
animation: piki-active-arrow 1.3s ease-out;
}
/* List items sit inside the list's padding; nudge the arrow further left so it
clears the bullet/number rather than colliding with it. */
li.piki-lead::before { left: -2.6em; }
/* The band keyframe specifies only `from`; the implicit `to` is the element's
own resting tint, so it fades up to whatever the (light/dark) theme sets — no
per-theme duplication. The arrow keyframe is self-contained: in (with a small
overshoot), a hold, then out, ending hidden like its resting style. */
@keyframes piki-active-band {
from { background-color: transparent; box-shadow: 0 0 0 0.3em transparent; }
}
@keyframes piki-active-arrow {
0% { opacity: 0; transform: translate(-0.7em, -50%); }
18% { opacity: 1; transform: translate(0.12em, -50%); }
28% { transform: translate(0, -50%); }
60% { opacity: 1; }
100% { opacity: 0; }
}
/* With motion reduced there is no sweep to notice, so keep the arrow shown for
the life of the selection rather than flashing it invisibly. */
@media (prefers-reduced-motion: reduce) {
.piki-active { animation: none; }
.piki-lead::before { animation: none; opacity: 1; }
}
hr { height: 0.25em; margin: 24px 0; background-color: #d0d7de; border: 0; }
img { max-width: 100%; }
#piki-status {
position: fixed;
top: 0;
left: 0;
right: 0;
padding: 8px 16px;
font-weight: 600;
text-align: center;
color: #ffffff;
background-color: #cf222e;
}
/* Subtle footer with attribution and the reading-mode toggles, pinned as a
small rounded pill in the bottom-right corner rather than a full-width bar, so
it takes up as little of the page as possible. With no `left`/`width` it
shrinks to fit its (single-line) content; the body's bottom padding keeps the
last line from hiding behind it. */
#piki-footer {
position: fixed;
right: 12px;
bottom: 12px;
padding: 5px 12px;
font-size: 12px;
white-space: nowrap;
color: #8b949e;
background-color: rgba(255, 255, 255, 0.92);
border: 1px solid #d8dee4;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(31, 35, 40, 0.12);
/* Fade in quickly when roused (see FOOTER_FADE_SCRIPT). */
transition: opacity 0.18s ease;
}
/* Idle state: faded fully out and click-through, so it never obscures or blocks
the content beneath it. Fades out slowly, unlike the quick fade-in above. */
#piki-footer.piki-faded {
opacity: 0;
pointer-events: none;
transition: opacity 1s ease;
}
@media (prefers-reduced-motion: reduce) {
#piki-footer, #piki-footer.piki-faded { transition: none; }
}
#piki-footer a.piki-col { color: #0969da; text-decoration: none; cursor: pointer; }
#piki-footer a.piki-col:hover { text-decoration: underline; }
#piki-footer a.piki-col.active {
color: inherit;
text-decoration: underline;
cursor: default;
}
/* The line-spacing toggle uses icons rather than text; the current choice is
shown grey (inherit) like the active column link, the other stays a blue
link. `currentColor` on the SVG strokes makes them follow that color. */
#piki-footer a.piki-spacing {
color: #0969da;
cursor: pointer;
display: inline-block;
vertical-align: middle;
margin: 0 1px;
}
#piki-footer a.piki-spacing.active { color: inherit; cursor: default; }
#piki-footer a.piki-spacing svg { display: block; }
/* Compact reading mode: tighter line height and block spacing so more content
fits on screen. Toggled from the footer; the `compact` class on <body>
survives live-reload because only #piki-doc is swapped. */
body.compact { line-height: 1.35; }
body.compact p,
body.compact ul,
body.compact ol,
body.compact pre,
body.compact blockquote,
body.compact table { margin-bottom: 10px; }
body.compact h1,
body.compact h2,
body.compact h3,
body.compact h4,
body.compact h5,
body.compact h6 { margin-top: 16px; margin-bottom: 8px; }
body.compact li + li { margin-top: 0.1em; }
/* Two-column reading mode: widen the page and flow the document into two
balanced columns. Content is allowed to break across the column boundary so
the columns balance — a long section (e.g. a heading with a big list) splits
between the two columns instead of being forced whole into one and
overflowing it. The rules below only forbid the *awkward* breaks: a heading
stranded at a column foot, or a list item / code block sliced in half. */
body.cols-2 { max-width: 1400px; }
body.cols-2 #piki-doc { column-count: 2; column-gap: 48px; }
/* Keep a heading with the content that follows it, so a column break never
orphans a heading at the foot of a column, while still letting the content
itself flow on into the next column. `avoid-column` is the value Firefox
honors in multicol; the `-webkit-`/`page-break-` forms cover WebKit/Blink
and older engines. */
#piki-doc h1, #piki-doc h2, #piki-doc h3,
#piki-doc h4, #piki-doc h5, #piki-doc h6 {
break-after: avoid;
break-after: avoid-column;
-webkit-column-break-after: avoid;
page-break-after: avoid;
break-inside: avoid;
}
#piki-doc pre, #piki-doc blockquote, #piki-doc table, #piki-doc li,
#piki-doc img { break-inside: avoid; }
@media (prefers-color-scheme: dark) {
body { color: #e6edf3; background-color: #0d1117; }
a { color: #4493f8; }
h1, h2 { border-bottom-color: #30363d; }
blockquote { color: #9198a1; border-left-color: #30363d; }
code, tt { background-color: rgba(110, 118, 129, 0.4); }
pre { background-color: #161b22; }
th, td { border-color: #30363d; }
tr:nth-child(2n) { background-color: #161b22; }
mark { background-color: #bb8009; color: #1f2328; }
.piki-active {
background-color: rgba(240, 140, 0, 0.22);
box-shadow: 0 0 0 0.3em rgba(240, 140, 0, 0.22);
}
.piki-lead::before {
border-color: transparent transparent transparent #f0a53a;
}
hr { background-color: #30363d; }
#piki-footer {
color: #8b949e;
background-color: rgba(13, 17, 23, 0.92);
border-color: #30363d;
box-shadow: 0 1px 4px rgba(1, 4, 9, 0.4);
}
#piki-footer a.piki-col { color: #4493f8; }
#piki-footer a.piki-col.active { color: inherit; }
#piki-footer a.piki-spacing { color: #4493f8; }
#piki-footer a.piki-spacing.active { color: inherit; }
}
"##;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_note_names() {
assert!(is_valid_note_name("frontpage"));
assert!(is_valid_note_name("project-a/standup"));
assert!(is_valid_note_name("sprint-q2.6"));
assert!(!is_valid_note_name(""));
assert!(!is_valid_note_name("/etc/passwd"));
assert!(!is_valid_note_name(".."));
assert!(!is_valid_note_name("a/../b"));
assert!(!is_valid_note_name("a/./b"));
assert!(!is_valid_note_name("a//b"));
assert!(!is_valid_note_name("a\\b"));
assert!(!is_valid_note_name("a\nb"));
}
#[test]
fn renders_non_printable_characters_visibly() {
let html = render_fragment("Production Depl\u{1b}oyment", &[]);
assert!(html.contains("Production Depl\u{241b}oyment"), "{html}");
assert!(!html.contains('\u{1b}'), "{html}");
}
#[test]
fn rewrites_internal_links_to_server_paths() {
assert_eq!(rewrite_link_target("other"), Some("/other".into()));
assert_eq!(
rewrite_link_target("path/to/note"),
Some("/path/to/note".into())
);
assert_eq!(
rewrite_link_target("note#section"),
Some("/note#section".into())
);
assert_eq!(
rewrite_link_target("piki://work/auth#security-model"),
Some("/work/auth#security-model".into())
);
assert_eq!(
rewrite_link_target("Notes: Meeting"),
Some("/Notes%3A%20Meeting".into())
);
}
#[test]
fn leaves_external_and_anchor_links_untouched() {
assert_eq!(rewrite_link_target("https://example.com/x"), None);
assert_eq!(rewrite_link_target("http://example.com"), None);
assert_eq!(rewrite_link_target("mailto:a@b.com"), None);
assert_eq!(rewrite_link_target("#section"), None);
assert_eq!(rewrite_link_target(" "), None);
}
#[test]
fn injects_heading_ids_in_order() {
let html = "<h1>Title</h1>\n<p>x</p>\n<h2>Sub</h2>";
let out = inject_heading_ids(html, &["title".into(), "sub".into()]);
assert!(out.contains("<h1 id=\"title\">Title</h1>"), "{out}");
assert!(out.contains("<h2 id=\"sub\">Sub</h2>"), "{out}");
}
#[test]
fn injects_heading_ids_alongside_active_class() {
let html = "<h1 class=\"piki-active\">Title</h1>\n<h2>Sub</h2>";
let out = inject_heading_ids(html, &["title".into(), "sub".into()]);
assert!(
out.contains("<h1 id=\"title\" class=\"piki-active\">Title</h1>"),
"{out}"
);
assert!(out.contains("<h2 id=\"sub\">Sub</h2>"), "{out}");
}
#[test]
fn render_fragment_rewrites_links_and_adds_anchors() {
let md = "# Hello World\n\nSee [other](other) and [ext](https://example.com).\n";
let fragment = render_fragment(md, &[]);
assert!(fragment.contains("<h1 id=\"hello-world\">"), "{fragment}");
assert!(
fragment.contains("<section class=\"piki-sec\">"),
"{fragment}"
);
assert!(fragment.contains("href=\"/other\""), "{fragment}");
assert!(
fragment.contains("href=\"https://example.com\""),
"{fragment}"
);
}
#[test]
fn marks_active_paragraph_only() {
let md = "# Title\n\nFirst para\n\nSecond para\n";
let f = render_fragment(md, &[HighlightTarget { block: 2, li: None }]);
assert!(
f.contains("<p class=\"piki-active piki-lead\">Second para</p>"),
"{f}"
);
assert!(f.contains("<p>First para</p>"), "{f}");
assert_eq!(f.matches("piki-active").count(), 1, "{f}");
assert_eq!(f.matches("piki-lead").count(), 1, "{f}");
}
#[test]
fn marks_active_heading_and_keeps_its_id() {
let md = "# Title\n\nBody\n";
let f = render_fragment(md, &[HighlightTarget { block: 0, li: None }]);
assert!(
f.contains("<h1 id=\"title\" class=\"piki-active piki-lead\">Title</h1>"),
"{f}"
);
}
#[test]
fn marks_active_list_item() {
let md = "- one\n- two\n- three\n";
let f = render_fragment(
md,
&[HighlightTarget {
block: 0,
li: Some(1),
}],
);
assert_eq!(f.matches("piki-active").count(), 1, "{f}");
assert!(
f.contains("<li class=\"piki-active piki-lead\">\n <p>two</p>"),
"{f}"
);
}
#[test]
fn marks_multiple_selected_paragraphs_with_one_lead() {
let md = "one\n\ntwo\n\nthree\n";
let f = render_fragment(
md,
&[
HighlightTarget { block: 0, li: None },
HighlightTarget { block: 1, li: None },
HighlightTarget { block: 2, li: None },
],
);
assert!(
f.contains("<p class=\"piki-active piki-lead\">one</p>"),
"{f}"
);
assert!(f.contains("<p class=\"piki-active\">two</p>"), "{f}");
assert!(f.contains("<p class=\"piki-active\">three</p>"), "{f}");
assert_eq!(f.matches("piki-active").count(), 3, "{f}");
assert_eq!(f.matches("piki-lead").count(), 1, "{f}");
}
#[test]
fn marks_multiple_selected_list_items_with_one_lead() {
let md = "- one\n- two\n- three\n";
let f = render_fragment(
md,
&[
HighlightTarget {
block: 0,
li: Some(1),
},
HighlightTarget {
block: 0,
li: Some(2),
},
],
);
assert_eq!(f.matches("piki-active").count(), 2, "{f}");
assert_eq!(f.matches("piki-lead").count(), 1, "{f}");
assert!(
f.contains("<li class=\"piki-active piki-lead\">\n <p>two</p>"),
"{f}"
);
assert!(
f.contains("<li class=\"piki-active\">\n <p>three</p>"),
"{f}"
);
assert!(f.contains("<li>\n <p>one</p>"), "{f}");
}
#[test]
fn out_of_range_highlight_is_a_no_op() {
let li_oob = render_fragment(
"- one\n- two\n",
&[HighlightTarget {
block: 0,
li: Some(9),
}],
);
assert!(!li_oob.contains("piki-active"), "{li_oob}");
let block_oob = render_fragment("# T\n\nBody\n", &[HighlightTarget { block: 9, li: None }]);
assert!(!block_oob.contains("piki-active"), "{block_oob}");
}
#[test]
fn page_styles_and_scrolls_the_active_highlight() {
let page = render_page("frontpage", "# Hi\n", "g1", &[]);
assert!(page.contains(".piki-active"), "{page}");
assert!(page.contains("li.piki-lead::before"), "{page}");
assert!(page.contains("rgba(240, 140, 0, 0.22)"), "{page}");
assert!(page.contains("scrollIntoView"), "{page}");
assert!(page.contains("@keyframes piki-active-band"), "{page}");
assert!(page.contains("@keyframes piki-active-arrow"), "{page}");
assert!(page.contains("prefers-reduced-motion"), "{page}");
}
#[test]
fn query_param_decodes_values() {
assert_eq!(query_param("note=a%2Fb", "note"), Some("a/b".into()));
assert_eq!(query_param("raw=1", "raw"), Some("1".into()));
assert_eq!(query_param("note=x&raw=1", "raw"), Some("1".into()));
assert_eq!(query_param("note=x", "raw"), None);
}
#[test]
fn page_has_footer_with_version_and_column_toggle() {
let page = render_page("frontpage", "# Hi\n", "g1", &[]);
assert!(page.contains("id=\"piki-footer\""), "{page}");
assert!(
page.contains(concat!("Shared by Piki v", env!("CARGO_PKG_VERSION"))),
"{page}"
);
assert!(page.contains("data-cols=\"1\""), "{page}");
assert!(page.contains("data-cols=\"2\""), "{page}");
assert!(page.contains("data-spacing=\"wide\""), "{page}");
assert!(page.contains("data-spacing=\"compact\""), "{page}");
assert!(page.contains("body.compact"), "{page}");
assert!(page.contains("#piki-footer.piki-faded"), "{page}");
assert!(page.contains("piki-faded"), "{page}");
assert!(page.contains("body.cols-2"), "{page}");
assert!(page.contains("avoid-column"), "{page}");
assert!(!render_fragment("# Hi\n", &[]).contains("piki-footer"));
}
#[test]
fn page_supports_native_dark_mode() {
let page = render_page("frontpage", "# Hi\n", "g1", &[]);
assert!(
page.contains("<meta name=\"color-scheme\" content=\"light dark\" />"),
"{page}"
);
assert!(page.contains("color-scheme: light dark;"), "{page}");
assert!(
page.contains("@media (prefers-color-scheme: dark)"),
"{page}"
);
}
#[test]
fn version_token_distinguishes_current_from_disk() {
let dir = Path::new("/tmp/does-not-exist-piki");
assert_eq!(version_token("cur", "cur", 7, dir), "g7");
assert_eq!(version_token("gone", "cur", 7, dir), "m0");
}
}