Skip to main content

Publishing Blog Posts

The ProjectAchilles blog lives at blog.projectachilles.io. Posts are Markdown files in the main repository under blog/content/posts/ — publishing a post is just merging a pull request. No CMS, no separate login.

Every post is validated when the site builds: if a required field is missing or malformed, the build fails and names the file and field, so a broken post can never reach production.

Quick start (no local setup needed)

You can write and publish a post entirely from the GitHub web UI:

  1. Open the repository on GitHub and navigate to blog/content/posts/.

  2. Click Add file → Create new file.

  3. Name the file following the pattern:

    YYYY-MM-DD-my-post-title.mdx

    The filename (minus .mdx) becomes the post's URL, e.g. blog.projectachilles.io/posts/2026-07-10-my-post-title. Only lowercase letters, digits, and hyphens are allowed — anything else fails the build.

  4. Paste the template below and write your post.

  5. Choose Create a new branch and start a pull request, then open the PR.

  6. Wait for the Blog check on the PR — it builds the site with your post and catches any mistakes. Fix anything it flags (the error names your file and the exact field).

  7. When the PR is approved and merged, the post goes live.

Post template

Copy this into your new file and replace the values:

---
title: My Post Title
description: One sentence shown in post cards, search results, and social shares.
date: "2026-07-10"
tags:
- detection
author: james
---

Your opening paragraph.

## A section heading

Regular Markdown works: **bold**, _italics_, [links](https://example.com),
lists, tables, and blockquotes.

```go
// Code blocks get syntax highlighting — set the language after the backticks.
fmt.Println("hello")
```

Frontmatter reference

The block between the --- markers is the post's metadata. All validation happens at build time.

FieldRequiredRules
titleyesAny text
descriptionyesOne sentence; used in cards, meta tags, and the RSS feed
dateyes"YYYY-MM-DD" in quotes; controls sort order — newest post is featured on the home page
tagsyes (≥1)Lowercase kebab-case only (purple-team, not Purple Team); each tag gets its own archive page at /tags/<tag>
authoryesAn author id from blog/content/authors.ts (see below)
imagenoPath to a hero image (place the file in blog/public/)
draftnotrue hides the post from production, the RSS feed, and the sitemap — it stays visible in local dev preview
langnoen (default) or es — the language the post is written in
translationKeynoKebab-case id shared by a Spanish/English pair; defaults to the post's own slug. See Languages & translations
Drafts

Not ready to publish? Add draft: true to the frontmatter and merge anyway. The post won't appear anywhere publicly. Remove the line in a follow-up PR when it's ready.

Languages & translations

Write your post in Spanish — an English version is generated for you.

  1. Add lang: es to your post's frontmatter and open the PR as usual.
  2. The Blog Translate workflow detects that the post has no English counterpart and commits an auto-generated English translation (lang: en, same translationKey) to your PR branch.
  3. Review the machine translation before merging — it's a regular file in your PR diff. Edit it freely; it's yours.

How the pair behaves once merged:

  • Each language keeps its own URL (the English file gets an idiomatic English slug).
  • The home page, tag pages, and RSS feed show one entry per article, preferring the English version.
  • Every post in a pair automatically shows a language toggle ("Leer en español" / "Read in English") in its header, and search engines get hreflang alternates.
Linking a pair manually

The two files are linked by an identical translationKey. If you write both versions yourself, give them the same key — two files with the same translationKey and the same lang fail the build.

If the translation workflow fails for any reason, the PR stays mergeable with the Spanish post only — the English version can always be added in a follow-up PR (any push to a PR touching blog/content/posts/ re-runs the workflow).

Writing tips

  • Code blocks: always set the language (```go, ```typescript, ```bash) — you get themed syntax highlighting in both dark and light mode.
  • Tables and task lists work (GitHub-flavored Markdown).
  • Images: commit the file to blog/public/ (e.g. blog/public/my-diagram.png) and reference it as ![Alt text](/my-diagram.png).
  • Reading time is computed automatically — nothing to fill in.
  • Keep the description under ~160 characters; it doubles as the SEO meta description.

Adding a new author

Authors live in blog/content/authors.ts. Add an entry (or ask a maintainer to):

export const authors: Record<string, Author> = {
james: {
id: 'james',
name: 'James Pichardo',
role: 'Founder, F0RT1KA',
},
newauthor: {
id: 'newauthor',
name: 'Full Name',
role: 'Role, Company',
},
};

Using an author: id that isn't registered fails the build with a clear error.

Local preview (optional)

If you have the repository checked out:

cd blog
npm install # first time only
npm run dev # http://localhost:4321 — drafts are visible here
npm test # content-loader unit tests
npm run build # the same validation gate the PR check runs

How publishing works

The blog deploys as its own Vercel project, completely independent of the platform frontend/backend — a blog post PR can never affect the product.

Deployment

Once the Vercel git integration is connected for the blog project (Root Directory = blog), every merge to main that touches blog/ deploys automatically. Until then, a maintainer publishes merged posts with cd blog && vercel deploy --prod.

Troubleshooting build failures

The Blog PR check (and npm run build) fails with a message naming your file and the problem:

ErrorFix
Invalid post filename … slug must be kebab-caseRename the file: lowercase letters, digits, and hyphens only
Invalid frontmatter in … title: … (or any field)The named field is missing or malformed — compare against the template
tags must be kebab-caseLowercase the tag, replace spaces with hyphens
unknown author idRegister the author in blog/content/authors.ts or use an existing id
date must be YYYY-MM-DDQuote the date: date: "2026-07-10"