PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.1
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.1
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-themes / compile.php

compile.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 1.1.1, at includes/desktop-themes/compile.php

331 lines 11.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Desktop-theme CSS compiler.
4 *
5 * Turns a **sanitized** manifest into a stylesheet made of custom-
6 * property declarations plus, when the theme bundles fonts, a block
7 * of `@font-face` rules. No author string ever becomes a selector,
8 * a property name, or a `url()` — the compiler writes every `url()`
9 * itself from a resolved, `rawurlencode`d path.
10 *
11 * ## The one at-rule we generate
12 *
13 * `@font-face` is the single exception to "custom properties only",
14 * and it is generated ENTIRELY by this file: the literal `@font-face`
15 * text, every descriptor name, and every `url()` come from here. The
16 * author contributes two constrained substrings — a family name
17 * matching `^[A-Za-z0-9][A-Za-z0-9 _-]{0,63}$` (so double-quoting it
18 * is airtight) and a file path that already passed the font-extension
19 * allowlist and a containment check. Everything else is a closed
20 * enum. See `openstation_sanitize_desktop_theme_fonts()`.
21 *
22 * `@font-face` is deliberately NOT scoped to the theme's selector —
23 * at-rules cannot be nested inside one, and there is nothing to
24 * scope: a face that nothing references costs a name in a table and
25 * no bytes on the wire. Fonts load when a token points at them.
26 *
27 * ## Textures are table-driven
28 *
29 * The slot => custom-property mapping lives in
30 * {@see openstation_desktop_theme_texture_slots()}, not here. This
31 * file knows how to turn `image` and `border-image` descriptors into
32 * declarations; it does not know that `TITLEBAR` exists. That is what
33 * lets a plugin texture a surface the framework has never heard of.
34 *
35 * ## Why the selector is doubled
36 *
37 * Output is scoped to BOTH:
38 *
39 * .os-shell[data-os-desktop-theme="<slug>"]
40 * body.os-desktop-theme-<slug>
41 *
42 * The shell root covers the desktop, dock, and every window. But
43 * toasts, confirm dialogs, tooltips, and context menus mount on
44 * `document.body`, OUTSIDE `#os-shell` — a shell-only
45 * scope would leave those surfaces on the default palette while
46 * everything around them reskinned.
47 *
48 * ## Why the dependency chain matters
49 *
50 * Both selectors weigh (0,2,0) — the same specificity as the
51 * per-admin-color-scheme blocks in `variables.css`
52 * (`.os-shell[data-os-scheme="…"]`). A
53 * specificity tie is broken by source order, so the compiled theme
54 * sheet MUST print after `variables.css`. That is enforced by
55 * registering the style handle with `os-variables` as a
56 * dependency (see `openstation_enqueue_desktop_theme_style()`);
57 * do not remove that dependency, and do not "simplify" the selector
58 * to a single class — it would lose the tie.
59 *
60 * @package OpenStation
61 */
62
63 defined( 'ABSPATH' ) || exit;
64
65 /**
66 * Resolve one manifest asset reference to an absolute URL.
67 *
68 * Code-registered themes carry absolute http(s) URLs (already
69 * validated by the URL asset resolver). Uploaded themes carry
70 * theme-relative paths, which get joined to the theme's base URL
71 * with every segment `rawurlencode`d — that encoding is also what
72 * guarantees the result can never contain a quote, paren, or
73 * whitespace that would break out of the `url("…")` wrapper.
74 *
75 * @internal
76 *
77 * ## Why the `?ver=` matters
78 *
79 * Re-uploading a theme with the same id is an UPDATE, by design. The
80 * files change but their paths do not, so without a version query
81 * every browser that had seen the old theme keeps serving its cached
82 * icons and textures — a theme author fixes their artwork, re-uploads,
83 * and sees no change. Stamping with the install timestamp gives each
84 * upload its own URL space.
85 *
86 * Absolute URLs (code-registered themes) are left alone: those assets
87 * belong to a plugin that owns its own cache-busting, and appending
88 * to a URL that may already carry a query is not ours to do.
89 *
90 * @param string $ref Manifest reference (relative path or URL).
91 * @param string $base_url Theme base URL, no trailing slash.
92 * @param string $version Cache-buster for relative refs (the theme's
93 * `installedAt`). Omit to skip versioning.
94 * @return string Absolute URL, or `''` when unusable.
95 */
96 function openstation_desktop_theme_asset_url( $ref, $base_url, $version = '' ) {
97 $ref = (string) $ref;
98 if ( '' === $ref ) {
99 return '';
100 }
101 if ( preg_match( '~^https?://~i', $ref ) ) {
102 return $ref;
103 }
104 $base = untrailingslashit( (string) $base_url );
105 if ( '' === $base ) {
106 return '';
107 }
108 $segments = array_map( 'rawurlencode', explode( '/', $ref ) );
109 $url = $base . '/' . implode( '/', $segments );
110 $version = (string) $version;
111 return '' !== $version ? $url . '?ver=' . rawurlencode( $version ) : $url;
112 }
113
114 /**
115 * Wrap a resolved asset URL in a CSS `url()` function.
116 *
117 * @internal
118 *
119 * @param string $url Absolute URL.
120 * @return string
121 */
122 function openstation_desktop_theme_css_url( $url ) {
123 return 'url("' . $url . '")';
124 }
125
126 /**
127 * Compile a sanitized manifest into a scoped stylesheet.
128 *
129 * Deterministic: the same manifest + slug + base URL always produce
130 * byte-identical output. Declarations are key-sorted, so authoring
131 * order in `theme.json` is irrelevant for tokens and textures.
132 * `@font-face` rules are the exception and keep the author's order,
133 * because for `unicodeRange`-subsetted faces of one family that
134 * order is semantic.
135 *
136 * @param array $manifest Sanitized manifest from
137 * {@see openstation_sanitize_desktop_theme_manifest()}.
138 * @param string $slug Storage slug.
139 * @param string $base_url Theme base URL (no trailing slash). May be
140 * empty for code themes whose assets are
141 * absolute URLs.
142 * @param string $version Cache-buster appended to generated asset
143 * URLs — see
144 * {@see openstation_desktop_theme_asset_url()}.
145 * The stylesheet itself is versioned by the
146 * enqueue, but the textures it references are
147 * not, and a re-upload must invalidate both.
148 * @return string Stylesheet text. `''` when the theme sets nothing.
149 */
150 function openstation_desktop_theme_compile_css( $manifest, $slug, $base_url = '', $version = '' ) {
151 $slug = sanitize_key( (string) $slug );
152 if ( '' === $slug || ! is_array( $manifest ) ) {
153 return '';
154 }
155
156 $declarations = array();
157
158 // --- Design tokens. ---
159 $tokens = isset( $manifest['tokens'] ) && is_array( $manifest['tokens'] )
160 ? $manifest['tokens']
161 : array();
162 ksort( $tokens );
163 foreach ( $tokens as $property => $value ) {
164 $declarations[] = "\t{$property}: {$value};";
165 }
166
167 // --- Textures. ---
168 $textures = isset( $manifest['textures'] ) && is_array( $manifest['textures'] )
169 ? $manifest['textures']
170 : array();
171 ksort( $textures );
172
173 $slots = openstation_desktop_theme_texture_slots();
174 // Slots that share one size token (the four window corners).
175 // First declared wins; `ksort` above makes "first" deterministic.
176 $size_groups = array();
177
178 foreach ( $textures as $slot => $entry ) {
179 if ( ! is_array( $entry ) || empty( $entry['path'] ) ) {
180 continue;
181 }
182 $definition = isset( $slots[ $slot ] ) && is_array( $slots[ $slot ] ) ? $slots[ $slot ] : null;
183 $prop = $definition && ! empty( $definition['prop'] ) ? (string) $definition['prop'] : '';
184 if ( '' === $prop ) {
185 // A slot the allowlist accepts but gives no property to
186 // write. Nothing to emit — see the filter docblock.
187 continue;
188 }
189 $url = openstation_desktop_theme_asset_url( $entry['path'], $base_url, $version );
190 if ( '' === $url ) {
191 continue;
192 }
193 $css_url = openstation_desktop_theme_css_url( $url );
194 $type = isset( $definition['type'] ) ? (string) $definition['type'] : 'image';
195
196 if ( 'border-image' === $type ) {
197 $declarations[] = "\t{$prop}-source: {$css_url};";
198 foreach ( array( 'slice', 'width', 'repeat' ) as $key ) {
199 if ( ! empty( $entry[ $key ] ) ) {
200 $declarations[] = "\t{$prop}-{$key}: {$entry[ $key ]};";
201 }
202 }
203 continue;
204 }
205
206 $declarations[] = "\t{$prop}: {$css_url};";
207
208 $size_group = ! empty( $definition['sizeGroup'] ) ? (string) $definition['sizeGroup'] : '';
209 if ( '' !== $size_group ) {
210 if ( ! isset( $size_groups[ $size_group ] ) && ! empty( $entry['size'] ) ) {
211 $size_groups[ $size_group ] = (string) $entry['size'];
212 }
213 continue;
214 }
215
216 // `companions => false` marks a variant slot (TITLEBAR_FOCUSED)
217 // that inherits its base slot's repeat + size.
218 if ( isset( $definition['companions'] ) && false === $definition['companions'] ) {
219 continue;
220 }
221 if ( ! empty( $entry['repeat'] ) ) {
222 $declarations[] = "\t{$prop}-repeat: {$entry['repeat']};";
223 }
224 if ( ! empty( $entry['size'] ) ) {
225 $declarations[] = "\t{$prop}-size: {$entry['size']};";
226 }
227 if ( ! empty( $entry['position'] ) ) {
228 $declarations[] = "\t{$prop}-position: {$entry['position']};";
229 }
230 }
231
232 foreach ( $size_groups as $property => $value ) {
233 $declarations[] = "\t{$property}: {$value};";
234 }
235
236 $font_faces = openstation_desktop_theme_compile_font_faces( $manifest, $base_url, $version );
237
238 if ( empty( $declarations ) ) {
239 return '' === $font_faces
240 ? ''
241 : "/* OpenStation desktop theme: {$slug} — compiled, do not edit. */\n" . $font_faces;
242 }
243
244 // Re-sort so the emitted block is stable regardless of the order
245 // the loops above happened to append in.
246 sort( $declarations, SORT_STRING );
247
248 $selector = '.os-shell[data-os-desktop-theme="' . $slug . '"],' . "\n"
249 . 'body.os-desktop-theme-' . $slug;
250
251 return "/* OpenStation desktop theme: {$slug} — compiled, do not edit. */\n"
252 . $font_faces
253 . $selector . " {\n"
254 . implode( "\n", $declarations ) . "\n"
255 . "}\n";
256 }
257
258 /**
259 * Compile a sanitized manifest's `fonts` block into `@font-face`
260 * rules.
261 *
262 * Emitted before the token rule so a `font-family` declaration in
263 * the same sheet can name a face defined above it — not that CSS
264 * requires the order, but reading top-to-bottom should show the
265 * faces before their first use.
266 *
267 * Descriptor order inside each rule is fixed by this function, and
268 * faces keep the author's declaration order (which is meaningful:
269 * `unicodeRange`-subsetted faces of the same family are matched in
270 * source order).
271 *
272 * @internal
273 *
274 * @param array $manifest Sanitized manifest.
275 * @param string $base_url Theme base URL (no trailing slash).
276 * @param string $version Cache-buster for relative refs.
277 * @return string Stylesheet fragment, or `''` when there are no
278 * usable faces.
279 */
280 function openstation_desktop_theme_compile_font_faces( $manifest, $base_url = '', $version = '' ) {
281 $fonts = isset( $manifest['fonts'] ) && is_array( $manifest['fonts'] )
282 ? $manifest['fonts']
283 : array();
284 if ( empty( $fonts ) ) {
285 return '';
286 }
287
288 $rules = array();
289 foreach ( $fonts as $face ) {
290 if ( ! is_array( $face ) || empty( $face['family'] ) || empty( $face['src'] ) || ! is_array( $face['src'] ) ) {
291 continue;
292 }
293
294 $sources = array();
295 foreach ( $face['src'] as $source ) {
296 if ( ! is_array( $source ) || empty( $source['path'] ) || empty( $source['format'] ) ) {
297 continue;
298 }
299 $url = openstation_desktop_theme_asset_url( $source['path'], $base_url, $version );
300 if ( '' === $url ) {
301 continue;
302 }
303 $sources[] = openstation_desktop_theme_css_url( $url )
304 . ' format("' . $source['format'] . '")';
305 }
306 if ( empty( $sources ) ) {
307 continue;
308 }
309
310 // The family name is quoted, and the sanitizer guarantees it
311 // contains nothing that could close the quote.
312 $lines = array( "\tfont-family: \"{$face['family']}\";" );
313 foreach ( array(
314 'style' => 'font-style',
315 'weight' => 'font-weight',
316 'stretch' => 'font-stretch',
317 'display' => 'font-display',
318 'unicodeRange' => 'unicode-range',
319 ) as $key => $descriptor ) {
320 if ( ! empty( $face[ $key ] ) ) {
321 $lines[] = "\t{$descriptor}: {$face[ $key ]};";
322 }
323 }
324 $lines[] = "\tsrc: " . implode( ",\n\t\t", $sources ) . ';';
325
326 $rules[] = "@font-face {\n" . implode( "\n", $lines ) . "\n}\n";
327 }
328
329 return empty( $rules ) ? '' : implode( '', $rules );
330 }
331