feature: Initial MVP

This commit is contained in:
Keith Solomon
2026-04-05 16:20:39 -05:00
parent 3af0b9cd0f
commit 812e5c2f2a
60 changed files with 5917 additions and 5 deletions
+51
View File
@@ -0,0 +1,51 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Atomic file writing support.
*/
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Writes files using a temporary file swap.
*/
final class AtomicWriter
{
/**
* Atomically write contents to a file.
*
* @param string $path Destination path.
* @param string $contents File contents.
*
* @return void
*/
public static function write(string $path, string $contents): void
{
ensure_directory(dirname($path));
$tmpPath = $path . '.tmp-' . bin2hex(random_bytes(4));
$handle = fopen($tmpPath, 'wb');
if ($handle === false) {
throw new \RuntimeException('Unable to open temp file: ' . $tmpPath);
}
try {
if (fwrite($handle, $contents) === false) {
throw new \RuntimeException('Unable to write temp file: ' . $tmpPath);
}
fflush($handle);
} finally {
fclose($handle);
}
if (!rename($tmpPath, $path)) {
@unlink($tmpPath);
throw new \RuntimeException('Unable to move temp file into place: ' . $path);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* File locking support.
*/
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Runs callbacks while holding an exclusive file lock.
*/
final class FileLock
{
/**
* Execute a callback under an exclusive lock.
*
* @param string $path Lock file path.
* @param callable $callback Callback to execute.
*
* @return mixed
*/
public static function run(string $path, callable $callback): mixed
{
ensure_directory(dirname($path));
$handle = fopen($path, 'c+');
if ($handle === false) {
throw new \RuntimeException('Unable to open lock file: ' . $path);
}
try {
if (!flock($handle, LOCK_EX)) {
throw new \RuntimeException('Unable to lock file: ' . $path);
}
return $callback();
} finally {
flock($handle, LOCK_UN);
fclose($handle);
}
}
}
+420
View File
@@ -0,0 +1,420 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Front matter parsing and dumping support.
*/
// phpcs:disable PEAR.NamingConventions.ValidFunctionName.PrivateNoUnderscore
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Parses and serializes a small YAML-like front matter format.
*/
final class FrontMatter
{
/**
* Parse a front matter document from disk.
*
* @param string $path File path.
*
* @return FrontMatterDocument
*/
public static function parseFile(string $path): FrontMatterDocument
{
$contents = is_file($path) ? (string) file_get_contents($path) : '';
return self::parse($contents);
}
/**
* Parse a front matter document from a string.
*
* @param string $contents Raw document contents.
*
* @return FrontMatterDocument
*/
public static function parse(string $contents): FrontMatterDocument
{
$normalized = str_replace(["\r\n", "\r"], "\n", $contents);
if (!str_starts_with($normalized, "---\n")) {
return new FrontMatterDocument([], trim($normalized));
}
$endMarker = strpos($normalized, "\n---\n", 4);
if ($endMarker === false) {
return new FrontMatterDocument([], trim($normalized));
}
$yaml = substr($normalized, 4, $endMarker - 4);
$body = substr($normalized, $endMarker + 5);
return new FrontMatterDocument(self::parseYaml($yaml), ltrim($body, "\n"));
}
/**
* Serialize front matter metadata and body.
*
* @param array<string, mixed> $meta Metadata map.
* @param string $body Markdown body.
*
* @return string
*/
public static function dump(array $meta, string $body): string
{
$yaml = rtrim(self::dumpYaml($meta));
$body = ltrim(str_replace(["\r\n", "\r"], "\n", $body), "\n");
return "---\n" . $yaml . "\n---\n\n" . $body . "\n";
}
/**
* Parse a YAML-like string into an array.
*
* @param string $yaml Raw YAML block.
*
* @return array<string, mixed>
*/
private static function parseYaml(string $yaml): array
{
$lines = preg_split("/\n/", $yaml) ?: [];
$index = 0;
return self::parseMap($lines, $index, 0);
}
/**
* Parse an indented map structure.
*
* @param list<string> $lines Source lines.
* @param int $index Current parse index.
* @param int $indent Expected indentation level.
*
* @return array<string, mixed>
*/
private static function parseMap(array $lines, int &$index, int $indent): array
{
$result = [];
$count = count($lines);
while ($index < $count) {
$rawLine = rtrim($lines[$index]);
if ($rawLine === '' || preg_match('/^\s*#/', $rawLine) === 1) {
$index++;
continue;
}
$lineIndent = self::indent($rawLine);
if ($lineIndent < $indent) {
break;
}
if ($lineIndent > $indent) {
$index++;
continue;
}
$trimmed = trim($rawLine);
if (str_starts_with($trimmed, '- ')) {
break;
}
[$key, $rest] = array_pad(explode(':', $trimmed, 2), 2, '');
$key = trim($key);
$rest = ltrim($rest);
$index++;
if ($rest !== '') {
$result[$key] = self::parseScalar($rest);
continue;
}
if ($index >= $count) {
$result[$key] = null;
continue;
}
$nextRaw = rtrim($lines[$index]);
if ($nextRaw === '') {
$result[$key] = null;
continue;
}
$nextIndent = self::indent($nextRaw);
if ($nextIndent <= $indent) {
$result[$key] = null;
continue;
}
$nextTrimmed = trim($nextRaw);
$result[$key] = str_starts_with($nextTrimmed, '- ')
? self::parseList($lines, $index, $nextIndent)
: self::parseMap($lines, $index, $nextIndent);
}
return $result;
}
/**
* Parse an indented list structure.
*
* @param list<string> $lines Source lines.
* @param int $index Current parse index.
* @param int $indent Expected indentation level.
*
* @return list<mixed>
*/
private static function parseList(array $lines, int &$index, int $indent): array
{
$result = [];
$count = count($lines);
while ($index < $count) {
$rawLine = rtrim($lines[$index]);
if ($rawLine === '' || preg_match('/^\s*#/', $rawLine) === 1) {
$index++;
continue;
}
$lineIndent = self::indent($rawLine);
if ($lineIndent < $indent) {
break;
}
$trimmed = trim($rawLine);
if (!str_starts_with($trimmed, '- ')) {
break;
}
$itemContent = substr($trimmed, 2);
$index++;
if ($itemContent === '') {
$result[] = self::parseMap($lines, $index, $indent + 2);
continue;
}
if (str_contains($itemContent, ':')) {
[$inlineKey, $inlineRest] = array_pad(explode(':', $itemContent, 2), 2, '');
$item = [trim($inlineKey) => self::parseScalar(ltrim($inlineRest))];
if ($index < $count) {
$nextRaw = rtrim($lines[$index]);
$nextIndent = $nextRaw === '' ? 0 : self::indent($nextRaw);
if ($nextRaw !== '' && $nextIndent > $indent) {
$item = array_merge($item, self::parseMap($lines, $index, $indent + 2));
}
}
$result[] = $item;
continue;
}
$result[] = self::parseScalar($itemContent);
}
return $result;
}
/**
* Serialize an array into YAML-like text.
*
* @param array<int|string, mixed> $value Value to serialize.
* @param int $indent Indentation level.
*
* @return string
*/
private static function dumpYaml(array $value, int $indent = 0): string
{
$lines = [];
$prefix = str_repeat(' ', $indent);
foreach ($value as $key => $item) {
if (is_array($item)) {
if (self::isList($item)) {
$lines[] = $prefix . $key . ':';
foreach ($item as $listItem) {
if (is_array($listItem)) {
$first = true;
foreach ($listItem as $nestedKey => $nestedValue) {
if ($first) {
$firstLine = $prefix . ' - ' . $nestedKey . ':';
if (!is_array($nestedValue)) {
$firstLine .= ' ' . self::dumpScalar($nestedValue);
}
$lines[] = $firstLine;
$first = false;
if (!is_array($nestedValue)) {
continue;
}
} elseif (is_array($nestedValue)) {
$lines[] = $prefix . ' ' . $nestedKey . ':';
} else {
$lines[] = $prefix . ' ' . $nestedKey . ': ' . self::dumpScalar($nestedValue);
continue;
}
if (is_array($nestedValue)) {
$lines[] = rtrim(self::dumpNested($nestedValue, $indent + 6));
}
}
} else {
$lines[] = $prefix . ' - ' . self::dumpScalar($listItem);
}
}
continue;
}
$lines[] = $prefix . $key . ':';
$lines[] = rtrim(self::dumpYaml($item, $indent + 2));
continue;
}
$lines[] = $prefix . $key . ': ' . self::dumpScalar($item);
}
return implode("\n", array_filter($lines, static fn ($line) => $line !== '')) . "\n";
}
/**
* Serialize nested arrays for YAML-like output.
*
* @param array<int|string, mixed> $value Value to serialize.
* @param int $indent Indentation level.
*
* @return string
*/
private static function dumpNested(array $value, int $indent): string
{
if (self::isList($value)) {
$lines = [];
$prefix = str_repeat(' ', $indent);
foreach ($value as $item) {
if (is_array($item)) {
$lines[] = $prefix . '-';
$lines[] = rtrim(self::dumpYaml($item, $indent + 2));
} else {
$lines[] = $prefix . '- ' . self::dumpScalar($item);
}
}
return implode("\n", $lines) . "\n";
}
return self::dumpYaml($value, $indent);
}
/**
* Parse a scalar value from YAML-like text.
*
* @param string $value Scalar text.
*
* @return mixed
*/
private static function parseScalar(string $value): mixed
{
$trimmed = trim($value);
if ($trimmed === 'null' || $trimmed === '~') {
return null;
}
if ($trimmed === 'true') {
return true;
}
if ($trimmed === 'false') {
return false;
}
if (preg_match('/^-?\d+$/', $trimmed) === 1) {
return (int) $trimmed;
}
if ((str_starts_with($trimmed, '"') && str_ends_with($trimmed, '"'))
|| (str_starts_with($trimmed, "'") && str_ends_with($trimmed, "'"))
) {
return substr($trimmed, 1, -1);
}
return $trimmed;
}
/**
* Serialize a scalar value for YAML-like output.
*
* @param mixed $value Scalar value.
*
* @return string
*/
private static function dumpScalar(mixed $value): string
{
if ($value === null) {
return 'null';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if (is_int($value) || is_float($value)) {
return (string) $value;
}
$stringValue = (string) $value;
if ($stringValue === '') {
return '""';
}
if (preg_match('/[:#\-\{\}\[\],&\*!|>\'%@`"]|\s$/', $stringValue) === 1 || str_contains($stringValue, "\n")) {
return '"' . addcslashes($stringValue, "\\\"") . '"';
}
return $stringValue;
}
/**
* Count the leading spaces for a line.
*
* @param string $line Input line.
*
* @return int
*/
private static function indent(string $line): int
{
return strlen($line) - strlen(ltrim($line, ' '));
}
/**
* Determine whether an array is a list.
*
* @param array<int|string, mixed> $value Array value.
*
* @return bool
*/
private static function isList(array $value): bool
{
return $value === [] || array_keys($value) === range(0, count($value) - 1);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Parsed front matter document value object.
*/
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Holds parsed front matter metadata and body content.
*/
final class FrontMatterDocument
{
/**
* Create a parsed document container.
*
* @param array<string, mixed> $meta Parsed metadata.
* @param string $body Markdown body content.
*/
public function __construct(
public array $meta,
public string $body
) {
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* HTTP helper support.
*/
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Small helpers for JSON HTTP responses and request validation.
*/
final class Http
{
/**
* Send a JSON response and end execution.
*
* @param array<string, mixed> $payload Response payload.
* @param int $status HTTP status code.
*
* @return never
*/
public static function json(array $payload, int $status = 200): never
{
http_response_code($status);
if (!(defined('IKB_TEST_MODE') && IKB_TEST_MODE)) {
header('Content-Type: application/json; charset=utf-8');
}
echo json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
if (defined('IKB_TEST_MODE') && IKB_TEST_MODE) {
throw new HttpHalt();
}
exit;
}
/**
* Send an error response and end execution.
*
* @param string $message Error message.
* @param int $status HTTP status code.
*
* @return never
*/
public static function error(string $message, int $status = 400): never
{
self::json(
[
'success' => false,
'error' => $message,
], $status
);
}
/**
* Parse request input from JSON or form data.
*
* @return array<string, mixed>
*/
public static function input(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return $_POST;
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Ensure the current request method matches the expected method.
*
* @param string $method Expected HTTP method.
*
* @return void
*/
public static function requireMethod(string $method): void
{
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') !== strtoupper($method)) {
self::error('method_not_allowed', 405);
}
}
/**
* Verify the CSRF token for the current request.
*
* @return void
*/
public static function verifyCsrf(): void
{
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ($_POST['_token'] ?? '');
if (!is_string($token) || $token === '' || !hash_equals(csrf_token(), $token)) {
self::error('invalid_csrf', 403);
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Internal test-only HTTP halt signal.
*/
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Signals that an HTTP response has been emitted and execution should stop.
*/
final class HttpHalt extends \RuntimeException
{
}
+56
View File
@@ -0,0 +1,56 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Path helper support.
*/
declare(strict_types=1);
namespace IronKanban\Support;
/**
* Resolves and validates storage paths.
*/
final class Path
{
/**
* Resolve a project's storage path.
*
* @param string $projectId Project identifier.
*
* @return string
*/
public static function project(string $projectId): string
{
self::assertSlug($projectId);
ensure_directory(project_root());
$path = project_root() . DIRECTORY_SEPARATOR . $projectId;
ensure_directory($path);
$resolved = realpath($path) ?: $path;
$root = realpath(project_root()) ?: project_root();
if (!str_starts_with($resolved, $root)) {
throw new \RuntimeException('Invalid project path.');
}
return $resolved;
}
/**
* Assert that a value is a valid slug.
*
* @param string $value Identifier candidate.
*
* @return void
*/
public static function assertSlug(string $value): void
{
if (preg_match('/^[a-z0-9][a-z0-9\-]*$/', $value) !== 1) {
throw new \InvalidArgumentException('Invalid identifier.');
}
}
}
+133
View File
@@ -0,0 +1,133 @@
<?php
// phpcs:disable PEAR.Commenting.FileComment,PEAR.Commenting.ClassComment
/**
* Global helper functions used by the application.
*/
// phpcs:disable PEAR.NamingConventions.ValidFunctionName
declare(strict_types=1);
/**
* Read a configuration value.
*
* @param string $key Configuration key.
* @param mixed $default Fallback value.
*
* @return mixed
*/
function config(string $key, mixed $default = null): mixed
{
global $appConfig;
return $appConfig[$key] ?? $default;
}
/**
* Resolve a path relative to the repository root.
*
* @param string $path Optional relative path.
*
* @return string
*/
function base_path(string $path = ''): string
{
$basePath = (string) config('base_path');
if ($path === '') {
return $basePath;
}
return $basePath . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR);
}
/**
* Return the project storage root.
*
* @return string
*/
function project_root(): string
{
return (string) config('project_root');
}
/**
* Escape a string for HTML output.
*
* @param string $value Raw value.
*
* @return string
*/
function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/**
* Return the current time in ISO-8601 format.
*
* @return string
*/
function now_iso8601(): string
{
return (new DateTimeImmutable('now'))->format(DateTimeInterface::ATOM);
}
/**
* Convert a string into a slug.
*
* @param string $value Raw input value.
*
* @return string
*/
function slugify(string $value): string
{
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? '';
$value = trim($value, '-');
return $value !== '' ? $value : 'item';
}
/**
* Generate a timestamp-based identifier.
*
* @param string $prefix Identifier prefix.
*
* @return string
*/
function generate_id(string $prefix): string
{
$timestamp = (new DateTimeImmutable('now'))->format('Ymd-His');
$random = substr(bin2hex(random_bytes(4)), 0, 6);
return $prefix . '-' . $timestamp . '-' . $random;
}
/**
* Get or create the session CSRF token.
*
* @return string
*/
function csrf_token(): string
{
if (!isset($_SESSION['csrf_token']) || !is_string($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Create a directory if it does not exist.
*
* @param string $path Directory path.
*
* @return void
*/
function ensure_directory(string $path): void
{
if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) {
throw new RuntimeException('Unable to create directory: ' . $path);
}
}