Skip to main content

max / makeover-build

8.2 KB · 215 lines History Blame Raw
1 //! Build-script support for the make-family design system.
2 //!
3 //! <!-- wiki: makeover-geometry -->
4 //!
5 //! Every consumer materialises the same generated files from a `build.rs`, and
6 //! until now every consumer wrote that code itself. GoingsOn and Balanced
7 //! Breakfast grew byte-identical copies of the theme materialiser during the
8 //! makeover-geometry adoption, and the layout stylesheet would have been the
9 //! third and fourth copies. This is that code, once.
10 //!
11 //! # What is deliberately not here
12 //!
13 //! The geometry emitter. GoingsOn and Balanced Breakfast do *not* agree on it:
14 //! GO scopes the touch preset to a `ui-mode-mobile` class set by a bootstrap
15 //! script, BB hangs it off `@media (hover: none)`, and audiofiles has no
16 //! switch at all. Extracting it would mean picking one of those policies by
17 //! accident, inside a shared crate, without anyone deciding. Density selection
18 //! is unowned and has its own task; the geometry half lands after it.
19 //!
20 //! Taking the identical half now and leaving the contested half alone is the
21 //! whole point: a helper crate should record agreement, not manufacture it.
22 //!
23 //! # Why these files are generated rather than checked in
24 //!
25 //! Tauri's resource globs are read by its CLI against the crate directory, so
26 //! they cannot point into a registry checkout or `OUT_DIR`. Materialising into
27 //! the crate keeps the source crate authoritative without vendoring a second
28 //! copy that drifts. Every path written here is expected to be gitignored.
29
30 #![forbid(unsafe_code)]
31
32 use std::path::Path;
33
34 /// Re-exported so a consumer's `build.rs` needs one dependency rather than
35 /// three. Nothing here wraps it; the emitter's options are the emitter's.
36 pub use makeover_webview::Emit;
37
38 /// Write the themes `makeover` ships into `dir`, as `<id>.toml`.
39 ///
40 /// Clears stale `.toml` files first, so a theme removed or renamed upstream
41 /// does not linger in the bundle from an earlier build. That detail is the
42 /// reason this is worth sharing rather than retyping: it is easy to omit and
43 /// its absence shows up as a theme that will not go away.
44 ///
45 /// # Panics
46 ///
47 /// If the directory cannot be created, read, or written. A build script has
48 /// nowhere useful to return an error to, and a half-materialised theme set is
49 /// worse than a failed build.
50 pub fn themes(dir: impl AsRef<Path>) {
51 let dir = dir.as_ref();
52 std::fs::create_dir_all(dir).expect("create themes dir");
53
54 for entry in std::fs::read_dir(dir).expect("read themes dir").flatten() {
55 let path = entry.path();
56 if path.extension().is_some_and(|e| e == "toml") {
57 std::fs::remove_file(&path).expect("remove stale theme");
58 }
59 }
60
61 for (id, source) in makeover::embedded_themes() {
62 std::fs::write(dir.join(format!("{id}.toml")), source).expect("write theme");
63 }
64 }
65
66 /// Write `makeover-webview`'s component stylesheet to `path`.
67 ///
68 /// Baked at build time rather than applied from JS the way the intent layer
69 /// is, because composition never changes at runtime: no theme may reach it, so
70 /// there is nothing to re-apply and no second pass over `:root` to pay for on
71 /// load.
72 ///
73 /// # Panics
74 ///
75 /// If the file cannot be written.
76 pub fn layout_css(path: impl AsRef<Path>, opts: &makeover_webview::Emit) {
77 std::fs::write(path, makeover_webview::stylesheet(opts)).expect("write layout css");
78 }
79
80 /// Write `makeover-geometry`'s spacing layer, with its canonical density
81 /// selection, to `path`.
82 ///
83 /// The policy is the crate's, not this one's: touch hangs off
84 /// `(hover: none), (pointer: coarse)` because density is a capability rather
85 /// than a device or a width, and `explicit_touch` names a selector an app sets
86 /// when the user has chosen. See [`makeover_geometry::density_css`]. All this
87 /// adds is the generated-file banner and the write.
88 ///
89 /// # Panics
90 ///
91 /// If the file cannot be written.
92 pub fn geometry_css(path: impl AsRef<Path>, explicit_touch: Option<&str>) {
93 let mut css = String::from(
94 "/* Generated by makeover-build from makeover-geometry. Do not edit.\n \
95 Spacing is named by relationship, not by size. Touch density is a\n \
96 capability question: a narrow desktop window still has a pointer, a\n \
97 full-width tablet still has a finger. */\n",
98 );
99 css.push_str(&makeover_geometry::density_css(explicit_touch));
100 std::fs::write(path, css).expect("write geometry css");
101 }
102
103 /// All three generated files at the layout every Tauri consumer already uses:
104 /// `themes/` beside the manifest, and `frontend/css/{geometry,layout}.css`
105 /// under it.
106 ///
107 /// Pass `env!("CARGO_MANIFEST_DIR")`. Consumers that want different paths call
108 /// [`themes`] and [`layout_css`] directly.
109 ///
110 /// # Panics
111 ///
112 /// If either file cannot be written.
113 pub fn tauri_frontend(
114 manifest_dir: impl AsRef<Path>,
115 opts: &makeover_webview::Emit,
116 explicit_touch: Option<&str>,
117 ) {
118 let root = manifest_dir.as_ref();
119 let css = root.join("frontend").join("css");
120 themes(root.join("themes"));
121 geometry_css(css.join("geometry.css"), explicit_touch);
122 layout_css(css.join("layout.css"), opts);
123 }
124
125 #[cfg(test)]
126 mod tests {
127 use super::*;
128
129 /// A scratch directory keyed by process id, so a parallel test run does
130 /// not collide. No timestamp: the pid is enough and is deterministic
131 /// within a run.
132 fn scratch(name: &str) -> std::path::PathBuf {
133 let dir =
134 std::env::temp_dir().join(format!("makeover-build-{}-{name}", std::process::id()));
135 let _ = std::fs::remove_dir_all(&dir);
136 std::fs::create_dir_all(&dir).expect("create scratch");
137 dir
138 }
139
140 #[test]
141 fn themes_are_written_one_file_per_id() {
142 let dir = scratch("themes");
143 themes(&dir);
144 let count = std::fs::read_dir(&dir).unwrap().count();
145 assert_eq!(count, makeover::embedded_themes().count());
146 assert!(count > 0, "makeover ships no themes?");
147 }
148
149 #[test]
150 fn a_theme_removed_upstream_does_not_linger() {
151 // The detail that makes this worth sharing rather than retyping.
152 let dir = scratch("stale");
153 std::fs::write(dir.join("gone-upstream.toml"), "# stale").unwrap();
154 themes(&dir);
155 assert!(!dir.join("gone-upstream.toml").exists());
156 }
157
158 #[test]
159 fn a_non_theme_file_is_left_alone() {
160 // Only .toml is cleared, so a README or a .gitignore in the bundle
161 // directory survives a rebuild.
162 let dir = scratch("keep");
163 std::fs::write(dir.join("README.md"), "not a theme").unwrap();
164 themes(&dir);
165 assert!(dir.join("README.md").exists());
166 }
167
168 #[test]
169 fn the_stylesheet_lands_and_names_no_colour() {
170 let dir = scratch("css");
171 let path = dir.join("layout.css");
172 layout_css(&path, &makeover_webview::Emit::default());
173 let css = std::fs::read_to_string(&path).unwrap();
174 assert!(css.contains("--bevel-raised"));
175 assert!(
176 !css.contains('#'),
177 "a colour literal reached a build output"
178 );
179 }
180
181 #[test]
182 fn the_geometry_file_carries_the_crates_policy_and_a_banner() {
183 // The policy itself is tested in makeover-geometry. What is this
184 // crate's job is that the banner is there and the policy reached the
185 // file at all.
186 let dir = scratch("geometry");
187 let path = dir.join("geometry.css");
188 geometry_css(&path, Some(".ui-mode-mobile"));
189 let css = std::fs::read_to_string(&path).unwrap();
190 assert!(css.starts_with("/* Generated by makeover-build"));
191 assert!(css.contains("@media (hover: none), (pointer: coarse)"));
192 assert!(css.contains(".ui-mode-mobile"));
193 }
194
195 #[test]
196 fn the_tauri_layout_puts_all_three_where_the_apps_look() {
197 let root = scratch("tauri");
198 std::fs::create_dir_all(root.join("frontend").join("css")).unwrap();
199 tauri_frontend(&root, &makeover_webview::Emit::default(), None);
200 assert!(
201 root.join("frontend")
202 .join("css")
203 .join("geometry.css")
204 .exists()
205 );
206 assert!(
207 root.join("frontend")
208 .join("css")
209 .join("layout.css")
210 .exists()
211 );
212 assert!(root.join("themes").is_dir());
213 }
214 }
215