Plugins in data/plugins are loaded at startup and can add a tab with a read-only node board, and the demo plugin shows every asset as a node (#113)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 19:00:03 +02:00
1 parent 2a390e52ff
commit 57a97ba94f
22 files changed
+1153 -57

No files matched your search

+29
View File
@@ -25,6 +25,9 @@ declare(strict_types=1);
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
* DELETE ?r=sounds/{file} → 204
*
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
* GET ?r=plugins/{file} → the module source as text/javascript
*
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
*
@@ -34,6 +37,7 @@ declare(strict_types=1);
require __DIR__ . '/lib/FontStore.php';
require __DIR__ . '/lib/OriginalName.php';
require __DIR__ . '/lib/PluginStore.php';
require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.php';
@@ -213,6 +217,30 @@ function routeSounds(array $parts, string $method): never
Response::error('Method not allowed', 405);
}
/** @param list<string> $parts */
function routePlugins(array $parts, string $method): never
{
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
$store = new PluginStore(dirname(__DIR__) . '/data/plugins');
if (count($parts) === 1) {
Response::json($store->list());
}
$source = $store->read($parts[1]);
if ($source === null) {
Response::error('Plugin not found', 404);
}
header('Content-Type: text/javascript; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// A replaced file has to be the one the next editor start runs.
header('Cache-Control: no-cache');
echo $source;
exit;
}
try {
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
Response::error('Request body too large', 413);
@@ -231,6 +259,7 @@ try {
'textures' => routeTextures($parts, $method),
'fonts' => routeFonts($parts, $method),
'sounds' => routeSounds($parts, $method),
'plugins' => routePlugins($parts, $method),
default => Response::error('Not found', 404),
};
} catch (Throwable $e) {
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
/**
* The editor plugins in `data/plugins/`: one ES module per `.js` file, and every file there is
* active. Read-only for now – putting a file there is done by hand, by the build and by the e2e
* seeder. The folder is not served statically: a module is only ever handed out by its bare file
* name, so no request can reach past it.
*/
final class PluginStore
{
public function __construct(private readonly string $dir)
{
}
/** A file name as it may be asked for: no path, no leading dot, `.js` at the end. */
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/', $name);
}
/**
* Sorted by file name, which is also the order the editor activates them in.
*
* @return list<array{file: string, bytes: int, updatedAt: int}>
*/
public function list(): array
{
$files = is_dir($this->dir) ? scandir($this->dir) : [];
$out = [];
foreach ($files === false ? [] : $files as $file) {
$path = "{$this->dir}/{$file}";
if (!self::isValidName($file) || !is_file($path)) {
continue;
}
$out[] = ['file' => $file, 'bytes' => (int) filesize($path), 'updatedAt' => (int) filemtime($path)];
}
usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file']));
return $out;
}
/** The module's source, or null when there is no such plugin. */
public function read(string $file): ?string
{
$path = "{$this->dir}/{$file}";
if (!self::isValidName($file) || !is_file($path)) {
return null;
}
$source = file_get_contents($path);
return $source === false ? null : $source;
}
}