Skip to main content

dmc_transform/builtin/
copy_linked_files.rs

1//! Asset copy + URL rewrite. See `transformers/copy-linked-files.md`
2//! for full docs.
3
4use crate::pipeline::Transformer;
5use crate::visit::{NodeAction, Visitor, walk_root};
6use dmc_diagnostic::Code;
7use dmc_diagnostic::metadata::SourceMeta;
8use dmc_parser::ast::*;
9use duck_diagnostic::{Diagnostic, Label, Span, diag};
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13
14/// Copy referenced asset files (image `src`s and relative `href`s) into
15/// `assets_dir`, hash-name them via `name_template`, and rewrite the AST
16/// node to point at the published URL under `base_url`. `map` caches
17/// `raw -> url` so repeated references hash the file only once.
18pub struct CopyLinkedFiles {
19  pub source_dir: PathBuf,
20  pub assets_dir: PathBuf,
21  pub base_url: String,
22  pub name_template: String,
23  pub map: Arc<Mutex<HashMap<String, String>>>,
24}
25
26impl CopyLinkedFiles {
27  pub fn new(source_dir: PathBuf, assets_dir: PathBuf, base_url: String) -> Self {
28    Self {
29      source_dir,
30      assets_dir,
31      base_url,
32      name_template: "[name]-[hash:8].[ext]".into(),
33      map: Arc::new(Mutex::new(HashMap::new())),
34    }
35  }
36}
37
38enum Outcome {
39  Skip,
40  Published(String),
41  SourceMissing(PathBuf, std::io::Error),
42  CopyFailed(PathBuf, std::io::Error),
43}
44
45impl Transformer for CopyLinkedFiles {
46  fn name(&self) -> &str {
47    "copy-linked-files"
48  }
49  fn transform(
50    &self,
51    doc: &mut Document,
52    _meta: &SourceMeta,
53    diag_engine: &mut duck_diagnostic::DiagnosticEngine<Code>,
54  ) {
55    let mut v = Apply { config: self, pending: Vec::new() };
56    walk_root(&mut doc.children, &mut v);
57    for d in v.pending.drain(..) {
58      diag_engine.emit(d);
59    }
60  }
61}
62
63struct Apply<'a> {
64  config: &'a CopyLinkedFiles,
65  pending: Vec<Diagnostic<Code>>,
66}
67
68impl<'a> Apply<'a> {
69  fn rewrite_slot(&mut self, raw_slot: &mut String, span: Span, kind: &'static str) {
70    match self.config.publish(raw_slot) {
71      Outcome::Skip => {},
72      Outcome::Published(url) => *raw_slot = url,
73      Outcome::SourceMissing(path, err) => {
74        self.pending.push(
75          diag!(
76            Code::AssetSourceMissing,
77            format!("copy-linked-files: cannot read {} source {} ({})", kind, path.display(), err)
78          )
79          .with_label(Label::primary(span, Some(format!("from this {}", kind)))),
80        );
81      },
82      Outcome::CopyFailed(path, err) => {
83        self.pending.push(
84          diag!(
85            Code::AssetCopyFailed,
86            format!("copy-linked-files: failed to write asset {} ({})", path.display(), err)
87          )
88          .with_label(Label::primary(span, Some(format!("for this {}", kind)))),
89        );
90      },
91    }
92  }
93}
94
95impl<'a> Visitor for Apply<'a> {
96  fn visit_node(&mut self, node: &mut Node) -> NodeAction {
97    match node {
98      Node::Image(i) => {
99        let span = i.span.clone();
100        self.rewrite_slot(&mut i.src, span, "image");
101      },
102      Node::Link(l) if l.href.starts_with("./") || l.href.starts_with("../") => {
103        let span = l.span.clone();
104        self.rewrite_slot(&mut l.href, span, "link");
105      },
106      _ => {},
107    }
108    NodeAction::Keep
109  }
110}
111
112impl CopyLinkedFiles {
113  fn publish(&self, raw: &str) -> Outcome {
114    if raw.starts_with("http://")
115      || raw.starts_with("https://")
116      || raw.starts_with("//")
117      || raw.starts_with('/')
118      || raw.starts_with('#')
119    {
120      return Outcome::Skip;
121    }
122    {
123      let map = self.map.lock().unwrap();
124      if let Some(u) = map.get(raw) {
125        return Outcome::Published(u.clone());
126      }
127    }
128    let path = self.source_dir.join(raw);
129    let bytes = match std::fs::read(&path) {
130      Ok(b) => b,
131      Err(e) => return Outcome::SourceMissing(path, e),
132    };
133    let hash = blake3::hash(&bytes);
134    let hash8 = &hash.to_hex().to_string()[..8];
135    let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("asset");
136    let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("bin");
137    let filename = self.name_template.replace("[name]", stem).replace("[hash:8]", hash8).replace("[ext]", ext);
138    let dest = self.assets_dir.join(&filename);
139    if let Err(e) = std::fs::create_dir_all(&self.assets_dir) {
140      return Outcome::CopyFailed(self.assets_dir.clone(), e);
141    }
142    if !dest.exists()
143      && let Err(e) = std::fs::write(&dest, &bytes)
144    {
145      return Outcome::CopyFailed(dest, e);
146    }
147    let mut url = self.base_url.clone();
148    if !url.ends_with('/') {
149      url.push('/');
150    }
151    url.push_str(&filename);
152    self.map.lock().unwrap().insert(raw.to_string(), url.clone());
153    Outcome::Published(url)
154  }
155}