@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 * Tracks and resolves dependencies the page declares with
5 * @{function:require_celerity_resource}, and then builds appropriate HTML or
6 * Ajax responses.
7 */
8final class CelerityStaticResourceResponse extends Phobject {
9
10 private $symbols = array();
11 private $needsResolve = true;
12 private $resolved;
13 private $packaged;
14 private $metadata = array();
15 private $metadataBlock = 0;
16 private $metadataLocked;
17 private $behaviors = array();
18 private $hasRendered = array();
19 private $postprocessorKey;
20 private $contentSecurityPolicyURIs = array();
21
22 public function __construct() {
23 if (isset($_REQUEST['__metablock__'])) {
24 $this->metadataBlock = (int)$_REQUEST['__metablock__'];
25 }
26 }
27
28 public function addMetadata($metadata) {
29 if ($this->metadataLocked) {
30 throw new Exception(
31 pht(
32 'Attempting to add more metadata after metadata has been '.
33 'locked.'));
34 }
35
36 $id = count($this->metadata);
37 $this->metadata[$id] = $metadata;
38 return $this->metadataBlock.'_'.$id;
39 }
40
41 public function addContentSecurityPolicyURI($kind, $uri) {
42 $this->contentSecurityPolicyURIs[$kind][] = $uri;
43 return $this;
44 }
45
46 public function getContentSecurityPolicyURIMap() {
47 return $this->contentSecurityPolicyURIs;
48 }
49
50 public function getMetadataBlock() {
51 return $this->metadataBlock;
52 }
53
54 public function setPostprocessorKey($postprocessor_key) {
55 $this->postprocessorKey = $postprocessor_key;
56 return $this;
57 }
58
59 public function getPostprocessorKey() {
60 return $this->postprocessorKey;
61 }
62
63 /**
64 * Register a behavior for initialization.
65 *
66 * NOTE: If `$config` is empty, a behavior will execute only once even if it
67 * is initialized multiple times. If `$config` is nonempty, the behavior will
68 * be invoked once for each configuration.
69 */
70 public function initBehavior(
71 $behavior,
72 array $config = array(),
73 $source_name = null) {
74
75 $this->requireResource('javelin-behavior-'.$behavior, $source_name);
76
77 if (empty($this->behaviors[$behavior])) {
78 $this->behaviors[$behavior] = array();
79 }
80
81 if ($config) {
82 $this->behaviors[$behavior][] = $config;
83 }
84
85 return $this;
86 }
87
88 public function requireResource($symbol, $source_name) {
89 if (isset($this->symbols[$source_name][$symbol])) {
90 return $this;
91 }
92
93 // Verify that the resource exists.
94 $map = CelerityResourceMap::getNamedInstance($source_name);
95 $name = $map->getResourceNameForSymbol($symbol);
96 if ($name === null) {
97 throw new Exception(
98 pht(
99 'No resource with symbol "%s" exists in source "%s"!',
100 $symbol,
101 $source_name));
102 }
103
104 $this->symbols[$source_name][$symbol] = true;
105 $this->needsResolve = true;
106
107 return $this;
108 }
109
110 private function resolveResources() {
111 if ($this->needsResolve) {
112 $this->packaged = array();
113 foreach ($this->symbols as $source_name => $symbols_map) {
114 $symbols = array_keys($symbols_map);
115
116 $map = CelerityResourceMap::getNamedInstance($source_name);
117 $packaged = $map->getPackagedNamesForSymbols($symbols);
118
119 $this->packaged[$source_name] = $packaged;
120 }
121 $this->needsResolve = false;
122 }
123 return $this;
124 }
125
126 public function renderSingleResource($symbol, $source_name) {
127 $map = CelerityResourceMap::getNamedInstance($source_name);
128 $packaged = $map->getPackagedNamesForSymbols(array($symbol));
129 return $this->renderPackagedResources($map, $packaged);
130 }
131
132 public function renderResourcesOfType($type) {
133 $this->resolveResources();
134
135 $result = array();
136 foreach ($this->packaged as $source_name => $resource_names) {
137 $map = CelerityResourceMap::getNamedInstance($source_name);
138
139 $resources_of_type = array();
140 foreach ($resource_names as $resource_name) {
141 $resource_type = $map->getResourceTypeForName($resource_name);
142 if ($resource_type == $type) {
143 $resources_of_type[] = $resource_name;
144 }
145 }
146
147 $result[] = $this->renderPackagedResources($map, $resources_of_type);
148 }
149
150 return phutil_implode_html('', $result);
151 }
152
153 private function renderPackagedResources(
154 CelerityResourceMap $map,
155 array $resources) {
156
157 $output = array();
158 foreach ($resources as $name) {
159 if (isset($this->hasRendered[$name])) {
160 continue;
161 }
162 $this->hasRendered[$name] = true;
163
164 $output[] = $this->renderResource($map, $name);
165 }
166
167 return $output;
168 }
169
170 private function renderResource(
171 CelerityResourceMap $map,
172 $name) {
173
174 $uri = $this->getURI($map, $name);
175 $type = $map->getResourceTypeForName($name);
176
177 $multimeter = MultimeterControl::getInstance();
178 if ($multimeter) {
179 $event_type = MultimeterEvent::TYPE_STATIC_RESOURCE;
180 $multimeter->newEvent($event_type, 'rsrc.'.$name, 1);
181 }
182
183 switch ($type) {
184 case 'css':
185 return phutil_tag(
186 'link',
187 array(
188 'rel' => 'stylesheet',
189 'type' => 'text/css',
190 'href' => $uri,
191 ));
192 case 'js':
193 return phutil_tag(
194 'script',
195 array(
196 'type' => 'text/javascript',
197 'src' => $uri,
198 ),
199 '');
200 }
201
202 throw new Exception(
203 pht(
204 'Unable to render resource "%s", which has unknown type "%s".',
205 $name,
206 $type));
207 }
208
209 public function renderHTMLFooter($is_frameable) {
210 $this->metadataLocked = true;
211
212 $merge_data = array(
213 'block' => $this->metadataBlock,
214 'data' => $this->metadata,
215 );
216 $this->metadata = array();
217
218 $behavior_lists = array();
219 if ($this->behaviors) {
220 $behaviors = $this->behaviors;
221 $this->behaviors = array();
222
223 $higher_priority_names = array(
224 'refresh-csrf',
225 'aphront-basic-tokenizer',
226 'dark-console',
227 'history-install',
228 );
229
230 $higher_priority_behaviors = array_select_keys(
231 $behaviors,
232 $higher_priority_names);
233
234 foreach ($higher_priority_names as $name) {
235 unset($behaviors[$name]);
236 }
237
238 $behavior_groups = array(
239 $higher_priority_behaviors,
240 $behaviors,
241 );
242
243 foreach ($behavior_groups as $group) {
244 if (!$group) {
245 continue;
246 }
247 $behavior_lists[] = $group;
248 }
249 }
250
251 $initializers = array();
252
253 // Even if there is no metadata on the page, Javelin uses the mergeData()
254 // call to start dispatching the event queue, so we always want to include
255 // this initializer.
256 $initializers[] = array(
257 'kind' => 'merge',
258 'data' => $merge_data,
259 );
260
261 foreach ($behavior_lists as $behavior_list) {
262 $initializers[] = array(
263 'kind' => 'behaviors',
264 'data' => $behavior_list,
265 );
266 }
267
268 if ($is_frameable) {
269 $initializers[] = array(
270 'data' => 'frameable',
271 'kind' => (bool)$is_frameable,
272 );
273 }
274
275 $tags = array();
276 foreach ($initializers as $initializer) {
277 $data = $initializer['data'];
278 if (is_array($data)) {
279 $json_data = AphrontResponse::encodeJSONForHTTPResponse($data);
280 } else {
281 $json_data = json_encode($data);
282 }
283
284 $tags[] = phutil_tag(
285 'data',
286 array(
287 'data-javelin-init-kind' => $initializer['kind'],
288 'data-javelin-init-data' => $json_data,
289 ));
290 }
291
292 return $tags;
293 }
294
295 public static function renderInlineScript($data) {
296 if (stripos($data, '</script>') !== false) {
297 throw new Exception(
298 pht(
299 'Literal %s is not allowed inside inline script.',
300 '</script>'));
301 }
302 if (strpos($data, '<!') !== false) {
303 throw new Exception(
304 pht(
305 'Literal %s is not allowed inside inline script.',
306 '<!'));
307 }
308 // We don't use <![CDATA[ ]]> because it is ignored by HTML parsers. We
309 // would need to send the document with XHTML content type.
310 return phutil_tag(
311 'script',
312 array('type' => 'text/javascript'),
313 phutil_safe_html($data));
314 }
315
316 public function buildAjaxResponse($payload, $error = null) {
317 $response = array(
318 'error' => $error,
319 'payload' => $payload,
320 );
321
322 if ($this->metadata) {
323 $response['javelin_metadata'] = $this->metadata;
324 $this->metadata = array();
325 }
326
327 if ($this->behaviors) {
328 $response['javelin_behaviors'] = $this->behaviors;
329 $this->behaviors = array();
330 }
331
332 $this->resolveResources();
333 $resources = array();
334 foreach ($this->packaged as $source_name => $resource_names) {
335 $map = CelerityResourceMap::getNamedInstance($source_name);
336 foreach ($resource_names as $resource_name) {
337 $resources[] = $this->getURI($map, $resource_name);
338 }
339 }
340 if ($resources) {
341 $response['javelin_resources'] = $resources;
342 }
343
344 return $response;
345 }
346
347 public function getURI(
348 CelerityResourceMap $map,
349 $name,
350 $use_primary_domain = false) {
351
352 $uri = $map->getURIForName($name);
353
354 // If we have a postprocessor selected, add it to the URI.
355 $postprocessor_key = $this->getPostprocessorKey();
356 if ($postprocessor_key) {
357 $uri = preg_replace('@^/res/@', '/res/'.$postprocessor_key.'X/', $uri);
358 }
359
360 // In developer mode, we dump file modification times into the URI. When a
361 // page is reloaded in the browser, any resources brought in by Ajax calls
362 // do not trigger revalidation, so without this it's very difficult to get
363 // changes to Ajaxed-in CSS to work (you must clear your cache or rerun
364 // the map script). In production, we can assume the map script gets run
365 // after changes, and safely skip this.
366 if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) {
367 $mtime = $map->getModifiedTimeForName($name);
368 $uri = preg_replace('@^/res/@', '/res/'.$mtime.'T/', $uri);
369 }
370
371 if ($use_primary_domain) {
372 return PhabricatorEnv::getURI($uri);
373 } else {
374 return PhabricatorEnv::getCDNURI($uri);
375 }
376 }
377
378}