use camino::Utf8Component;
use camino::{Utf8Path, Utf8PathBuf};
use thiserror::Error;
use crate::Many;
use crate::One;
use crate::core::Dynamic;
use crate::engine::Handle;
use crate::engine::Map;
use crate::engine::TrackerPtr;
pub fn source_to_bundle(path: impl AsRef<Utf8Path>) -> Utf8PathBuf {
let path = path.as_ref().with_extension("");
if let Some("index") = path.file_name() {
if let Some(parent) = path.parent() {
return parent.to_path_buf();
}
}
path.to_path_buf()
}
pub fn source_to_href(path: &Utf8Path, base: Option<&str>) -> String {
let path = if let Some(base) = base {
path.strip_prefix(base).unwrap_or(path)
} else {
path
};
let mut url = String::from("/");
if let Some(parent) = path.parent() {
url.push_str(parent.as_str());
}
let stem = path.file_stem().unwrap_or_default();
if stem != "index" {
if !url.ends_with('/') {
url.push('/');
}
url.push_str(stem);
}
if !url.ends_with('/') {
url.push('/');
}
if url.starts_with("//") {
url[1..].to_string()
} else {
url
}
}
pub fn href_to_dist(href: &str, dist_root: impl AsRef<Utf8Path>) -> Utf8PathBuf {
dist_root
.as_ref()
.join(href.trim_start_matches('/'))
.join("index.html")
}
pub(crate) fn normalize_path(path: &Utf8Path) -> Utf8PathBuf {
let mut components = path.components().peekable();
let mut ret = if let Some(c @ Utf8Component::Prefix(..)) = components.peek().cloned() {
components.next();
Utf8PathBuf::from(c.as_str())
} else {
Utf8PathBuf::new()
};
for component in components {
match component {
Utf8Component::Prefix(..) => unreachable!(),
Utf8Component::RootDir => {
ret.push(Utf8Component::RootDir);
}
Utf8Component::CurDir => {}
Utf8Component::ParentDir => {
if ret.ends_with(Utf8Component::ParentDir) {
ret.push(Utf8Component::ParentDir);
} else {
let popped = ret.pop();
if !popped && !ret.has_root() {
ret.push(Utf8Component::ParentDir);
}
}
}
Utf8Component::Normal(c) => {
ret.push(c);
}
}
}
ret
}
fn normalize_path_html(path: impl AsRef<Utf8Path>) -> Utf8PathBuf {
let mut buffer = path.as_ref().to_path_buf();
if let Some(file_name) = buffer.file_name() {
if file_name == "index" || file_name.starts_with("index.") {
buffer.set_extension("html");
} else {
buffer.set_extension("");
buffer.push("index.html");
}
} else {
buffer.push("index.html");
}
buffer
}
#[derive(Debug, Error)]
pub enum OutputPathError {
#[error("Output path '{0}' is outside the configured dist directory")]
UnsafePath(Utf8PathBuf),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputTargetKind {
Page,
File,
}
#[derive(Debug, Clone)]
pub struct OutputTarget {
path: Utf8PathBuf,
kind: OutputTargetKind,
}
impl OutputTarget {
fn infer(path: impl Into<Utf8PathBuf>) -> Self {
let path = path.into();
let raw = path.as_str();
let kind = if raw.is_empty()
|| raw.starts_with('/')
|| raw.ends_with('/')
|| path.extension().is_none()
{
OutputTargetKind::Page
} else {
OutputTargetKind::File
};
Self { path, kind }
}
fn page(route: impl Into<Utf8PathBuf>) -> Self {
Self {
path: route.into(),
kind: OutputTargetKind::Page,
}
}
fn file(path: impl Into<Utf8PathBuf>) -> Self {
Self {
path: path.into(),
kind: OutputTargetKind::File,
}
}
fn into_dist_path(self) -> Result<Utf8PathBuf, OutputPathError> {
let path = match self.kind {
OutputTargetKind::Page => route_to_page_path(&self.path),
OutputTargetKind::File => self.path,
};
validate_output_path(path)
}
}
pub trait IntoOutputTarget {
fn into_output_target(self) -> OutputTarget;
}
impl IntoOutputTarget for OutputTarget {
fn into_output_target(self) -> OutputTarget {
self
}
}
impl IntoOutputTarget for &str {
fn into_output_target(self) -> OutputTarget {
OutputTarget::infer(self)
}
}
impl IntoOutputTarget for String {
fn into_output_target(self) -> OutputTarget {
OutputTarget::infer(self)
}
}
impl IntoOutputTarget for &String {
fn into_output_target(self) -> OutputTarget {
OutputTarget::infer(self.as_str())
}
}
impl IntoOutputTarget for Utf8PathBuf {
fn into_output_target(self) -> OutputTarget {
OutputTarget::infer(self)
}
}
impl IntoOutputTarget for &Utf8Path {
fn into_output_target(self) -> OutputTarget {
OutputTarget::infer(self.to_path_buf())
}
}
impl IntoOutputTarget for &Utf8PathBuf {
fn into_output_target(self) -> OutputTarget {
OutputTarget::infer(self.clone())
}
}
impl<T> IntoOutputTarget for &crate::loader::generic::Document<T> {
fn into_output_target(self) -> OutputTarget {
OutputTarget::page(self.meta.href.as_str())
}
}
impl<T> IntoOutputTarget for &&crate::loader::generic::Document<T> {
fn into_output_target(self) -> OutputTarget {
OutputTarget::page(self.meta.href.as_str())
}
}
impl IntoOutputTarget for &crate::loader::generic::DocumentMeta {
fn into_output_target(self) -> OutputTarget {
OutputTarget::page(self.href.as_str())
}
}
impl IntoOutputTarget for &&crate::loader::generic::DocumentMeta {
fn into_output_target(self) -> OutputTarget {
OutputTarget::page(self.href.as_str())
}
}
fn route_to_page_path(route: &Utf8Path) -> Utf8PathBuf {
let route = route.as_str().trim_start_matches('/').trim_end_matches('/');
if route.is_empty() {
Utf8PathBuf::from("index.html")
} else {
Utf8Path::new(route).join("index.html")
}
}
fn validate_output_path(path: Utf8PathBuf) -> Result<Utf8PathBuf, OutputPathError> {
let normalized = normalize_path(&path);
let safe = !path.as_str().is_empty()
&& !path.as_str().split('/').any(|component| component == ".")
&& normalized == path
&& path
.components()
.all(|component| matches!(component, Utf8Component::Normal(_)));
if safe {
Ok(path)
} else {
Err(OutputPathError::UnsafePath(path))
}
}
pub struct OutputTargetBuilder {
target: OutputTarget,
}
impl OutputTargetBuilder {
pub fn html(self, data: impl Into<String>) -> Result<Output, OutputPathError> {
Ok(Output {
path: self.target.into_dist_path()?,
data: OutputData::Utf8(data.into()),
})
}
pub fn text(self, data: impl Into<String>) -> Result<Output, OutputPathError> {
Ok(Output {
path: self.target.into_dist_path()?,
data: OutputData::Utf8(data.into()),
})
}
pub fn bytes(self, data: impl Into<Vec<u8>>) -> Result<Output, OutputPathError> {
Ok(Output {
path: self.target.into_dist_path()?,
data: OutputData::Binary(data.into()),
})
}
}
#[derive(Debug, Clone, Hash)]
pub enum OutputData {
Utf8(String),
Binary(Vec<u8>),
}
impl AsRef<[u8]> for OutputData {
fn as_ref(&self) -> &[u8] {
match self {
OutputData::Utf8(s) => s.as_bytes(),
OutputData::Binary(b) => b.as_slice(),
}
}
}
#[derive(Debug, Clone, Hash)]
pub struct Output {
pub path: Utf8PathBuf,
pub data: OutputData,
}
impl Output {
pub fn to(target: impl IntoOutputTarget) -> OutputTargetBuilder {
OutputTargetBuilder {
target: target.into_output_target(),
}
}
pub fn page(route: impl Into<Utf8PathBuf>) -> OutputTargetBuilder {
OutputTargetBuilder {
target: OutputTarget::page(route),
}
}
pub fn file(path: impl Into<Utf8PathBuf>) -> OutputTargetBuilder {
OutputTargetBuilder {
target: OutputTarget::file(path),
}
}
pub fn mapper(source: impl Into<Utf8PathBuf>) -> OutputBuilder {
OutputBuilder {
current: source.into(),
}
}
pub fn html(path: impl AsRef<Utf8Path>, data: impl Into<String>) -> Self {
Self {
path: normalize_path_html(path),
data: OutputData::Utf8(data.into()),
}
}
pub fn binary(path: impl Into<Utf8PathBuf>, data: impl Into<Vec<u8>>) -> Self {
Self {
path: path.into(),
data: OutputData::Binary(data.into()),
}
}
}
pub struct OutputBuilder {
current: Utf8PathBuf,
}
impl OutputBuilder {
pub fn strip_prefix(
mut self,
prefix: impl AsRef<Utf8Path>,
) -> Result<Self, std::path::StripPrefixError> {
self.current = self
.current
.strip_prefix(prefix.as_ref())
.map(|p| p.to_path_buf())?;
Ok(self)
}
pub fn html(mut self) -> Self {
self.current = source_to_bundle(&self.current)
.join("index")
.with_extension("html");
self
}
pub fn ext(mut self, extension: &str) -> Self {
self.current.set_extension(extension);
self
}
pub fn content(self, body: impl Into<String>) -> Output {
let path = if (self.current.extension() == Some("html"))
|| self.current.file_name() == Some("index")
{
normalize_path_html(&self.current)
} else {
normalize_path(&self.current)
};
Output {
path,
data: OutputData::Utf8(body.into()),
}
}
}
pub trait OutputHandle: Handle {
fn resolve_refs(item: &Dynamic) -> (Option<TrackerPtr>, Vec<&Output>);
}
impl OutputHandle for One<Output> {
fn resolve_refs(item: &Dynamic) -> (Option<TrackerPtr>, Vec<&Output>) {
match item.downcast_ref::<Output>() {
Some(item) => (None, vec![item]),
None => unreachable!(),
}
}
}
impl OutputHandle for One<Vec<Output>> {
fn resolve_refs(item: &Dynamic) -> (Option<TrackerPtr>, Vec<&Output>) {
match item.downcast_ref::<Vec<Output>>() {
Some(item) => (None, item.iter().collect()),
None => unreachable!(),
}
}
}
impl OutputHandle for Many<Output> {
fn resolve_refs(item: &Dynamic) -> (Option<TrackerPtr>, Vec<&Output>) {
match item.downcast_ref::<Map<Output>>() {
Some(map) => {
let ptr = TrackerPtr::default();
let mut items = Vec::new();
{
#[allow(clippy::unwrap_used)]
let mut tracker = ptr.ptr.lock().unwrap();
for (key, (output, provenance)) in &map.map {
tracker.accessed.insert(key.clone(), *provenance);
items.push(output);
}
}
(Some(ptr), items)
}
None => unreachable!(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_source_to_href() {
assert_eq!(
source_to_href(Utf8Path::new("content/posts/hello.md"), Some("content")),
"/posts/hello/"
);
assert_eq!(
source_to_href(Utf8Path::new("content/posts/index.md"), Some("content")),
"/posts/"
);
assert_eq!(
source_to_href(Utf8Path::new("posts/hello.md"), None),
"/posts/hello/"
);
assert_eq!(source_to_href(Utf8Path::new("index.md"), None), "/");
assert_eq!(
source_to_href(Utf8Path::new("content/hello.md"), Some("content")),
"/hello/"
);
assert_eq!(
source_to_href(Utf8Path::new("content/index.md"), Some("content")),
"/"
);
assert_eq!(
source_to_href(Utf8Path::new("content/a/b/c.md"), Some("content")),
"/a/b/c/"
);
}
#[test]
fn test_source_to_bundle() {
assert_eq!(
source_to_bundle("content/foo/bar.md"),
Utf8Path::new("content/foo/bar")
);
assert_eq!(
source_to_bundle("content/foo/index.md"),
Utf8Path::new("content/foo")
);
assert_eq!(source_to_bundle("index.md"), Utf8Path::new(""));
}
#[test]
fn test_href_to_dist() {
assert_eq!(
href_to_dist("/posts/hello/", "dist"),
Utf8Path::new("dist/posts/hello/index.html")
);
assert_eq!(href_to_dist("/", "dist"), Utf8Path::new("dist/index.html"));
assert_eq!(
href_to_dist("/a/b/c/", "dist"),
Utf8Path::new("dist/a/b/c/index.html")
);
}
#[test]
fn test_output_to_route_html() -> Result<(), OutputPathError> {
let page = Output::to("/posts/hello/").html("hello")?;
assert_eq!(page.path, Utf8Path::new("posts/hello/index.html"));
let page = Output::to("posts").html("hello")?;
assert_eq!(page.path, Utf8Path::new("posts/index.html"));
let page = Output::to("").html("hello")?;
assert_eq!(page.path, Utf8Path::new("index.html"));
Ok(())
}
#[test]
fn test_output_to_file_text_and_bytes() -> Result<(), OutputPathError> {
let text = Output::to("posts/rss.xml").text("<rss />")?;
assert_eq!(text.path, Utf8Path::new("posts/rss.xml"));
assert!(matches!(text.data, OutputData::Utf8(_)));
let bytes = Output::file("files/archive.zip").bytes([1, 2, 3])?;
assert_eq!(bytes.path, Utf8Path::new("files/archive.zip"));
assert!(matches!(bytes.data, OutputData::Binary(_)));
Ok(())
}
#[test]
fn test_output_to_rejects_unsafe_paths() {
assert!(Output::file("../escape.txt").text("bad").is_err());
assert!(Output::file("same/./file.txt").text("bad").is_err());
}
#[test]
fn test_output_to_document_html() -> Result<(), OutputPathError> {
let document = crate::loader::generic::Document {
matter: Box::new(()),
text: String::new(),
meta: crate::loader::generic::DocumentMeta {
path: Utf8PathBuf::from("content/posts/hello.md"),
base: Some("content".into()),
href: "/posts/hello/".to_string(),
},
};
let page = Output::to(&document).html("hello")?;
assert_eq!(page.path, Utf8Path::new("posts/hello/index.html"));
let page = Output::to(&document.meta).html("hello")?;
assert_eq!(page.path, Utf8Path::new("posts/hello/index.html"));
let document_ref = &document;
fn from_double_ref(
document: &&crate::loader::generic::Document<()>,
) -> Result<Output, OutputPathError> {
Output::to(document).html("hello")
}
let page = from_double_ref(&document_ref)?;
assert_eq!(page.path, Utf8Path::new("posts/hello/index.html"));
Ok(())
}
}