Skip to main content

typst_pack/
manifest.rs

1//! The pack manifest stored as `typst-pack.toml` inside the archive.
2
3use std::collections::BTreeSet;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7use typst::syntax::VirtualPath;
8use typst::syntax::package::PackageSpec;
9
10/// The archive entry name of the manifest.
11pub const MANIFEST_PATH: &str = "typst-pack.toml";
12
13/// The pack format version this crate reads and writes.
14pub const FORMAT_VERSION: u32 = 1;
15
16/// The parsed contents of `typst-pack.toml`.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case", deny_unknown_fields)]
19pub struct Manifest {
20    /// The pack format version. Readers must reject versions they don't know.
21    pub format_version: u32,
22    /// The packed Typst project.
23    pub project: ProjectManifest,
24    /// Package dependencies observed while creating the pack.
25    #[serde(default, skip_serializing_if = "PackagesManifest::is_empty")]
26    pub packages: PackagesManifest,
27    /// Fonts embedded in the pack.
28    #[serde(default, skip_serializing_if = "Vec::is_empty")]
29    pub fonts: Vec<FontManifest>,
30    /// Optional descriptive metadata about the packed project.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub metadata: Option<Metadata>,
33}
34
35/// The `[project]` section.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case", deny_unknown_fields)]
38pub struct ProjectManifest {
39    /// The root-relative path of the entrypoint file, e.g. `main.typ`.
40    pub entrypoint: String,
41    /// Non-source project resources supplied externally at compilation time.
42    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
43    pub external_resources: BTreeSet<String>,
44}
45
46/// The `[packages]` section.
47#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "kebab-case", deny_unknown_fields)]
49pub struct PackagesManifest {
50    /// Exact specs of packages whose files are stored inside the pack.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub vendored: Vec<String>,
53    /// Exact specs of observed dependencies that are *not* stored inside the
54    /// pack and must be resolved from a package directory, cache, or registry
55    /// when compiling.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub external: Vec<String>,
58}
59
60impl PackagesManifest {
61    fn is_empty(&self) -> bool {
62        self.vendored.is_empty() && self.external.is_empty()
63    }
64}
65
66/// One `[[fonts]]` entry.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case", deny_unknown_fields)]
69pub struct FontManifest {
70    /// The archive entry holding the font data, e.g. `fonts/dejavu-sans.ttf`.
71    pub path: String,
72    /// The face index inside the font file (non-zero for collections).
73    #[serde(default, skip_serializing_if = "is_zero")]
74    pub index: u32,
75    /// Family names provided by this face, informational only.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub families: Vec<String>,
78}
79
80fn is_zero(index: &u32) -> bool {
81    *index == 0
82}
83
84/// The optional `[metadata]` section.
85#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
86#[serde(rename_all = "kebab-case", deny_unknown_fields)]
87pub struct Metadata {
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub name: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub description: Option<String>,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub authors: Vec<String>,
94}
95
96/// A manifest that could not be accepted.
97#[derive(Debug, thiserror::Error)]
98pub enum ManifestError {
99    #[error("failed to parse manifest: {0}")]
100    Parse(#[from] toml::de::Error),
101    #[error("unsupported pack format version {0} (this reader supports up to {FORMAT_VERSION})")]
102    UnsupportedVersion(u32),
103    #[error("invalid entrypoint path `{path}`: {message}")]
104    InvalidEntrypoint { path: String, message: String },
105    #[error("invalid external project resource path `{path}`: {message}")]
106    InvalidExternalResource { path: String, message: String },
107    #[error("invalid package spec `{spec}`: {message}")]
108    InvalidPackageSpec { spec: String, message: String },
109    #[error("invalid font path `{path}`: {message}")]
110    InvalidFontPath { path: String, message: String },
111}
112
113impl Manifest {
114    /// Parses and validates a manifest from TOML text.
115    pub fn from_toml(text: &str) -> Result<Self, ManifestError> {
116        let mut manifest: Manifest = toml::from_str(text)?;
117        manifest.normalize_external_resources();
118        manifest.validate()?;
119        Ok(manifest)
120    }
121
122    /// Serializes the manifest to TOML text.
123    pub fn to_toml(&self) -> String {
124        let mut manifest = self.clone();
125        manifest.normalize_external_resources();
126        toml::to_string_pretty(&manifest).expect("manifest is always serializable")
127    }
128
129    /// Checks internal consistency of the manifest.
130    pub fn validate(&self) -> Result<(), ManifestError> {
131        if self.format_version > FORMAT_VERSION {
132            return Err(ManifestError::UnsupportedVersion(self.format_version));
133        }
134        self.entrypoint()?;
135        for path in &self.project.external_resources {
136            let virtual_path =
137                VirtualPath::new(path).map_err(|err| ManifestError::InvalidExternalResource {
138                    path: path.clone(),
139                    message: err.to_string(),
140                })?;
141            let canonical = virtual_path.get_without_slash();
142            if canonical != path {
143                return Err(ManifestError::InvalidExternalResource {
144                    path: path.clone(),
145                    message: format!("path is not canonical; use `{canonical}`"),
146                });
147            }
148        }
149        for spec in self.packages.vendored.iter().chain(&self.packages.external) {
150            parse_spec(spec)?;
151        }
152        for font in &self.fonts {
153            if let Err(err) = VirtualPath::new(&font.path) {
154                return Err(ManifestError::InvalidFontPath {
155                    path: font.path.clone(),
156                    message: err.to_string(),
157                });
158            }
159        }
160        Ok(())
161    }
162
163    /// The entrypoint as a validated virtual path.
164    pub fn entrypoint(&self) -> Result<VirtualPath, ManifestError> {
165        VirtualPath::new(&self.project.entrypoint).map_err(|err| ManifestError::InvalidEntrypoint {
166            path: self.project.entrypoint.clone(),
167            message: err.to_string(),
168        })
169    }
170
171    /// The vendored package specs, parsed.
172    pub fn vendored_packages(&self) -> Result<Vec<PackageSpec>, ManifestError> {
173        self.packages
174            .vendored
175            .iter()
176            .map(|spec| parse_spec(spec))
177            .collect()
178    }
179
180    /// The external (non-vendored) package specs, parsed.
181    pub fn external_packages(&self) -> Result<Vec<PackageSpec>, ManifestError> {
182        self.packages
183            .external
184            .iter()
185            .map(|spec| parse_spec(spec))
186            .collect()
187    }
188
189    fn normalize_external_resources(&mut self) {
190        self.project.external_resources = self
191            .project
192            .external_resources
193            .iter()
194            .map(|path| {
195                VirtualPath::new(path)
196                    .map(|path| path.get_without_slash().to_owned())
197                    .unwrap_or_else(|_| path.clone())
198            })
199            .collect();
200    }
201}
202
203fn parse_spec(spec: &str) -> Result<PackageSpec, ManifestError> {
204    PackageSpec::from_str(spec).map_err(|err| ManifestError::InvalidPackageSpec {
205        spec: spec.to_owned(),
206        message: err.to_string(),
207    })
208}