@kirigami/php-prepros 1.2.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -568,6 +568,10 @@ class MD {
568
568
  // Processed before XSS encoding — re-injected as the very last step.
569
569
  // ====================================================================
570
570
  $pluginBlocks = [];
571
+ // Literal, XSS-escaped source of each captured tag, keyed by the same
572
+ // placeholder. Used to restore a `{% tag %}` that turns out to sit
573
+ // inside a code span / code block as verbatim text instead of expanding it.
574
+ $pluginLiterals = [];
571
575
 
572
576
  /**
573
577
  * Parses an argument string into an array.
@@ -596,7 +600,7 @@ class MD {
596
600
  // Group 2: inline args (everything on the first line after the name)
597
601
  // Group 3: multi-line body (present only for block tags)
598
602
  '/\{%\s*([a-zA-Z0-9_-]+)([^\n%]*?)(?:\n([\s\S]*?))?\s*%\}/m',
599
- function ($matches) use (&$pluginBlocks, $parseArgs): string {
603
+ function ($matches) use (&$pluginBlocks, &$pluginLiterals, $parseArgs): string {
600
604
  $name = strtolower(trim($matches[1]));
601
605
  $args = $parseArgs(trim($matches[2] ?? ''));
602
606
  // $matches[3] exists only if the tag is multi-line
@@ -609,7 +613,8 @@ class MD {
609
613
 
610
614
  $output = (self::$plugins[$name])($args, $body);
611
615
  $placeholder = "\x02PLG" . count($pluginBlocks) . "\x03";
612
- $pluginBlocks[$placeholder] = $output;
616
+ $pluginBlocks[$placeholder] = $output;
617
+ $pluginLiterals[$placeholder] = htmlspecialchars($matches[0], ENT_QUOTES, 'UTF-8');
613
618
  return $placeholder;
614
619
  },
615
620
  $html
@@ -726,6 +731,16 @@ class MD {
726
731
  return $placeholder;
727
732
  }, $html);
728
733
 
734
+ // A `{% tag %}` sitting inside a code span or code block was captured
735
+ // by STEP 2 and is now a plugin placeholder embedded in the stored
736
+ // code. Swap those back for the literal (escaped) tag source so code
737
+ // shows `{% tag %}` verbatim instead of its rendered output — or a
738
+ // stray control-char placeholder that never gets restored.
739
+ if ($pluginLiterals) {
740
+ foreach ($codeBlocks as $k => $v) $codeBlocks[$k] = strtr($v, $pluginLiterals);
741
+ foreach ($inlineCodes as $k => $v) $inlineCodes[$k] = strtr($v, $pluginLiterals);
742
+ }
743
+
729
744
 
730
745
  // ====================================================================
731
746
  // STEP 3b: CHARACTER ESCAPING (\* \_ \# etc.)
@@ -1222,4 +1237,12 @@ class MD {
1222
1237
 
1223
1238
  return $html;
1224
1239
  }
1225
- }
1240
+ }
1241
+
1242
+
1243
+ // Default Markdown plugins ({% codepen %}, {% youtube %}, {% checklist %},
1244
+ // {% callout %}) — "registered out of the box" per the README. Loaded here so
1245
+ // every entrypoint (prepros.php, runenv.php, imagebatch.php) gets them without
1246
+ // an explicit include. A project can still MD::unregisterPlugin() any of them,
1247
+ // or MD::registerPlugin() its own with the same name to override.
1248
+ include_once(__DIR__ . '/md.plugins.php');
@@ -3,7 +3,13 @@
3
3
 
4
4
 
5
5
  // =============================================================================
6
- // DEFAULT PLUGINS (optional, uncomment what you need)
6
+ // DEFAULT MARKDOWN PLUGINS
7
+ //
8
+ // Auto-loaded from md.class.php, so these are available in every render without
9
+ // any setup ("registered out of the box"). To drop one, call
10
+ // MD::unregisterPlugin('name') (or md_unregister_plugin('name')) from an
11
+ // `includes` file; to change one, MD::registerPlugin() your own under the same
12
+ // name.
7
13
  // =============================================================================
8
14
 
9
15
  // --- CodePen (inline) ---
@@ -20,6 +20,11 @@ final class PREPROS
20
20
  if (!is_file(realpath(self::$root . $path))) continue;
21
21
  else include_once(realpath(self::$root . $path));
22
22
  }
23
+ // Plugin-contributed PHP (kiri 'prepros:php' hook): absolute virtual
24
+ // paths, mounted outside the project by @kirigami/php-prepros.
25
+ if (!empty($config->phpIncludes)) foreach ($config->phpIncludes as $path) {
26
+ if (is_file($path)) include_once($path);
27
+ }
23
28
  }
24
29
 
25
30
 
@@ -30,8 +35,12 @@ final class PREPROS
30
35
  if(!$file = realpath($file)) return false;
31
36
  self::$file = $file;
32
37
 
33
- // to review = add the baseurl path
34
- $absurl = str_replace('//', '/', str_replace('\\', '/', pathinfo(str_replace(realpath(self::$root), '', $file), PATHINFO_DIRNAME)) . '/');
38
+ // absolute URL path of the page's dir, prefixed with the baseurl's
39
+ // subpath (if any) so it stays valid when the site is deployed under a
40
+ // subfolder, e.g. https://kirigami.github.io/template/
41
+ $basepath = rtrim((string)parse_url(self::$config->data->baseurl, PHP_URL_PATH), '/');
42
+ $reldir = str_replace('\\', '/', pathinfo(str_replace(realpath(self::$root), '', $file), PATHINFO_DIRNAME));
43
+ $absurl = preg_replace('#/+#', '/', $basepath . '/' . trim($reldir, '/') . '/');
35
44
 
36
45
  $relroot = FS::getRelativePath($dir, self::$root);
37
46
  $page = self::processHook('page_info', [$file, FS::phpFileInfo($file)]);
@@ -42,8 +51,9 @@ final class PREPROS
42
51
  self::processHook('pre_render', file_get_contents($file));
43
52
 
44
53
  ob_start();
54
+ self::processHook('pre_before', self::$config->before);
45
55
  if (self::$config->before) include(realpath(self::$root . self::$config->before));
46
- $header = ob_get_clean();
56
+ $header = self::processHook('post_before', ob_get_clean());
47
57
 
48
58
  if(empty($content)) {
49
59
  ob_start();
@@ -58,15 +68,22 @@ final class PREPROS
58
68
  }
59
69
 
60
70
  ob_start();
71
+ self::processHook('pre_after', self::$config->after);
61
72
  if (self::$config->after) include(realpath(self::$root . self::$config->after));
62
- $footer = ob_get_clean();
73
+ $footer = self::processHook('post_after', ob_get_clean());
63
74
 
64
75
  $contents = $header . $body . $footer;
65
76
  $contents = self::processTags($contents);
66
77
  $contents = self::processHook('post_render', $contents);
67
78
 
79
+ if ((self::$config->head ?? true) !== false) {
80
+ $contents = self::injectHead($contents, $relroot);
81
+ }
82
+
68
83
  if(!empty(self::$config->format)) $contents = HTML::format($contents);
69
84
 
85
+ $contents = self::replaceTokens($contents);
86
+
70
87
  file_put_contents($target, $contents);
71
88
  self::exportFile($target);
72
89
  self::$file = '';
@@ -116,6 +133,93 @@ final class PREPROS
116
133
  }
117
134
 
118
135
 
136
+ /**
137
+ * Auto-wires each page: a theme/FOUC guard as the first child of <head>, a
138
+ * <link> for every `sass` task output, a <script> (no `defer`, before
139
+ * </body>) for every `esbuild` task output, and — when `format` is on — a
140
+ * tiny de-indent script that flattens the leading whitespace HTML::format()
141
+ * adds inside `<pre><code>`. Paths use $relroot so they work at any depth.
142
+ *
143
+ * Runs unless `prepros.head` is `false`. A single task opts out with
144
+ * `head: false`. A file already referenced in the page is left alone.
145
+ * `###TIMESTAMP###` is expanded by replaceTokens() right after.
146
+ */
147
+ private static function injectHead(string $contents, string $relroot): string
148
+ {
149
+ $inject = [];
150
+
151
+ // Theme guard — skip if the page already carries one.
152
+ if (!str_contains($contents, 'kirigami-theme')) {
153
+ $inject[] = '<script>(function(){var e=document.documentElement;'
154
+ . 'e.classList.add("js");try{var t=localStorage.getItem("kirigami-theme");'
155
+ . 'if(t==="dark"||t==="light")e.setAttribute("data-theme",t)}catch(x){}})();</script>';
156
+ }
157
+
158
+ $links = [];
159
+ $scripts = [];
160
+
161
+ // Undo the <pre><code> indentation HTML::format() added, at parse time
162
+ // (before first paint), for blocks a build-time highlighter didn't
163
+ // already flatten (those carry child <span>s).
164
+ if (!empty(self::$config->format)) {
165
+ $scripts[] = '<script>document.querySelectorAll("pre>code").forEach(function(c){'
166
+ . 'if(c.children.length)return;'
167
+ . 'var L=c.textContent.replace(/^\n+/,"").replace(/\s+$/,"").split("\n"),n=1/0;'
168
+ . 'L.forEach(function(l){if(l.trim())n=Math.min(n,l.match(/^\s*/)[0].length)});'
169
+ . 'if(n&&n<1/0)c.textContent=L.map(function(l){return l.slice(n)}).join("\n")});</script>';
170
+ }
171
+ foreach ((array) (self::$config->tasks ?? []) as $task) {
172
+ $task = (array) $task;
173
+ if (($task['head'] ?? true) === false) continue;
174
+ $entry = (string) ($task['entry'] ?? '');
175
+ if ($entry === '') continue;
176
+
177
+ if (($task['type'] ?? '') === 'sass') {
178
+ $out = preg_replace('/\.s?css$/', '.min.css', $entry);
179
+ if ($out !== $entry && !str_contains($contents, $out)) {
180
+ $links[] = '<link rel="stylesheet" href="' . $relroot . $out . '?###TIMESTAMP###">';
181
+ }
182
+ } elseif (($task['type'] ?? '') === 'esbuild') {
183
+ $out = preg_replace('/\.[jt]s$/', '.min.js', $entry);
184
+ if ($out !== $entry && !str_contains($contents, $out)) {
185
+ $scripts[] = '<script src="' . $relroot . $out . '?###TIMESTAMP###"></script>';
186
+ }
187
+ }
188
+ }
189
+
190
+ $head = implode("\n ", array_merge($inject, $links));
191
+ if ($head !== '') {
192
+ // Slot it right after <meta charset> when there is one (keep the
193
+ // charset first), otherwise right after <head>.
194
+ if (preg_match('/<meta\s+charset=[^>]*>/i', $contents)) {
195
+ $contents = preg_replace('/(<meta\s+charset=[^>]*>)/i', "$1\n " . $head, $contents, 1);
196
+ } elseif (preg_match('/<head\b[^>]*>/i', $contents)) {
197
+ $contents = preg_replace('/(<head\b[^>]*>)/i', "$1\n " . $head, $contents, 1);
198
+ }
199
+ }
200
+ if ($scripts && preg_match('/<\/body>/i', $contents)) {
201
+ $contents = preg_replace('/(<\/body>)/i', " " . implode("\n ", $scripts) . "\n$1", $contents, 1);
202
+ }
203
+
204
+ return $contents;
205
+ }
206
+
207
+
208
+ /**
209
+ * Expands the build-time text tokens in a generated file. Runs at render
210
+ * time (so `kiri build` / `kiri watch` previews show real values, not the
211
+ * literal `###YEAR###`), and again — harmlessly — on export.
212
+ */
213
+ private static function replaceTokens(string $contents): string
214
+ {
215
+ return strtr($contents, [
216
+ '###YEAR###' => date('Y'),
217
+ '###TIMESTAMP###' => (string) time(),
218
+ '###TODAY###' => date('Y-m-d'),
219
+ ]);
220
+ }
221
+
222
+
119
223
  public static function exportFile(string|array $file): void
120
224
  {
121
225
  if(is_string($file)) $file = [$file];
@@ -174,6 +278,12 @@ final class PREPROS
174
278
  }
175
279
 
176
280
 
281
+ public static function runHook(string $hook, $data = null)
282
+ {
283
+ return self::processHook($hook, $data);
284
+ }
285
+
286
+
177
287
  private static function processHook(string $hook, $data = null)
178
288
  {
179
289
  if (!empty(self::$hooks[$hook])) {
@@ -32,21 +32,47 @@ PREPROS::registerTag('img', function ($tag, $attrs, $body) {
32
32
 
33
33
 
34
34
  PREPROS::registerHook('page_info', function($info) {
35
- list($file, $page) = $info;
35
+ // The hook fires with [$file, $pageInfo]; every callback then returns just
36
+ // the (possibly modified) $pageInfo, so a callback registered after this
37
+ // one receives the bare object. Accept either shape.
38
+ if (is_array($info)) {
39
+ [$file, $page] = $info;
40
+ } else {
41
+ $page = $info;
42
+ $file = PREPROS::$file;
43
+ }
36
44
  foreach($page as $k => $v) {
37
45
  $ext = strtolower(pathinfo($v, PATHINFO_EXTENSION));
38
46
  if(in_array($ext, ['yaml', 'yml', 'json', 'md']) ) {
39
- if(preg_match('#^https?:#i', $v)) $filename = $v;
47
+ $isUrl = STR::is_url($v);
48
+ if($isUrl) $filename = $v;
40
49
  else $filename = pathinfo(realpath($file), PATHINFO_DIRNAME) . '/' . $v;
41
- if(is_file($filename) || preg_match('#^https?:#i', $v)) {
50
+ if($isUrl || is_file($filename)) {
51
+ // Remote URLs always go through CURL::getContents — never
52
+ // file_get_contents, which this PHP build can't use for http(s).
53
+ $raw = $isUrl ? CURL::getContents($filename) : file_get_contents($filename);
54
+ if($raw === false) continue;
42
55
  $page->{$k} = match ($ext) {
43
- 'yml', 'yaml' => YAML::parseFile($filename),
44
- 'json' => json_decode(file_get_contents($filename)),
45
- 'md' => MD::toHtml(file_get_contents($filename)),
56
+ 'yml', 'yaml' => YAML::parse($raw),
57
+ 'json' => json_decode($raw),
58
+ 'md' => MD::toHtml($raw),
46
59
  default => $filename,
47
60
  };
48
61
  }
49
62
  }
50
63
  }
51
64
  return $page;
65
+ });
66
+
67
+
68
+ // LD — capture the page under render, then inject the automatic
69
+ // schema.org JSON-LD `<script>` into its `<head>` once the HTML is assembled.
70
+ // Registered after the data-loading hook above so `$page` arrives resolved.
71
+ PREPROS::registerHook('page_info', function($page) {
72
+ LD::capture($page);
73
+ return $page;
74
+ });
75
+
76
+ PREPROS::registerHook('post_render', function($html) {
77
+ return LD::inject($html);
52
78
  });
@@ -24,6 +24,9 @@ class STD {
24
24
  $return->files = PREPROS::getExportedFiles();
25
25
  if(is_string($props)) $return->error = $props;
26
26
  else foreach($props as $k => $v) $return->{$k} = $v;
27
+ // Consumers read `->error`; keep it populated whatever key the caller
28
+ // used (some pass `message`) so nothing ever surfaces as "undefined".
29
+ if(empty($return->error)) $return->error = $return->message ?? 'Unknown error.';
27
30
  file_put_contents(self::RESULT_PATH, json_encode($return, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), FILE_APPEND);
28
31
  exit(1);
29
32
  }
@@ -78,7 +78,7 @@ class STR
78
78
 
79
79
  public static function is_url(string $str): bool
80
80
  {
81
- if (!$sheme = strtolower(@parse_url($str, PHP_URL_SCHEME))) return false;
81
+ if (!$sheme = strtolower((string) @parse_url($str, PHP_URL_SCHEME))) return false;
82
82
  return in_array($sheme, ['http', 'https', 'itunes', 'ftp', 'ftps', 'ssh', 'ssl', 'sftp']);
83
83
  }
84
84
 
package/src/prepros.js CHANGED
@@ -37,6 +37,10 @@ const getPHPInstance = async () => {
37
37
  preprosConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
38
38
  preprosConfig.root = joinWith('/project/', config?.kirigami?.root);
39
39
  preprosConfig.data = config.kirigami || {};
40
+ preprosConfig.jsonld = config.jsonld ?? null;
41
+ // The build task list — read by PREPROS::injectHead() to auto-wire each
42
+ // page's <head> with a <link>/<script> per sass/esbuild task output.
43
+ preprosConfig.tasks = config.tasks || [];
40
44
 
41
45
  __php = await (preprosConfig.network ? getPHPRuntimeWithNetwork() : getPHPRuntime());
42
46
  __php.setSpawnHandler((command, args, options) => spawn(command, args, options));
@@ -156,23 +160,60 @@ const run = async (args = [], script = null, mountfiles = []) => {
156
160
  retobj.files[i] = dest;
157
161
  }));
158
162
  } else {
159
- retobj = { success: true, files: [], debug: stdout };
163
+ // No result file: PHP exited before STD::succeed()/error() — a
164
+ // parse error, an OOM, a die(), a hard crash. Pull whatever
165
+ // explanation we can out of the streams instead of a silent pass.
166
+ const crash = extractPhpError(stderr) || extractPhpError(stdout);
167
+ retobj = {
168
+ success: false,
169
+ files: [],
170
+ error: crash || 'PHP produced no result (exited early — check the debug output).',
171
+ debug: stdout,
172
+ stderr,
173
+ };
160
174
  }
161
175
  } catch (e) {
162
- retobj = { success: false, error: 'Response parsing error.' + e, debug: stdout, stderr };
176
+ retobj = { success: false, error: 'Response parsing error: ' + (e?.message || e), debug: stdout, stderr };
163
177
  } finally {
164
178
  if (await php.fileExists(resultPath)) {
165
179
  await php.unlink(resultPath);
166
180
  }
167
181
  }
182
+
183
+ // A fatal that still managed to write a (stale/partial) result file, or
184
+ // warnings printed to stderr. Only a genuine fatal marker overrides a
185
+ // result the PHP side reported as a success; plain warnings are attached
186
+ // as `warnings` so a stray notice doesn't sink an otherwise-clean build.
168
187
  if (stderr) {
169
- retobj = { success: false, error: stderr, debug: stdout };
188
+ const fatal = extractPhpError(stderr);
189
+ if (fatal && (!retobj || retobj.success !== false)) {
190
+ retobj = { success: false, files: [], error: fatal, debug: stdout, stderr };
191
+ } else if (retobj && retobj.success && !fatal) {
192
+ retobj.warnings = stderr.trim();
193
+ } else if (retobj && retobj.success === false && !retobj.stderr) {
194
+ retobj.stderr = stderr.trim();
195
+ }
196
+ }
197
+
198
+ // Never hand back a failure without a usable message.
199
+ if (retobj && retobj.success === false && !retobj.error) {
200
+ retobj.error = retobj.message || retobj.stderr || 'Unknown error.';
170
201
  }
171
202
 
172
203
  return retobj;
173
204
  }
174
205
 
175
206
 
207
+ // Pulls the first meaningful PHP error line out of a stdout/stderr blob
208
+ // (fatals, parse errors, uncaught throwables). Returns null when the text
209
+ // holds nothing that looks like a hard error.
210
+ const extractPhpError = (text) => {
211
+ if (!text) return null;
212
+ const m = text.match(/^.*\b(?:PHP\s+)?(?:Fatal error|Parse error|Uncaught\s+\w+|Recoverable fatal error)\b.*$/mi);
213
+ return m ? m[0].trim() : null;
214
+ }
215
+
216
+
176
217
  const runenv = async (script, paths = [], ...args) => {
177
218
  if(!script) throw "Missing PHP file.";
178
219
  const file = path.resolve(script);
@@ -183,12 +224,43 @@ const runenv = async (script, paths = [], ...args) => {
183
224
  }
184
225
 
185
226
 
186
- const render = async (file = '.') => {
227
+ // Run a batch of image jobs (resize / palette) through /prepros/imagebatch.php,
228
+ // i.e. the IMG class (GD + Imagick). Used by @kirigami/kirigami's `sass` task so
229
+ // that img-asset() / colors() and the PHP IMG::asset() / <img asset> tag share
230
+ // one engine. `jobs` is the list documented in imagebatch.php; the result is a
231
+ // PreprosResult plus a `colors` map ({ "<src>:<count>": ["#rrggbb", …] }).
232
+ const processImages = async (jobs = []) => {
233
+ if (!Array.isArray(jobs) || !jobs.length) return { success: true, files: [], colors: {} };
234
+ const result = await run(['imagebatch', JSON.stringify(jobs)], '/prepros/imagebatch.php');
235
+ if (!result.colors) result.colors = {};
236
+ if (!result.files) result.files = [];
237
+ return result;
238
+ }
239
+
240
+
241
+ const render = async (file = '.', phpIncludes = []) => {
187
242
  const target = path.resolve(config?.kirigami?.root, file);
188
243
  const fsvm = path.join('/project', config?.kirigami?.root, file).replace(/\\/g, '/');
189
244
  await mountPath(target);
190
245
  if(config?.prepros?.before) await mountPath(path.resolve(config?.kirigami?.root, config?.prepros?.before));
191
246
  if(config?.prepros?.after) await mountPath(path.resolve(config?.kirigami?.root, config?.prepros?.after));
247
+
248
+ // Extra PHP files contributed by plugins (the kiri 'prepros:php' hook):
249
+ // mounted outside /project and include_once'd once, before any page
250
+ // renders, so they can PREPROS::registerTag()/registerHook() from PHP.
251
+ if (Array.isArray(phpIncludes) && phpIncludes.length) {
252
+ const php = await getPHPInstance();
253
+ const mounted = [];
254
+ for (let i = 0; i < phpIncludes.length; i++) {
255
+ const abs = path.resolve(phpIncludes[i]);
256
+ if (!fs.existsSync(abs)) continue;
257
+ const virt = `/plugins/${i}_${path.basename(abs)}`;
258
+ await mountPath(abs, virt, php);
259
+ mounted.push(virt);
260
+ }
261
+ php.preprosConfig.phpIncludes = mounted;
262
+ }
263
+
192
264
  return run([fsvm]);
193
265
  }
194
266
 
@@ -199,4 +271,4 @@ const sitemap = async () => {
199
271
  }
200
272
 
201
273
 
202
- export { runenv, render, sitemap, mountPath };
274
+ export { runenv, render, sitemap, mountPath, processImages };
package/src/prepros.php CHANGED
@@ -24,8 +24,15 @@ try {
24
24
  throw new Exception("Invalid target.");
25
25
  }
26
26
 
27
- } catch(Exception $e) {
28
- STD::error(['message' => $e->getMessage()]);
27
+ } catch(Throwable $e) {
28
+ // Catch Error/TypeError too, not just Exception, so a fatal in a template
29
+ // (a bad call, a null arg, …) still comes back as a structured result with
30
+ // a real message and the page that blew up — never a bare "Error: undefined".
31
+ STD::error([
32
+ 'error' => $e->getMessage(),
33
+ 'page' => PREPROS::$file ?: ($argv[1] ?? null),
34
+ 'where' => $e->getFile() . ':' . $e->getLine(),
35
+ ]);
29
36
  }
30
37
 
31
38
 
package/src/runenv.php CHANGED
@@ -10,8 +10,11 @@ try {
10
10
 
11
11
  require_once($argv[1]);
12
12
 
13
- } catch(Exception $e) {
14
- STD::error($e->getMessage());
13
+ } catch(Throwable $e) {
14
+ STD::error([
15
+ 'error' => $e->getMessage(),
16
+ 'where' => $e->getFile() . ':' . $e->getLine(),
17
+ ]);
15
18
  }
16
19
 
17
20
  STD::succeed(['files' => PREPROS::getExportedFiles()]);
package/src/utils.inc.php CHANGED
@@ -23,6 +23,7 @@ spl_autoload_register(function ($class) {
23
23
  'FS' => 'fs.class.php',
24
24
  'HTML' => 'html.class.php',
25
25
  'IMG' => 'img.class.php',
26
+ 'LD' => 'ld.class.php',
26
27
  'MD' => 'md.class.php',
27
28
  'NORM' => 'norm.class.php',
28
29
  'OBF' => 'obf.class.php',
@@ -40,10 +41,19 @@ spl_autoload_register(function ($class) {
40
41
  }, true, true);
41
42
 
42
43
 
44
+
43
45
  $argv = array_merge(['prepros.php'], json_decode(getenv('PREPROS_ARGS'), true));
44
46
  $config = json_decode(getenv('PREPROS_CONFIG'));
45
47
  date_default_timezone_set($config->timezone);
46
48
  chdir('/project');
47
49
 
50
+ require_once(__DIR__ . '/libraries/aliases.inc.php');
51
+
52
+ PREPROS::loadConfig($config);
48
53
 
49
- PREPROS::loadConfig($config);
54
+ // Bootstrap is done: config loaded, `includes` pulled in, aliases available.
55
+ // Fires once per process, before any page renders, for every entrypoint
56
+ // (prepros.php, runenv.php, imagebatch.php). A plugin registered from an
57
+ // `includes` file (or prepros.plugins.php) can hook here to pull in extra
58
+ // PHP files or wire itself up.
59
+ PREPROS::runHook('boot', $config);