@kirigami/php-prepros 1.9.3 → 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,9 @@ 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
- preprosConfig.jsonld = config.jsonld ?? null;
50
- preprosConfig.meta = config.meta ?? null;
66
+ // The SEO block, read by both META and LD; `jsonld: true` switches
67
+ // LD's JSON-LD on (see META/LD's docblocks).
68
+ preprosConfig.seo = config.seo ?? null;
51
69
  // META auto-detects favicon / apple-touch-icon / humans.txt at the
52
70
  // source root; those extensions aren't mounted into the sandbox, so the
53
71
  // presence check is done here on the real filesystem instead.
@@ -60,16 +78,21 @@ const getPHPInstance = async () => {
60
78
  // page's <head> with a <link>/<script> per sass/esbuild task output.
61
79
  preprosConfig.tasks = config.tasks || [];
62
80
 
63
- __php = await (preprosConfig.network ? getPHPRuntimeWithNetwork() : getPHPRuntime());
81
+ __php = await createPHPRuntime({ network: Boolean(preprosConfig.network) });
64
82
  __php.setSpawnHandler((command, args, options) => spawn(command, args, options));
65
83
  __php.preprosConfig = preprosConfig;
66
84
  __php.setIniValues({
67
- log_errors: 1,
68
- html_errors: 0,
69
- display_errors: 1,
70
- error_reporting: 32767,
71
- error_log: 'php://stderr',
72
- 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',
73
96
  });
74
97
 
75
98
  await mountPath(__dirname, '/prepros', __php);
@@ -97,22 +120,34 @@ const getPHPInstance = async () => {
97
120
  results: results
98
121
  });
99
122
  } catch(e) {
100
- return {
123
+ return JSON.stringify({
101
124
  success: false,
102
125
  error: typeof e == 'string' ? e : e.message
103
- }
126
+ });
104
127
  }
105
128
  });
106
129
  }
107
130
  return __php;
108
131
  }
109
132
 
133
+ const getPHPInstance = async () => {
134
+ try {
135
+ return await initializePHPInstance();
136
+ } catch (error) {
137
+ await reset();
138
+ throw error;
139
+ }
140
+ };
141
+
110
142
 
111
143
  const mountPath = async (localPath, virtualDir, php) => {
112
144
  const config = await loadConfig();
113
145
  php = php || await getPHPInstance();
114
146
  if(!path.isAbsolute(localPath)) localPath = path.join(__project, localPath);
115
- 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('/'));
116
151
  const includeExtensions = new Set(['.php', '.json', '.yaml', '.yml', '.md', '.db', '.txt', ...(config?.prepros?.mountext || [])]);
117
152
  const stat = fs.statSync(localPath);
118
153
  if (stat.isDirectory()) {
@@ -140,27 +175,37 @@ const mountPath = async (localPath, virtualDir, php) => {
140
175
  const run = async (args = [], script = null, mountfiles = []) => {
141
176
  const php = await getPHPInstance();
142
177
 
143
- await Promise.all(mountfiles.map(async item => {
144
- const file = path.resolve(item);
145
- if(!fs.existsSync(file)) return;
146
- if(!path.relative(__project, file)) return;
147
- const dest = path.join('/project', file.replace(__project, '')).replaceAll('\\', '/');
178
+ await Promise.all(mountfiles.map(async ({ file, dest }) => {
148
179
  const buf = fs.readFileSync(file);
149
180
  const parentDir = dest.substring(0, dest.lastIndexOf('/'));
150
181
  if (parentDir) php.mkdirTree(parentDir);
151
182
  php.writeFile(dest, isBinary(buf) ? buf : buf.toString('utf8'));
152
183
  }));
153
184
 
154
- const output = await php.runStream({
155
- scriptPath: script || '/prepros/prepros.php',
156
- env: {
157
- PREPROS_ARGS: JSON.stringify(args),
158
- PREPROS_CONFIG: JSON.stringify(php.preprosConfig)
159
- }
160
- });
161
-
162
- const stdout = await output.stdoutText;
163
- 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
+ }
164
209
 
165
210
  let retobj;
166
211
  const resultPath = '/internal/prepros_result.json';
@@ -236,14 +281,60 @@ const extractPhpError = (text) => {
236
281
  }
237
282
 
238
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
+
239
307
  const runenv = async (script, paths = [], ...args) => {
240
- if(!script) throw "Missing PHP file.";
241
- const file = path.resolve(script);
242
- if(!path.relative(__project, file)) throw "PHP file outside project";
243
- if(!fs.existsSync(file)) throw "Can't find PHP file";
244
- const dest = path.join('/project', file.replace(__project, '')).replaceAll('\\', '/');
245
- return run([dest, ...args], '/prepros/runenv.php', [file, ...paths]);
246
- }
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
+ };
247
338
 
248
339
 
249
340
  // Run a batch of image jobs (resize / palette) through /prepros/imagebatch.php,
@@ -267,22 +358,25 @@ const render = async (file = '.', phpIncludes = []) => {
267
358
  await mountPath(target);
268
359
  if(config?.prepros?.before) await mountPath(path.resolve(config?.kirigami?.root, config?.prepros?.before));
269
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
+ }
270
365
 
271
366
  // Extra PHP files contributed by plugins (the kiri 'prepros:php' hook):
272
367
  // mounted outside /project and include_once'd once, before any page
273
368
  // renders, so they can PREPROS::registerTag()/registerHook() from PHP.
274
- if (Array.isArray(phpIncludes) && phpIncludes.length) {
275
- const php = await getPHPInstance();
276
- const mounted = [];
277
- for (let i = 0; i < phpIncludes.length; i++) {
278
- const abs = path.resolve(phpIncludes[i]);
279
- if (!fs.existsSync(abs)) continue;
280
- const virt = `/plugins/${i}_${path.basename(abs)}`;
281
- await mountPath(abs, virt, php);
282
- mounted.push(virt);
283
- }
284
- 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);
285
378
  }
379
+ php.preprosConfig.phpIncludes = mounted;
286
380
 
287
381
  return run([fsvm]);
288
382
  }
@@ -295,4 +389,14 @@ const sitemap = async () => {
295
389
  }
296
390
 
297
391
 
298
- 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
+ });