{"id":22,"date":"2026-10-19T09:00:00","date_gmt":"2026-10-19T03:00:00","guid":{"rendered":"https:\/\/nazmulrobin.com\/blog\/?p=22"},"modified":"2026-10-26T00:35:12","modified_gmt":"2026-10-25T18:35:12","slug":"publish-wordpress-posts-from-markdown","status":"publish","type":"post","link":"https:\/\/nazmulrobin.com\/blog\/publish-wordpress-posts-from-markdown\/","title":{"rendered":"Publishing WordPress posts from Markdown files on GitHub"},"content":{"rendered":"\n<p>I write this blog in Markdown. Each post is a file in a GitHub repository, and pushing the file is what publishes it. WordPress still serves the site, but I do not write in its editor.<\/p>\n<p>This is the third post about how this blog is built. The earlier ones cover <a href=\"https:\/\/nazmulrobin.com\/blog\/laravel-and-wordpress-on-one-domain\/\">running WordPress at \/blog on a Laravel site<\/a> and <a href=\"https:\/\/nazmulrobin.com\/blog\/share-wordpress-posts-on-linkedin\/\">sharing posts on LinkedIn<\/a>.<\/p>\n<h2>How it works<\/h2>\n<ol>\n<li>I add or edit <code>posts\/some-slug.md<\/code> and push to <code>main<\/code>.<\/li>\n<li>A GitHub Actions workflow logs in to the server over SSH, pulls the repo and runs one WP-CLI command.<\/li>\n<li>That command, which lives in my site plugin, converts each changed file to HTML and creates or updates the matching post.<\/li>\n<\/ol>\n<p>A post file looks like this:<\/p>\n<pre><code class=\"language-markdown hljs\">---\ntitle: My first post\ndate: 2026-10-20 09:00\nexcerpt: One or two sentences shown in listings and on LinkedIn.\ncategories: [WordPress]\n<span class=\"hljs-section\">tags: [composer, deployment]\n---<\/span>\n\nThe post body in Markdown.<\/code><\/pre>\n<h2>The rules I settled on<\/h2>\n<ul>\n<li><strong>The file name is the post&#8217;s URL.<\/strong> <code>posts\/my-first-post.md<\/code> becomes <code>\/blog\/my-first-post\/<\/code>.<\/li>\n<li><strong>The Markdown file is the source of truth.<\/strong> A change made in wp-admin is overwritten the next time the file changes.<\/li>\n<li><strong>A future <code>date<\/code> schedules the post.<\/strong> Pushing a file never has to mean publishing it now. I write several posts in one sitting and give each a date.<\/li>\n<li><strong>A past or missing <code>date<\/code> publishes straight away.<\/strong> This is also how a typo fix reaches a live post.<\/li>\n<li><strong><code>status: draft<\/code> holds a post back<\/strong> whatever its date.<\/li>\n<li><strong>Deleting a file moves the post to the Trash.<\/strong> Nothing is deleted for good.<\/li>\n<li><strong>Unchanged files are skipped,<\/strong> so a push only touches the posts I edited.<\/li>\n<\/ul>\n<h2>Public or private repo<\/h2>\n<p>I went with a private repo. In a public one, every unfinished draft and every old revision is visible from the moment it is pushed. A private repo can be made public later; the reverse does not un-publish anything.<\/p>\n<p>I also kept posts in their own repo, away from the blog&#8217;s code. The code repo has a release branch and runs Composer on every deploy, which is too much ceremony for fixing a typo.<\/p>\n<h2>The workflow<\/h2>\n<pre><code class=\"language-yaml hljs\"><span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Publish<\/span> <span class=\"hljs-string\">posts<\/span>\n\n<span class=\"hljs-attr\">on:<\/span>\n  <span class=\"hljs-attr\">push:<\/span>\n    <span class=\"hljs-attr\">branches:<\/span> <span class=\"hljs-string\">[<\/span> <span class=\"hljs-string\">\"main\"<\/span> <span class=\"hljs-string\">]<\/span>\n    <span class=\"hljs-attr\">paths:<\/span> <span class=\"hljs-string\">[<\/span> <span class=\"hljs-string\">\"posts\/**\"<\/span> <span class=\"hljs-string\">]<\/span>\n  <span class=\"hljs-attr\">workflow_dispatch:<\/span>\n\n<span class=\"hljs-attr\">jobs:<\/span>\n  <span class=\"hljs-attr\">publish:<\/span>\n    <span class=\"hljs-attr\">runs-on:<\/span> <span class=\"hljs-string\">ubuntu-latest<\/span>\n    <span class=\"hljs-attr\">steps:<\/span>\n    <span class=\"hljs-bullet\">-<\/span> <span class=\"hljs-attr\">name:<\/span> <span class=\"hljs-string\">Sync<\/span> <span class=\"hljs-string\">posts<\/span> <span class=\"hljs-string\">to<\/span> <span class=\"hljs-string\">WordPress<\/span>\n      <span class=\"hljs-attr\">uses:<\/span> <span class=\"hljs-string\">appleboy\/ssh-action@v1.2.0<\/span>\n      <span class=\"hljs-attr\">with:<\/span>\n        <span class=\"hljs-attr\">host:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_HOST<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">username:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_USERNAME<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">key:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_PRIVATE_KEY<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">port:<\/span> <span class=\"hljs-string\">${{<\/span> <span class=\"hljs-string\">secrets.SSH_PORT<\/span> <span class=\"hljs-string\">}}<\/span>\n        <span class=\"hljs-attr\">script:<\/span> <span class=\"hljs-string\">|\n          set -e\n          cd ~\/blog_posts\n          git pull origin main\n          wp --path=~\/blog_wp myblog sync-posts ~\/blog_posts\/posts<\/span><\/code><\/pre>\n<p>The server has its own clone of the posts repo and pulls it with a read-only deploy key.<\/p>\n<h2>The sync command<\/h2>\n<p>The command is a plain function registered with <code>WP_CLI::add_command()<\/code>. These are the parts worth explaining.<\/p>\n<p><strong>Front matter.<\/strong> WordPress has no YAML parser and I did not want one for six keys. A few lines of regex read <code>key: value<\/code> pairs, and turn <code>[a, b]<\/code> into a list:<\/p>\n<pre><code class=\"language-php hljs\"><span class=\"hljs-keyword\">if<\/span> ( preg_match( <span class=\"hljs-string\">'\/\\A---\\R(.*?)\\R---(?:\\R(.*))?\\z\/s'<\/span>, $text, $parts ) ) {\n    $text = <span class=\"hljs-keyword\">isset<\/span>( $parts[<span class=\"hljs-number\">2<\/span>] ) ? $parts[<span class=\"hljs-number\">2<\/span>] : <span class=\"hljs-string\">''<\/span>;\n\n    <span class=\"hljs-keyword\">foreach<\/span> ( preg_split( <span class=\"hljs-string\">'\/\\R\/'<\/span>, $parts[<span class=\"hljs-number\">1<\/span>] ) <span class=\"hljs-keyword\">as<\/span> $line ) {\n        <span class=\"hljs-keyword\">if<\/span> ( preg_match( <span class=\"hljs-string\">'\/^([a-z_]+):\\s*(.*)$\/'<\/span>, $line, $pair ) ) {\n            $meta[ $pair[<span class=\"hljs-number\">1<\/span>] ] = trim( $pair[<span class=\"hljs-number\">2<\/span>] ); <span class=\"hljs-comment\">\/\/ lists are split on commas here<\/span>\n        }\n    }\n}<\/code><\/pre>\n<p><strong>Markdown to HTML.<\/strong> I use <a href=\"https:\/\/parsedown.org\">Parsedown<\/a>, installed with Composer:<\/p>\n<pre><code class=\"language-php hljs\">$html = ( <span class=\"hljs-keyword\">new<\/span> Parsedown() )-&gt;text( $markdown );<\/code><\/pre>\n<p><strong>Knowing which post belongs to which file.<\/strong> Each post stores its file name and an MD5 hash of the file in post meta. The file name finds the post on the next run. The hash tells the command whether anything changed:<\/p>\n<pre><code class=\"language-php hljs\"><span class=\"hljs-keyword\">if<\/span> ( $post &amp;&amp; get_post_meta( $post-&gt;ID, <span class=\"hljs-string\">'_myblog_markdown_hash'<\/span>, <span class=\"hljs-keyword\">true<\/span> ) === md5( $contents ) ) {\n    <span class=\"hljs-keyword\">continue<\/span>;\n}<\/code><\/pre>\n<p><strong>Saving the post.<\/strong> The content is wrapped in a single Custom HTML block:<\/p>\n<pre><code class=\"language-php hljs\">$postarr = <span class=\"hljs-keyword\">array<\/span>(\n    <span class=\"hljs-string\">'post_type'<\/span>    =&gt; <span class=\"hljs-string\">'post'<\/span>,\n    <span class=\"hljs-string\">'post_name'<\/span>    =&gt; $slug,\n    <span class=\"hljs-string\">'post_title'<\/span>   =&gt; $meta[<span class=\"hljs-string\">'title'<\/span>],\n    <span class=\"hljs-string\">'post_excerpt'<\/span> =&gt; $meta[<span class=\"hljs-string\">'excerpt'<\/span>],\n    <span class=\"hljs-string\">'post_content'<\/span> =&gt; <span class=\"hljs-string\">\"&lt;!-- wp:html --&gt;\\n\"<\/span> . $html . <span class=\"hljs-string\">\"\\n&lt;!-- \/wp:html --&gt;\"<\/span>,\n    <span class=\"hljs-string\">'post_status'<\/span>  =&gt; <span class=\"hljs-string\">'draft'<\/span> === $meta[<span class=\"hljs-string\">'status'<\/span>] ? <span class=\"hljs-string\">'draft'<\/span> : <span class=\"hljs-string\">'publish'<\/span>,\n    <span class=\"hljs-string\">'tags_input'<\/span>   =&gt; $meta[<span class=\"hljs-string\">'tags'<\/span>],\n);\n\n$saved = $post\n    ? wp_update_post( wp_slash( $postarr + <span class=\"hljs-keyword\">array<\/span>( <span class=\"hljs-string\">'ID'<\/span> =&gt; $post-&gt;ID ) ), <span class=\"hljs-keyword\">true<\/span> )\n    : wp_insert_post( wp_slash( $postarr ), <span class=\"hljs-keyword\">true<\/span> );<\/code><\/pre>\n<p><strong>Scheduling<\/strong> needs no code of its own. When a post is saved as <code>publish<\/code> with a date in the future, WordPress stores it as scheduled and publishes it at that time.<\/p>\n<h2>Things that caught me<\/h2>\n<ul>\n<li><strong>Wrap the HTML in a Custom HTML block.<\/strong> Without block markup, WordPress runs <code>wpautop<\/code> over the content when displaying it, and every line break inside a paragraph becomes a <code>&lt;br&gt;<\/code>.<\/li>\n<li><strong>Call <code>wp_slash()<\/code> on the data.<\/strong> <code>wp_insert_post()<\/code> expects slashed input and strips one level of backslashes. Without it, every backslash in a code sample disappears.<\/li>\n<li><strong>Use <code>wp_update_post()<\/code> for existing posts.<\/strong> My first version called <code>wp_insert_post()<\/code> with an ID for updates. That does not merge with the existing post, so editing a live post reset its date to today. I caught it in local testing.<\/li>\n<li><strong>Scheduled posts need a real cron job.<\/strong> WordPress&#8217;s built-in cron only runs when someone visits the site. I added a server cron entry that runs <code>wp cron event run --due-now<\/code> every five minutes.<\/li>\n<li><strong>Check the site timezone.<\/strong> Mine was UTC, so <code>09:00<\/code> in a file would have meant three in the afternoon here. It is set to my own timezone now.<\/li>\n<li><strong>Guard against an empty folder.<\/strong> If the command is pointed at the wrong path it would see no files and trash every post. It stops and changes nothing when it finds no Markdown files.<\/li>\n<li><strong>Renaming a file creates a new post.<\/strong> The file name is the identity, so the old post is trashed and a new one appears at the new URL.<\/li>\n<\/ul>\n<h2>The edit link<\/h2>\n<p>Because each post knows its file name, the plugin adds an &#8220;Edit Markdown&#8221; link that opens the file on GitHub. It shows in the Posts list, in the admin bar when I view a post, and as a notice in the editor that says changes made there will be overwritten. Only logged-in users who can edit the post see it.<\/p>\n<h2>What it does not do yet<\/h2>\n<p>When I wrote this, images were not stored in the repo, and featured images and syntax highlighting were on the list. All three have been added since: a post&#8217;s images now live next to it in the repo, and the sync adds them to the Media Library.<\/p>\n<p>The post you are reading was published this way, from a file named <code>03-publish-wordpress-posts-from-markdown.md<\/code>. The number in front only keeps the files in order; the rest of the name is the URL.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Every post on this blog is a Markdown file in a GitHub repo. Pushing the file publishes or schedules the post in WordPress. Here is how the pieces fit, with the code.<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[8,13,12,14],"class_list":["post-22","post","type-post","status-publish","format-standard","hentry","category-wordpress","tag-building-this-blog","tag-github-actions","tag-markdown","tag-wp-cli"],"_links":{"self":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/22","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/comments?post=22"}],"version-history":[{"count":3,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/22\/revisions"}],"predecessor-version":[{"id":81,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/posts\/22\/revisions\/81"}],"wp:attachment":[{"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/media?parent=22"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/categories?post=22"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/nazmulrobin.com\/blog\/wp-json\/wp\/v2\/tags?post=22"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}