Files
tcms/cli/blog.php
T
Ty Clifford c4c10c256c - branding
2026-07-04 20:19:56 -04:00

333 lines
9.5 KiB
PHP

#!/usr/bin/env php
<?php
declare(strict_types=1);
use NeonBlog\ContentRepository;
$app = require dirname(__DIR__) . '/app/bootstrap.php';
$repo = $app->repository();
$comments = $app->comments();
$config = $app->config();
$stats = $app->stats();
$argv = $_SERVER['argv'] ?? [];
array_shift($argv);
$command = array_shift($argv) ?? 'help';
try {
match ($command) {
'help', '--help', '-h' => help(),
'init' => init($repo),
'rebuild' => rebuild($repo),
'list' => listContent($repo, options($argv)),
'create' => createContent($repo, $argv),
'update' => updateContent($repo, $argv),
'delete' => deleteContent($repo, $argv),
'comments:list' => listComments($comments, options($argv)),
'comments:approve' => setCommentStatus($comments, $argv, 'approved'),
'comments:pending' => setCommentStatus($comments, $argv, 'pending'),
'comments:spam' => setCommentStatus($comments, $argv, 'spam'),
'comments:delete' => deleteComment($comments, $argv),
'site:set' => setConfig($config, $argv),
'social:list' => listSocial($config),
'social:add' => addSocial($config, $argv),
'stats:summary' => statsSummary($stats),
default => fail('Unknown command "' . $command . '". Run php cli/blog.php help.'),
};
} catch (Throwable $error) {
fail($error->getMessage());
}
function help(): void
{
echo <<<TEXT
Ty Clifford CMS CLI
Content:
php cli/blog.php init
php cli/blog.php list [--type=post|page] [--all]
php cli/blog.php create "Title" [--slug=custom] [--type=post|page] [--status=published|draft] [--category=Name] [--tags=a,b] [--summary=text] [--body-file=/path/file.md] [--no-comments]
php cli/blog.php update slug [--title=New] [--status=published|draft] [--category=Name] [--tags=a,b] [--summary=text] [--body-file=/path/file.md]
php cli/blog.php delete slug
php cli/blog.php rebuild
Comments:
php cli/blog.php comments:list [--status=pending|approved|spam] [--slug=post-slug]
php cli/blog.php comments:approve 12
php cli/blog.php comments:pending 12
php cli/blog.php comments:spam 12
php cli/blog.php comments:delete 12
Site:
php cli/blog.php site:set site.title "My Blog"
php cli/blog.php social:list
php cli/blog.php social:add github "GitHub" https://github.com/example
php cli/blog.php stats:summary
TEXT;
}
function init(ContentRepository $repo): void
{
$repo->rebuildIndex();
line('Content index rebuilt.');
}
function rebuild(ContentRepository $repo): void
{
$repo->rebuildIndex();
line('Content index rebuilt.');
}
/** @param array<string, mixed> $opts */
function listContent(ContentRepository $repo, array $opts): void
{
$includeDrafts = isset($opts['all']);
$type = (string) ($opts['type'] ?? 'post');
$items = $type === 'page' ? $repo->pages($includeDrafts) : $repo->posts($includeDrafts);
if ($items === []) {
line('No content found.');
return;
}
foreach ($items as $item) {
printf(
"%-22s %-10s %-10s %s\n",
$item['slug'],
$item['type'],
$item['status'],
$item['title']
);
}
}
/** @param array<int, string> $args */
function createContent(ContentRepository $repo, array $args): void
{
$title = array_shift($args);
if ($title === null || str_starts_with($title, '--')) {
fail('A title is required.');
}
$opts = options($args);
$slug = (string) ($opts['slug'] ?? ContentRepository::slugify($title));
$body = bodyFromOptions($opts, "# {$title}\n\nStart writing here.");
$metadata = metadataFromOptions($opts, [
'title' => $title,
'type' => $opts['type'] ?? 'post',
'status' => $opts['status'] ?? 'draft',
]);
$item = $repo->save($slug, $metadata, $body);
line('Saved ' . $item['type'] . ' "' . $item['title'] . '" at bl-content/pages/' . $item['slug'] . '/index.txt');
}
/** @param array<int, string> $args */
function updateContent(ContentRepository $repo, array $args): void
{
$slug = array_shift($args);
if ($slug === null || str_starts_with($slug, '--')) {
fail('A slug is required.');
}
$item = $repo->find($slug, true);
if ($item === null) {
fail('No content exists for slug "' . $slug . '".');
}
$opts = options($args);
$body = bodyFromOptions($opts, (string) $item['markdown']);
$metadata = metadataFromOptions($opts, $item);
$newSlug = (string) ($opts['slug'] ?? $slug);
$saved = $repo->save($newSlug, $metadata, $body);
if ($newSlug !== $slug) {
$repo->delete($slug);
}
line('Updated ' . $saved['slug'] . '.');
}
/** @param array<int, string> $args */
function deleteContent(ContentRepository $repo, array $args): void
{
$slug = $args[0] ?? '';
if ($slug === '') {
fail('A slug is required.');
}
line($repo->delete($slug) ? 'Deleted ' . $slug . '.' : 'No content exists for slug "' . $slug . '".');
}
/** @param array<string, mixed> $opts */
function listComments($comments, array $opts): void
{
$rows = $comments->list($opts['status'] ?? null, $opts['slug'] ?? null);
if ($rows === []) {
line('No comments found.');
return;
}
foreach ($rows as $row) {
printf(
"#%-4d %-9s %-20s %-18s %s\n",
$row['id'],
$row['status'],
$row['slug'],
$row['author'],
mb_substr(str_replace(["\r", "\n"], ' ', $row['body']), 0, 64)
);
}
}
/** @param array<int, string> $args */
function setCommentStatus($comments, array $args, string $status): void
{
$id = (int) ($args[0] ?? 0);
if ($id < 1) {
fail('A numeric comment id is required.');
}
line($comments->setStatus($id, $status) ? 'Comment updated.' : 'Comment not found.');
}
/** @param array<int, string> $args */
function deleteComment($comments, array $args): void
{
$id = (int) ($args[0] ?? 0);
if ($id < 1) {
fail('A numeric comment id is required.');
}
line($comments->delete($id) ? 'Comment deleted.' : 'Comment not found.');
}
/** @param array<int, string> $args */
function setConfig($config, array $args): void
{
$key = $args[0] ?? '';
$value = $args[1] ?? null;
if ($key === '' || $value === null) {
fail('Usage: php cli/blog.php site:set site.title "My Blog"');
}
$config->set($key, parseCliValue($value));
$config->save();
line('Updated config key ' . $key . '.');
}
function listSocial($config): void
{
$social = $config->get('social', []);
if (!is_array($social) || $social === []) {
line('No social links configured.');
return;
}
foreach ($social as $network => $item) {
printf("%-14s %-18s %s\n", $network, $item['label'] ?? $network, $item['url'] ?? '');
}
}
/** @param array<int, string> $args */
function addSocial($config, array $args): void
{
[$network, $label, $url] = [$args[0] ?? '', $args[1] ?? '', $args[2] ?? ''];
if ($network === '' || $label === '' || $url === '') {
fail('Usage: php cli/blog.php social:add github "GitHub" https://github.com/example');
}
$social = $config->get('social', []);
$social[$network] = ['label' => $label, 'url' => $url];
$config->set('social', $social);
$config->save();
line('Added social link ' . $network . '.');
}
function statsSummary($stats): void
{
$summary = $stats->summary();
line('Visits: ' . $summary['visits']);
line('Routes:');
foreach ($summary['routes'] as $route => $count) {
line(' ' . $route . ': ' . $count);
}
line('Top paths:');
foreach ($summary['top_paths'] as $path => $count) {
line(' ' . $path . ': ' . $count);
}
}
/** @param array<int, string> $args @return array<string, mixed> */
function options(array $args): array
{
$options = [];
foreach ($args as $arg) {
if (!str_starts_with($arg, '--')) {
continue;
}
$arg = substr($arg, 2);
if (str_contains($arg, '=')) {
[$key, $value] = explode('=', $arg, 2);
$options[$key] = parseCliValue($value);
} else {
$options[$arg] = true;
}
}
return $options;
}
/** @param array<string, mixed> $opts @param array<string, mixed> $base @return array<string, mixed> */
function metadataFromOptions(array $opts, array $base): array
{
foreach (['title', 'slug', 'type', 'status', 'date', 'category', 'summary', 'author', 'cover', 'menu_order'] as $key) {
if (array_key_exists($key, $opts)) {
$base[$key] = $opts[$key];
}
}
if (array_key_exists('tags', $opts)) {
$base['tags'] = ContentRepository::normalizeList($opts['tags']);
}
if (isset($opts['no-comments'])) {
$base['allow_comments'] = false;
}
return $base;
}
/** @param array<string, mixed> $opts */
function bodyFromOptions(array $opts, string $fallback): string
{
if (!isset($opts['body-file'])) {
return $fallback;
}
$path = (string) $opts['body-file'];
if (!is_file($path)) {
fail('Body file not found: ' . $path);
}
return (string) file_get_contents($path);
}
function parseCliValue(string $value): mixed
{
$lower = mb_strtolower($value);
if (in_array($lower, ['true', 'false'], true)) {
return $lower === 'true';
}
if (is_numeric($value) && preg_match('/^\d+$/', $value)) {
return (int) $value;
}
return $value;
}
function line(string $message): void
{
echo $message . PHP_EOL;
}
function fail(string $message): never
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}