@kirigami/php-prepros 3.1.1 → 3.2.1

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.
package/package.json CHANGED
@@ -1,59 +1,59 @@
1
- {
2
- "name": "@kirigami/php-prepros",
3
- "version": "3.1.1",
4
- "description": "PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.",
5
- "keywords": [
6
- "kirigami",
7
- "php",
8
- "preprocessor",
9
- "static-site-generator",
10
- "ssg",
11
- "wasm",
12
- "php-wasm",
13
- "markdown",
14
- "yaml",
15
- "github-pages",
16
- "pages"
17
- ],
18
- "license": "GPL-3.0-or-later",
19
- "author": "Maxime Larrivée-Roy",
20
- "type": "module",
21
- "main": "index.js",
22
- "types": "index.d.ts",
23
- "files": [
24
- "index.js",
25
- "index.d.ts",
26
- "src/",
27
- "LICENSE",
28
- "README.md"
29
- ],
30
- "engines": {
31
- "node": ">=24.0.0",
32
- "npm": ">=10.2.3"
33
- },
34
- "exports": {
35
- ".": {
36
- "import": "./index.js"
37
- },
38
- "./package.json": "./package.json"
39
- },
40
- "publishConfig": {
41
- "access": "public"
42
- },
43
- "scripts": {
44
- "test": "node --test --test-concurrency=1 \"test/*.test.js\""
45
- },
46
- "dependencies": {
47
- "@kirigami/php-wasm": "8.5.11-1",
48
- "@kirigami/struct-walker": "1.0.6",
49
- "picomatch": "^4.0.7"
50
- },
51
- "repository": {
52
- "type": "git",
53
- "url": "git+https://github.com/php-kirigami/kirigami.git"
54
- },
55
- "homepage": "https://php-kirigami.github.io",
56
- "bugs": {
57
- "url": "https://github.com/php-kirigami/kirigami/issues"
58
- }
59
- }
1
+ {
2
+ "name": "@kirigami/php-prepros",
3
+ "version": "3.2.1",
4
+ "description": "PHP preprocessor for the Kirigami static site generator. Compile PHP page templates to clean, deployable HTML — with zero server dependency.",
5
+ "keywords": [
6
+ "kirigami",
7
+ "php",
8
+ "preprocessor",
9
+ "static-site-generator",
10
+ "ssg",
11
+ "wasm",
12
+ "php-wasm",
13
+ "markdown",
14
+ "yaml",
15
+ "github-pages",
16
+ "pages"
17
+ ],
18
+ "license": "GPL-3.0-or-later",
19
+ "author": "Maxime Larrivée-Roy",
20
+ "type": "module",
21
+ "main": "index.js",
22
+ "types": "index.d.ts",
23
+ "files": [
24
+ "index.js",
25
+ "index.d.ts",
26
+ "src/",
27
+ "LICENSE",
28
+ "README.md"
29
+ ],
30
+ "engines": {
31
+ "node": ">=24.0.0",
32
+ "npm": ">=10.2.3"
33
+ },
34
+ "exports": {
35
+ ".": {
36
+ "import": "./index.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "test": "node --test --test-concurrency=1 \"test/*.test.js\""
45
+ },
46
+ "dependencies": {
47
+ "@kirigami/php-wasm": "8.5.11-1",
48
+ "@kirigami/struct-walker": "1.0.6",
49
+ "picomatch": "^4.0.7"
50
+ },
51
+ "repository": {
52
+ "type": "git",
53
+ "url": "git+https://github.com/php-kirigami/kirigami.git"
54
+ },
55
+ "homepage": "https://php-kirigami.github.io",
56
+ "bugs": {
57
+ "url": "https://github.com/php-kirigami/kirigami/issues"
58
+ }
59
+ }
@@ -52,8 +52,8 @@ class FS
52
52
  * Lists the immediate child pages of the calling template.
53
53
  *
54
54
  * Scans the directories directly below the folder of the file that
55
- * called this function, keeps the ones that hold an `_index.php`, parses
56
- * that file's first PHPDOC block via {@see FS::phpFileInfo()} and returns
55
+ * called this function, keeps the ones that hold a page file (see
56
+ * {@see FS::indexFile()}), parses its header via {@see FS::phpFileInfo()} and returns
57
57
  * the pages ordered by `@position` ascending (a missing `@position`
58
58
  * counts as 999999), then by folder name.
59
59
  *
@@ -76,10 +76,8 @@ class FS
76
76
 
77
77
  $children = [];
78
78
  foreach (glob($dir . '/*', GLOB_ONLYDIR) as $subdir) {
79
- $index = $subdir . '/_index.php';
80
- if (!is_file($index)) continue;
79
+ if (!$index = self::indexFile($subdir)) continue;
81
80
  $info = FS::phpFileInfo($index) ?: new stdClass;
82
- $info = clone $info;
83
81
  $info->file = realpath($index);
84
82
  $position = (isset($info->position) && is_numeric($info->position)) ? (int) $info->position : 999999;
85
83
  $children[] = ['position' => $position, 'name' => pathinfo($subdir, PATHINFO_BASENAME), 'info' => $info];
@@ -99,7 +97,7 @@ class FS
99
97
  *
100
98
  * Starting from the folder *above* the caller's own folder (the current
101
99
  * page is never part of its own trail), it walks the parent directories
102
- * upward, collecting the `_index.php` of each one via
100
+ * upward, collecting the page file (`_index.php` or `_index.md`) of each one via
103
101
  * {@see FS::phpFileInfo()}. The walk stops at the source root, or at the
104
102
  * first ancestor `_index.php` that does not carry an active `@breadcrumb`
105
103
  * tag — that page is a pure separator and is left out of the result.
@@ -138,11 +136,9 @@ class FS
138
136
  $dir = $parent;
139
137
  if (strncmp($dir . '/', $root . '/', strlen($root) + 1) !== 0) break; // above the source root
140
138
 
141
- $index = $dir . '/_index.php';
142
- if (is_file($index)) {
139
+ if ($index = self::indexFile($dir)) {
143
140
  $info = FS::phpFileInfo($index) ?: new stdClass;
144
141
  if (!self::truthy($info->breadcrumb ?? null)) break; // separator page: stop, exclude it
145
- $info = clone $info;
146
142
  $info->file = realpath($index);
147
143
  $trail[] = $info;
148
144
  }
@@ -168,23 +164,170 @@ class FS
168
164
  }
169
165
 
170
166
 
167
+ /**
168
+ * The page file of a directory: its `_index.php`, else its `_index.md`,
169
+ * else null. A folder holding both is a PHP page, and the `.md` is data.
170
+ */
171
+ public static function indexFile(string $dir): ?string
172
+ {
173
+ $dir = rtrim($dir, '\/');
174
+ foreach (['/_index.php', '/_index.md'] as $name) {
175
+ if (is_file($dir . $name)) return $dir . $name;
176
+ }
177
+ return null;
178
+ }
179
+
180
+
181
+ /**
182
+ * Parses a page's header annotations and returns them as a `stdClass`
183
+ * (a fresh copy on each call: callers may add keys without them leaking
184
+ * into the page's own variables).
185
+ *
186
+ * - `.php`: the file's first PHPDOC block ({@see FS::parseDocBlock()}).
187
+ * - `.md`: the `@tag value` lines at the top of the file, up to the
188
+ * first line that is neither a tag nor an indented continuation
189
+ * ({@see FS::splitHeader()}).
190
+ *
191
+ * Inheritance: an `@@tag value` sets `tag` on the page and on every page
192
+ * below it (see {@see FS::inheritedInfo()}). A page's own `@tag`
193
+ * overrides an inherited value for that page only; its own `@@tag`
194
+ * overrides it and passes the new value down.
195
+ */
171
196
  public static function phpFileInfo(string $file): object|bool
172
197
  {
173
198
  static $files = [];
174
199
  if (!$file = realpath($file)) return false;
175
- if (!isset($files[$file])) {
176
- $tokens = token_get_all(file_get_contents($file));
177
- foreach ($tokens as $tok) {
178
- if (!is_array($tok)) continue;
179
- if ($tok[0] == T_DOC_COMMENT) {
200
+ $files[$file] ??= (object) array_merge(self::inheritedInfo($file), self::rawInfo($file)[0]);
201
+ return clone $files[$file];
202
+ }
203
+
204
+
205
+ /**
206
+ * A file's own annotations, as `[all tags, tags passed down (@@)]`.
207
+ *
208
+ * @return array{0: array<string,string>, 1: array<string,string>}
209
+ */
210
+ private static function rawInfo(string $file): array
211
+ {
212
+ static $files = [];
213
+ if (isset($files[$file])) return $files[$file];
214
+
215
+ $inherit = [];
216
+ if (strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'md') {
217
+ [$info, , $inherit] = self::splitHeader(file_get_contents($file));
218
+ } else {
219
+ $block = null;
220
+ foreach (token_get_all(file_get_contents($file)) as $tok) {
221
+ if (is_array($tok) && $tok[0] == T_DOC_COMMENT) {
180
222
  $block = $tok[1];
181
223
  break;
182
224
  }
183
225
  }
184
- if (empty($block)) return new stdClass;
185
- $files[$file] = (object) self::parseDocBlock($block);
226
+ $info = $block ? self::parseDocBlock($block, $inherit) : [];
227
+ }
228
+
229
+ return $files[$file] = [$info, array_intersect_key($info, array_flip($inherit))];
230
+ }
231
+
232
+
233
+ /**
234
+ * The `@@` values a page inherits: those of the page file of every
235
+ * folder above it within `kirigami.root`, top-most first, a nearer
236
+ * ancestor winning. A non-index page (`_post.php`) also inherits from
237
+ * its own folder's `_index`. A relative data-file value
238
+ * (`@@menu _menu.yaml`) is rewritten relative to the inheriting page, so
239
+ * it still points at the file next to the page that declared it.
240
+ *
241
+ * @return array<string,string>
242
+ */
243
+ private static function inheritedInfo(string $file): array
244
+ {
245
+ if (!isset(PREPROS::$config->root) || !$root = realpath(PREPROS::$config->root)) return [];
246
+ $root = rtrim(str_replace('\\', '/', $root), '/');
247
+ $self = str_replace('\\', '/', $file);
248
+ $dir = dirname($self);
249
+ $in = fn(string $d) => $d === $root || str_starts_with($d, $root . '/');
250
+ if (!$in($dir)) return [];
251
+
252
+ $dirs = [];
253
+ $isIndex = strtolower(pathinfo($self, PATHINFO_FILENAME)) === '_index';
254
+ for ($d = $isIndex ? dirname($dir) : $dir; $in($d); $d = dirname($d)) {
255
+ array_unshift($dirs, $d);
256
+ if ($d === $root) break;
257
+ }
258
+
259
+ $vars = [];
260
+ foreach ($dirs as $d) {
261
+ $index = self::indexFile($d);
262
+ if (!$index || !$index = realpath($index)) continue;
263
+ foreach (self::rawInfo($index)[1] as $k => $v) {
264
+ $ext = strtolower(pathinfo($v, PATHINFO_EXTENSION));
265
+ if (in_array($ext, ['yaml', 'yml', 'json', 'md'], true) && !STR::is_url($v) && is_file($d . '/' . $v)) {
266
+ $v = self::relativeTo($dir, realpath($d . '/' . $v));
267
+ }
268
+ $vars[$k] = $v;
269
+ }
186
270
  }
187
- return $files[$file];
271
+ return $vars;
272
+ }
273
+
274
+
275
+ /** Path of `$file` relative to the directory `$from` (`../x/y.yaml`). */
276
+ private static function relativeTo(string $from, string $file): string
277
+ {
278
+ $from = explode('/', trim(str_replace('\\', '/', $from), '/'));
279
+ $to = explode('/', trim(str_replace('\\', '/', $file), '/'));
280
+ while ($from && $to && $from[0] === $to[0]) {
281
+ array_shift($from);
282
+ array_shift($to);
283
+ }
284
+ return str_repeat('../', count($from)) . implode('/', $to);
285
+ }
286
+
287
+
288
+ /**
289
+ * Splits a Markdown page into its header annotations and its body.
290
+ *
291
+ * The header is the `@tag value` lines at the top of the file (leading
292
+ * blank lines allowed), with the same rules as a PHPDOC block: a value
293
+ * wraps onto following indented lines. It ends at the first blank line
294
+ * (consumed) or flush-left line that isn't a tag, where the body starts.
295
+ * An `@@tag` is a tag passed down to child pages too. A file with no
296
+ * header is all body.
297
+ *
298
+ * @return array{0: array<string,string>, 1: string, 2: string[]}
299
+ * [annotations, body, names of the `@@` tags]
300
+ */
301
+ public static function splitHeader(string $text): array
302
+ {
303
+ $lines = preg_split('/\r\n|\r|\n/', preg_replace('/^\xEF\xBB\xBF/', '', $text));
304
+ $info = [];
305
+ $inherit = [];
306
+ $current = null;
307
+ $count = count($lines);
308
+ $i = 0;
309
+
310
+ while ($i < $count && trim($lines[$i]) === '') $i++;
311
+ if ($i === $count || $lines[$i][0] !== '@') return [[], $text, []];
312
+
313
+ for (; $i < $count; $i++) {
314
+ $line = $lines[$i];
315
+ if (preg_match('/^@(@?)([A-Za-z0-9_]+)[ \t]*(.*)$/', $line, $m)) {
316
+ $current = $m[2];
317
+ $info[$current] = trim($m[3]);
318
+ $inherit = array_diff($inherit, [$current]);
319
+ if ($m[1] !== '') $inherit[] = $current;
320
+ } elseif (str_starts_with($line, '@')) {
321
+ $current = null;
322
+ } elseif ($current !== null && trim($line) !== '' && preg_match('/^[ \t]/', $line)) {
323
+ $info[$current] = trim($info[$current] . ' ' . trim($line));
324
+ } else {
325
+ if (trim($line) === '') $i++; // the separating blank line
326
+ break;
327
+ }
328
+ }
329
+
330
+ return [$info, implode("\n", array_slice($lines, $i)), array_values($inherit)];
188
331
  }
189
332
 
190
333
 
@@ -198,10 +341,14 @@ class FS
198
341
  * up to the next `@tag`, a blank line, a flush-left prose line, or the end
199
342
  * of the block; continuation lines are joined with a single space.
200
343
  *
201
- * @param string $block Raw `/** … *&#47;` doc-comment text.
344
+ * `@@tag` is a tag like any other, whose name is also added to `$inherit`
345
+ * (values passed down to child pages).
346
+ *
347
+ * @param string $block Raw `/** … *&#47;` doc-comment text.
348
+ * @param string[] $inherit Receives the names of the `@@` tags.
202
349
  * @return array<string,string>
203
350
  */
204
- private static function parseDocBlock(string $block): array
351
+ private static function parseDocBlock(string $block, array &$inherit = []): array
205
352
  {
206
353
  $info = [];
207
354
  $current = null;
@@ -212,9 +359,11 @@ class FS
212
359
  $line = preg_replace('#\s*\*/\s*$#', '', $line);
213
360
  $line = preg_replace('#^[ \t]*\*[ \t]?#', '', $line, 1);
214
361
 
215
- if (preg_match('/^@([A-Za-z0-9_]+)[ \t]*(.*)$/', $line, $m)) {
216
- $current = trim($m[1]);
217
- $info[$current] = trim($m[2]);
362
+ if (preg_match('/^@(@?)([A-Za-z0-9_]+)[ \t]*(.*)$/', $line, $m)) {
363
+ $current = $m[2];
364
+ $info[$current] = trim($m[3]);
365
+ $inherit = array_values(array_diff($inherit, [$current]));
366
+ if ($m[1] !== '') $inherit[] = $current;
218
367
  } elseif (trim($line) === '') {
219
368
  $current = null; // blank line ends a value
220
369
  } elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
@@ -25,6 +25,7 @@ class HTML
25
25
  'code', 'dfn', 'em', 'i', 'img', 'input', 'kbd', 'label', 'map',
26
26
  'mark', 'object', 'output', 'q', 's', 'samp', 'select', 'small',
27
27
  'span', 'strong', 'sub', 'sup', 'textarea', 'time', 'tt', 'u', 'var',
28
+ 'bdi', 'data', 'del', 'font', 'ins', 'rp', 'rt', 'ruby', 'strike', 'wbr',
28
29
  ];
29
30
 
30
31
  // The HTML namespace URI Lexbor assigns to plain HTML elements (foreign
@@ -188,20 +189,63 @@ class HTML
188
189
  }
189
190
 
190
191
  $inner = '';
191
- foreach ($children as $child) {
192
- $inner .= static::renderNode($child, $depth + 1);
192
+ foreach (static::groupInlineRuns($children) as $group) {
193
+ if (is_array($group)) {
194
+ // Text and inline elements that follow each other: one line, so
195
+ // the spaces between them survive ("<b>bold</b>, next").
196
+ $inner .= str_repeat(' ', ($depth + 1) * self::INDENT) . trim(static::renderInlineNodes($group)) . "\n";
197
+ continue;
198
+ }
199
+ $inner .= static::renderNode($group, $depth + 1);
193
200
  }
194
201
 
195
202
  return "{$pad}<{$tag}{$attrs}>\n{$inner}{$pad}</{$tag}>\n";
196
203
  }
197
204
 
205
+ // Splits a block's children into single nodes and runs (arrays) of
206
+ // consecutive text/inline nodes that contain real text. A run without text
207
+ // (a row of <a><img></a>, say) stays as separate nodes, one per line.
208
+ private static function groupInlineRuns(array $children): array
209
+ {
210
+ $groups = [];
211
+ $run = [];
212
+ $flush = static function () use (&$groups, &$run) {
213
+ $hasText = false;
214
+ foreach ($run as $node) {
215
+ if ($node->nodeType === XML_TEXT_NODE && trim($node->nodeValue) !== '') $hasText = true;
216
+ }
217
+ if ($hasText) $groups[] = $run;
218
+ else foreach ($run as $node) $groups[] = $node;
219
+ $run = [];
220
+ };
221
+ foreach ($children as $child) {
222
+ $isInline = $child->nodeType === XML_TEXT_NODE
223
+ || ($child->nodeType === XML_ELEMENT_NODE
224
+ && static::isInline($child)
225
+ && static::hasOnlyInlineChildren($child));
226
+ if ($isInline) {
227
+ $run[] = $child;
228
+ } else {
229
+ $flush();
230
+ $groups[] = $child;
231
+ }
232
+ }
233
+ $flush();
234
+ return $groups;
235
+ }
236
+
198
237
  // Serializes a node's inline content on a single line, collapsing any run
199
238
  // of spaces/tabs/newlines in the source text to a single space (equivalent
200
239
  // to HTML's whitespace-collapsing behavior).
201
240
  private static function renderInline(Dom\Node $node): string
241
+ {
242
+ return static::renderInlineNodes($node->childNodes);
243
+ }
244
+
245
+ private static function renderInlineNodes(iterable $nodes): string
202
246
  {
203
247
  $out = '';
204
- foreach ($node->childNodes as $child) {
248
+ foreach ($nodes as $child) {
205
249
  if ($child->nodeType === XML_TEXT_NODE) {
206
250
  $text = preg_replace('/\s+/', ' ', $child->nodeValue);
207
251
  $out .= htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
@@ -244,6 +288,14 @@ class HTML
244
288
  return $elementCount <= 1;
245
289
  }
246
290
 
291
+ // A known inline element, or a custom element (its name always has a
292
+ // hyphen), which browsers lay out inline unless a stylesheet says otherwise.
293
+ private static function isInline(Dom\Node $node): bool
294
+ {
295
+ $name = strtolower($node->nodeName);
296
+ return in_array($name, self::INLINE, true) || str_contains($name, '-');
297
+ }
298
+
247
299
  // Checks that every direct descendant is inline (text, inline void, inline elements)
248
300
  // The inline elements themselves must not contain block elements
249
301
  private static function hasOnlyInlineChildren(Dom\Node $node): bool
@@ -255,7 +307,7 @@ class HTML
255
307
  if ($child->nodeType !== XML_ELEMENT_NODE) {
256
308
  return false;
257
309
  }
258
- if (!in_array(strtolower($child->nodeName), self::INLINE, true)) {
310
+ if (!static::isInline($child)) {
259
311
  return false;
260
312
  }
261
313
  // Recursive: the inline element must not contain block elements
@@ -709,8 +709,8 @@ final class LD
709
709
  for ($guard = 0; $guard < 50; $guard++) {
710
710
  if (strncmp($dir . '/', $root . '/', strlen($root) + 1) !== 0) break;
711
711
 
712
- $index = $dir . '/_index.php';
713
- if (is_file($index) && (str_replace('\\', '/', @realpath($index) ?: $index)) !== $selfNorm) {
712
+ $index = FS::indexFile($dir);
713
+ if ($index && (str_replace('\\', '/', @realpath($index) ?: $index)) !== $selfNorm) {
714
714
  $info = FS::phpFileInfo($index) ?: new stdClass;
715
715
  $fallback = $dir === $root ? self::config()->name : self::humanize(basename($dir));
716
716
  $trail[] = [
@@ -30,8 +30,10 @@ final class PREPROS
30
30
  }
31
31
 
32
32
 
33
- // Only page files beneath the configured source root are publishable.
34
- // Check every relative directory, but not the configured root's own name.
33
+ // Only page files beneath the configured source root are publishable: a
34
+ // `_*.php`, or an `_index.md` that starts with an `@tag` header, in a
35
+ // folder that has no `_index.php` (otherwise the `.md` is left alone as
36
+ // data). Check every relative directory, but not the configured root's own name.
35
37
  public static function isPage(string $file): bool
36
38
  {
37
39
  $file = realpath($file);
@@ -41,7 +43,9 @@ final class PREPROS
41
43
  if (!str_starts_with($file, $root)) return false;
42
44
  $parts = explode('/', substr($file, strlen($root)));
43
45
  $name = array_pop($parts);
44
- if (!preg_match('/^_.*\.php$/i', $name)) return false;
46
+ $isMd = strcasecmp($name, '_index.md') === 0 && !is_file(dirname($file) . '/_index.php')
47
+ && FS::splitHeader(file_get_contents($file))[0] !== [];
48
+ if (!$isMd && !preg_match('/^_.*\.php$/i', $name)) return false;
45
49
  foreach ($parts as $part) {
46
50
  if (str_starts_with($part, '_')) return false;
47
51
  }
@@ -66,6 +70,13 @@ final class PREPROS
66
70
  $relroot = FS::getRelativePath($dir, self::$root);
67
71
  $page = self::processHook('page_info', [$file, FS::phpFileInfo($file)]);
68
72
 
73
+ // A Markdown page is never executed: its body (below the header) is
74
+ // the page content, unless the header points `@content` elsewhere.
75
+ $isMd = strtolower(pathinfo($file, PATHINFO_EXTENSION)) === 'md';
76
+ if ($isMd && !isset($page->content)) {
77
+ $page->content = MD::toHtml(FS::splitHeader(file_get_contents($file))[1]);
78
+ }
79
+
69
80
  extract((array)self::$config->data);
70
81
  extract((array)$page);
71
82
 
@@ -76,7 +87,8 @@ final class PREPROS
76
87
  if (self::$config->before) include(realpath(self::$root . self::$config->before));
77
88
  $header = self::processHook('post_before', ob_get_clean());
78
89
 
79
- if(empty($content)) {
90
+ if ($isMd) $body = (string) $content;
91
+ elseif(empty($content)) {
80
92
  ob_start();
81
93
  include($file);
82
94
  $body = ob_get_clean();
@@ -130,10 +142,11 @@ final class PREPROS
130
142
  {
131
143
  $paths = [];
132
144
  $root = realpath(self::$root);
133
- foreach (FS::dig($root . '/_index.php') as $file) {
145
+ foreach ([...FS::dig($root . '/_index.php'), ...FS::dig($root . '/_index.md')] as $file) {
134
146
  if (!self::isPage($file)) continue;
135
147
  $paths[] = str_replace('\\', '/', ltrim(str_replace($root, '', pathinfo(realpath($file), PATHINFO_DIRNAME)), DIRECTORY_SEPARATOR));
136
148
  }
149
+ sort($paths);
137
150
  if (empty($paths)) $paths[] = '';
138
151
  $dom = new DOMDocument('1.0', 'UTF-8');
139
152
  $dom->formatOutput = true;
@@ -51,6 +51,7 @@ PREPROS::registerHook('page_info', function($info) {
51
51
  $file = PREPROS::$file;
52
52
  }
53
53
  foreach($page as $k => $v) {
54
+ if (!is_string($v)) continue; // a key a hook or helper added
54
55
  $ext = strtolower(pathinfo($v, PATHINFO_EXTENSION));
55
56
  if(in_array($ext, ['yaml', 'yml', 'json', 'md']) ) {
56
57
  $isUrl = STR::is_url($v);
package/src/prepros.php CHANGED
@@ -13,7 +13,7 @@ try {
13
13
  elseif (!$target = realpath($argv[1])) STD::error("Invalid target.");
14
14
  else if (is_dir($target)) {
15
15
  $prj = new PREPROS($config);
16
- foreach (FS::dig($target . '/*.php', true) as $file) {
16
+ foreach ([...FS::dig($target . '/*.php'), ...FS::dig($target . '/_index.md')] as $file) {
17
17
  if (!PREPROS::isPage($file)) continue;
18
18
  PREPROS::render($file);
19
19
  }