@kirigami/php-prepros 1.1.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.
@@ -4,8 +4,9 @@
4
4
  final class PREPROS
5
5
  {
6
6
 
7
- public static mixed $config;
8
- private static string $root;
7
+ public static mixed $config;
8
+ public static string $file = '';
9
+ private static string $root = '';
9
10
  private static array $files = [];
10
11
  private static array $hooks = [];
11
12
  private static array $tags = [];
@@ -19,6 +20,11 @@ final class PREPROS
19
20
  if (!is_file(realpath(self::$root . $path))) continue;
20
21
  else include_once(realpath(self::$root . $path));
21
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
+ }
22
28
  }
23
29
 
24
30
 
@@ -26,8 +32,16 @@ final class PREPROS
26
32
  {
27
33
  $dir = pathinfo($file, PATHINFO_DIRNAME) . S;
28
34
  $target = $dir . ltrim(pathinfo($file, PATHINFO_FILENAME), '_') . '.html';
29
- $file = realpath($file);
30
- $absurl = str_replace('//', '/', str_replace('\\', '/', pathinfo(str_replace(realpath(self::$root), '', $file), PATHINFO_DIRNAME)) . '/');
35
+ if(!$file = realpath($file)) return false;
36
+ self::$file = $file;
37
+
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, '/') . '/');
44
+
31
45
  $relroot = FS::getRelativePath($dir, self::$root);
32
46
  $page = self::processHook('page_info', [$file, FS::phpFileInfo($file)]);
33
47
 
@@ -37,8 +51,9 @@ final class PREPROS
37
51
  self::processHook('pre_render', file_get_contents($file));
38
52
 
39
53
  ob_start();
54
+ self::processHook('pre_before', self::$config->before);
40
55
  if (self::$config->before) include(realpath(self::$root . self::$config->before));
41
- $header = ob_get_clean();
56
+ $header = self::processHook('post_before', ob_get_clean());
42
57
 
43
58
  if(empty($content)) {
44
59
  ob_start();
@@ -53,17 +68,25 @@ final class PREPROS
53
68
  }
54
69
 
55
70
  ob_start();
71
+ self::processHook('pre_after', self::$config->after);
56
72
  if (self::$config->after) include(realpath(self::$root . self::$config->after));
57
- $footer = ob_get_clean();
73
+ $footer = self::processHook('post_after', ob_get_clean());
58
74
 
59
75
  $contents = $header . $body . $footer;
60
76
  $contents = self::processTags($contents);
61
77
  $contents = self::processHook('post_render', $contents);
62
78
 
79
+ if ((self::$config->head ?? true) !== false) {
80
+ $contents = self::injectHead($contents, $relroot);
81
+ }
82
+
63
83
  if(!empty(self::$config->format)) $contents = HTML::format($contents);
64
84
 
85
+ $contents = self::replaceTokens($contents);
86
+
65
87
  file_put_contents($target, $contents);
66
88
  self::exportFile($target);
89
+ self::$file = '';
67
90
  return realpath($target);
68
91
  }
69
92
 
@@ -100,11 +123,103 @@ final class PREPROS
100
123
 
101
124
  $dest = self::$root . 'sitemap.xml';
102
125
  file_put_contents($dest, $dom->saveXML());
103
- self::exportFile($dest);
126
+
127
+ $destrobots = self::$root . 'robots.txt';
128
+ $urlrobots = rtrim(self::$config->data->baseurl, '/') . '/sitemap.xml';
129
+ file_put_contents($destrobots, "User-agent: *\nAllow: /\nSitemap: {$urlrobots}");
130
+
131
+ self::exportFile([$dest, $destrobots]);
104
132
  return realpath($dest);
105
133
  }
106
134
 
107
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
+
108
223
  public static function exportFile(string|array $file): void
109
224
  {
110
225
  if(is_string($file)) $file = [$file];
@@ -118,7 +233,7 @@ final class PREPROS
118
233
  public static function getExportedFiles(): array
119
234
  {
120
235
  $files = array_unique(self::$files);
121
- sort($files);
236
+ // sort($files);
122
237
  return $files;
123
238
  }
124
239
 
@@ -132,19 +247,43 @@ final class PREPROS
132
247
  }
133
248
 
134
249
 
135
- public static function mount(string|array $patterns) {
250
+ public static function mount(string|array $patterns)
251
+ {
136
252
  $results = self::callJS('mount', ['patterns' => $patterns]);
137
- if(!$results->success) return false;
253
+ if (!$results->success) return false;
138
254
  return $results->results;
139
255
  }
140
256
 
141
257
 
258
+ public static function fstat(string $path)
259
+ {
260
+ if (!$results = self::callJS('fstat', ['path' => $path])) return false;
261
+ if (!$results->success) return false;
262
+ if (!$results->results->exists) return false;
263
+ return $results->results;
264
+ }
265
+
266
+
267
+ public static function backtraceFile()
268
+ {
269
+ $debug = debug_backtrace();
270
+ if(!isset($debug[1])) return false;
271
+ else return $debug[1]['file'];
272
+ }
273
+
274
+
142
275
  public static function registerHook(string $hook, callable $clb)
143
276
  {
144
277
  self::$hooks[$hook][] = $clb;
145
278
  }
146
279
 
147
280
 
281
+ public static function runHook(string $hook, $data = null)
282
+ {
283
+ return self::processHook($hook, $data);
284
+ }
285
+
286
+
148
287
  private static function processHook(string $hook, $data = null)
149
288
  {
150
289
  if (!empty(self::$hooks[$hook])) {
@@ -7,22 +7,72 @@ PREPROS::registerTag('markdown', function ($tag, $attrs, $body) {
7
7
  });
8
8
 
9
9
 
10
+ PREPROS::registerTag('img', function ($tag, $attrs, $body) {
11
+ if(empty($attrs['asset'])) return $tag;
12
+ $width = 0;
13
+ $height = 0;
14
+ $cover = false;
15
+ if(isset($attrs['width'])) {
16
+ $width = $attrs['width'] ?? 0;
17
+ unset($attrs['width']);
18
+ }
19
+ if(isset($attrs['height'])) {
20
+ $height = $attrs['height'] ?? 0;
21
+ unset($attrs['height']);
22
+ }
23
+ if(isset($attrs['cover'])) {
24
+ $cover = true;
25
+ unset($attrs['cover']);
26
+ }
27
+ $attrs['src'] = IMG::asset($attrs['asset'], $width, $height, $cover, PREPROS::$file);
28
+ unset($attrs['asset']);
29
+ foreach($attrs as $k => $v) $props[] = $k.'="'.$v.'"';
30
+ return '<img'.(!empty($props) ? ' '.join(' ', $props): '').'>';
31
+ });
32
+
33
+
10
34
  PREPROS::registerHook('page_info', function($info) {
11
- 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
+ }
12
44
  foreach($page as $k => $v) {
13
45
  $ext = strtolower(pathinfo($v, PATHINFO_EXTENSION));
14
46
  if(in_array($ext, ['yaml', 'yml', 'json', 'md']) ) {
15
- if(preg_match('#^https?:#i', $v)) $filename = $v;
47
+ $isUrl = STR::is_url($v);
48
+ if($isUrl) $filename = $v;
16
49
  else $filename = pathinfo(realpath($file), PATHINFO_DIRNAME) . '/' . $v;
17
- 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;
18
55
  $page->{$k} = match ($ext) {
19
- 'yml', 'yaml' => YAML::parseFile($filename),
20
- 'json' => json_decode(file_get_contents($filename)),
21
- 'md' => MD::toHtml(file_get_contents($filename)),
56
+ 'yml', 'yaml' => YAML::parse($raw),
57
+ 'json' => json_decode($raw),
58
+ 'md' => MD::toHtml($raw),
22
59
  default => $filename,
23
60
  };
24
61
  }
25
62
  }
26
63
  }
27
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);
28
78
  });