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
3 changes: 3 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
branches: [master]
pull_request:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 XSAVO
Copyright (c) 2026 ApexCode

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
180 changes: 126 additions & 54 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
# xsavo/markdown-blog
# apxcde/markdown-blog

`xsavo/markdown-blog` is a Laravel package for loading markdown-based blog articles from your application files.
[![Latest Version on Packagist](https://img.shields.io/packagist/v/apxcde/markdown-blog.svg)](https://packagist.org/packages/apxcde/markdown-blog)
[![Tests](https://github.com/apxcde/markdown-blog/actions/workflows/tests.yml/badge.svg)](https://github.com/apxcde/markdown-blog/actions/workflows/tests.yml)
[![License](https://img.shields.io/packagist/l/apxcde/markdown-blog.svg)](LICENSE.md)

It provides:
A Laravel package that turns a directory of markdown files into blog articles.

- discovery of article files from a configured directory
It gives you:

- recursive discovery of article files under a configured directory
- frontmatter parsing for common article metadata
- normalized article payloads
- generated excerpts from markdown content
- date formatting for display
- slug-based article lookup
- normalized article payloads as plain arrays
- a generated excerpt when an article declares no description
- display-ready date formatting
- slug-based article lookup, newest first

It deliberately stops there: routes, controllers, Livewire components, and views
stay in your application.

## Requirements

Expand All @@ -18,25 +25,23 @@ It provides:

## Installation

Require the package with Composer:

```bash
composer require xsavo/markdown-blog
composer require apxcde/markdown-blog
```

The package service provider is auto-registered through Laravel package discovery.
The service provider and the `MarkdownBlog` facade alias are registered
automatically through Laravel package discovery.

## Configuration

Publish the config file if you want to customize the article location or formatting:
Publish the config file to change where articles live or how they are formatted:

```bash
php artisan vendor:publish --tag=markdown-blog-config
```

Default configuration:

```php
// config/markdown-blog.php
return [
'articles_path' => resource_path('markdown/articles'),
'article_filename' => 'page.md',
Expand All @@ -45,84 +50,139 @@ return [
];
```

## Article structure
| Key | Purpose |
| --- | --- |
| `articles_path` | Directory scanned for articles. A missing directory yields an empty collection rather than an error. |
| `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`. |

By default, the package looks for files named `page.md` inside the configured articles directory.
## Article structure

Example:
Each article is a directory containing one `page.md`. The directory is scanned
recursively, so you are free to group articles into subdirectories.

```text
resources/
└── markdown/
└── articles/
├── first-post/
│ └── page.md
└── another-post/
└── page.md
└── 2024/
└── another-post/
└── page.md
```

Example article:

```md
---
title: Infinite Scroll with Laravel and Livewire
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
title: "Infinite Scroll with Laravel and Livewire"
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"
---

# Infinite Scroll with Laravel and Livewire

Article body goes here.
```

Supported frontmatter fields:
### Frontmatter fields

Every field is optional. When one is **absent**, the package falls back as
follows. Note that a key which is present but blank (`title:`) counts as a
value — you get an empty string, not the fallback.

| Field | Fallback |
| --- | --- |
| `slug` | The article's parent directory name. Either way the value is passed through `Str::slug()`. An article that resolves to an empty slug is skipped. |
| `title` | `Str::headline()` of the slug, so `first-post` becomes `First Post`. |
| `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`. |

### Frontmatter syntax

- `title`
- `description`
- `author`
- `date`
- `slug`
This is **not** a YAML parser. It handles a small, deliberate subset: flat
`key: value` pairs, one per line. Values may be unquoted, single-quoted, or
double-quoted; surrounding quotes are stripped and every value is returned as a
string, so `42` and `true` are not cast. Blank lines, `#` comments, and lines
with no colon are skipped.

If some fields are omitted, the package falls back to sensible defaults where possible.
Richer YAML is not rejected — it is quietly mis-read, so avoid it:

| You write | You get |
| --- | --- |
| `author:` then an indented `name: Rick` | Indentation is ignored, so `name` is hoisted to a top-level key and `author` becomes `''`. |
| `tags:` then `- php`, `- laravel` | The `- ` lines have no colon and are dropped; `tags` becomes `''`. |
| `items:` then `- name: foo` | That line *does* contain a colon, so you get a literal key `- name`. |
| `tags: [php, laravel]` | Kept verbatim as the string `'[php, laravel]'`. |

Keep frontmatter flat and scalar, and parse richer values (a comma-separated
list, for example) in your own code.

## Usage

### Resolve the repository
### Facade

```php
use xsavo\MarkdownBlog\ArticleRepository;
use apxcde\MarkdownBlog\Facades\MarkdownBlog;

$repository = app(ArticleRepository::class);

$articles = $repository->all();
$article = $repository->findBySlug('infinite-scroll-with-laravel-and-livewire');
$articles = MarkdownBlog::all();
$article = MarkdownBlog::findBySlug('infinite-scroll-with-laravel-and-livewire');
```

### Resolve the package service
### Resolved service

```php
use xsavo\MarkdownBlog\MarkdownBlog;
use apxcde\MarkdownBlog\MarkdownBlog;

$blog = app(MarkdownBlog::class);

$articles = $blog->all();
$article = $blog->findBySlug('infinite-scroll-with-laravel-and-livewire');
$repository = $blog->repository();
```

### Use the facade
### Repository

```php
use xsavo\MarkdownBlog\Facades\MarkdownBlog;
use apxcde\MarkdownBlog\ArticleRepository;

$articles = MarkdownBlog::all();
$article = MarkdownBlog::findBySlug('infinite-scroll-with-laravel-and-livewire');
$repository = app(ArticleRepository::class);

$articles = $repository->all();
$article = $repository->findBySlug('infinite-scroll-with-laravel-and-livewire');
```

## Returned article shape
### In a controller

```php
use apxcde\MarkdownBlog\Facades\MarkdownBlog;

Route::get('/blog', fn () => view('blog.index', [
'articles' => MarkdownBlog::all(),
]));

Route::get('/blog/{slug}', function (string $slug) {
abort_if(! $article = MarkdownBlog::findBySlug($slug), 404);

return view('blog.show', ['article' => $article]);
});
```

Each article is returned as an array like:
## API

### `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.

### `findBySlug(string $slug): ?array`

Returns the matching article, or `null`. The argument is run through
`Str::slug()` first, so `Infinite Scroll` and `infinite-scroll` both match.

## Returned article shape

```php
[
Expand All @@ -132,20 +192,32 @@ Each article is returned as an array like:
'author' => 'Rick Mwamodo',
'date' => '2024-01-17',
'formatted_date' => 'Jan 17, 2024',
'content' => '# Infinite Scroll with Laravel and Livewire...',
'content' => 'Article body goes here.',
]
```

## Notes

This package handles article loading and normalization only. Rendering routes, controllers, Livewire components, and views remain the responsibility of the host application.
`date` is returned exactly as written in the frontmatter. `formatted_date` is
that date parsed by Carbon and rendered with `date_format`; it is `null` when no
date is set, and falls back to the raw string when the date cannot be parsed.
`content` is the markdown body with the frontmatter block removed, trimmed, and
left unrendered — render it in your view.

## Testing

```bash
composer test
```

## Contributing

This package is developed inside ApexCode's Turbine platform monorepo and
published here as a one-way mirror. Please open **issues** on this repository.

Pull requests are welcome, but note that this repository is machine-managed:
its branches are overwritten by the next sync from the monorepo, so a PR cannot
simply be merged here. Accepted changes are ported upstream and land back
through a later sync, with credit preserved.

## License

The MIT License (MIT). See [LICENSE.md](LICENSE.md) for details.
19 changes: 19 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
{
"name": "apxcde/markdown-blog",
"description": "Markdown-powered blog package for Laravel apps",
"keywords": [
"laravel",
"markdown",
"blog",
"articles",
"frontmatter",
"turbine"
],
"homepage": "https://github.com/apxcde/markdown-blog",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "ApexCode",
"homepage": "https://apexcode.dev"
}
],
"support": {
"issues": "https://github.com/apxcde/markdown-blog/issues",
"source": "https://github.com/apxcde/markdown-blog"
},
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
Expand Down
Loading