diff --git a/.docker/Dockerfile b/.docker/Dockerfile
new file mode 100644
index 0000000000..5d7552229f
--- /dev/null
+++ b/.docker/Dockerfile
@@ -0,0 +1,21 @@
+FROM php:8.4-cli
+
+ARG UID=1000
+ARG GID=1000
+
+RUN apt-get update && \
+ apt-get install -y git default-jre-headless
+
+WORKDIR /var/www
+
+ADD https://api.github.com/repos/php/phd/git/refs/heads/master version-phd.json
+ADD https://api.github.com/repos/php/docbook-cs/git/refs/heads/main version-docbook-cs.json
+
+RUN echo 'memory_limit = 512M' >> /usr/local/etc/php/conf.d/local.ini
+
+RUN chown $UID:$GID /var/www
+
+USER $UID:$GID
+
+RUN git clone --depth 1 https://github.com/php/phd.git && \
+ git clone --depth 1 https://github.com/php/docbook-cs.git
diff --git a/.gitignore b/.gitignore
index 8e5220c8b2..c11692a3b4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
# Files generated by the configure script
.manual.xml
+.manual.*.xml
.revcheck.json
version.xml
sources.xml
@@ -9,3 +10,6 @@ fileModHistory.php
# A plece for all temporary or generated files (idempotent build)
temp/
+
+# Docker dev build stamp
+.docker/built
diff --git a/docbookcs.dev.xml b/docbookcs.dev.xml
new file mode 100644
index 0000000000..b6405720b3
--- /dev/null
+++ b/docbookcs.dev.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+ @LANG@
+ doc-base
+
+
+
+
+
+
+
+
+
+
+
+ .
+
+
+
+ extensions.ent
+ language-defs.ent
+ language-snippets.ent
+ entities/
+ ../doc-base/entities/
+ ../doc-base/temp/manual.ent
+ ../doc-base/temp/entities.ent
+ ../doc-base/temp/file-entities.ent
+ ../doc-base/temp/file-entities
+
+
+
+ output/*
+
+
+
diff --git a/docs/local-setup.md b/docs/local-setup.md
index 103d9add29..b00e820dbb 100644
--- a/docs/local-setup.md
+++ b/docs/local-setup.md
@@ -133,4 +133,4 @@ revert the changes with commands below and open an issue on
git config --unset core.autocrlf
git reset
git status
-```
\ No newline at end of file
+```
diff --git a/phpdoc.php b/phpdoc.php
new file mode 100755
index 0000000000..92d7a671db
--- /dev/null
+++ b/phpdoc.php
@@ -0,0 +1,17 @@
+#!/usr/bin/env php
+run(array_slice($argv, 1)));
diff --git a/scripts/dev/Application.php b/scripts/dev/Application.php
new file mode 100644
index 0000000000..cb73f39667
--- /dev/null
+++ b/scripts/dev/Application.php
@@ -0,0 +1,173 @@
+ $args Command line arguments, without argv[0].
+ */
+ public function run(array $args): int
+ {
+ $options = new Options();
+ $command = $this->parse($args, $options);
+
+ if ($command === null) {
+ return 1;
+ }
+
+ if ($command === 'help') {
+ return (new HelpCommand())->execute($options);
+ }
+
+ $subcommand = null;
+
+ if ($command === 'docker') {
+ $subcommand = array_shift($options->args);
+
+ if (!in_array($subcommand, ['build', 'shell'], true)) {
+ fwrite(STDERR, "Usage: php phpdoc.php docker (see: php phpdoc.php help)\n");
+ return 1;
+ }
+
+ $options->docker = true;
+ }
+
+ if ($command === 'render') {
+ $subcommand = array_shift($options->args);
+
+ if (!in_array($subcommand, ['xhtml', 'php'], true)) {
+ fwrite(STDERR, "Usage: php phpdoc.php render (see: php phpdoc.php help)\n");
+ return 1;
+ }
+
+ $options->format = $subcommand;
+ }
+
+ // "serve" takes an optional subject: plain serve shows the rendered
+ // manual, "serve web-doc" runs the doc.php.net site.
+ if ($command === 'serve' && ($options->args[0] ?? null) === 'web-doc') {
+ array_shift($options->args);
+ $subcommand = 'web-doc';
+ }
+
+ if ($command === 'cs') {
+ $subcommand = array_shift($options->args);
+
+ if (!in_array($subcommand, ['lint', 'fix'], true)) {
+ fwrite(STDERR, "Usage: php phpdoc.php cs (see: php phpdoc.php help)\n");
+ return 1;
+ }
+ }
+
+ $runner = new ProcessRunner();
+ $dockerAvailable = $runner->runQuiet(['docker', 'version', '--format', '{{.Server.Version}}']) === 0;
+
+ if ($options->docker === true && !$dockerAvailable) {
+ fwrite(STDERR, "error: Docker requested but the docker command is not available.\n");
+ return 1;
+ }
+
+ $workspace = new Workspace($this->basedir, $runner, $options->assumeYes);
+ $environment = ($options->docker ?? $dockerAvailable)
+ ? new DockerEnvironment($workspace, $runner)
+ : new LocalEnvironment($workspace, $runner);
+
+ $configure = new ConfigureCommand($workspace, $environment);
+
+ switch ($command) {
+ case 'pull':
+ return (new PullCommand($workspace, $environment))->execute($options);
+ case 'configure':
+ return $configure->execute($options);
+ case 'render':
+ return (new RenderCommand($workspace, $environment, $configure))->execute($options);
+ case 'cs':
+ return (new LintCommand($workspace, $environment, $configure, fix: $subcommand === 'fix'))
+ ->execute($options);
+ case 'serve':
+ return $subcommand === 'web-doc'
+ ? (new WebDocServeCommand($workspace, $environment))->execute($options)
+ : (new ServeCommand($workspace, $environment))->execute($options);
+ case 'docker':
+ return $subcommand === 'build'
+ ? (new BuildCommand($environment))->execute($options)
+ : (new ShellCommand($environment))->execute($options);
+ }
+
+ fwrite(STDERR, "Unknown command: $command (see: php phpdoc.php help)\n");
+
+ return 1;
+ }
+
+ /** @param list $args */
+ private function parse(array $args, Options $options): ?string
+ {
+ $command = null;
+
+ foreach ($args as $arg) {
+ if (preg_match('/^--lang=(.+)$/', $arg, $m)) {
+ $options->lang = $m[1];
+ continue;
+ }
+
+ if (preg_match('/^--port=(\d+)$/', $arg, $m)) {
+ $options->port = (int) $m[1];
+ continue;
+ }
+
+ if ($arg === '--docker') {
+ $options->docker = true;
+ continue;
+ }
+
+ if ($arg === '--no-docker') {
+ $options->docker = false;
+ continue;
+ }
+
+ if ($arg === '--yes' || $arg === '-y') {
+ $options->assumeYes = true;
+ continue;
+ }
+
+ if ($command === null) {
+ if ($arg[0] !== '-') {
+ $command = $arg;
+ continue;
+ }
+
+ if ($arg === '-h' || $arg === '--help') {
+ $command = 'help';
+ continue;
+ }
+
+ $fileName = $_SERVER['SCRIPT_FILENAME'];
+ fwrite(STDERR, "Unknown option: $arg (see: php $fileName help)\n");
+ return null;
+ }
+
+ $options->args[] = $arg;
+ }
+
+ return $command ?? 'help';
+ }
+}
diff --git a/scripts/dev/Command/BuildCommand.php b/scripts/dev/Command/BuildCommand.php
new file mode 100644
index 0000000000..eae7883836
--- /dev/null
+++ b/scripts/dev/Command/BuildCommand.php
@@ -0,0 +1,20 @@
+environment->buildImage();
+ }
+}
diff --git a/scripts/dev/Command/Command.php b/scripts/dev/Command/Command.php
new file mode 100644
index 0000000000..e3e0866727
--- /dev/null
+++ b/scripts/dev/Command/Command.php
@@ -0,0 +1,12 @@
+lang;
+
+ if (!$this->workspace->ensureLangRepos($lang, $this->environment->canMapDirectoryNames())) {
+ return 1;
+ }
+
+ $this->workspace->pullSideRepos($lang);
+
+ $args = array_merge([
+ ($this->workspace->isBaseLang($lang) ? '--with-base-lang=' : '--with-lang=') . $lang,
+ '--enable-xml-details',
+ '--disable-libxml-check',
+ '--redirect-stderr-to-stdout',
+ ], $options->args);
+
+ return $this->environment->configure($lang, $args);
+ }
+}
diff --git a/scripts/dev/Command/HelpCommand.php b/scripts/dev/Command/HelpCommand.php
new file mode 100644
index 0000000000..a5962b4500
--- /dev/null
+++ b/scripts/dev/Command/HelpCommand.php
@@ -0,0 +1,49 @@
+ [options] [extra arguments]
+
+ Commands:
+ pull Clone missing sibling repositories and update existing ones
+ configure Assemble and validate the manual, without rendering
+ render xhtml configure + render the chunked XHTML manual to /output
+ render php configure + render the web (PHP) version to /output
+ cs lint Run docbook-cs; extra arguments are passed through (paths, --wide)
+ cs fix Same as cs lint, with --fix: rewrite violations that have fixers
+ serve Serve /output over HTTP
+ serve web-doc Run a local doc.php.net site from the web-doc checkout
+ docker build Build the Docker image
+ docker shell Interactive shell inside the container
+
+ Options:
+ --lang=XX Language to operate on (default: en)
+ --port=NNNN Port for serve (default: 8080)
+ --docker Force Docker mode (default: used when available)
+ --no-docker Force local mode
+ --yes, -y Clone missing repositories without asking for confirmation
+
+ Any other argument after the command is passed through: to configure.php
+ for configure/render (e.g. --with-partial=book.datetime), and to
+ docbook-cs for cs lint/cs fix (e.g. reference/datetime --wide).
+
+ HELP;
+
+ return 0;
+ }
+}
diff --git a/scripts/dev/Command/LintCommand.php b/scripts/dev/Command/LintCommand.php
new file mode 100644
index 0000000000..b9ea6346bb
--- /dev/null
+++ b/scripts/dev/Command/LintCommand.php
@@ -0,0 +1,57 @@
+lang;
+
+ if (!$this->workspace->ensureLangRepos($lang, $this->environment->canMapDirectoryNames())) {
+ return 1;
+ }
+
+ if ($this->configure->execute($options) !== 0) {
+ echo "\nconfigure reported problems (see above); linting anyway.\n\n";
+ }
+
+ $langdir = $this->workspace->langDir($lang);
+ $args = $options->args;
+
+ if ($this->fix) {
+ array_unshift($args, '--fix');
+ }
+
+ if (!file_exists("$langdir/docbookcs.xml")) {
+ $template = $this->workspace->getDocbookcsConfig();
+
+ if ($template === null) {
+ return 1;
+ }
+
+ $config = "$langdir/.docbookcs.dev.xml";
+ file_put_contents($config, str_replace('@LANG@', $lang, $template));
+ register_shutdown_function(static function () use ($config): void {
+ @unlink($config);
+ });
+ array_unshift($args, '--config=.docbookcs.dev.xml');
+ }
+
+ return $this->environment->lint($lang, $args);
+ }
+}
diff --git a/scripts/dev/Command/PullCommand.php b/scripts/dev/Command/PullCommand.php
new file mode 100644
index 0000000000..2a763c8f01
--- /dev/null
+++ b/scripts/dev/Command/PullCommand.php
@@ -0,0 +1,31 @@
+environment->canMapDirectoryNames();
+
+ if (!$this->workspace->ensureLangRepos($options->lang, $mapNames)) {
+ return 1;
+ }
+
+ $this->workspace->pullSideRepos($options->lang, verbose: true);
+
+ return 0;
+ }
+}
diff --git a/scripts/dev/Command/RenderCommand.php b/scripts/dev/Command/RenderCommand.php
new file mode 100644
index 0000000000..fe4f70c22d
--- /dev/null
+++ b/scripts/dev/Command/RenderCommand.php
@@ -0,0 +1,59 @@
+configure->execute($options);
+
+ if ($ret !== 0) {
+ return $ret;
+ }
+
+ $lang = $options->lang;
+ $format = $options->format;
+
+ // PhD never cleans its output directory, so files from removed or
+ // renamed pages would linger forever. Remove this format's own
+ // output tree before rendering; other formats next to it are kept.
+ $stale = $this->workspace->langDir($lang) . '/output/'
+ . ($format === 'php' ? 'php-web' : 'php-chunked-xhtml');
+
+ if (is_dir($stale)) {
+ echo "Removing previous $stale\n";
+ $this->workspace->removeTree($stale);
+ }
+
+ return $this->environment->render($lang, $this->docbook($options), $format);
+ }
+
+ /**
+ * configure.php writes a partial build (--with-partial=xml-id) to
+ * .manual..xml, next to the always-written full .manual.xml.
+ */
+ private function docbook(Options $options): string
+ {
+ foreach ($options->args as $arg) {
+ if (preg_match('/^--with-partial=(.+)$/', $arg, $m)) {
+ return '.manual.' . $m[1] . '.xml';
+ }
+ }
+
+ return '.manual.xml';
+ }
+}
diff --git a/scripts/dev/Command/ServeCommand.php b/scripts/dev/Command/ServeCommand.php
new file mode 100644
index 0000000000..da0c03f112
--- /dev/null
+++ b/scripts/dev/Command/ServeCommand.php
@@ -0,0 +1,37 @@
+lang;
+ $output = $this->workspace->langDir($lang) . '/output';
+
+ if (!is_dir($output)) {
+ echo "Note: $output does not exist yet; run \"php phpdoc.php render xhtml --lang=$lang\" first.\n";
+ }
+
+ // PhD renders each format into its own subdirectory of output/.
+ // Serve the chunked XHTML tree directly, so http://localhost:/
+ // lands on its index page instead of a 404.
+ $subdir = is_dir("$output/php-chunked-xhtml") ? '/php-chunked-xhtml' : '';
+
+ echo "Serving the $lang manual at http://localhost:{$options->port}/ (Ctrl-C to stop)\n";
+
+ return $this->environment->serve($lang, $options->port, $subdir);
+ }
+}
diff --git a/scripts/dev/Command/ShellCommand.php b/scripts/dev/Command/ShellCommand.php
new file mode 100644
index 0000000000..f7f9e60ea5
--- /dev/null
+++ b/scripts/dev/Command/ShellCommand.php
@@ -0,0 +1,20 @@
+environment->shell($options->lang);
+ }
+}
diff --git a/scripts/dev/Command/WebDocServeCommand.php b/scripts/dev/Command/WebDocServeCommand.php
new file mode 100644
index 0000000000..2b005712d0
--- /dev/null
+++ b/scripts/dev/Command/WebDocServeCommand.php
@@ -0,0 +1,98 @@
+environment->canMapDirectoryNames();
+
+ // The site needs en at request time (revcheck shells out to git in
+ // it), web-doc itself, and web-shared inside it.
+ if (!$this->workspace->ensureWebDocRepos() || !$this->workspace->ensureLang('en', $mapNames)) {
+ return 1;
+ }
+
+ $sqlite = $this->workspace->webDocDir() . '/sqlite/status.sqlite';
+
+ if (!is_file($sqlite)) {
+ $generate = $this->workspace->confirm(
+ "Generate $sqlite\n (translation status data; parses git history and can take minutes)?"
+ );
+
+ if ($generate) {
+ if ($this->generateDb($options, $mapNames) !== 0) {
+ return 1;
+ }
+ } else {
+ echo "Note: serving without status.sqlite; translation status pages will be empty.\n";
+ }
+ }
+
+ echo "Serving doc.php.net at http://localhost:{$options->port}/ (Ctrl-C to stop)\n";
+
+ return $this->environment->serveWebDoc($options->port);
+ }
+
+ private function generateDb(Options $options, bool $mapNames): int
+ {
+ $langs = array_keys($this->workspace->translationCheckouts());
+
+ if ($langs === [] && !$this->workspace->isBaseLang($options->lang)) {
+ if (!$this->workspace->ensureLang($options->lang, $mapNames)) {
+ return 1;
+ }
+
+ $langs = [$options->lang];
+ }
+
+ if ($langs === []) {
+ echo "Note: no translation checkouts found; skipping status.sqlite "
+ . "(clone one, or pass --lang=XX, and re-run).\n";
+ return 0;
+ }
+
+ if ($this->workspace->isShallowRepo($this->workspace->langDir('en'))) {
+ echo "Note: the en checkout has shallow git history; status data will be "
+ . "incomplete (fix with: git -C " . $this->workspace->langDir('en') . " fetch --unshallow).\n";
+ }
+
+ $sqliteDir = $this->workspace->webDocDir() . '/sqlite';
+ @mkdir($sqliteDir);
+ @unlink("$sqliteDir/status.sqlite.new");
+
+ echo 'Generating translation status data for: ' . implode(', ', $langs) . "\n";
+
+ if ($this->environment->generateRevisionDb($langs) !== 0) {
+ return 1;
+ }
+
+ // genrevdb can exit 0 even on failure, so also judge success by its
+ // output file; renaming afterwards keeps a half-written database from
+ // ever being published under the name the site reads.
+ if (!is_file("$sqliteDir/status.sqlite.new")) {
+ fwrite(STDERR, "error: generating status.sqlite failed.\n");
+ return 1;
+ }
+
+ if (!rename("$sqliteDir/status.sqlite.new", "$sqliteDir/status.sqlite")) {
+ fwrite(STDERR, "error: could not move status.sqlite.new into place.\n");
+ return 1;
+ }
+
+ return 0;
+ }
+}
diff --git a/scripts/dev/Environment/DockerEnvironment.php b/scripts/dev/Environment/DockerEnvironment.php
new file mode 100644
index 0000000000..090ea0a3b7
--- /dev/null
+++ b/scripts/dev/Environment/DockerEnvironment.php
@@ -0,0 +1,264 @@
+ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun($this->mounts($lang), array_merge(['php', 'doc-base/configure.php'], $args));
+ }
+
+ public function render(string $lang, string $docbook, string $format): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun($this->mounts($lang), [
+ 'php',
+ 'phd/render.php',
+ '--docbook',
+ "doc-base/$docbook",
+ '--output=/var/www/' . $lang . '/output',
+ '--package',
+ 'PHP',
+ '--format',
+ $format,
+ ]);
+ }
+
+ public function lint(string $lang, array $args): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun(
+ $this->mounts($lang),
+ array_merge(['php', '/var/www/docbook-cs/bin/docbook-cs'], $args),
+ $this->gitSafeDirectoryEnv(),
+ "/var/www/$lang"
+ );
+ }
+
+ public function serve(string $lang, int $port, string $subdir): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ // Inside the container the server must bind 0.0.0.0 to be reachable
+ // through the published port; the host side stays localhost-only.
+ return $this->dockerRun(
+ $this->mounts($lang),
+ ['php', '-S', "0.0.0.0:$port", '-t', "/var/www/$lang/output$subdir"],
+ ['-p', "127.0.0.1:$port:$port"]
+ );
+ }
+
+ public function serveWebDoc(int $port): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ // The site shells out to git inside the mounted checkouts at
+ // request time, hence the safe.directory override while serving.
+ return $this->dockerRun(
+ $this->webDocMounts(),
+ ['php', '-S', "0.0.0.0:$port", 'router.php'],
+ array_merge(
+ [
+ '-p',
+ "127.0.0.1:$port:$port",
+ '-e',
+ 'PHPDOC_GIT_DIR=/var/www',
+ '-e',
+ 'SQLITE_DIR=/var/www/web-doc/sqlite',
+ '-e',
+ 'BASE_DOCS_PATH=/var/www/doc-base/docs',
+ ],
+ $this->gitSafeDirectoryEnv()
+ ),
+ '/var/www/web-doc'
+ );
+ }
+
+ public function generateRevisionDb(array $langs): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun(
+ $this->webDocMounts(),
+ array_merge(
+ ['php', 'doc-base/scripts/translation/genrevdb.php', 'web-doc/sqlite/status.sqlite.new'],
+ $langs
+ ),
+ $this->gitSafeDirectoryEnv()
+ );
+ }
+
+ public function shell(string $lang): int
+ {
+ if (!$this->ensureImage()) {
+ return 1;
+ }
+
+ return $this->dockerRun($this->mounts($lang), ['bash'], ['-it']);
+ }
+
+ public function buildImage(): int
+ {
+ return $this->build() ? 0 : 1;
+ }
+
+ private function ensureImage(): bool
+ {
+ $dockerfile = $this->workspace->basedir() . '/.docker/Dockerfile';
+ $stamp = $this->workspace->basedir() . '/.docker/built';
+
+ if (
+ $this->runner->runQuiet(['docker', 'image', 'inspect', self::IMAGE]) === 0
+ && file_exists($stamp)
+ && filemtime($stamp) >= filemtime($dockerfile)
+ ) {
+ return true;
+ }
+
+ return $this->build();
+ }
+
+ private function build(): bool
+ {
+ $cmd = ['docker', 'build'];
+ $ids = $this->unixIds();
+
+ if ($ids !== null) {
+ array_push($cmd, '--build-arg', 'UID=' . $ids[0]);
+ array_push($cmd, '--build-arg', 'GID=' . $ids[1]);
+ }
+
+ array_push($cmd, '-t', self::IMAGE, $this->workspace->basedir() . '/.docker');
+
+ if ($this->runner->run($cmd) !== 0) {
+ return false;
+ }
+
+ touch($this->workspace->basedir() . '/.docker/built');
+
+ return true;
+ }
+
+ private function mounts(string $lang): array
+ {
+ $root = $this->workspace->rootdir();
+ $mounts = [realpath($this->workspace->basedir()) => '/var/www/doc-base'];
+
+ $mounts[$this->workspace->langDir($lang)] = "/var/www/$lang";
+
+ if (!$this->workspace->isBaseLang($lang)) {
+ $mounts[$this->workspace->langDir('en')] = '/var/www/en';
+ }
+
+ foreach (['phd', 'docbook-cs'] as $tool) {
+ if (is_dir("$root/$tool")) {
+ $mounts[realpath("$root/$tool")] = "/var/www/$tool";
+ }
+ }
+
+ return $mounts;
+ }
+
+ private function webDocMounts(): array
+ {
+ $mounts = [
+ realpath($this->workspace->basedir()) => '/var/www/doc-base',
+ $this->workspace->webDocDir() => '/var/www/web-doc',
+ $this->workspace->langDir('en') => '/var/www/en',
+ ];
+
+ // Mount every translation checkout so PHPDOC_GIT_DIR=/var/www looks
+ // like a full doc.php.net workspace to the site.
+ foreach ($this->workspace->translationCheckouts() as $lang => $dir) {
+ $mounts[$dir] = "/var/www/$lang";
+ }
+
+ return $mounts;
+ }
+
+ /** @return list */
+ private function gitSafeDirectoryEnv(): array
+ {
+ return [
+ '-e',
+ 'GIT_CONFIG_COUNT=1',
+ '-e',
+ 'GIT_CONFIG_KEY_0=safe.directory',
+ '-e',
+ 'GIT_CONFIG_VALUE_0=*',
+ ];
+ }
+
+ /**
+ * @param array $mounts Host path => container path.
+ * @param list $inner Command to run inside the container.
+ * @param list $extra Extra docker run arguments.
+ */
+ private function dockerRun(array $mounts, array $inner, array $extra = [], string $workdir = '/var/www'): int
+ {
+ // --init: without it the command runs as PID 1, which ignores
+ // SIGINT, so Ctrl-C would leave the container running forever.
+ $cmd = ['docker', 'run', '--rm', '--init'];
+
+ foreach ($mounts as $host => $container) {
+ array_push($cmd, '-v', "$host:$container");
+ }
+
+ array_push($cmd, '-w', $workdir);
+ $ids = $this->unixIds();
+
+ if ($ids !== null) {
+ array_push($cmd, '-u', $ids[0] . ':' . $ids[1]);
+ }
+
+ $cmd = array_merge($cmd, $extra);
+ $cmd[] = self::IMAGE;
+
+ return $this->runner->run(array_merge($cmd, $inner));
+ }
+
+ /** @return array{int, int}|null */
+ private function unixIds(): ?array
+ {
+ if (function_exists('posix_getuid')) {
+ return [posix_getuid(), posix_getgid()];
+ }
+
+ return null;
+ }
+}
diff --git a/scripts/dev/Environment/Environment.php b/scripts/dev/Environment/Environment.php
new file mode 100644
index 0000000000..434a64f666
--- /dev/null
+++ b/scripts/dev/Environment/Environment.php
@@ -0,0 +1,45 @@
+ checkout can be presented under its language
+ * name without touching the filesystem (Docker mounts can).
+ */
+ public function canMapDirectoryNames(): bool;
+
+ /** @param list $args configure.php arguments */
+ public function configure(string $lang, array $args): int;
+
+ /** @param string $docbook Manual file name inside doc-base, e.g. ".manual.xml". */
+ public function render(string $lang, string $docbook, string $format): int;
+
+ /** @param list $args docbook-cs arguments; runs in the language directory */
+ public function lint(string $lang, array $args): int;
+
+ /** @param string $subdir Path inside /output to use as web root, or "". */
+ public function serve(string $lang, int $port, string $subdir): int;
+
+ /** Serve the doc.php.net site from the web-doc checkout. */
+ public function serveWebDoc(int $port): int;
+
+ /**
+ * Run genrevdb.php from the workspace root, writing the translation
+ * status database to web-doc/sqlite/status.sqlite.new.
+ *
+ * @param list $langs Translation language codes.
+ */
+ public function generateRevisionDb(array $langs): int;
+
+ public function shell(string $lang): int;
+
+ public function buildImage(): int;
+}
diff --git a/scripts/dev/Environment/LocalEnvironment.php b/scripts/dev/Environment/LocalEnvironment.php
new file mode 100644
index 0000000000..8b64a1c662
--- /dev/null
+++ b/scripts/dev/Environment/LocalEnvironment.php
@@ -0,0 +1,163 @@
+requireLocalPhp(80400, 'building the manual')) {
+ return 1;
+ }
+
+ $args[] = '--with-php=' . PHP_BINARY;
+
+ return $this->runner->run(
+ array_merge([PHP_BINARY, $this->workspace->basedir() . '/configure.php'], $args),
+ $this->workspace->rootdir()
+ );
+ }
+
+ public function render(string $lang, string $docbook, string $format): int
+ {
+ $root = $this->workspace->rootdir();
+
+ if (!$this->workspace->ensureRepo("$root/phd", 'https://github.com/php/phd.git')) {
+ return 1;
+ }
+
+ return $this->runner->run([
+ PHP_BINARY,
+ "$root/phd/render.php",
+ '--docbook',
+ $this->workspace->basedir() . '/' . $docbook,
+ '--output=' . $this->workspace->langDir($lang) . '/output',
+ '--package',
+ 'PHP',
+ '--format',
+ $format,
+ ], $root);
+ }
+
+ public function lint(string $lang, array $args): int
+ {
+ if (!$this->requireLocalPhp(80500, 'docbook-cs')) {
+ return 1;
+ }
+
+ $csdir = $this->workspace->rootdir() . '/docbook-cs';
+
+ if (!$this->workspace->ensureRepo($csdir, 'https://github.com/php/docbook-cs.git')) {
+ return 1;
+ }
+
+ $langdir = $this->workspace->langDir($lang);
+
+ if (file_exists("$csdir/vendor/autoload.php")) {
+ return $this->runner->run(
+ array_merge([PHP_BINARY, "$csdir/bin/docbook-cs"], $args),
+ $langdir
+ );
+ }
+
+ // No composer install needed: docbook-cs has no runtime
+ // dependencies, so a plain PSR-4 autoloader on src/ is enough to
+ // run it in-process.
+ spl_autoload_register(static function (string $class) use ($csdir): void {
+ if (str_starts_with($class, 'DocbookCS\\')) {
+ require $csdir . '/src/' . str_replace('\\', '/', substr($class, 10)) . '.php';
+ }
+ });
+
+ chdir($langdir);
+ array_unshift($args, 'docbook-cs');
+
+ return \DocbookCS\Application::withArguments($args)->run();
+ }
+
+ public function serve(string $lang, int $port, string $subdir): int
+ {
+ return $this->runner->run([
+ PHP_BINARY,
+ '-S',
+ "localhost:$port",
+ '-t',
+ $this->workspace->langDir($lang) . '/output' . $subdir,
+ ]);
+ }
+
+ public function serveWebDoc(int $port): int
+ {
+ $webdoc = $this->workspace->webDocDir();
+
+ // router.php resolves www/ relative to the working directory, so
+ // the server has to run from inside the web-doc checkout.
+ return $this->runner->run(
+ [PHP_BINARY, '-S', "localhost:$port", 'router.php'],
+ $webdoc,
+ [
+ 'PHPDOC_GIT_DIR' => $this->workspace->rootdir(),
+ 'SQLITE_DIR' => "$webdoc/sqlite",
+ 'BASE_DOCS_PATH' => $this->workspace->basedir() . '/docs',
+ ]
+ );
+ }
+
+ public function generateRevisionDb(array $langs): int
+ {
+ if (!$this->requireLocalPhp(80100, 'generating status.sqlite')) {
+ return 1;
+ }
+
+ // genrevdb resolves en and each language relative to the working
+ // directory, so it must run from the workspace root.
+ return $this->runner->run(array_merge([
+ PHP_BINARY,
+ $this->workspace->basedir() . '/scripts/translation/genrevdb.php',
+ $this->workspace->webDocDir() . '/sqlite/status.sqlite.new',
+ ], $langs), $this->workspace->rootdir());
+ }
+
+ public function shell(string $lang): int
+ {
+ fwrite(STDERR, "error: docker shell requires Docker.\n");
+
+ return 1;
+ }
+
+ public function buildImage(): int
+ {
+ fwrite(STDERR, "error: docker build requires Docker.\n");
+
+ return 1;
+ }
+
+ private function requireLocalPhp(int $minimum, string $what): bool
+ {
+ if (PHP_VERSION_ID >= $minimum) {
+ return true;
+ }
+
+ $need = sprintf('%d.%d', intdiv($minimum, 10000), intdiv($minimum % 10000, 100));
+ fwrite(STDERR, "error: $what requires PHP $need+ without Docker (this is PHP "
+ . PHP_VERSION . "). Install Docker or a newer PHP.\n");
+
+ return false;
+ }
+}
diff --git a/scripts/dev/Options.php b/scripts/dev/Options.php
new file mode 100644
index 0000000000..6aa0fd43d6
--- /dev/null
+++ b/scripts/dev/Options.php
@@ -0,0 +1,21 @@
+ */
+ public array $args = [];
+}
diff --git a/scripts/dev/ProcessRunner.php b/scripts/dev/ProcessRunner.php
new file mode 100644
index 0000000000..c5017aa190
--- /dev/null
+++ b/scripts/dev/ProcessRunner.php
@@ -0,0 +1,63 @@
+ $cmd
+ * @param array $env Extra environment variables.
+ */
+ public function run(array $cmd, ?string $cwd = null, array $env = []): int
+ {
+ $envp = $env === [] ? null : array_merge(getenv(), $env);
+ $proc = @proc_open($cmd, [STDIN, STDOUT, STDERR], $pipes, $cwd, $envp);
+
+ if (!is_resource($proc)) {
+ fwrite(STDERR, "error: failed to execute {$cmd[0]}.\n");
+ return 127;
+ }
+
+ return proc_close($proc);
+ }
+
+ /** @param list $cmd */
+ public function output(array $cmd, ?string $cwd = null): ?string
+ {
+ $spec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']];
+ $proc = @proc_open($cmd, $spec, $pipes, $cwd);
+
+ if (!is_resource($proc)) {
+ return null;
+ }
+
+ fclose($pipes[0]);
+ $stdout = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+
+ return proc_close($proc) === 0 ? (string) $stdout : null;
+ }
+
+ /** @param list $cmd */
+ public function runQuiet(array $cmd): int
+ {
+ $spec = [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']];
+ $proc = @proc_open($cmd, $spec, $pipes);
+
+ if (!is_resource($proc)) {
+ return 127; /* command not found */
+ }
+
+ fclose($pipes[0]);
+ stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ stream_get_contents($pipes[2]);
+ fclose($pipes[2]);
+
+ return proc_close($proc);
+ }
+}
diff --git a/scripts/dev/Workspace.php b/scripts/dev/Workspace.php
new file mode 100644
index 0000000000..73821ab5a6
--- /dev/null
+++ b/scripts/dev/Workspace.php
@@ -0,0 +1,267 @@
+basedir;
+ }
+
+ public function rootdir(): string
+ {
+ return dirname($this->basedir);
+ }
+
+ public function isBaseLang(string $lang): bool
+ {
+ return in_array($lang, ['extensions', 'en'], true);
+ }
+
+ public function langDir(string $lang): string
+ {
+ $root = $this->rootdir();
+
+ if (is_dir("$root/$lang")) {
+ return realpath("$root/$lang");
+ }
+
+ return realpath("$root/doc-$lang") ?: "$root/$lang";
+ }
+
+ public function webDocDir(): string
+ {
+ $dir = $this->rootdir() . '/web-doc';
+
+ return realpath($dir) ?: $dir;
+ }
+
+ public function ensureWebDocRepos(): bool
+ {
+ $dir = $this->webDocDir();
+
+ return $this->ensureRepo($dir, 'https://github.com/php/web-doc.git')
+ && $this->ensureRepo("$dir/shared", 'https://github.com/php/web-shared.git');
+ }
+
+ /**
+ * Language checkouts present in the workspace, i.e. sibling directories
+ * with a translation.xml (so en/doc-base are never included).
+ *
+ * @return array Language code => absolute directory.
+ */
+ public function translationCheckouts(): array
+ {
+ $checkouts = [];
+
+ foreach (glob($this->rootdir() . '/*', GLOB_ONLYDIR) ?: [] as $dir) {
+ if (!is_file("$dir/translation.xml")) {
+ continue;
+ }
+
+ $lang = basename($dir);
+ $bare = !str_starts_with($lang, 'doc-');
+
+ if (!$bare) {
+ $lang = substr($lang, 4);
+ }
+
+ // When both "xx" and "doc-xx" exist the bare name wins,
+ // matching langDir().
+ if ($bare || !isset($checkouts[$lang])) {
+ $checkouts[$lang] = realpath($dir);
+ }
+ }
+
+ return $checkouts;
+ }
+
+ public function ensureRepo(string $dir, string $url): bool
+ {
+ if (is_dir($dir)) {
+ return true;
+ }
+
+ if (!$this->confirm("Clone $url\n into $dir?")) {
+ fwrite(STDERR, "error: cannot continue without $dir.\n");
+ return false;
+ }
+
+ return $this->runner->run(['git', 'clone', $url, $dir]) === 0;
+ }
+
+ /**
+ * @param bool $mapNames Whether the environment can present a
+ * doc- checkout under its language name
+ * (Docker mounts can, local builds cannot).
+ */
+ public function ensureLang(string $lang, bool $mapNames): bool
+ {
+ $root = $this->rootdir();
+
+ if (is_dir("$root/$lang")) {
+ return true;
+ }
+
+ if (is_dir("$root/doc-$lang")) {
+ if ($mapNames) {
+ return true;
+ }
+
+ if (@symlink("doc-$lang", "$root/$lang")) {
+ echo "Created symlink $lang -> doc-$lang\n";
+ return true;
+ }
+
+ fwrite(STDERR, "error: found $root/doc-$lang but could not create a '$lang' "
+ . "symlink next to it; rename the directory to '$lang' or use Docker.\n");
+ return false;
+ }
+
+ $repo = 'doc-' . strtolower($lang);
+
+ return $this->ensureRepo("$root/$lang", "https://github.com/php/$repo.git");
+ }
+
+ public function ensureLangRepos(string $lang, bool $mapNames): bool
+ {
+ if (!$this->isBaseLang($lang) && !$this->ensureLang('en', $mapNames)) {
+ return false;
+ }
+
+ if ($lang === 'en') {
+ return $this->ensureLang('en', $mapNames);
+ }
+
+ return $this->ensureLang($lang, $mapNames);
+ }
+
+ public function pullSideRepos(string $lang, bool $verbose = false): void
+ {
+ $root = $this->rootdir();
+ $repos = [$this->basedir];
+
+ if (!$this->isBaseLang($lang)) {
+ $repos[] = $this->langDir('en');
+ }
+
+ foreach (['phd', 'docbook-cs', 'web-doc', 'web-doc/shared'] as $tool) {
+ if (is_dir("$root/$tool")) {
+ $repos[] = realpath("$root/$tool");
+ }
+ }
+
+ foreach ($repos as $repo) {
+ $this->pullRepo($repo, $verbose);
+ }
+ }
+
+ private function pullRepo(string $dir, bool $verbose): void
+ {
+ if (!is_dir("$dir/.git")) {
+ return;
+ }
+
+ $name = basename($dir);
+ $branch = trim((string) $this->runner->output(['git', '-C', $dir, 'rev-parse', '--abbrev-ref', 'HEAD']));
+
+ if (!in_array($branch, ['master', 'main'], true)) {
+ if ($verbose) {
+ echo "Not updating $name: on branch '$branch'.\n";
+ }
+
+ return;
+ }
+
+ $before = $this->runner->output(['git', '-C', $dir, 'rev-parse', 'HEAD']);
+
+ if ($this->runner->run(['git', '-C', $dir, 'pull', '--ff-only', '--quiet', 'origin', $branch]) !== 0) {
+ echo "Could not update $name; continuing with the current checkout.\n";
+ return;
+ }
+
+ $after = $this->runner->output(['git', '-C', $dir, 'rev-parse', 'HEAD']);
+
+ if ($before !== $after) {
+ echo "Updated $name.\n";
+ } elseif ($verbose) {
+ echo "$name is up to date.\n";
+ }
+ }
+
+ public function isShallowRepo(string $dir): bool
+ {
+ $out = $this->runner->output(['git', '-C', $dir, 'rev-parse', '--is-shallow-repository']);
+
+ return trim((string) $out) === 'true';
+ }
+
+ public function removeTree(string $dir): void
+ {
+ $items = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($items as $item) {
+ if ($item->isDir() && !$item->isLink()) {
+ rmdir($item->getPathname());
+ } else {
+ unlink($item->getPathname());
+ }
+ }
+
+ rmdir($dir);
+ }
+
+ public function getDocbookcsConfig(): ?string
+ {
+ $template = $this->basedir . '/docbookcs.dev.xml';
+ $config = @file_get_contents($template);
+
+ if ($config === false) {
+ fwrite(STDERR, "error: cannot read $template.\n");
+ return null;
+ }
+
+ return $config;
+ }
+
+ public function confirm(string $question): bool
+ {
+ if ($this->assumeYes) {
+ return true;
+ }
+
+ if (!stream_isatty(STDIN)) {
+ fwrite(STDERR, "error: confirmation needed but there is no terminal; re-run with --yes.\n");
+ return false;
+ }
+
+ echo $question . ' [Y/n] ';
+ $line = fgets(STDIN);
+
+ // EOF (Ctrl-D, closed stdin) is not consent.
+ if ($line === false) {
+ echo "\n";
+ return false;
+ }
+
+ $answer = strtolower(trim($line));
+ return in_array($answer, ['', 'y', 'yes']);
+ }
+}