From bd0bfdc09ffe5013bd8f5555895a7a9ced1f9198 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Sat, 4 Jul 2026 20:14:52 -0400 Subject: [PATCH] - Import --- README.md | 19 ++ app/CommentService.php | 42 +++ app/ContentRepository.php | 44 +++ cli/import-bludit.php | 646 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 751 insertions(+) create mode 100755 cli/import-bludit.php diff --git a/README.md b/README.md index c889543..f7cd71f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,25 @@ php cli/blog.php comments:spam 2 php cli/blog.php comments:delete 3 ``` +## Import Bludit + +Import from a local directory on the server. The source can be the old Bludit root or the old `bl-content` folder. + +```bash +php cli/import-bludit.php /var/www/old-bludit --dry-run +php cli/import-bludit.php /var/www/old-bludit +php cli/import-bludit.php /var/www/old-bludit/bl-content +php cli/import-bludit.php --source=/var/www/old-bludit --overwrite +``` + +The importer preserves original publish dates, modified dates, post/static-page type, draft/published state, categories, tags, authors, cover image metadata, allow-comments flags, page order, page-local files, and `bl-content/uploads`. + +It also scans local comment export files under the old `bl-content/databases` and `bl-content/workspaces` directories. To point it at a specific local comment export: + +```bash +php cli/import-bludit.php /var/www/old-bludit --comments=/var/www/old-bludit/comments.json +``` + ## Stats Visits are appended to `storage/stats/visits.csv` with a header row suitable for spreadsheet import. diff --git a/app/CommentService.php b/app/CommentService.php index a7e8b18..3d8fa79 100644 --- a/app/CommentService.php +++ b/app/CommentService.php @@ -87,6 +87,42 @@ final class CommentService ]; } + /** @param array $data */ + public function import(string $slug, array $data): int + { + $createdAt = $this->normalizeDate((string) ($data['created_at'] ?? $data['date'] ?? '')); + $updatedAt = $this->normalizeDate((string) ($data['updated_at'] ?? $data['modified'] ?? $createdAt)); + $status = (string) ($data['status'] ?? 'approved'); + if (!in_array($status, ['pending', 'approved', 'spam'], true)) { + $status = match ($status) { + 'published', 'published-comment', 'visible', '1', 'true' => 'approved', + 'trash', 'deleted', 'spam-comment' => 'spam', + default => 'pending', + }; + } + + $statement = $this->pdo->prepare( + 'INSERT INTO comments + (slug, author, email, website, body, status, ip_hash, user_agent, created_at, updated_at) + VALUES + (:slug, :author, :email, :website, :body, :status, :ip_hash, :user_agent, :created_at, :updated_at)' + ); + $statement->execute([ + 'slug' => $slug, + 'author' => trim((string) ($data['author'] ?? $data['name'] ?? 'Imported')), + 'email' => trim((string) ($data['email'] ?? '')), + 'website' => trim((string) ($data['website'] ?? $data['site_url'] ?? $data['url'] ?? '')), + 'body' => trim((string) ($data['body'] ?? $data['content'] ?? $data['comment'] ?? '')), + 'status' => $status, + 'ip_hash' => trim((string) ($data['ip_hash'] ?? '')), + 'user_agent' => substr((string) ($data['user_agent'] ?? $data['agent'] ?? 'import'), 0, 300), + 'created_at' => $createdAt, + 'updated_at' => $updatedAt, + ]); + + return (int) $this->pdo->lastInsertId(); + } + /** @return array> */ public function list(?string $status = null, ?string $slug = null): array { @@ -144,4 +180,10 @@ final class CommentService return hash_hmac('sha256', $ip, $salt); } + + private function normalizeDate(string $value): string + { + $timestamp = strtotime($value); + return date('c', $timestamp ?: time()); + } } diff --git a/app/ContentRepository.php b/app/ContentRepository.php index bffe73d..c6bd378 100644 --- a/app/ContentRepository.php +++ b/app/ContentRepository.php @@ -254,6 +254,50 @@ final class ContentRepository return $this->find($slug, true) ?? []; } + /** @param array $metadata */ + public function saveImported(string $slug, array $metadata, string $markdown, bool $rebuildIndex = true): array + { + $slug = self::slugify($slug !== '' ? $slug : (string) ($metadata['title'] ?? 'untitled')); + if ($slug === '') { + throw new RuntimeException('A title or slug is required.'); + } + + $directory = $this->pagesRoot . '/' . $slug; + if (!is_dir($directory)) { + mkdir($directory, 0775, true); + } + + $date = (string) ($metadata['date'] ?? date('c')); + $metadata = array_replace([ + 'title' => self::titleFromSlug($slug), + 'type' => 'post', + 'status' => 'published', + 'date' => $date, + 'modified' => $metadata['modified'] ?? $date, + 'category' => 'Notes', + 'tags' => [], + 'summary' => '', + 'author' => 'Editor', + 'cover' => '', + 'allow_comments' => true, + 'menu_order' => 0, + ], $metadata); + + $metadata['slug'] = $slug; + $metadata['tags'] = self::normalizeList($metadata['tags'] ?? []); + $metadata['allow_comments'] = self::toBool($metadata['allow_comments'] ?? true); + + file_put_contents($directory . '/index.txt', $this->serialize($metadata, $markdown)); + $timestamp = strtotime((string) ($metadata['modified'] ?? $metadata['date'])) ?: time(); + touch($directory . '/index.txt', $timestamp); + + if ($rebuildIndex) { + $this->rebuildIndex(); + } + + return $this->find($slug, true) ?? []; + } + public function delete(string $slug): bool { $slug = self::slugify($slug); diff --git a/cli/import-bludit.php b/cli/import-bludit.php new file mode 100755 index 0000000..39994b5 --- /dev/null +++ b/cli/import-bludit.php @@ -0,0 +1,646 @@ +#!/usr/bin/env php +repository(); +$comments = $app->comments(); + +[$args, $opts] = parseArguments(array_slice($_SERVER['argv'] ?? [], 1)); + +if (isset($opts['help']) || isset($opts['h']) || ($args === [] && !isset($opts['source']))) { + help(); + exit(isset($opts['help']) || isset($opts['h']) ? 0 : 1); +} + +$source = (string) ($opts['source'] ?? $args[0]); +$dryRun = isset($opts['dry-run']); +$overwrite = isset($opts['overwrite']); +$skipUploads = isset($opts['skip-uploads']); +$skipComments = isset($opts['skip-comments']); + +try { + $paths = resolveBluditSource($source); + $siteDb = readDatabase($paths['databases'] . '/site.php') + ?: readDatabase($paths['databases'] . '/site.json'); + $timezone = (string) ($opts['source-timezone'] ?? $siteDb['timezone'] ?? date_default_timezone_get()); + + $pagesDb = readDatabase($paths['databases'] . '/pages.php') + ?: readDatabase($paths['databases'] . '/pages.json'); + $categoriesDb = readDatabase($paths['databases'] . '/categories.php') + ?: readDatabase($paths['databases'] . '/categories.json'); + $tagsDb = readDatabase($paths['databases'] . '/tags.php') + ?: readDatabase($paths['databases'] . '/tags.json'); + + $entries = discoverPages($paths['pages'], $pagesDb); + if ($entries === []) { + fail('No Bludit pages were found in ' . $paths['pages']); + } + + $summary = [ + 'source' => $paths['content'], + 'dry_run' => $dryRun, + 'pages_found' => count($entries), + 'content_imported' => 0, + 'uploads_copied' => 0, + 'page_assets_copied' => 0, + 'comments_imported' => 0, + 'skipped' => [], + ]; + + $slugMap = []; + $reservedSlugs = []; + foreach ($entries as $entry) { + $oldKey = (string) $entry['key']; + $row = is_array($entry['db']) ? $entry['db'] : []; + $contentPath = $entry['path']; + $markdown = is_file($contentPath) ? (string) file_get_contents($contentPath) : ''; + $baseSlug = slugFromBluditKey($oldKey); + $slug = uniqueSlug($repo, $baseSlug, $reservedSlugs, $overwrite); + $reservedSlugs[$slug] = true; + $metadata = metadataFromBludit($oldKey, $row, $markdown, $categoriesDb, $tagsDb, $timezone); + + $slugMap[$oldKey] = $slug; + if (!$dryRun) { + $repo->saveImported($slug, $metadata, $markdown, false); + } + $summary['page_assets_copied'] += copyPageAssets(dirname($contentPath), BLOG_ROOT . '/bl-content/pages/' . $slug, $dryRun); + $summary['content_imported']++; + + printf( + "%s %-10s %-10s %s -> %s\n", + $dryRun ? 'Would import' : 'Imported', + $metadata['type'], + $metadata['status'], + $oldKey, + $slug + ); + } + + if (!$skipUploads && is_dir($paths['uploads'])) { + $summary['uploads_copied'] = copyTree($paths['uploads'], BLOG_ROOT . '/bl-content/uploads', $dryRun); + line(($dryRun ? 'Would copy' : 'Copied') . ' uploads from ' . $paths['uploads']); + } + + if (!$skipComments) { + $commentFiles = []; + if (isset($opts['comments'])) { + $commentFiles[] = (string) $opts['comments']; + } + $commentFiles = array_merge($commentFiles, discoverCommentFiles($paths['content'])); + $seenCommentFiles = []; + foreach ($commentFiles as $commentFile) { + $real = realpath($commentFile); + if ($real === false || isset($seenCommentFiles[$real])) { + continue; + } + $seenCommentFiles[$real] = true; + $records = readComments($real); + foreach ($records as $record) { + $oldSlug = (string) ($record['slug'] ?? ''); + $slug = $slugMap[$oldSlug] ?? $slugMap[str_replace('-', '/', $oldSlug)] ?? ContentRepository::slugify($oldSlug); + $body = trim((string) ($record['body'] ?? $record['content'] ?? $record['comment'] ?? '')); + if ($slug === '' || $body === '') { + continue; + } + if (!$dryRun) { + $comments->import($slug, [ + 'author' => $record['author'] ?? $record['name'] ?? 'Imported', + 'email' => $record['email'] ?? '', + 'website' => $record['website'] ?? $record['url'] ?? '', + 'body' => $body, + 'status' => $record['status'] ?? 'approved', + 'created_at' => normalizeDate((string) ($record['date'] ?? $record['created_at'] ?? ''), $timezone), + 'updated_at' => normalizeDate((string) ($record['modified'] ?? $record['updated_at'] ?? $record['date'] ?? ''), $timezone), + 'user_agent' => $record['user_agent'] ?? $record['agent'] ?? 'bludit-import', + ]); + } + $summary['comments_imported']++; + } + } + } + + if (!$dryRun) { + $repo->rebuildIndex(); + } + + line(''); + line('Import summary:'); + foreach ($summary as $key => $value) { + if (is_array($value)) { + continue; + } + line(' ' . $key . ': ' . (is_bool($value) ? ($value ? 'yes' : 'no') : (string) $value)); + } +} catch (Throwable $error) { + fail($error->getMessage()); +} + +function help(): void +{ + echo <<, 1: array} */ +function parseArguments(array $argv): array +{ + $args = []; + $opts = []; + foreach ($argv as $arg) { + if (str_starts_with($arg, '--')) { + $option = substr($arg, 2); + if (str_contains($option, '=')) { + [$key, $value] = explode('=', $option, 2); + $opts[$key] = $value; + } else { + $opts[$option] = true; + } + } elseif (str_starts_with($arg, '-')) { + $opts[ltrim($arg, '-')] = true; + } else { + $args[] = $arg; + } + } + + return [$args, $opts]; +} + +/** @return array{root: string, content: string, pages: string, databases: string, uploads: string} */ +function resolveBluditSource(string $source): array +{ + $path = realpath($source); + if ($path === false || !is_dir($path)) { + fail('Source directory not found or not readable: ' . $source); + } + + if (is_dir($path . '/bl-content')) { + $root = $path; + $content = $path . '/bl-content'; + } elseif (basename($path) === 'bl-content' || is_dir($path . '/pages')) { + $content = $path; + $root = dirname($path); + } else { + fail('Source must be a Bludit root or a bl-content directory: ' . $source); + } + + $pages = $content . '/pages'; + $databases = $content . '/databases'; + if (!is_dir($pages)) { + fail('Bludit pages directory not found: ' . $pages); + } + + return [ + 'root' => $root, + 'content' => $content, + 'pages' => $pages, + 'databases' => is_dir($databases) ? $databases : $content, + 'uploads' => $content . '/uploads', + ]; +} + +/** @return array */ +function readDatabase(string $path): array +{ + if (!is_file($path)) { + return []; + } + + $raw = (string) file_get_contents($path); + $json = trim($raw); + $firstObject = strpos($json, '{'); + $firstArray = strpos($json, '['); + $start = false; + if ($firstObject !== false && $firstArray !== false) { + $start = min($firstObject, $firstArray); + } elseif ($firstObject !== false) { + $start = $firstObject; + } elseif ($firstArray !== false) { + $start = $firstArray; + } + if ($start !== false) { + $json = substr($json, $start); + } + + $decoded = json_decode($json, true); + return is_array($decoded) ? $decoded : []; +} + +/** @return array}> */ +function discoverPages(string $pagesRoot, array $pagesDb): array +{ + $entries = []; + if ($pagesDb !== []) { + foreach ($pagesDb as $key => $row) { + $file = $pagesRoot . '/' . $key . '/index.txt'; + if (is_file($file)) { + $entries[] = [ + 'key' => (string) $key, + 'path' => $file, + 'db' => is_array($row) ? $row : [], + ]; + } + } + } + + if ($entries !== []) { + return $entries; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($pagesRoot, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getFilename() !== 'index.txt') { + continue; + } + $directory = dirname($file->getPathname()); + $key = trim(str_replace($pagesRoot, '', $directory), DIRECTORY_SEPARATOR); + $entries[] = [ + 'key' => str_replace(DIRECTORY_SEPARATOR, '/', $key), + 'path' => $file->getPathname(), + 'db' => [], + ]; + } + + usort($entries, static fn (array $a, array $b): int => strcmp($a['key'], $b['key'])); + return $entries; +} + +/** @param array $row @param array $categories @param array $tags */ +function metadataFromBludit(string $key, array $row, string $markdown, array $categories, array $tags, string $timezone): array +{ + $bluditType = (string) ($row['type'] ?? 'published'); + $type = $bluditType === 'static' ? 'page' : 'post'; + $status = in_array($bluditType, ['published', 'static', 'sticky'], true) ? 'published' : 'draft'; + $categoryKey = (string) ($row['category'] ?? ''); + $coverImage = (string) ($row['coverImage'] ?? ''); + if ($coverImage !== '' && !filter_var($coverImage, FILTER_VALIDATE_URL)) { + $uuid = (string) ($row['uuid'] ?? ''); + $coverImage = $uuid !== '' + ? '/bl-content/uploads/pages/' . $uuid . '/' . ltrim($coverImage, '/') + : '/bl-content/uploads/' . ltrim($coverImage, '/'); + } + + return [ + 'title' => trim((string) ($row['title'] ?? '')) !== '' + ? (string) $row['title'] + : titleFromMarkdownOrKey($markdown, $key), + 'type' => $type, + 'status' => $status, + 'date' => normalizeDate((string) ($row['date'] ?? ''), $timezone), + 'modified' => normalizeDate((string) ($row['dateModified'] ?? $row['date'] ?? ''), $timezone), + 'category' => categoryName($categoryKey, $categories), + 'tags' => tagNames($row['tags'] ?? [], $tags), + 'summary' => (string) ($row['description'] ?? ''), + 'author' => (string) ($row['username'] ?? 'Imported'), + 'cover' => $coverImage, + 'allow_comments' => (bool) ($row['allowComments'] ?? true), + 'menu_order' => (int) ($row['position'] ?? 0), + ]; +} + +function normalizeDate(string $value, string $timezone): string +{ + $value = trim($value); + if ($value === '') { + return date('c'); + } + + try { + $zone = new DateTimeZone($timezone); + } catch (Throwable) { + $zone = new DateTimeZone(date_default_timezone_get()); + } + + try { + $date = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $value, $zone) + ?: DateTimeImmutable::createFromFormat(DateTimeInterface::ATOM, $value) + ?: new DateTimeImmutable($value, $zone); + } catch (Throwable) { + $date = new DateTimeImmutable('now', $zone); + } + + return $date->format(DateTimeInterface::ATOM); +} + +function categoryName(string $key, array $categories): string +{ + if ($key !== '' && isset($categories[$key]) && is_array($categories[$key])) { + return (string) ($categories[$key]['name'] ?? $key); + } + + return $key !== '' ? ucwords(str_replace('-', ' ', $key)) : 'Notes'; +} + +function tagNames(mixed $rawTags, array $tagsDb): array +{ + if (is_string($rawTags)) { + return ContentRepository::normalizeList($rawTags); + } + if (!is_array($rawTags)) { + return []; + } + + $tags = []; + foreach ($rawTags as $key => $value) { + if (is_string($value) && $value !== '') { + $tags[] = $value; + } elseif (is_string($key) && isset($tagsDb[$key]) && is_array($tagsDb[$key])) { + $tags[] = (string) ($tagsDb[$key]['name'] ?? $key); + } elseif (is_string($key)) { + $tags[] = ucwords(str_replace('-', ' ', $key)); + } + } + + return ContentRepository::normalizeList($tags); +} + +function titleFromMarkdownOrKey(string $markdown, string $key): string +{ + foreach (explode("\n", $markdown) as $line) { + if (preg_match('/^#\s+(.+)$/', trim($line), $matches)) { + return trim($matches[1]); + } + } + + return ucwords(str_replace(['-', '/'], ' ', $key)); +} + +function slugFromBluditKey(string $key): string +{ + return ContentRepository::slugify(str_replace('/', '-', $key)); +} + +/** @param array $reserved */ +function uniqueSlug(ContentRepository $repo, string $baseSlug, array $reserved, bool $overwrite): string +{ + $baseSlug = $baseSlug !== '' ? $baseSlug : 'imported-page'; + if ($overwrite) { + return $baseSlug; + } + + $slug = $baseSlug; + $i = 2; + while (isset($reserved[$slug]) || $repo->find($slug, true) !== null) { + $slug = $baseSlug . '-' . $i; + $i++; + } + + return $slug; +} + +function copyPageAssets(string $sourceDir, string $targetDir, bool $dryRun): int +{ + $count = 0; + foreach (glob($sourceDir . '/*') ?: [] as $path) { + if (basename($path) === 'index.txt') { + continue; + } + $count += copyTree($path, $targetDir . '/' . basename($path), $dryRun); + } + + return $count; +} + +function copyTree(string $source, string $target, bool $dryRun): int +{ + if (!file_exists($source)) { + return 0; + } + $sourceReal = realpath($source); + $targetReal = realpath($target); + if ($sourceReal !== false && $targetReal !== false && $sourceReal === $targetReal) { + return 0; + } + if ($dryRun) { + if (is_file($source) || is_link($source)) { + return 1; + } + $count = 0; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if ($file->isFile() || $file->isLink()) { + $count++; + } + } + return $count; + } + + if (is_link($source)) { + $real = realpath($source); + return $real === false ? 0 : copyTree($real, $target, false); + } + if (is_file($source)) { + if (!is_dir(dirname($target))) { + mkdir(dirname($target), 0775, true); + } + copy($source, $target); + touch($target, filemtime($source) ?: time()); + return 1; + } + + if (!is_dir($target)) { + mkdir($target, 0775, true); + } + + $count = 0; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($iterator as $item) { + $relative = substr($item->getPathname(), strlen($source) + 1); + $destination = $target . '/' . $relative; + if ($item->isDir() && !$item->isLink()) { + if (!is_dir($destination)) { + mkdir($destination, 0775, true); + } + } else { + $count += copyTree($item->getPathname(), $destination, false); + } + } + + return $count; +} + +/** @return array */ +function discoverCommentFiles(string $contentRoot): array +{ + $files = []; + foreach ([$contentRoot . '/databases', $contentRoot . '/workspaces'] as $root) { + if (!is_dir($root)) { + continue; + } + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS) + ); + foreach ($iterator as $file) { + if (!$file->isFile()) { + continue; + } + $name = strtolower($file->getFilename()); + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (str_contains($name, 'comment') && in_array($extension, ['php', 'json', 'csv', 'sqlite', 'db'], true)) { + $files[] = $file->getPathname(); + } + } + } + + return $files; +} + +/** @return array> */ +function readComments(string $path): array +{ + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + if ($extension === 'csv') { + return readCommentCsv($path); + } + if (in_array($extension, ['sqlite', 'db'], true)) { + return readCommentSqlite($path); + } + + return collectComments(readDatabase($path)); +} + +/** @return array> */ +function readCommentCsv(string $path): array +{ + $handle = fopen($path, 'rb'); + if (!$handle) { + return []; + } + $header = fgetcsv($handle, null, ',', '"', '') ?: []; + $records = []; + while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { + $record = array_combine($header, $row); + if (is_array($record)) { + $records[] = normalizeCommentRecord($record); + } + } + fclose($handle); + + return $records; +} + +/** @return array> */ +function readCommentSqlite(string $path): array +{ + if (!class_exists(PDO::class)) { + return []; + } + + try { + $pdo = new PDO('sqlite:' . $path); + $tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")->fetchAll(PDO::FETCH_COLUMN); + $records = []; + foreach ($tables as $table) { + if (!str_contains(strtolower((string) $table), 'comment')) { + continue; + } + $rows = $pdo->query('SELECT * FROM "' . str_replace('"', '""', (string) $table) . '"')->fetchAll(PDO::FETCH_ASSOC); + foreach ($rows as $row) { + $records[] = normalizeCommentRecord($row); + } + } + return $records; + } catch (Throwable) { + return []; + } +} + +/** @return array> */ +function collectComments(mixed $value, string $slugHint = ''): array +{ + if (!is_array($value)) { + return []; + } + if (isCommentRecord($value)) { + $record = normalizeCommentRecord($value); + if (($record['slug'] ?? '') === '' && $slugHint !== '') { + $record['slug'] = $slugHint; + } + return [$record]; + } + + $records = []; + foreach ($value as $key => $child) { + $nextHint = is_string($key) && $key !== '' && !is_numeric($key) ? $key : $slugHint; + foreach (collectComments($child, $nextHint) as $record) { + $records[] = $record; + } + } + + return $records; +} + +function isCommentRecord(array $value): bool +{ + $bodyKeys = ['body', 'content', 'comment', 'message', 'text']; + $identityKeys = ['author', 'name', 'email', 'username']; + return array_intersect($bodyKeys, array_keys($value)) !== [] + && array_intersect($identityKeys, array_keys($value)) !== []; +} + +/** @param array $record @return array */ +function normalizeCommentRecord(array $record): array +{ + $slug = (string) ( + $record['slug'] + ?? $record['page'] + ?? $record['pageKey'] + ?? $record['page_key'] + ?? $record['post'] + ?? $record['post_slug'] + ?? '' + ); + + return [ + 'slug' => $slug, + 'author' => $record['author'] ?? $record['name'] ?? $record['username'] ?? 'Imported', + 'email' => $record['email'] ?? '', + 'website' => $record['website'] ?? $record['url'] ?? $record['site'] ?? '', + 'body' => $record['body'] ?? $record['content'] ?? $record['comment'] ?? $record['message'] ?? $record['text'] ?? '', + 'status' => $record['status'] ?? $record['state'] ?? 'approved', + 'date' => $record['date'] ?? $record['created'] ?? $record['created_at'] ?? '', + 'modified' => $record['modified'] ?? $record['updated'] ?? $record['updated_at'] ?? '', + 'user_agent' => $record['user_agent'] ?? $record['agent'] ?? '', + ]; +} + +function line(string $message): void +{ + echo $message . PHP_EOL; +} + +function fail(string $message): never +{ + fwrite(STDERR, $message . PHP_EOL); + exit(1); +}