introduce the virtual filesystem everywhere
this unfortunately means I had to cut some features (bye bye commit history! for now) stuff's not quite 100% working just yet (like branch links, which were and are still broken) we also don't have content_version impls just yet
This commit is contained in:
parent
db0329077e
commit
377fbe4dab
42 changed files with 1613 additions and 1655 deletions
|
@ -2,228 +2,181 @@
|
|||
mod live_reload;
|
||||
|
||||
use std::fmt::Write;
|
||||
use std::{net::Ipv4Addr, path::PathBuf, sync::Arc};
|
||||
use std::{net::Ipv4Addr, sync::Arc};
|
||||
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
extract::{Path, Query, RawQuery, State},
|
||||
http::{
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE, LOCATION},
|
||||
header::{CACHE_CONTROL, CONTENT_TYPE},
|
||||
HeaderValue, StatusCode,
|
||||
},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use log::{error, info};
|
||||
use log::info;
|
||||
use serde::Deserialize;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
html::EscapeHtml,
|
||||
state::{Source, Treehouse},
|
||||
};
|
||||
use crate::generate::Sources;
|
||||
use crate::vfs::asynch::AsyncDir;
|
||||
use crate::vfs::VPath;
|
||||
use crate::{html::EscapeHtml, state::Source};
|
||||
|
||||
use super::Paths;
|
||||
mod system {
|
||||
use crate::vfs::VPath;
|
||||
|
||||
struct SystemPages {
|
||||
index: String,
|
||||
four_oh_four: String,
|
||||
b_docs: String,
|
||||
sandbox: String,
|
||||
|
||||
navmap: String,
|
||||
pub const INDEX: &VPath = VPath::new_const("index");
|
||||
pub const FOUR_OH_FOUR: &VPath = VPath::new_const("_treehouse/404");
|
||||
pub const B_DOCS: &VPath = VPath::new_const("_treehouse/b");
|
||||
}
|
||||
|
||||
struct Server {
|
||||
config: Config,
|
||||
treehouse: Treehouse,
|
||||
target_dir: PathBuf,
|
||||
system_pages: SystemPages,
|
||||
sources: Arc<Sources>,
|
||||
target: AsyncDir,
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
config: Config,
|
||||
treehouse: Treehouse,
|
||||
paths: &Paths<'_>,
|
||||
port: u16,
|
||||
) -> anyhow::Result<()> {
|
||||
pub async fn serve(sources: Arc<Sources>, target: AsyncDir, port: u16) -> anyhow::Result<()> {
|
||||
let app = Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/*page", get(page))
|
||||
.route("/", get(index)) // needed explicitly because * does not match empty paths
|
||||
.route("/*path", get(vfs_entry))
|
||||
.route("/b", get(branch))
|
||||
.route("/navmap.js", get(navmap))
|
||||
.route("/sandbox", get(sandbox))
|
||||
.route("/static/*file", get(static_file))
|
||||
.fallback(get(four_oh_four))
|
||||
.with_state(Arc::new(Server {
|
||||
config,
|
||||
treehouse,
|
||||
target_dir: paths.target_dir.to_owned(),
|
||||
system_pages: SystemPages {
|
||||
index: std::fs::read_to_string(paths.target_dir.join("index.html"))
|
||||
.context("cannot read index page")?,
|
||||
four_oh_four: std::fs::read_to_string(paths.target_dir.join("_treehouse/404.html"))
|
||||
.context("cannot read 404 page")?,
|
||||
b_docs: std::fs::read_to_string(paths.target_dir.join("_treehouse/b.html"))
|
||||
.context("cannot read /b documentation page")?,
|
||||
sandbox: std::fs::read_to_string(paths.target_dir.join("static/html/sandbox.html"))
|
||||
.context("cannot read sandbox page")?,
|
||||
navmap: std::fs::read_to_string(paths.target_dir.join("navmap.js"))
|
||||
.context("cannot read navigation map")?,
|
||||
},
|
||||
}));
|
||||
.with_state(Arc::new(Server { sources, target }));
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let app = live_reload::live_reload(app);
|
||||
let app = app.nest("/dev/live-reload", live_reload::router());
|
||||
|
||||
info!("serving on port {port}");
|
||||
let listener = TcpListener::bind((Ipv4Addr::from([0u8, 0, 0, 0]), port)).await?;
|
||||
Ok(axum::serve(listener, app).await?)
|
||||
}
|
||||
|
||||
fn get_content_type(path: &str) -> Option<&'static str> {
|
||||
match () {
|
||||
_ if path.ends_with(".html") => Some("text/html"),
|
||||
_ if path.ends_with(".js") => Some("text/javascript"),
|
||||
_ if path.ends_with(".woff2") => Some("font/woff2"),
|
||||
_ if path.ends_with(".svg") => Some("image/svg+xml"),
|
||||
fn get_content_type(extension: &str) -> Option<&'static str> {
|
||||
match extension {
|
||||
"html" => Some("text/html"),
|
||||
"js" => Some("text/javascript"),
|
||||
"woff" => Some("font/woff2"),
|
||||
"svg" => Some("image/svg+xml"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn index(State(state): State<Arc<Server>>) -> Response {
|
||||
Html(state.system_pages.index.clone()).into_response()
|
||||
#[derive(Deserialize)]
|
||||
struct VfsQuery {
|
||||
#[serde(rename = "v")]
|
||||
content_version: Option<String>,
|
||||
}
|
||||
|
||||
async fn navmap(State(state): State<Arc<Server>>) -> Response {
|
||||
let mut response = state.system_pages.navmap.clone().into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static("text/javascript"));
|
||||
response
|
||||
async fn get_static_file(path: &str, query: &VfsQuery, state: &Server) -> Option<Response> {
|
||||
let vpath = VPath::try_new(path).ok()?;
|
||||
let content = state.target.content(vpath).await?;
|
||||
let mut response = content.into_response();
|
||||
|
||||
if let Some(content_type) = vpath.extension().and_then(get_content_type) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
} else {
|
||||
response.headers_mut().remove(CONTENT_TYPE);
|
||||
}
|
||||
|
||||
if query.content_version.is_some() {
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
}
|
||||
|
||||
Some(response)
|
||||
}
|
||||
|
||||
async fn vfs_entry(
|
||||
Path(path): Path<String>,
|
||||
Query(query): Query<VfsQuery>,
|
||||
State(state): State<Arc<Server>>,
|
||||
) -> Response {
|
||||
if let Some(response) = get_static_file(&path, &query, &state).await {
|
||||
response
|
||||
} else {
|
||||
four_oh_four(State(state)).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn system_page(target: &AsyncDir, path: &VPath) -> Response {
|
||||
if let Some(content) = target.content(path).await {
|
||||
(StatusCode::NOT_FOUND, Html(content)).into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("500 Internal Server Error: system page {path} is not available"),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn index(State(state): State<Arc<Server>>) -> Response {
|
||||
system_page(&state.target, system::INDEX).await
|
||||
}
|
||||
|
||||
async fn four_oh_four(State(state): State<Arc<Server>>) -> Response {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Html(state.system_pages.four_oh_four.clone()),
|
||||
)
|
||||
.into_response()
|
||||
system_page(&state.target, system::FOUR_OH_FOUR).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct StaticFileQuery {
|
||||
cache: Option<String>,
|
||||
}
|
||||
|
||||
async fn static_file(
|
||||
Path(path): Path<String>,
|
||||
Query(query): Query<StaticFileQuery>,
|
||||
State(state): State<Arc<Server>>,
|
||||
) -> Response {
|
||||
if let Ok(file) = tokio::fs::read(state.target_dir.join("static").join(&path)).await {
|
||||
let mut response = file.into_response();
|
||||
|
||||
if let Some(content_type) = get_content_type(&path) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
} else {
|
||||
response.headers_mut().remove(CONTENT_TYPE);
|
||||
}
|
||||
|
||||
if query.cache.is_some() {
|
||||
response.headers_mut().insert(
|
||||
CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=31536000, immutable"),
|
||||
);
|
||||
}
|
||||
|
||||
response
|
||||
} else {
|
||||
four_oh_four(State(state)).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn page(Path(path): Path<String>, State(state): State<Arc<Server>>) -> Response {
|
||||
let bare_path = path.strip_suffix(".html").unwrap_or(&path);
|
||||
if let Some(redirected_path) = state.config.redirects.page.get(bare_path) {
|
||||
return (
|
||||
StatusCode::MOVED_PERMANENTLY,
|
||||
[(LOCATION, format!("{}/{redirected_path}", state.config.site))],
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let html_path = format!("{bare_path}.html");
|
||||
if let Ok(file) = tokio::fs::read(state.target_dir.join(&*html_path)).await {
|
||||
([(CONTENT_TYPE, "text/html")], file).into_response()
|
||||
} else {
|
||||
four_oh_four(State(state)).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn sandbox(State(state): State<Arc<Server>>) -> Response {
|
||||
// Small hack to prevent the LiveReloadLayer from injecting itself into the sandbox.
|
||||
// The sandbox is always nested under a different page, so there's no need to do that.
|
||||
let mut response = Html(state.system_pages.sandbox.clone()).into_response();
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
response
|
||||
.extensions_mut()
|
||||
.insert(live_reload::DisableLiveReload);
|
||||
}
|
||||
// Debounce requests a bit. There's a tendency to have very many sandboxes on a page, and
|
||||
// loading this page as many times as there are sandboxes doesn't seem like the best way to do
|
||||
// things.
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("max-age=10"));
|
||||
response
|
||||
}
|
||||
|
||||
async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Html<String> {
|
||||
async fn branch(RawQuery(named_id): RawQuery, State(state): State<Arc<Server>>) -> Response {
|
||||
if let Some(named_id) = named_id {
|
||||
let branch_id = state
|
||||
.sources
|
||||
.treehouse
|
||||
.branches_by_named_id
|
||||
.get(&named_id)
|
||||
.copied()
|
||||
.or_else(|| state.treehouse.branch_redirects.get(&named_id).copied());
|
||||
.or_else(|| {
|
||||
state
|
||||
.sources
|
||||
.treehouse
|
||||
.branch_redirects
|
||||
.get(&named_id)
|
||||
.copied()
|
||||
});
|
||||
if let Some(branch_id) = branch_id {
|
||||
let branch = state.treehouse.tree.branch(branch_id);
|
||||
let branch = state.sources.treehouse.tree.branch(branch_id);
|
||||
if let Source::Tree {
|
||||
input, target_path, ..
|
||||
} = state.treehouse.source(branch.file_id)
|
||||
} = state.sources.treehouse.source(branch.file_id)
|
||||
{
|
||||
match std::fs::read_to_string(target_path) {
|
||||
Ok(content) => {
|
||||
let branch_markdown_content = input[branch.content.clone()].trim();
|
||||
let mut per_page_metadata =
|
||||
String::from("<meta property=\"og:description\" content=\"");
|
||||
write!(per_page_metadata, "{}", EscapeHtml(branch_markdown_content))
|
||||
.unwrap();
|
||||
per_page_metadata.push_str("\">");
|
||||
if let Some(content) = state
|
||||
.target
|
||||
.content(target_path)
|
||||
.await
|
||||
.and_then(|s| String::from_utf8(s).ok())
|
||||
{
|
||||
let branch_markup = input[branch.content.clone()].trim();
|
||||
let mut per_page_metadata =
|
||||
String::from("<meta property=\"og:description\" content=\"");
|
||||
write!(per_page_metadata, "{}", EscapeHtml(branch_markup)).unwrap();
|
||||
per_page_metadata.push_str("\">");
|
||||
|
||||
const PER_PAGE_METADATA_REPLACEMENT_STRING: &str = "<!-- treehouse-ca37057a-cff5-45b3-8415-3b02dbf6c799-per-branch-metadata -->";
|
||||
return Html(content.replacen(
|
||||
PER_PAGE_METADATA_REPLACEMENT_STRING,
|
||||
&per_page_metadata,
|
||||
// Replace one under the assumption that it appears in all pages.
|
||||
1,
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("error while reading file {target_path:?}: {e:?}");
|
||||
}
|
||||
const PER_PAGE_METADATA_REPLACEMENT_STRING: &str = "<!-- treehouse-ca37057a-cff5-45b3-8415-3b02dbf6c799-per-branch-metadata -->";
|
||||
return Html(content.replacen(
|
||||
PER_PAGE_METADATA_REPLACEMENT_STRING,
|
||||
&per_page_metadata,
|
||||
// Replace one under the assumption that it appears in all pages.
|
||||
1,
|
||||
))
|
||||
.into_response();
|
||||
} else {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("500 Internal Server Error: branch metadata points to entry {target_path} which does not have readable content")
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Html(state.system_pages.four_oh_four.clone())
|
||||
system_page(&state.target, system::FOUR_OH_FOUR).await
|
||||
} else {
|
||||
Html(state.system_pages.b_docs.clone())
|
||||
system_page(&state.target, system::B_DOCS).await
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue