Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -47,6 +49,7 @@ return [
'article_filename' => 'page.md',
'excerpt_length' => 220,
'date_format' => 'M j, Y',
'show_drafts' => env('MARKDOWN_BLOG_SHOW_DRAFTS'),
];
```

Expand All @@ -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

Expand All @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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');
```

Expand All @@ -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();
```
Expand All @@ -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) {
Expand All @@ -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

Expand All @@ -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.',
]
```
Expand Down
7 changes: 7 additions & 0 deletions config/markdown-blog.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
];
67 changes: 59 additions & 8 deletions src/ArticleRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
];
}
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/Facades/MarkdownBlog.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
*/
Expand Down
20 changes: 20 additions & 0 deletions src/MarkdownBlog.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/MarkdownBlogServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
});

Expand Down
Loading
Loading