diff --git a/README.md b/README.md index 29a1a5d..9300d9e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ It gives you: - a generated excerpt when an article declares no description - display-ready date formatting - slug-based article lookup, newest first +- optional `status` frontmatter (`current`, `archive`, or `draft`; missing or unknown values default to `current`) +- drafts hidden outside the `local` environment unless you say otherwise It deliberately stops there: routes, controllers, Livewire components, and views stay in your application. @@ -47,6 +49,7 @@ return [ 'article_filename' => 'page.md', 'excerpt_length' => 220, 'date_format' => 'M j, Y', + 'show_drafts' => env('MARKDOWN_BLOG_SHOW_DRAFTS'), ]; ``` @@ -56,6 +59,7 @@ return [ | `article_filename` | Only files with this exact name are treated as articles. | | `excerpt_length` | Truncation length for the excerpt generated when `description` is absent. An ellipsis is appended, so the result runs a few characters longer. | | `date_format` | PHP date format applied to `formatted_date`. | +| `show_drafts` | Visibility of `status: draft` articles. `null` (default) shows them only when the application environment is `local`; `true` or `false` forces them on or off in every environment. | ## Article structure @@ -80,6 +84,7 @@ description: "Infinite scrolling is a popular feature for content-heavy pages." author: "Rick Mwamodo" date: "2024-01-17" slug: "infinite-scroll-with-laravel-and-livewire" +status: archive --- Article body goes here. @@ -98,6 +103,7 @@ value — you get an empty string, not the fallback. | `description` | An excerpt built from the body: markdown rendered, tags stripped, whitespace collapsed, truncated to `excerpt_length`. | | `author` | An empty string. | | `date` | An empty string, and `formatted_date` is then `null`. | +| `status` | `current`. Recognized values are `current`, `archive`, and `draft` (case-insensitive). Anything else, including a blank `status:`, is treated as `current` for backward compatibility. Start new posts as `status: draft` so they stay hidden until you publish them. | ### Frontmatter syntax @@ -127,6 +133,10 @@ list, for example) in your own code. use apxcde\MarkdownBlog\Facades\MarkdownBlog; $articles = MarkdownBlog::all(); +$current = MarkdownBlog::current(); +$archived = MarkdownBlog::archived(); +$drafts = MarkdownBlog::drafts(); +$listed = MarkdownBlog::listed(); $article = MarkdownBlog::findBySlug('infinite-scroll-with-laravel-and-livewire'); ``` @@ -138,6 +148,10 @@ use apxcde\MarkdownBlog\MarkdownBlog; $blog = app(MarkdownBlog::class); $articles = $blog->all(); +$current = $blog->current(); +$archived = $blog->archived(); +$drafts = $blog->drafts(); +$listed = $blog->listed(); $article = $blog->findBySlug('infinite-scroll-with-laravel-and-livewire'); $repository = $blog->repository(); ``` @@ -159,7 +173,8 @@ $article = $repository->findBySlug('infinite-scroll-with-laravel-and-livewire'); use apxcde\MarkdownBlog\Facades\MarkdownBlog; Route::get('/blog', fn () => view('blog.index', [ - 'articles' => MarkdownBlog::all(), + 'articles' => MarkdownBlog::listed(), + 'archived' => MarkdownBlog::archived(), ])); Route::get('/blog/{slug}', function (string $slug) { @@ -173,14 +188,45 @@ Route::get('/blog/{slug}', function (string $slug) { ### `all(): Illuminate\Support\Collection` -Returns every article as an array, sorted by `date` descending — newest first. -Articles with no date, or with a date Carbon cannot parse, sort last. If -`articles_path` does not exist, you get an empty collection. +Returns every **visible** article as an array, sorted by `date` descending — +newest first. Articles with no date, or with a date Carbon cannot parse, sort +last. If `articles_path` does not exist, you get an empty collection. `all()` +does not hide archived articles; use `current()`, `archived()`, or `listed()` +when a consumer wants a subset. + +Drafts (`status: draft`) are included only while drafts are visible. By default +that means the application environment is `local`; set the `show_drafts` config +key to `true` or `false` to override that in any environment. `findBySlug()` +uses the same gate, so a hidden draft's slug resolves to `null`. + +Each repository instance walks `articles_path` once and reuses the result for +every call, so `listed()` followed by `archived()` costs a single directory +scan. Resolve a fresh instance to pick up files added since. + +### `current(): Illuminate\Support\Collection` + +Returns articles whose normalized `status` is `current`, still newest first. +Drafts are never included. + +### `archived(): Illuminate\Support\Collection` + +Returns articles whose normalized `status` is `archive`, still newest first. + +### `drafts(): Illuminate\Support\Collection` + +Returns articles whose normalized `status` is `draft`, newest first, while +drafts are visible. Otherwise an empty collection. + +### `listed(): Illuminate\Support\Collection` + +Returns the public listing: `current` posts, plus drafts while they are +visible, still newest first. Archived posts are omitted. ### `findBySlug(string $slug): ?array` -Returns the matching article, or `null`. The argument is run through +Returns the matching **visible** article, or `null`. The argument is run through `Str::slug()` first, so `Infinite Scroll` and `infinite-scroll` both match. +Draft slugs resolve only while drafts are visible. ## Returned article shape @@ -192,6 +238,7 @@ Returns the matching article, or `null`. The argument is run through 'author' => 'Rick Mwamodo', 'date' => '2024-01-17', 'formatted_date' => 'Jan 17, 2024', + 'status' => 'archive', 'content' => 'Article body goes here.', ] ``` diff --git a/config/markdown-blog.php b/config/markdown-blog.php index f0b049f..c1c65ee 100644 --- a/config/markdown-blog.php +++ b/config/markdown-blog.php @@ -5,4 +5,11 @@ 'article_filename' => 'page.md', 'excerpt_length' => 220, 'date_format' => 'M j, Y', + + /* + * Visibility of articles whose frontmatter declares `status: draft`. + * null (default) shows drafts only when the application environment is + * "local"; true or false forces them on or off in every environment. + */ + 'show_drafts' => env('MARKDOWN_BLOG_SHOW_DRAFTS'), ]; diff --git a/src/ArticleRepository.php b/src/ArticleRepository.php index 1e372d7..aefaf61 100644 --- a/src/ArticleRepository.php +++ b/src/ArticleRepository.php @@ -11,26 +11,46 @@ class ArticleRepository { + private ?Collection $scanned = null; + public function __construct( private readonly FrontmatterParser $frontmatterParser, private readonly string $articlesPath, private readonly string $articleFilename = 'page.md', private readonly int $excerptLength = 220, private readonly string $dateFormat = 'M j, Y', + private readonly bool $showDrafts = false, ) {} public function all(): Collection { - if (! File::isDirectory($this->articlesPath)) { - return collect(); + $articles = $this->scan(); + + if ($this->showDrafts) { + return $articles->values(); } - return collect(File::allFiles($this->articlesPath)) - ->filter(fn ($file) => $file->getFilename() === $this->articleFilename) - ->map(fn ($file) => $this->hydrate($file->getPathname())) - ->filter() - ->sort(fn (array $left, array $right) => $this->compareArticleDates($left, $right)) - ->values(); + return $articles->where('status', '!=', 'draft')->values(); + } + + public function current(): Collection + { + return $this->all()->where('status', 'current')->values(); + } + + public function archived(): Collection + { + return $this->all()->where('status', 'archive')->values(); + } + + public function drafts(): Collection + { + return $this->all()->where('status', 'draft')->values(); + } + + public function listed(): Collection + { + return $this->all()->where('status', '!=', 'archive')->values(); } public function findBySlug(string $slug): ?array @@ -60,6 +80,7 @@ private function hydrate(string $path): ?array 'author' => $this->asString($frontmatter['author'] ?? ''), 'date' => $date, 'formatted_date' => $this->formatDate($date), + 'status' => $this->normalizeStatus($this->asString($frontmatter['status'] ?? '')), 'content' => $content, ]; } @@ -103,6 +124,36 @@ private function sortableDateValue(string $date): int } } + /** + * Walk the articles directory once per repository instance. + */ + private function scan(): Collection + { + if ($this->scanned !== null) { + return $this->scanned; + } + + if (! File::isDirectory($this->articlesPath)) { + return $this->scanned = collect(); + } + + return $this->scanned = collect(File::allFiles($this->articlesPath)) + ->filter(fn ($file) => $file->getFilename() === $this->articleFilename) + ->map(fn ($file) => $this->hydrate($file->getPathname())) + ->filter() + ->sort(fn (array $left, array $right) => $this->compareArticleDates($left, $right)) + ->values(); + } + + private function normalizeStatus(string $status): string + { + return match (strtolower(trim($status))) { + 'archive' => 'archive', + 'draft' => 'draft', + default => 'current', + }; + } + private function asString(mixed $value): string { if ($value instanceof \DateTimeInterface) { diff --git a/src/Facades/MarkdownBlog.php b/src/Facades/MarkdownBlog.php index 53db3e5..c0a60a2 100644 --- a/src/Facades/MarkdownBlog.php +++ b/src/Facades/MarkdownBlog.php @@ -8,6 +8,10 @@ * @see \apxcde\MarkdownBlog\MarkdownBlog * * @method static \Illuminate\Support\Collection all() + * @method static \Illuminate\Support\Collection current() + * @method static \Illuminate\Support\Collection archived() + * @method static \Illuminate\Support\Collection drafts() + * @method static \Illuminate\Support\Collection listed() * @method static array|null findBySlug(string $slug) * @method static \apxcde\MarkdownBlog\ArticleRepository repository() */ diff --git a/src/MarkdownBlog.php b/src/MarkdownBlog.php index dd21f49..1b24d54 100755 --- a/src/MarkdownBlog.php +++ b/src/MarkdownBlog.php @@ -15,6 +15,26 @@ public function all(): Collection return $this->articleRepository->all(); } + public function current(): Collection + { + return $this->articleRepository->current(); + } + + public function archived(): Collection + { + return $this->articleRepository->archived(); + } + + public function drafts(): Collection + { + return $this->articleRepository->drafts(); + } + + public function listed(): Collection + { + return $this->articleRepository->listed(); + } + public function findBySlug(string $slug): ?array { return $this->articleRepository->findBySlug($slug); diff --git a/src/MarkdownBlogServiceProvider.php b/src/MarkdownBlogServiceProvider.php index a0817cb..aef591e 100644 --- a/src/MarkdownBlogServiceProvider.php +++ b/src/MarkdownBlogServiceProvider.php @@ -20,12 +20,17 @@ public function packageRegistered(): void $this->app->singleton(FrontmatterParser::class); $this->app->bind(ArticleRepository::class, function ($app): ArticleRepository { + $showDrafts = $app['config']->get('markdown-blog.show_drafts'); + return new ArticleRepository( frontmatterParser: $app->make(FrontmatterParser::class), articlesPath: (string) $app['config']->get('markdown-blog.articles_path', resource_path('markdown/articles')), articleFilename: (string) $app['config']->get('markdown-blog.article_filename', 'page.md'), excerptLength: (int) $app['config']->get('markdown-blog.excerpt_length', 220), dateFormat: (string) $app['config']->get('markdown-blog.date_format', 'M j, Y'), + showDrafts: $showDrafts === null + ? $app->environment('local') + : filter_var($showDrafts, FILTER_VALIDATE_BOOLEAN), ); }); diff --git a/tests/ArticleRepositoryTest.php b/tests/ArticleRepositoryTest.php index c68c2af..17ac066 100644 --- a/tests/ArticleRepositoryTest.php +++ b/tests/ArticleRepositoryTest.php @@ -1,5 +1,7 @@ all(); expect($articles->pluck('slug')->all())->toBe([ + 'archived-post', 'custom-newest', 'human-date', 'no-description', 'older-post', + 'unknown-status', ])->and($articles->first())->toMatchArray([ - 'slug' => 'custom-newest', - 'title' => 'Newest Article', - 'description' => 'Newest description.', + 'slug' => 'archived-post', + 'title' => 'Archived Article', + 'description' => 'Archived description.', 'author' => 'Rick Mwamodo', - 'date' => '2024-01-17', - 'formatted_date' => 'Jan 17, 2024', + 'date' => '2024-02-01', + 'formatted_date' => 'Feb 1, 2024', + 'status' => 'archive', ]); }); +it('defaults missing or unknown status to current and lists current before archive filters', function () { + $repository = app(ArticleRepository::class); + + expect($repository->current()->pluck('slug')->all())->toBe([ + 'custom-newest', + 'human-date', + 'no-description', + 'older-post', + 'unknown-status', + ])->and($repository->archived()->pluck('slug')->all())->toBe([ + 'archived-post', + ])->and($repository->listed()->pluck('slug')->all())->toBe([ + 'custom-newest', + 'human-date', + 'no-description', + 'older-post', + 'unknown-status', + ])->and($repository->findBySlug('no-description')['status'])->toBe('current') + ->and($repository->findBySlug('unknown-status')['status'])->toBe('current') + ->and($repository->findBySlug('archived-post')['status'])->toBe('archive') + ->and($repository->findBySlug('draft-post'))->toBeNull() + ->and($repository->drafts())->toHaveCount(0) + ->and($repository->all()->pluck('slug')->all())->not->toContain('draft-post'); +}); + +it('exposes current and archived collections through the package service', function () { + $blog = app(MarkdownBlog::class); + + expect($blog->current()->pluck('status')->unique()->all())->toBe(['current']) + ->and($blog->archived()->pluck('status')->unique()->all())->toBe(['archive']) + ->and($blog->drafts())->toHaveCount(0); +}); + +it('exposes drafts only when the application environment is local', function () { + $this->app['env'] = 'local'; + + $blog = app(MarkdownBlog::class); + + expect($blog->drafts()->pluck('slug')->all())->toBe(['draft-post']) + ->and($blog->all()->pluck('slug')->all())->toBe([ + 'draft-post', + 'archived-post', + 'custom-newest', + 'human-date', + 'no-description', + 'older-post', + 'unknown-status', + ])->and($blog->listed()->pluck('slug')->all())->toBe([ + 'draft-post', + 'custom-newest', + 'human-date', + 'no-description', + 'older-post', + 'unknown-status', + ])->and($blog->current()->pluck('slug')->all())->not->toContain('draft-post') + ->and($blog->archived()->pluck('slug')->all())->toBe(['archived-post']) + ->and($blog->findBySlug('draft-post'))->toMatchArray([ + 'slug' => 'draft-post', + 'status' => 'draft', + 'title' => 'Draft Article', + ]); +}); + +it('shows drafts in any environment when show_drafts is true', function () { + config()->set('markdown-blog.show_drafts', true); + + $repository = app(ArticleRepository::class); + + expect(app()->environment())->not->toBe('local') + ->and($repository->drafts()->pluck('slug')->all())->toBe(['draft-post']) + ->and($repository->listed()->first()['slug'])->toBe('draft-post') + ->and($repository->current()->pluck('slug')->all())->not->toContain('draft-post') + ->and($repository->findBySlug('draft-post'))->not->toBeNull(); +}); + +it('hides drafts in the local environment when show_drafts is false', function () { + $this->app['env'] = 'local'; + config()->set('markdown-blog.show_drafts', false); + + $repository = app(ArticleRepository::class); + + expect($repository->drafts())->toHaveCount(0) + ->and($repository->all()->pluck('slug')->all())->not->toContain('draft-post') + ->and($repository->findBySlug('draft-post'))->toBeNull(); +}); + +it('scans the articles directory once per repository instance', function () { + $path = sys_get_temp_dir().'/markdown-blog-'.Str::random(8); + File::copyDirectory(__DIR__.'/Fixtures/articles', $path); + config()->set('markdown-blog.articles_path', $path); + + try { + $repository = app(ArticleRepository::class); + $before = $repository->all()->count(); + + File::ensureDirectoryExists($path.'/late-post'); + File::put($path.'/late-post/page.md', "---\ntitle: Late Post\ndate: 2024-05-01\n---\n\nLate body."); + + expect($repository->all())->toHaveCount($before) + ->and($repository->findBySlug('late-post'))->toBeNull() + ->and(app(ArticleRepository::class)->all())->toHaveCount($before + 1); + } finally { + File::deleteDirectory($path); + } +}); + it('sorts Carbon-parseable non-iso dates correctly', function () { $articles = app(ArticleRepository::class)->all()->keyBy('slug'); diff --git a/tests/Fixtures/articles/archived-post/page.md b/tests/Fixtures/articles/archived-post/page.md new file mode 100644 index 0000000..391293a --- /dev/null +++ b/tests/Fixtures/articles/archived-post/page.md @@ -0,0 +1,9 @@ +--- +title: "Archived Article" +description: "Archived description." +author: "Rick Mwamodo" +date: "2024-02-01" +status: archive +--- + +This is an archived article body. diff --git a/tests/Fixtures/articles/draft-post/page.md b/tests/Fixtures/articles/draft-post/page.md new file mode 100644 index 0000000..9910638 --- /dev/null +++ b/tests/Fixtures/articles/draft-post/page.md @@ -0,0 +1,9 @@ +--- +title: "Draft Article" +description: "Draft description." +author: "Rick Mwamodo" +date: "2024-03-01" +status: draft +--- + +This is a draft article body. diff --git a/tests/Fixtures/articles/unknown-status/page.md b/tests/Fixtures/articles/unknown-status/page.md new file mode 100644 index 0000000..c491963 --- /dev/null +++ b/tests/Fixtures/articles/unknown-status/page.md @@ -0,0 +1,9 @@ +--- +title: "Unknown Status Article" +description: "Unknown status description." +author: "Rick Mwamodo" +date: "2023-01-01" +status: preview +--- + +This article declares an unrecognized status and should be treated as current.