Duncan McClean

My Documentation Sites: How they work

19th February 2022

Last year, I unified the way I handled the docs sites for my addons.

They're all handled by a single Laravel app which pulls in the content from each addon's GitHub repo.

I thought it could be interesting to share how it works...

1. Get the content from GitHub: If we don't already have the docs content, it'll go grab it using GitHub's zip download feature.

Then, it'll extract the zip and move the docs into their own folder. The images are also moved around, into the 'public' directory.

if (! File::exists($package->clonePath())) {
$zipContents = file_get_contents($package->archiveUrl());
file_put_contents("{$package->clonePath()}.zip", $zipContents);
$zip = new ZipArchive;
if ($zip->open("{$package->clonePath()}.zip") === true) {
$firstDirectory = File::directories($package->clonePath())[0];
File::copyDirectory("$firstDirectory/docs", $package->docsPath());
if (File::exists("$firstDirectory/docs/images")) {
File::copyDirectory("$firstDirectory/docs/images", public_path("img/{$package->slug}"));

2. Next up, we go and get the file from the directory we just copied them to. If the requested file doesn't exist, we throw a 404.

try {
$contents = File::get($package->docsPath() . '/' . $path . '.md');
} catch (FileNotFoundException $e) {

3. Now we have the file contents: it's passed into Commonmark's Markdown converter and piped through some extensions, like Torchlight, a YAML front matter parser and one that handles Markdown tables.

$commonmarkEnvironment = new Environment([
'heading_permalink' => [
'symbol' => '',
'table_of_contents' => [
'min_heading_level' => 1,
'max_heading_level' => 3,
$commonmarkEnvironment->addExtension(new CommonMarkCoreExtension);
$commonmarkEnvironment->addExtension(new FrontMatterExtension);
$commonmarkEnvironment->addExtension(new TableExtension);
$commonmarkEnvironment->addExtension(new TorchlightExtension);
$commonmarkEnvironment->addExtension(new HeadingPermalinkExtension);
$commonmarkEnvironment->addExtension(new TableOfContentsExtension);
$converter = new MarkdownConverter($commonmarkEnvironment);
$result = $converter->convertToHtml($contents);

4. Now we have the HTML, it's passed into a Document class, along with some bits of metadata, like the page title & path.

At this point, whatever we returned is sent to the Cache so it's super duper quick for the next user to visit this docs page.

$document = Cache::rememberForever("oxygen.{$package->slug}.$path", function () use ($package, $path) {
// ...
return new Document(
array_merge($result->getFrontMatter(), [
'path' => $path,

5. Last but not least..

Whenever I push changes to my addon's on GitHub, a webhook is triggered which clears the cloned files & the Laravel cache for the addon that's been updated.

class GitHubWebhookController extends Controller
public function __invoke(Request $request)
$payload = $request->all();
->filter(function (Package $package) use ($payload) {
return $payload['repository']['full_name'] === $package->gitRepo;
->filter(function (Package $package) use ($payload) {
return str_contains($payload['ref'], $package->gitBranch);
->each(function (Package $package) {
info("Webhook: Clearing {$package->slug}");
return new Response();

And that's it! Honestly a pretty simple system and I'm pretty happy with it.

You can see it in action over on the Runway docs: https://runway.duncanmcclean.com/