@kirigami/php-prepros 2.0.0 → 3.0.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.
package/src/prepros.js CHANGED
@@ -5,7 +5,7 @@ import path, { dirname } from "path";
5
5
  import { spawn } from 'child_process';
6
6
  import { fileURLToPath, pathToFileURL } from "url";
7
7
  import { walkFile } from '@kirigami/struct-walker';
8
- import { getPHPRuntime, getPHPRuntimeWithNetwork } from "@kirigami/php-wasm";
8
+ import { createPHPRuntime } from "@kirigami/php-wasm";
9
9
 
10
10
 
11
11
  const __modules = new Map;
@@ -16,6 +16,23 @@ let __root = null;
16
16
  let __php = null;
17
17
  let __config = null;
18
18
 
19
+ // Mounts, PHP requests and resets share mutable state. Queue the entire
20
+ // operation so reload cannot dispose a runtime between mounting and execution.
21
+ let pending = Promise.resolve();
22
+ const serial = operation => (...args) => {
23
+ const result = pending.then(() => operation(...args));
24
+ pending = result.catch(() => {});
25
+ return result;
26
+ };
27
+
28
+ const reset = async () => {
29
+ const php = __php;
30
+ __php = null;
31
+ __root = null;
32
+ __config = null;
33
+ if (php) php.exit();
34
+ };
35
+
19
36
 
20
37
  // Load and cache kirigami.yaml on first use. Deferred (not run at import) so
21
38
  // `import '@kirigami/php-prepros'` has no side effects — e.g. `kiri build -h`
@@ -29,7 +46,7 @@ const loadConfig = async () => {
29
46
  }
30
47
 
31
48
 
32
- const getPHPInstance = async () => {
49
+ const initializePHPInstance = async () => {
33
50
  const config = await loadConfig();
34
51
  if(!__php) {
35
52
  if(config?.kirigami?.root === undefined) throw `Missing prepros:root property in config file: ${__configpath}`;
@@ -46,8 +63,8 @@ const getPHPInstance = async () => {
46
63
  preprosConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
47
64
  preprosConfig.root = joinWith('/project/', config?.kirigami?.root);
48
65
  preprosConfig.data = config.kirigami || {};
49
- // The unified SEO block — META reads it directly; LD reads its own
50
- // `jsonld` sub-key (see META/LD's docblocks). One block, one toggle.
66
+ // The SEO block, read by both META and LD; `jsonld: true` switches
67
+ // LD's JSON-LD on (see META/LD's docblocks).
51
68
  preprosConfig.seo = config.seo ?? null;
52
69
  // META auto-detects favicon / apple-touch-icon / humans.txt at the
53
70
  // source root; those extensions aren't mounted into the sandbox, so the
@@ -61,16 +78,21 @@ const getPHPInstance = async () => {
61
78
  // page's <head> with a <link>/<script> per sass/esbuild task output.
62
79
  preprosConfig.tasks = config.tasks || [];
63
80
 
64
- __php = await (preprosConfig.network ? getPHPRuntimeWithNetwork() : getPHPRuntime());
81
+ __php = await createPHPRuntime({ network: Boolean(preprosConfig.network) });
65
82
  __php.setSpawnHandler((command, args, options) => spawn(command, args, options));
66
83
  __php.preprosConfig = preprosConfig;
67
84
  __php.setIniValues({
68
- log_errors: 1,
69
- html_errors: 0,
70
- display_errors: 1,
71
- error_reporting: 32767,
72
- error_log: 'php://stderr',
73
- memory_limit: '2G',
85
+ log_errors: 1,
86
+ html_errors: 0,
87
+ // Diagnostics belong on stderr, never inside generated HTML.
88
+ display_errors: 0,
89
+ error_reporting: 32767,
90
+ error_log: 'php://stderr',
91
+ memory_limit: '2G',
92
+ // The framework bootstrap (autoloader, $argv/$config, aliases,
93
+ // the `boot` hook) — every entrypoint used to `include` this by
94
+ // hand; auto_prepend_file makes it a single config point instead.
95
+ auto_prepend_file: '/prepros/utils.inc.php',
74
96
  });
75
97
 
76
98
  await mountPath(__dirname, '/prepros', __php);
@@ -98,22 +120,34 @@ const getPHPInstance = async () => {
98
120
  results: results
99
121
  });
100
122
  } catch(e) {
101
- return {
123
+ return JSON.stringify({
102
124
  success: false,
103
125
  error: typeof e == 'string' ? e : e.message
104
- }
126
+ });
105
127
  }
106
128
  });
107
129
  }
108
130
  return __php;
109
131
  }
110
132
 
133
+ const getPHPInstance = async () => {
134
+ try {
135
+ return await initializePHPInstance();
136
+ } catch (error) {
137
+ await reset();
138
+ throw error;
139
+ }
140
+ };
141
+
111
142
 
112
143
  const mountPath = async (localPath, virtualDir, php) => {
113
144
  const config = await loadConfig();
114
145
  php = php || await getPHPInstance();
115
146
  if(!path.isAbsolute(localPath)) localPath = path.join(__project, localPath);
116
- virtualDir = virtualDir || path.posix.join('/project', localPath.replace(__project + path.sep, ''));
147
+ // POSIX separators: on Windows a nested path ("src\\about\\_index.php")
148
+ // would otherwise land at "/project/src\\about\\_index.php", a separate
149
+ // file, leaving the real page's VFS copy stale.
150
+ virtualDir = virtualDir || path.posix.join('/project', path.relative(__project, localPath).split(path.sep).join('/'));
117
151
  const includeExtensions = new Set(['.php', '.json', '.yaml', '.yml', '.md', '.db', '.txt', ...(config?.prepros?.mountext || [])]);
118
152
  const stat = fs.statSync(localPath);
119
153
  if (stat.isDirectory()) {
@@ -141,27 +175,37 @@ const mountPath = async (localPath, virtualDir, php) => {
141
175
  const run = async (args = [], script = null, mountfiles = []) => {
142
176
  const php = await getPHPInstance();
143
177
 
144
- await Promise.all(mountfiles.map(async item => {
145
- const file = path.resolve(item);
146
- if(!fs.existsSync(file)) return;
147
- if(!path.relative(__project, file)) return;
148
- const dest = path.join('/project', file.replace(__project, '')).replaceAll('\\', '/');
178
+ await Promise.all(mountfiles.map(async ({ file, dest }) => {
149
179
  const buf = fs.readFileSync(file);
150
180
  const parentDir = dest.substring(0, dest.lastIndexOf('/'));
151
181
  if (parentDir) php.mkdirTree(parentDir);
152
182
  php.writeFile(dest, isBinary(buf) ? buf : buf.toString('utf8'));
153
183
  }));
154
184
 
155
- const output = await php.runStream({
156
- scriptPath: script || '/prepros/prepros.php',
157
- env: {
158
- PREPROS_ARGS: JSON.stringify(args),
159
- PREPROS_CONFIG: JSON.stringify(php.preprosConfig)
160
- }
161
- });
162
-
163
- const stdout = await output.stdoutText;
164
- const stderr = await output.stderrText;
185
+ let stdout, stderr;
186
+ try {
187
+ const output = await php.runStream({
188
+ scriptPath: script || '/prepros/prepros.php',
189
+ env: {
190
+ PREPROS_ARGS: JSON.stringify(args),
191
+ PREPROS_CONFIG: JSON.stringify(php.preprosConfig)
192
+ }
193
+ });
194
+ stdout = await output.stdoutText;
195
+ stderr = await output.stderrText;
196
+ } catch (e) {
197
+ // The WASM runtime aborted (e.g. `RuntimeError: unreachable`): its
198
+ // heap and request state are gone, and reusing it only yields
199
+ // follow-up errors ("Cannot redeclare function ..."). Drop it so the
200
+ // next operation starts a fresh one, and report this run as failed.
201
+ try { await reset(); } catch { /* already dead */ }
202
+ return {
203
+ success: false,
204
+ files: [],
205
+ error: `PHP runtime crashed: ${e?.message || e}. It has been restarted; run the build again.`,
206
+ stderr: String(e?.stack || e),
207
+ };
208
+ }
165
209
 
166
210
  let retobj;
167
211
  const resultPath = '/internal/prepros_result.json';
@@ -237,14 +281,60 @@ const extractPhpError = (text) => {
237
281
  }
238
282
 
239
283
 
284
+ const inside = (root, target) => {
285
+ const relative = path.relative(root, target);
286
+ return relative && relative !== '..' && !relative.startsWith('..' + path.sep)
287
+ && !path.isAbsolute(relative);
288
+ };
289
+
290
+
291
+ // Require both the authored path and its real target to stay inside the project.
292
+ // Validate the whole input list before booting PHP or copying any file.
293
+ const projectFile = (input, optional = false) => {
294
+ const file = path.resolve(__project, input);
295
+ if (!inside(__project, file)) throw new Error('PHP file outside project');
296
+ if (!fs.existsSync(file)) {
297
+ if (optional) return null;
298
+ throw new Error("Can't find PHP file");
299
+ }
300
+ if (!inside(fs.realpathSync(__project), fs.realpathSync(file))) {
301
+ throw new Error('PHP file outside project');
302
+ }
303
+ if (!fs.statSync(file).isFile()) throw new TypeError('Expected a project file, not a directory');
304
+ return { file, dest: '/project/' + path.relative(__project, file).split(path.sep).join('/') };
305
+ };
306
+
240
307
  const runenv = async (script, paths = [], ...args) => {
241
- if(!script) throw "Missing PHP file.";
242
- const file = path.resolve(script);
243
- if(!path.relative(__project, file)) throw "PHP file outside project";
244
- if(!fs.existsSync(file)) throw "Can't find PHP file";
245
- const dest = path.join('/project', file.replace(__project, '')).replaceAll('\\', '/');
246
- return run([dest, ...args], '/prepros/runenv.php', [file, ...paths]);
247
- }
308
+ if (!script) throw new Error('Missing PHP file.');
309
+ const entry = projectFile(script);
310
+ const mounts = [entry, ...paths.map(file => projectFile(file, true)).filter(Boolean)];
311
+ return run([entry.dest, ...args], '/prepros/runenv.php', mounts);
312
+ };
313
+
314
+
315
+ // A plugin-registered script lives in its plugin's package, which a linked or
316
+ // workspace install places outside the project. The caller vouches for
317
+ // `pluginRoot` (an active plugin's resolved package directory); the script's
318
+ // authored and real paths must both stay inside it. Extra `paths` are still
319
+ // project files.
320
+ const runPluginScript = async (script, pluginRoot, paths = [], ...args) => {
321
+ if (!script) throw new Error('Missing PHP file.');
322
+ if (!pluginRoot) throw new Error('Missing plugin package directory.');
323
+ const root = path.resolve(pluginRoot);
324
+ const file = path.resolve(root, script);
325
+ if (!inside(root, file)) throw new Error('PHP file outside plugin package');
326
+ if (!fs.existsSync(file)) throw new Error("Can't find PHP file");
327
+ const realRoot = fs.realpathSync(root);
328
+ const realFile = fs.realpathSync(file);
329
+ if (!inside(realRoot, realFile)) throw new Error('PHP file outside plugin package');
330
+ if (!fs.statSync(realFile).isFile()) throw new TypeError('Expected a plugin file, not a directory');
331
+ const entry = {
332
+ file: realFile,
333
+ dest: '/plugin-scripts/' + path.basename(realRoot) + '/' + path.relative(realRoot, realFile).split(path.sep).join('/'),
334
+ };
335
+ const mounts = [entry, ...paths.map(p => projectFile(p, true)).filter(Boolean)];
336
+ return run([entry.dest, ...args], '/prepros/runenv.php', mounts);
337
+ };
248
338
 
249
339
 
250
340
  // Run a batch of image jobs (resize / palette) through /prepros/imagebatch.php,
@@ -268,22 +358,25 @@ const render = async (file = '.', phpIncludes = []) => {
268
358
  await mountPath(target);
269
359
  if(config?.prepros?.before) await mountPath(path.resolve(config?.kirigami?.root, config?.prepros?.before));
270
360
  if(config?.prepros?.after) await mountPath(path.resolve(config?.kirigami?.root, config?.prepros?.after));
361
+ for (const type of Object.values(config?.prepros?.types || {})) {
362
+ if (type?.before) await mountPath(path.resolve(config?.kirigami?.root, type.before));
363
+ if (type?.after) await mountPath(path.resolve(config?.kirigami?.root, type.after));
364
+ }
271
365
 
272
366
  // Extra PHP files contributed by plugins (the kiri 'prepros:php' hook):
273
367
  // mounted outside /project and include_once'd once, before any page
274
368
  // renders, so they can PREPROS::registerTag()/registerHook() from PHP.
275
- if (Array.isArray(phpIncludes) && phpIncludes.length) {
276
- const php = await getPHPInstance();
277
- const mounted = [];
278
- for (let i = 0; i < phpIncludes.length; i++) {
279
- const abs = path.resolve(phpIncludes[i]);
280
- if (!fs.existsSync(abs)) continue;
281
- const virt = `/plugins/${i}_${path.basename(abs)}`;
282
- await mountPath(abs, virt, php);
283
- mounted.push(virt);
284
- }
285
- php.preprosConfig.phpIncludes = mounted;
369
+ const php = await getPHPInstance();
370
+ const mounted = [];
371
+ const includes = Array.isArray(phpIncludes) ? phpIncludes : [];
372
+ for (let i = 0; i < includes.length; i++) {
373
+ const abs = path.resolve(includes[i]);
374
+ if (!fs.existsSync(abs)) continue;
375
+ const virt = `/plugins/${i}_${path.basename(abs)}`;
376
+ await mountPath(abs, virt, php);
377
+ mounted.push(virt);
286
378
  }
379
+ php.preprosConfig.phpIncludes = mounted;
287
380
 
288
381
  return run([fsvm]);
289
382
  }
@@ -296,4 +389,14 @@ const sitemap = async () => {
296
389
  }
297
390
 
298
391
 
299
- export { runenv, render, sitemap, mountPath, processImages };
392
+ const queuedRunenv = serial(runenv);
393
+ const queuedRunPluginScript = serial(runPluginScript);
394
+ const queuedRender = serial(render);
395
+ const queuedSitemap = serial(sitemap);
396
+ const queuedMountPath = serial(mountPath);
397
+ const queuedProcessImages = serial(processImages);
398
+ const resetRuntime = serial(reset);
399
+ export {
400
+ queuedRunenv as runenv, queuedRunPluginScript as runPluginScript, queuedRender as render, queuedSitemap as sitemap,
401
+ queuedMountPath as mountPath, queuedProcessImages as processImages, resetRuntime,
402
+ };
package/src/prepros.php CHANGED
@@ -1,6 +1,7 @@
1
1
  <?php
2
2
 
3
- include(__DIR__ . '/utils.inc.php');
3
+ // Framework bootstrap (autoloader, $argv/$config, aliases, `boot` hook) is
4
+ // loaded via php.ini's auto_prepend_file, not an explicit include here.
4
5
 
5
6
  try {
6
7
  if (!isset($argv[1])) STD::error("Invalid argument.");
@@ -13,12 +14,10 @@ try {
13
14
  else if (is_dir($target)) {
14
15
  $prj = new PREPROS($config);
15
16
  foreach (FS::dig($target . '/*.php', true) as $file) {
16
- $parent = pathinfo(pathinfo($file, PATHINFO_DIRNAME), PATHINFO_BASENAME);
17
- if (strpos($parent, '_') === 0) continue;
18
- if (strpos(pathinfo($file, PATHINFO_FILENAME), '_') !== 0) continue;
17
+ if (!PREPROS::isPage($file)) continue;
19
18
  PREPROS::render($file);
20
19
  }
21
- } elseif (preg_match('#^_(.*)\.php$#i', pathinfo($target, PATHINFO_BASENAME), $m)) {
20
+ } elseif (PREPROS::isPage($target)) {
22
21
  PREPROS::render($target);
23
22
  } else {
24
23
  throw new Exception("Invalid target.");
package/src/runenv.php CHANGED
@@ -1,7 +1,7 @@
1
1
  <?php
2
2
 
3
- include(__DIR__ . '/utils.inc.php');
4
-
3
+ // Framework bootstrap (autoloader, $argv/$config, aliases, `boot` hook) is
4
+ // loaded via php.ini's auto_prepend_file, not an explicit include here.
5
5
 
6
6
  try {
7
7
 
package/src/utils.inc.php CHANGED
@@ -26,7 +26,6 @@ spl_autoload_register(function ($class) {
26
26
  'LD' => 'ld.class.php',
27
27
  'MD' => 'md.class.php',
28
28
  'META' => 'meta.class.php',
29
- 'NORM' => 'norm.class.php',
30
29
  'OBF' => 'obf.class.php',
31
30
  'PREPROS' => 'prepros.class.php',
32
31
  'SCHEMA' => 'schema.class.php',
@@ -36,7 +35,10 @@ spl_autoload_register(function ($class) {
36
35
  'YAML' => 'yaml.class.php',
37
36
 
38
37
  // Fallbacks
39
- 'Normalizer' => 'normalizer.class.php'
38
+ 'MD_LEGACY' => 'md-legacy.class.php',
39
+ 'NORMALIZER_LEGACY' => 'normalizer-legacy.class.php',
40
+ 'SCHEMA_LEGACY' => 'schema-legacy.class.php',
41
+ 'YAML_LEGACY' => 'yaml-legacy.class.php'
40
42
  ];
41
43
  if (isset($catalog[$class])) require_once(__DIR__ . '/libraries/' . $catalog[$class]);
42
44
  }, true, true);
@@ -58,3 +60,11 @@ PREPROS::loadConfig($config);
58
60
  // `includes` file (or prepros.plugins.php) can hook here to pull in extra
59
61
  // PHP files or wire itself up.
60
62
  PREPROS::runHook('boot', $config);
63
+
64
+ // Symmetric `shutdown` hook, fired via register_shutdown_function rather
65
+ // than auto_append_file: every entrypoint ends in STD::succeed()/STD::error(),
66
+ // both of which exit() — and PHP skips auto_append_file whenever the script
67
+ // terminates through exit()/die(). A shutdown function has no such gap.
68
+ register_shutdown_function(function () {
69
+ PREPROS::runHook('shutdown');
70
+ });