From b33b93c9688516361fb304e54ada2dc43e59bb3f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 08:36:11 +0000 Subject: [PATCH 1/3] Add current/archive status to markdown-blog articles. Frontmatter status defaults to current. archive is the only other recognized value. Consumers can list with current() and archived() without changing all() or slug lookup. Co-authored-by: Eric Mwamodo --- README.md | 20 ++++++++++- src/ArticleRepository.php | 16 +++++++++ src/Facades/MarkdownBlog.php | 2 ++ src/MarkdownBlog.php | 10 ++++++ tests/ArticleRepositoryTest.php | 36 ++++++++++++++++--- tests/Fixtures/articles/archived-post/page.md | 9 +++++ .../Fixtures/articles/unknown-status/page.md | 9 +++++ 7 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 tests/Fixtures/articles/archived-post/page.md create mode 100644 tests/Fixtures/articles/unknown-status/page.md diff --git a/README.md b/README.md index 29a1a5d..5c687c1 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ 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` or `archive`; missing or unknown values default to `current`) It deliberately stops there: routes, controllers, Livewire components, and views stay in your application. @@ -80,6 +81,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 +100,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`. The only other recognized value is `archive` (case-insensitive). Anything else, including a blank `status:`, is treated as `current`. | ### Frontmatter syntax @@ -127,6 +130,8 @@ list, for example) in your own code. use apxcde\MarkdownBlog\Facades\MarkdownBlog; $articles = MarkdownBlog::all(); +$current = MarkdownBlog::current(); +$archived = MarkdownBlog::archived(); $article = MarkdownBlog::findBySlug('infinite-scroll-with-laravel-and-livewire'); ``` @@ -138,6 +143,8 @@ use apxcde\MarkdownBlog\MarkdownBlog; $blog = app(MarkdownBlog::class); $articles = $blog->all(); +$current = $blog->current(); +$archived = $blog->archived(); $article = $blog->findBySlug('infinite-scroll-with-laravel-and-livewire'); $repository = $blog->repository(); ``` @@ -175,7 +182,17 @@ Route::get('/blog/{slug}', function (string $slug) { 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. +`articles_path` does not exist, you get an empty collection. `all()` does not +hide archived articles; use `current()` or `archived()` when a consumer wants +only one status. + +### `current(): Illuminate\Support\Collection` + +Returns articles whose normalized `status` is `current`, still newest first. + +### `archived(): Illuminate\Support\Collection` + +Returns articles whose normalized `status` is `archive`, still newest first. ### `findBySlug(string $slug): ?array` @@ -192,6 +209,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' => 'current', 'content' => 'Article body goes here.', ] ``` diff --git a/src/ArticleRepository.php b/src/ArticleRepository.php index 1e372d7..2257af3 100644 --- a/src/ArticleRepository.php +++ b/src/ArticleRepository.php @@ -33,6 +33,16 @@ public function all(): Collection ->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 findBySlug(string $slug): ?array { return $this->all()->firstWhere('slug', Str::slug($slug)); @@ -60,6 +70,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 +114,11 @@ private function sortableDateValue(string $date): int } } + private function normalizeStatus(string $status): string + { + return strtolower(trim($status)) === 'archive' ? 'archive' : '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..868ea0f 100644 --- a/src/Facades/MarkdownBlog.php +++ b/src/Facades/MarkdownBlog.php @@ -8,6 +8,8 @@ * @see \apxcde\MarkdownBlog\MarkdownBlog * * @method static \Illuminate\Support\Collection all() + * @method static \Illuminate\Support\Collection current() + * @method static \Illuminate\Support\Collection archived() * @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..877f63f 100755 --- a/src/MarkdownBlog.php +++ b/src/MarkdownBlog.php @@ -15,6 +15,16 @@ 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 findBySlug(string $slug): ?array { return $this->articleRepository->findBySlug($slug); diff --git a/tests/ArticleRepositoryTest.php b/tests/ArticleRepositoryTest.php index c68c2af..fe2ec5b 100644 --- a/tests/ArticleRepositoryTest.php +++ b/tests/ArticleRepositoryTest.php @@ -11,20 +11,46 @@ $articles = app(ArticleRepository::class)->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->findBySlug('no-description')['status'])->toBe('current') + ->and($repository->findBySlug('unknown-status')['status'])->toBe('current') + ->and($repository->findBySlug('archived-post')['status'])->toBe('archive'); +}); + +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']); +}); + 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/unknown-status/page.md b/tests/Fixtures/articles/unknown-status/page.md new file mode 100644 index 0000000..15b2e51 --- /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: draft +--- + +This article declares an unrecognized status and should be treated as current. From 2998621939d1875c4bf3f3bac9c60eccd8235fd0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 02:22:03 +0000 Subject: [PATCH 2/3] Add local-only draft status to markdown-blog. Recognize status: draft alongside current and archive. Drafts appear in all(), drafts(), listed(), and findBySlug only when APP_ENV=local. Missing or unknown status still defaults to current; new posts should set draft. Co-authored-by: Eric Mwamodo --- README.md | 37 ++++++++++--- src/ArticleRepository.php | 52 +++++++++++++++---- src/Facades/MarkdownBlog.php | 2 + src/MarkdownBlog.php | 10 ++++ tests/ArticleRepositoryTest.php | 44 +++++++++++++++- tests/Fixtures/articles/draft-post/page.md | 9 ++++ .../Fixtures/articles/unknown-status/page.md | 2 +- 7 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 tests/Fixtures/articles/draft-post/page.md diff --git a/README.md b/README.md index 5c687c1..101f141 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ 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` or `archive`; missing or unknown values default to `current`) +- optional `status` frontmatter (`current`, `archive`, or `draft`; missing or unknown values default to `current`) It deliberately stops there: routes, controllers, Livewire components, and views stay in your application. @@ -100,7 +100,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`. The only other recognized value is `archive` (case-insensitive). Anything else, including a blank `status:`, is treated as `current`. | +| `status` | `current`. Recognized values are `current`, `archive`, and `draft` (case-insensitive). Anything else, including a blank `status:`, is treated as `current` for backward compatibility. **New posts must set `status: draft`.** Erick is the only publisher (`draft` → `current`). | ### Frontmatter syntax @@ -132,6 +132,8 @@ 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'); ``` @@ -145,6 +147,8 @@ $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(); ``` @@ -180,24 +184,41 @@ 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. `all()` does not -hide archived articles; use `current()` or `archived()` when a consumer wants -only one status. +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** when the application environment +is `local` (`APP_ENV=local`). They are omitted in `testing`, `staging`, and +`production`. `findBySlug()` uses the same gate, so a draft slug is `null` +outside local. ### `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, and only +when `APP_ENV=local`. Otherwise an empty collection. + +### `listed(): Illuminate\Support\Collection` + +Returns the public listing: `current` posts, plus drafts when local, 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 when `APP_ENV=local`. ## Returned article shape diff --git a/src/ArticleRepository.php b/src/ArticleRepository.php index 2257af3..7a4ba34 100644 --- a/src/ArticleRepository.php +++ b/src/ArticleRepository.php @@ -21,16 +21,13 @@ public function __construct( public function all(): Collection { - if (! File::isDirectory($this->articlesPath)) { - return collect(); + $articles = $this->scan(); + + if ($this->draftsAreVisible()) { + return $articles; } - 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 @@ -43,6 +40,20 @@ public function archived(): Collection return $this->all()->where('status', 'archive')->values(); } + public function drafts(): Collection + { + if (! $this->draftsAreVisible()) { + return collect(); + } + + return $this->scan()->where('status', 'draft')->values(); + } + + public function listed(): Collection + { + return $this->all()->where('status', '!=', 'archive')->values(); + } + public function findBySlug(string $slug): ?array { return $this->all()->firstWhere('slug', Str::slug($slug)); @@ -114,9 +125,32 @@ private function sortableDateValue(string $date): int } } + private function scan(): Collection + { + if (! File::isDirectory($this->articlesPath)) { + return collect(); + } + + 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(); + } + + private function draftsAreVisible(): bool + { + return app()->environment('local'); + } + private function normalizeStatus(string $status): string { - return strtolower(trim($status)) === 'archive' ? 'archive' : 'current'; + return match (strtolower(trim($status))) { + 'archive' => 'archive', + 'draft' => 'draft', + default => 'current', + }; } private function asString(mixed $value): string diff --git a/src/Facades/MarkdownBlog.php b/src/Facades/MarkdownBlog.php index 868ea0f..c0a60a2 100644 --- a/src/Facades/MarkdownBlog.php +++ b/src/Facades/MarkdownBlog.php @@ -10,6 +10,8 @@ * @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 877f63f..1b24d54 100755 --- a/src/MarkdownBlog.php +++ b/src/MarkdownBlog.php @@ -25,6 +25,16 @@ 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/tests/ArticleRepositoryTest.php b/tests/ArticleRepositoryTest.php index fe2ec5b..def9c3e 100644 --- a/tests/ArticleRepositoryTest.php +++ b/tests/ArticleRepositoryTest.php @@ -39,16 +39,56 @@ '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('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->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('sorts Carbon-parseable non-iso dates correctly', function () { 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 index 15b2e51..c491963 100644 --- a/tests/Fixtures/articles/unknown-status/page.md +++ b/tests/Fixtures/articles/unknown-status/page.md @@ -3,7 +3,7 @@ title: "Unknown Status Article" description: "Unknown status description." author: "Rick Mwamodo" date: "2023-01-01" -status: draft +status: preview --- This article declares an unrecognized status and should be treated as current. From cc3c8444d3e6b91ba213059b6018da2e03d890ef Mon Sep 17 00:00:00 2001 From: Erick Mwamodo Date: Tue, 1 Sep 2026 19:09:21 +0300 Subject: [PATCH 3/3] Add per-environment control for markdown-blog draft visibility - Introduces a `show_drafts` config flag so drafts can be forced on/off independent of `APP_ENV`, instead of always gating on `local`. - Caches the articles directory scan per repository instance to avoid redundant filesystem walks across repeated calls. - Refreshes the blog fixture content (Beacon 0.3.3-beta note, archived REST/MCP post) and OG images, with new tests covering the archive listing, OG image presence, and drafts scan caching. --- README.md | 32 ++++++++++++-------- config/markdown-blog.php | 7 +++++ src/ArticleRepository.php | 29 ++++++++++--------- src/MarkdownBlogServiceProvider.php | 5 ++++ tests/ArticleRepositoryTest.php | 45 +++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 101f141..9300d9e 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ It gives you: - 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. @@ -48,6 +49,7 @@ return [ 'article_filename' => 'page.md', 'excerpt_length' => 220, 'date_format' => 'M j, Y', + 'show_drafts' => env('MARKDOWN_BLOG_SHOW_DRAFTS'), ]; ``` @@ -57,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 @@ -100,7 +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. **New posts must set `status: draft`.** Erick is the only publisher (`draft` → `current`). | +| `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 @@ -170,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) { @@ -190,10 +194,14 @@ 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** when the application environment -is `local` (`APP_ENV=local`). They are omitted in `testing`, `staging`, and -`production`. `findBySlug()` uses the same gate, so a draft slug is `null` -outside local. +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` @@ -206,19 +214,19 @@ Returns articles whose normalized `status` is `archive`, still newest first. ### `drafts(): Illuminate\Support\Collection` -Returns articles whose normalized `status` is `draft`, newest first, and only -when `APP_ENV=local`. Otherwise an empty 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 when local, still -newest first. Archived posts are omitted. +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 **visible** article, or `null`. The argument is run through `Str::slug()` first, so `Infinite Scroll` and `infinite-scroll` both match. -Draft slugs resolve only when `APP_ENV=local`. +Draft slugs resolve only while drafts are visible. ## Returned article shape @@ -230,7 +238,7 @@ Draft slugs resolve only when `APP_ENV=local`. 'author' => 'Rick Mwamodo', 'date' => '2024-01-17', 'formatted_date' => 'Jan 17, 2024', - 'status' => 'current', + '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 7a4ba34..aefaf61 100644 --- a/src/ArticleRepository.php +++ b/src/ArticleRepository.php @@ -11,20 +11,23 @@ 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 { $articles = $this->scan(); - if ($this->draftsAreVisible()) { - return $articles; + if ($this->showDrafts) { + return $articles->values(); } return $articles->where('status', '!=', 'draft')->values(); @@ -42,11 +45,7 @@ public function archived(): Collection public function drafts(): Collection { - if (! $this->draftsAreVisible()) { - return collect(); - } - - return $this->scan()->where('status', 'draft')->values(); + return $this->all()->where('status', 'draft')->values(); } public function listed(): Collection @@ -125,13 +124,20 @@ 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 collect(); + return $this->scanned = collect(); } - return collect(File::allFiles($this->articlesPath)) + return $this->scanned = collect(File::allFiles($this->articlesPath)) ->filter(fn ($file) => $file->getFilename() === $this->articleFilename) ->map(fn ($file) => $this->hydrate($file->getPathname())) ->filter() @@ -139,11 +145,6 @@ private function scan(): Collection ->values(); } - private function draftsAreVisible(): bool - { - return app()->environment('local'); - } - private function normalizeStatus(string $status): string { return match (strtolower(trim($status))) { 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 def9c3e..17ac066 100644 --- a/tests/ArticleRepositoryTest.php +++ b/tests/ArticleRepositoryTest.php @@ -1,5 +1,7 @@ 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');