diff --git a/metadata.json b/metadata.json
new file mode 100644
index 0000000..991e6b5
--- /dev/null
+++ b/metadata.json
@@ -0,0 +1,10 @@
+{
+ "author": "interlope",
+ "email": "",
+ "website": "https://github.com/interlope/bludit-github",
+ "version": "1.0.0",
+ "releaseDate": "2026-06-01",
+ "license": "MIT",
+ "compatible": "3.20",
+ "notes": "Export Bludit articles to a GitHub repository as Markdown files with YAML frontmatter."
+}
diff --git a/plugin.php b/plugin.php
new file mode 100644
index 0000000..fcc84d1
--- /dev/null
+++ b/plugin.php
@@ -0,0 +1,376 @@
+dbFields = [
+ 'token' => '',
+ 'owner' => '',
+ 'repo' => '',
+ 'branch' => 'main',
+ 'path' => 'posts',
+ 'exportDrafts' => false,
+ ];
+ }
+
+ public function form()
+ {
+ $token = $this->getValue('token');
+ $owner = $this->getValue('owner');
+ $repo = $this->getValue('repo');
+ $branch = $this->getValue('branch');
+ $path = $this->getValue('path');
+ $exportDrafts = $this->getValue('exportDrafts') ? 'checked' : '';
+ $status = $this->getStatus();
+
+ $html = '
';
+ $html .= '
';
+ $html .= '
';
+ $html .= '
Requires repo scope. Generate token';
+ $html .= '
';
+
+ $html .= '';
+ $html .= '';
+ $html .= '';
+ $html .= '
';
+
+ $html .= '';
+ $html .= '';
+ $html .= '';
+ $html .= '
';
+
+ $html .= '';
+ $html .= '';
+ $html .= '';
+ $html .= '
';
+
+ $html .= '';
+ $html .= '';
+ $html .= '';
+ $html .= 'Directory in the repo where articles are written (e.g. posts). Leave empty for root.';
+ $html .= '
';
+
+ $html .= '';
+ $html .= '';
+ $html .= '
';
+
+ $html .= '';
+ $html .= '
';
+ $html .= '
Push all ' . ($exportDrafts ? '' : 'published ') . 'articles to GitHub at once.
';
+ $html .= '
';
+ $html .= '
';
+
+ if ($status) {
+ $isError = stripos($status, 'error') !== false;
+ $alertClass = $isError ? 'alert-danger' : 'alert-success';
+ $html .= '' . htmlspecialchars($status) . '
';
+ }
+
+ return $html;
+ }
+
+ public function post()
+ {
+ parent::post();
+
+ if (!empty($_POST['bulkExport'])) {
+ $this->bulkExport();
+ }
+ }
+
+ public function afterPageCreate()
+ {
+ $key = $this->getAffectedPageKey();
+ if (!$key) {
+ return;
+ }
+ try {
+ $page = new Page($key);
+ if ($this->shouldExport($page)) {
+ $this->exportPage($page);
+ }
+ } catch (Exception $e) {
+ // page not found
+ }
+ }
+
+ public function afterPageModify()
+ {
+ $key = $this->getAffectedPageKey();
+ if (!$key) {
+ return;
+ }
+ try {
+ $page = new Page($key);
+ if ($this->shouldExport($page)) {
+ $this->exportPage($page);
+ }
+ } catch (Exception $e) {
+ // page not found
+ }
+ }
+
+ public function afterPageDelete()
+ {
+ if (!$this->isConfigured()) {
+ return;
+ }
+
+ $currentKeys = $this->getAllPageKeys();
+ $exportPath = trim($this->getValue('path'), '/');
+ $endpoint = '/contents/' . ($exportPath ?: '');
+
+ $files = $this->githubRequest('GET', $endpoint);
+ if (!is_array($files)) {
+ return;
+ }
+
+ foreach ($files as $file) {
+ if (!isset($file['type']) || $file['type'] !== 'file') {
+ continue;
+ }
+ if (substr($file['name'], -3) !== '.md') {
+ continue;
+ }
+ $key = substr($file['name'], 0, -3);
+ if (!in_array($key, $currentKeys, true)) {
+ $this->githubRequest('DELETE', '/contents/' . $file['path'], [
+ 'message' => 'Delete: ' . $key,
+ 'sha' => $file['sha'],
+ 'branch' => $this->getValue('branch'),
+ ]);
+ }
+ }
+ }
+
+ // --- Private ---
+
+ private function shouldExport($page)
+ {
+ return $page->status() !== 'draft' || $this->getValue('exportDrafts');
+ }
+
+ private function exportPage($page, $saveStatus = true)
+ {
+ $content = $this->buildMarkdown($page);
+ $filePath = $this->getFilePath($page->key());
+ $sha = $this->getFileSha($filePath);
+
+ $data = [
+ 'message' => ($sha ? 'Update' : 'Add') . ': ' . $page->title(),
+ 'content' => base64_encode($content),
+ 'branch' => $this->getValue('branch'),
+ ];
+ if ($sha) {
+ $data['sha'] = $sha;
+ }
+
+ $result = $this->githubRequest('PUT', '/contents/' . $filePath, $data);
+
+ if ($result !== false && $saveStatus) {
+ $this->setStatus(date('Y-m-d H:i:s') . ' — Exported: ' . $page->title());
+ }
+ }
+
+ private function bulkExport()
+ {
+ if (!$this->isConfigured()) {
+ $this->setStatus('Error: GitHub settings not configured.');
+ return;
+ }
+
+ global $pages;
+
+ $includeDrafts = (bool)$this->getValue('exportDrafts');
+ $list = $pages->getList(1, 9999, true, true, true, $includeDrafts, false);
+ $count = 0;
+
+ foreach ($list as $key) {
+ try {
+ $page = new Page($key);
+ if ($this->shouldExport($page)) {
+ $this->exportPage($page, false);
+ $count++;
+ }
+ } catch (Exception $e) {
+ continue;
+ }
+ }
+
+ $this->setStatus(date('Y-m-d H:i:s') . " — Bulk export: {$count} articles pushed.");
+ }
+
+ private function buildMarkdown($page)
+ {
+ // Prefer raw content (Markdown as typed); fall back to rendered HTML
+ $content = $page->getValue('content');
+ if (empty($content)) {
+ $content = $page->content();
+ }
+
+ // Tags
+ $tags = $page->tags();
+ $tagLines = '';
+ if (!empty($tags)) {
+ foreach ($tags as $tag) {
+ $tagName = is_object($tag) ? $tag->name() : (string)$tag;
+ $tagLines .= "\n - " . $tagName;
+ }
+ }
+
+ $fm = "---\n";
+ $fm .= 'title: "' . addslashes($page->title()) . "\"\n";
+ $fm .= 'date: ' . date('Y-m-d', strtotime($page->getValue('dateRaw'))) . "\n";
+ $fm .= 'slug: ' . $page->key() . "\n";
+ $fm .= 'status: ' . $page->status() . "\n";
+ $fm .= 'tags:' . ($tagLines ?: ' []') . "\n";
+
+ $description = $page->description();
+ if (!empty($description)) {
+ $fm .= 'description: "' . addslashes($description) . "\"\n";
+ }
+
+ $category = $page->category();
+ if (!empty($category)) {
+ $catKey = is_object($category) ? $category->key() : (string)$category;
+ if ($catKey && $catKey !== 'uncategorized') {
+ $fm .= 'category: ' . $catKey . "\n";
+ }
+ }
+
+ $cover = $page->coverImage();
+ if (!empty($cover)) {
+ $fm .= 'cover: ' . $cover . "\n";
+ }
+
+ $fm .= "---\n\n";
+
+ return $fm . $content;
+ }
+
+ private function getFilePath($key)
+ {
+ $exportPath = trim($this->getValue('path'), '/');
+ return ($exportPath ? $exportPath . '/' : '') . $key . '.md';
+ }
+
+ private function getFileSha($filePath)
+ {
+ $result = $this->githubRequest('GET', '/contents/' . $filePath);
+ return (is_array($result) && isset($result['sha'])) ? $result['sha'] : null;
+ }
+
+ private function githubRequest($method, $endpoint, $data = null)
+ {
+ $token = $this->getValue('token');
+ $owner = $this->getValue('owner');
+ $repo = $this->getValue('repo');
+
+ if (!$token || !$owner || !$repo) {
+ return false;
+ }
+
+ $url = 'https://api.github.com/repos/' . $owner . '/' . $repo . $endpoint;
+
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => [
+ 'Authorization: Bearer ' . $token,
+ 'Accept: application/vnd.github+json',
+ 'X-GitHub-Api-Version: 2022-11-28',
+ 'User-Agent: Bludit-GitHub-Export',
+ 'Content-Type: application/json',
+ ],
+ CURLOPT_SSL_VERIFYPEER => true,
+ CURLOPT_TIMEOUT => 15,
+ ]);
+
+ if ($method === 'PUT') {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
+ } elseif ($method === 'DELETE') {
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
+ curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
+ }
+
+ $response = curl_exec($ch);
+ $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ $curlError = curl_error($ch);
+ curl_close($ch);
+
+ if ($response === false) {
+ $this->setStatus('Error: cURL — ' . $curlError);
+ return false;
+ }
+
+ // 404 on GET means the file doesn't exist yet — not an error
+ if ($httpCode === 404) {
+ return null;
+ }
+
+ $decoded = json_decode($response, true);
+
+ if ($httpCode >= 400) {
+ $message = isset($decoded['message']) ? $decoded['message'] : 'HTTP ' . $httpCode;
+ $this->setStatus('Error: GitHub API (' . $httpCode . ') — ' . $message);
+ return false;
+ }
+
+ return $decoded;
+ }
+
+ private function getAffectedPageKey()
+ {
+ // Bludit puts the slug in POST on page save
+ foreach (['slug', 'key', 'friendlyURL'] as $field) {
+ if (!empty($_POST[$field])) {
+ return Sanitize::slug($_POST[$field]);
+ }
+ }
+
+ // Fallback: most recently modified page directory
+ if (!defined('PATH_PAGES') || !is_dir(PATH_PAGES)) {
+ return null;
+ }
+
+ $newest = null;
+ $newestTime = 0;
+
+ foreach (glob(PATH_PAGES . '*', GLOB_ONLYDIR) as $dir) {
+ $indexFile = $dir . DS . 'index.php';
+ if (file_exists($indexFile)) {
+ $mtime = filemtime($indexFile);
+ if ($mtime > $newestTime) {
+ $newestTime = $mtime;
+ $newest = basename($dir);
+ }
+ }
+ }
+
+ return $newest;
+ }
+
+ private function getAllPageKeys()
+ {
+ global $pages;
+ return $pages->getList(1, 9999, true, true, true, true, false);
+ }
+
+ private function isConfigured()
+ {
+ return $this->getValue('token') && $this->getValue('owner') && $this->getValue('repo');
+ }
+
+ private function setStatus($message)
+ {
+ file_put_contents($this->workspace() . 'status.txt', $message);
+ }
+
+ private function getStatus()
+ {
+ $file = $this->workspace() . 'status.txt';
+ return file_exists($file) ? file_get_contents($file) : '';
+ }
+}