✨feature: Initial MVP
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user