| 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 |
|