1use std::collections::BTreeSet;
4use std::str::FromStr;
5
6use serde::{Deserialize, Serialize};
7use typst::syntax::VirtualPath;
8use typst::syntax::package::PackageSpec;
9
10pub const MANIFEST_PATH: &str = "typst-pack.toml";
12
13pub const FORMAT_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(rename_all = "kebab-case", deny_unknown_fields)]
19pub struct Manifest {
20 pub format_version: u32,
22 pub project: ProjectManifest,
24 #[serde(default, skip_serializing_if = "PackagesManifest::is_empty")]
26 pub packages: PackagesManifest,
27 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub fonts: Vec<FontManifest>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub metadata: Option<Metadata>,
33}
34
35#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case", deny_unknown_fields)]
38pub struct ProjectManifest {
39 pub entrypoint: String,
41 #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
43 pub external_resources: BTreeSet<String>,
44}
45
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
48#[serde(rename_all = "kebab-case", deny_unknown_fields)]
49pub struct PackagesManifest {
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
52 pub vendored: Vec<String>,
53 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(rename_all = "kebab-case", deny_unknown_fields)]
69pub struct FontManifest {
70 pub path: String,
72 #[serde(default, skip_serializing_if = "is_zero")]
74 pub index: u32,
75 #[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#[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#[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 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 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 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 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 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 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}