Skip to main content

dmc_transform/builtin/
copy_linked_files.rs

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