A minimalist, Git-based Markdown publishing system built with Astro and TypeScript.
Write in Markdown → Commit to Git → Push to GitHub → Site rebuilds and deploys automatically.
No database. No admin panel. No backend. Plain .md files become a fast, fully-featured static website.
- What Is HAST CMS
- Features
- Quick Start
- Project Structure
- Available Scripts
- Creating a Blog Post
- Frontmatter Reference
- Markdown Support
- Site Configuration
- Branding and Site Info
- Content Types
- Taxonomy: Categories, Tags, Hashtags
- Series
- Drafts and Scheduled Posts
- Authors
- Images and Static Files
- Search
- RSS and JSON Feeds
- SEO
- Redirects
- Analytics
- Deployment
- GitHub Actions
- Troubleshooting
HAST CMS is a static site publishing platform where your content lives entirely in plain text files inside a Git repository. There is no database, no login screen, and no content management backend.
The publishing workflow is:
1. Create a .md file in src/content/posts/
2. Write your article in Markdown
3. git add → git commit → git push
4. GitHub Actions builds the site and deploys it
5. Your post is live
Every change — new post, edit, delete, rebrand — is a Git commit. You get a full history of every article ever written, with the ability to roll back any change.
Publishing
- Markdown and MDX blog posts
- Draft posts (hidden until published)
- Scheduled posts (hidden until the set date)
- Featured and pinned posts on the homepage
- Multi-part article series with navigation
- Previous/Next post navigation
- Related posts suggestions
Organization
- Categories (one per post, auto-generates index pages)
- Tags (multiple per post, auto-generates index pages)
- Hashtags (separate
#tagstyle, auto-generates index pages) - Year/month archive
- Author pages
Site Features
- Client-side full-text search
- Paginated latest articles feed
- Table of contents sidebar (auto-generated from headings)
- Syntax-highlighted code blocks with copy button
- RSS feed (
/rss.xml) - JSON feed (
/feed.json) - Auto-generated sitemap
- 404 page
SEO and Metadata
- Canonical URLs
- Open Graph and Twitter/X card tags
- Schema.org structured data (BlogPosting, WebSite, Person, BreadcrumbList)
- Per-post
noindexsupport - Custom OG images per post
Developer Experience
- TypeScript throughout
- Content validation script
- Post creation script
- Hot-reload development server
- Zero runtime dependencies (pure static output)
- Node.js 18 or later
- npm
- Git
# Clone the repository
git clone https://github.com/th30d4y/HAST-CMS.git
cd HAST-CMS
# Install dependencies
npm install
# Start the development server
npm run devOpen http://localhost:4321 in your browser. The site reloads automatically when you save files.
HAST-CMS/
├── src/
│ ├── config/
│ │ └── site.ts # Structural config: URL, navigation, analytics
│ ├── content/
│ │ ├── posts/ # Blog posts — one .md file per post
│ │ ├── pages/ # Static pages (About, Disclaimer, etc.)
│ │ ├── authors/ # Author data (.json files)
│ │ └── settings/
│ │ └── site.md # Branding config: site name, author, description
│ ├── layouts/
│ │ ├── BaseLayout.astro # Base HTML shell with all SEO meta tags
│ │ └── PostLayout.astro # Article layout: TOC, author box, related posts
│ ├── pages/ # Astro route files (do not edit for content)
│ │ ├── index.astro # Homepage
│ │ ├── posts/[slug].astro # Individual post pages
│ │ ├── latest/[...page].astro # Paginated post index
│ │ ├── categories/index.astro # All categories
│ │ ├── category/[slug].astro # Posts in one category
│ │ ├── tags/index.astro # All tags
│ │ ├── tag/[slug].astro # Posts with one tag
│ │ ├── hashtags/index.astro # All hashtags
│ │ ├── hashtag/[slug].astro # Posts with one hashtag
│ │ ├── series/index.astro # All series
│ │ ├── series/[slug].astro # Posts in one series
│ │ ├── archive/index.astro # Year/month archive
│ │ ├── author/[slug].astro # Posts by one author
│ │ ├── search/index.astro # Search page
│ │ ├── rss.xml.ts # RSS feed endpoint
│ │ ├── feed.json.ts # JSON feed endpoint
│ │ ├── robots.txt.ts # robots.txt endpoint
│ │ └── 404.astro # 404 page
│ ├── styles/
│ │ └── global.css # All styles — monochrome design system
│ └── utils/
│ ├── posts.ts # Post fetching, filtering, sorting, taxonomy
│ ├── settings.ts # Async branding config loader
│ ├── slugify.ts # URL slug generation helpers
│ ├── reading-time.ts # Reading time estimation
│ └── toc.ts # Table of contents builder
├── templates/
│ └── post.md # Post template (used by npm run new:post)
├── scripts/
│ ├── new-post.js # Creates a new post file from the template
│ └── validate-content.js # Validates post frontmatter before building
├── public/
│ └── _redirects # Netlify/Cloudflare redirect rules
├── .github/
│ └── workflows/ # GitHub Actions CI/CD workflows
├── astro.config.mjs # Astro build configuration
├── tsconfig.json # TypeScript configuration
└── package.json
| Directory | What goes here |
|---|---|
src/content/posts/ |
Your blog posts — one .md file per post |
src/content/pages/ |
Static pages like About, Disclaimer |
src/content/authors/ |
Author profile data (.json files) |
src/content/settings/site.md |
Site name, author, description |
public/ |
Images and static files served as-is |
npm run dev # Start development server at http://localhost:4321
npm run build # Build static site to dist/
npm run preview # Preview the built site locally
npm run new:post "Title" # Create a new post from the template
npm run validate # Validate all post frontmatter
npm run check # TypeScript type checkThe fastest way to start a new post:
npm run new:post "How I Built a Blog"This creates src/content/posts/how-i-built-a-blog.md pre-filled with all frontmatter fields and draft: true. Open the file, write your article, set draft: false, then commit and push.
Checks all posts for:
- Missing required fields (
title,description,date) - Invalid date formats
- Duplicate slugs
- Description over 300 characters
- Invalid
seriesOrdervalues
Run this before pushing to catch errors early:
npm run validate
Validating 8 post(s)...
Validation complete: 8 file(s), 0 error(s), 0 warning(s)npm run new:post "My Post Title"
# Creates: src/content/posts/my-post-title.mdOr create manually:
# File name becomes the URL: /posts/my-post-title/
touch src/content/posts/my-post-title.md---
title: "My Post Title"
description: "A clear, one-sentence summary of the post (under 160 characters)."
date: 2026-08-16
category: "Tutorials"
tags:
- markdown
- tutorial
draft: false
toc: true
---
Your article starts here.
## First Section
Write in standard Markdown. All GFM features are supported.npm run dev
# Open http://localhost:4321/posts/my-post-title/npm run validategit add src/content/posts/my-post-title.md
git commit -m "publish: My Post Title"
git pushGitHub Actions builds and deploys the site. The post is live within 1–2 minutes.
All supported fields for posts in src/content/posts/:
| Field | Type | Description |
|---|---|---|
title |
string | Post headline. Shown as <h1>, in all indexes, and in the browser tab. |
description |
string | Short summary, max 300 characters. Shown in index rows, search results, and meta tags. |
date |
YYYY-MM-DD | Publication date. Controls sort order across all indexes. |
| Field | Type | Default | Description |
|---|---|---|---|
slug |
string | filename | Override the URL slug. File my-post.md → /posts/my-post/ by default. |
updated |
YYYY-MM-DD | — | Date of last substantive edit. Shown in post header and used in dateModified structured data. |
author |
string | site author | Author display name. Links to /author/<slug>/. |
category |
string | Uncategorized |
One category per post. Auto-creates /category/<slug>/. |
tags |
string[] | [] |
Keyword tags. Each auto-creates /tag/<slug>/. |
hashtags |
string[] | [] |
#tag style labels. Each auto-creates /hashtag/<slug>/. Include # or omit — both work. |
featured |
boolean | false |
Shows post in the Featured section on the homepage (max 3 shown). |
pinned |
boolean | false |
Shows post in the Pinned section on the homepage. |
draft |
boolean | false |
Hides post from all public indexes, RSS, search, and sitemap. |
series |
string | — | Series name for multi-part posts. All posts with the same value are grouped. |
seriesOrder |
integer | — | Position within a series (1, 2, 3…). |
cover |
string | — | Cover image path (relative to /public/ or absolute URL). Shown above the post title. |
ogImage |
string | — | Custom Open Graph image for social sharing. Overrides cover if both are set. |
canonical |
URL | — | Canonical URL if the post was originally published elsewhere. |
toc |
boolean | true |
Show the table of contents sidebar. Requires at least 2–3 H2/H3 headings. |
comments |
boolean | false |
Reserved for future comment provider integration. No effect currently. |
noindex |
boolean | false |
Exclude from search engines, sitemap, and robots.txt. |
---
title: "Building a REST API with Node.js"
description: "Step-by-step guide to building a production-ready REST API using Node.js, Express, and JSON — no database required."
date: 2026-08-16
updated: 2026-08-16
slug: "nodejs-rest-api"
author: "HAST CMS"
category: "Backend Development"
tags:
- node-js
- rest-api
- express
- javascript
hashtags:
- "#NodeJS"
- "#WebDev"
featured: false
pinned: false
draft: false
series: "Node.js Guides"
seriesOrder: 1
cover: "/images/nodejs-cover.jpg"
toc: true
noindex: false
---HAST CMS supports full GitHub Flavored Markdown (GFM).
Use ## for top-level sections in post bodies. # is reserved for the post title (set from frontmatter).
## Main Section
### Subsection
#### Minor SubsectionH2 and H3 headings appear in the table of contents sidebar automatically.
**bold** *italic* ~~strikethrough~~ `inline code`[Link text](https://example.com)
Use fenced code blocks with a language identifier for syntax highlighting:
```javascript
function greet(name) {
return `Hello, ${name}!`;
}
```Every code block gets a COPY button automatically.
Supported languages: bash, sh, javascript, typescript, python, go, rust, c, cpp, java, php, ruby, sql, html, css, json, yaml, toml, dockerfile, powershell, solidity, and more.
| Column A | Column B | Column C |
|---|---|---|
| Value 1 | Value 2 | Value 3 |- [x] Completed task
- [ ] Incomplete task> This is a blockquote.
Text with a footnote.[^1]
[^1]: Footnote content here.
---Raw HTML is supported and rendered as-is:
<details>
<summary>Click to expand</summary>
Hidden content.
</details>There are two configuration files.
This is the user-facing config file. Edit the YAML frontmatter to change what visitors see:
---
siteName: "HAST CMS"
author: "HAST CMS"
authorRole: "Git-based Markdown Publishing"
authorBio: "HAST CMS is a minimalist, Git-based Markdown publishing system."
siteDescription: "A minimalist, Git-based Markdown publishing system."
---| Field | Where it appears |
|---|---|
siteName |
Header logo, browser tab, RSS title, Open Graph site_name |
author |
Footer, post bylines, meta author tag, structured data |
authorRole |
Footer subtitle, author page |
authorBio |
Author page, post footer author box |
siteDescription |
Homepage subtitle, default meta description, JSON feed |
Edit this for settings that rarely change:
export const SITE = {
siteURL: 'https://your-domain.com', // Full deployed URL — used in canonical links and feeds
navigation: [ // Top navigation links
{ label: 'HOME', href: '/' },
{ label: 'LATEST', href: '/latest/' },
{ label: 'CATEGORIES', href: '/categories/' },
{ label: 'ARCHIVE', href: '/archive/' },
{ label: 'ABOUT', href: '/about/' },
{ label: 'SEARCH', href: '/search/' },
],
social: {
github: 'https://github.com/th30d4y/HAST-CMS',
},
postsPerPage: 10, // Posts shown per page on /latest/
relatedPostsCount: 3, // Related posts shown at the bottom of articles
rss: {
feedItems: 20, // Number of posts in RSS/JSON feeds
},
};To rename the site, change the author, or update the description:
- Open
src/content/settings/site.md - Edit the frontmatter values
- Save, commit, and push
git add src/content/settings/site.md
git commit -m "config: update site name to My Blog"
git pushAll pages, feeds, meta tags, and structured data update on the next build.
HAST CMS uses four content collections, each a directory under src/content/:
.md files. The main content type. Each file generates a public post page.
.md files with simplified frontmatter. Used for About, Disclaimer, and other static pages. URLs are generated from the filename: about.md → /about/.
---
title: "About"
description: "About this site."
updated: 2026-08-16
---.json files. Filename = author slug. Used on author archive pages and post footers.
{
"name": "HAST CMS",
"role": "Git-based Markdown Publishing",
"bio": "HAST CMS is a minimalist publishing system.",
"social": {
"github": "https://github.com/th30d4y/HAST-CMS"
}
}One file: site.md. Controls the user-visible site name and identity. See Branding and Site Info.
All taxonomy pages are generated automatically — you never create them manually.
One category per post. Set in frontmatter:
category: "Tutorials"Auto-generates:
/categories/— all categories with post counts/category/tutorials/— all posts in that category
Category URL slugs are derived from the value: "Web Security" → /category/web-security/.
Multiple tags per post:
tags:
- markdown
- tutorial
- beginnerAuto-generates:
/tags/— all tags with post counts/tag/markdown/— all posts with that tag
#tag style labels with their own separate index:
hashtags:
- "#HastCMS"
- "#Markdown"Auto-generates:
/hashtags/— all hashtags with post counts/hashtag/hastcms/— all posts with that hashtag (case-insensitive)
The # prefix is optional in frontmatter values.
Group related posts into a named series. A navigation box linking all parts appears inside each post.
series: "Building a Static Site"
seriesOrder: 1Rules:
- All posts in a series must use the exact same
seriesstring (case-sensitive) seriesOrdermust be unique positive integers within a series- Series pages auto-generate at
/series/<slug>/
draft: trueDraft posts are invisible in all production indexes, feeds, and the sitemap. They are visible during local development (npm run dev).
To publish: set draft: false and push.
date: 2026-12-01
draft: falsePosts with a future date are automatically hidden until that date. The getAllPosts() utility filters out any post whose date is later than today. No action is required on publication day — the next build after that date will include the post.
Author data lives in src/content/authors/. The filename (without .json) is the author slug.
src/content/authors/hast-cms.json → /author/hast-cms/
When a post has author: "HAST CMS", the system looks for a matching author entry and shows their role and bio in the post footer. Author archive pages at /author/<slug>/ list all posts by that author.
If no matching .json file exists, the post still renders — it just shows the name without a bio.
Place all images and downloadable files in the public/ directory. Files there are served from the root path.
public/images/my-photo.jpg → /images/my-photo.jpg
public/files/report.pdf → /files/report.pdf
Reference in Markdown:

[Download the report](/files/report.pdf)For cover images, use the cover frontmatter field:
cover: "/images/post-cover.jpg"Static client-side search is available at /search/. No external service required.
Searches across: title, description, category, tags, hashtags, and author.
Supports URL query parameters:
/search/?q=markdown
The search index is built at compile time from all published posts.
| Feed | URL | Format |
|---|---|---|
| RSS 2.0 | /rss.xml |
Standard RSS, compatible with all feed readers |
| JSON Feed 1.0 | /feed.json |
Machine-readable JSON format |
Both feeds include the 20 most recent published posts (configurable via rss.feedItems in site.ts).
Every page automatically gets:
<title>— post title + site name separator + site name<meta name="description">— post description or site description<link rel="canonical">— canonical URL- Open Graph tags —
og:title,og:description,og:image,og:type,og:site_name - Twitter/X Card tags —
twitter:card,twitter:title,twitter:description,twitter:image - Article dates —
article:published_time,article:modified_time - Schema.org JSON-LD —
WebSite,Person,BlogPosting,BreadcrumbList
# Canonical URL (for republished content)
canonical: "https://original-source.com/article"
# Custom OG image for social sharing (1200×630px recommended)
ogImage: "/images/my-og-image.jpg"
# Exclude from search engines and sitemap
noindex: trueWhen you change a post slug, add a redirect to public/_redirects (Netlify/Cloudflare syntax):
/posts/old-slug/ /posts/new-slug/ 301
/posts/removed/ / 301
For Vercel, add to vercel.json:
{
"redirects": [
{
"source": "/posts/old-slug/",
"destination": "/posts/new-slug/",
"permanent": true
}
]
}Analytics is disabled by default. To enable, edit src/config/site.ts:
analytics: {
enabled: true,
provider: 'plausible', // 'plausible' | 'umami' | 'goatcounter'
scriptSrc: 'https://plausible.io/js/script.js',
dataId: 'your-domain.com',
},No external scripts load unless enabled: true is set.
- Go to Settings → Pages in your repository
- Set Source to GitHub Actions
- Push to
main— the workflow deploys automatically
The site deploys to https://<username>.github.io/<repo>/.
For a custom domain, add a CNAME file to public/:
your-domain.com
Then set the custom domain in Settings → Pages → Custom domain and update siteURL in src/config/site.ts.
- Connect the repository in Cloudflare Pages
- Build command:
npm run build - Build output directory:
dist
The public/_redirects file is processed automatically.
Connect the repository. Build command: npm run build. Publish directory: dist.
Build command: npm run build. Output directory: dist.
Two workflows are included in .github/workflows/.
Runs on every push to main and on a daily schedule (for scheduled post publishing):
- Checkout repository
- Install Node.js 20
npm ci— install dependenciesnpm run validate— validate all post frontmatternpm run check— TypeScript type checknpm run build— build the static site- Deploy
dist/to GitHub Pages
Runs on every pull request:
- Validates content
- Type checks
- Builds (without deploying)
PRs with validation errors or build failures are blocked automatically.
Posts not showing after the dev server started:
The Astro content cache can go stale when new files are added. Clear it and restart:
rm -rf .astro/data-store.json .astro/collections
npm run devPost not appearing on the live site:
- Check
draft: falseis set in frontmatter - Check the
dateis today or in the past (not a future date) - Check the GitHub Actions workflow completed without errors (Actions tab)
- Run
npm run validatelocally to check for frontmatter errors
Build fails:
npm run validate # Check for frontmatter errors
npm run check # Check for TypeScript errors
npm run build # See the full error outputDuplicate slug warning:
Two posts are resolving to the same URL. Either rename one file or add a unique slug: field to one of them.
Series navigation not showing:
- All posts in the series must use the exact same
seriesstring (it is case-sensitive) - Each post must have a unique
seriesOrderinteger
Category or tag page returning 404:
The page is generated only if at least one published, non-draft post uses that category or tag. Check that draft: false and the date is not in the future.
Images not loading:
Images in public/ are served from the root. Use /images/file.png, not public/images/file.png, in your Markdown.
Search not finding a post:
The search index is built at compile time. Run npm run build to regenerate it. Make sure JavaScript is enabled in the browser — search is client-side only.