DEV Community

David JULIEN
David JULIEN

Posted on • Originally published at davidjulien.hashnode.dev

Treating blog posts like production code

When I start a new project, I immediately set up a repository, linters, tests, git hooks, and CI. Not because I'm forced to, but because fast feedback loops make me a better developer.

When I decided to start this blog, I asked myself: why should writing be any different?

Repository setup

Go to your GitHub account (https://github.com/<YOUR_ACCOUNT>?tab=repositories) and create a new repository named blog (for example).

Then, set it up with the following commands:

mkdir blog

cd blog

echo "# blog" >> README.md

git init
git branch -M main
git add README.md
git commit -m "Initial commit"
git remote add origin https://github.com/davidjulien/blog.git
git push -u origin main
Enter fullscreen mode Exit fullscreen mode

Linters

Linters ensure that articles are well-formatted and consistent.

Here are the linters I set up:

  • markdownlint to check Markdown files and flag style issues.
  • vale for writing style.
  • ltex-ls-plus to check for grammar and spelling mistakes.
  • lychee to check that the links in my articles are valid.

Checking Markdown files with markdownlint-cli2

Since I use Markdown for my articles, I want to ensure that they're well-formatted and consistent. For that, I use markdownlint-cli2, which is a command-line interface for markdownlint.

You can also try markdownlint-cli.

I chose markdownlint-cli2 for the following reasons:

  • jsonc for configuration, which allows me to add comments and explanations in the configuration file.
  • Written by the markdownlint author himself
  • Has an official GitHub Action

Setting up markdownlint-cli2

Install the tool as a development dependency:

npm install markdownlint-cli2 --save-dev
Enter fullscreen mode Exit fullscreen mode

And write package.json with the following content:

{
  "scripts": {
    "lint:md": "markdownlint-cli2",
    "lint:md:fix": "markdownlint-cli2 --fix"
  },
  "devDependencies": {
    "markdownlint-cli2": "^0.21.0"
  }
}
Enter fullscreen mode Exit fullscreen mode

Then, add a basic configuration file .markdownlint-cli2.jsonc at the root of your repository.

{
  "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/refs/heads/main/schema/markdownlint-cli2-config-schema.json",
  // Only lint articles
  "globs": [
    "articles/**/*.md"
  ],
  "config": {
  }
}
Enter fullscreen mode Exit fullscreen mode

I prefer starting with everything enabled, then turn off with intention. Every rule you turn off should be a conscious decision, not a default.
That applies beyond linting. Code reviews, architecture choices, dependency upgrades: start strict, relax where it makes sense, and know why.
Don't forget to add comments in the configuration file to explain why you choose to enable or turn off certain rules.

markdownlint documentation lists available rules and their default configuration.

In my case, I started with this configuration:

{
  "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/refs/heads/main/schema/markdownlint-cli2-config-schema.json",
  // Only lint articles
  "globs": [
    "articles/**/*.md"
  ],
  "config": {
    // MD003: Enforce ATX-style headings (e.g. "# Heading") for consistency
    "heading-style": {
      "style": "atx"
    },
    // MD004: Use dashes for unordered lists to keep a uniform style
    "ul-style": {
      "style": "dash"
    },
    // MD025: Tells markdownlint not to treat the frontmatter title as an h1
    "single-h1": {
      "front_matter_title": ""
    },
    // MD026: Allow trailing punctuation in headings (e.g. "What happened?")
    "no-trailing-punctuation": {
      "punctuation": ".,;:!"
    },
    // MD029: Ordered list items must use incrementing numbers (1, 2, 3) for clarity
    "ol-prefix": {
      "style": "ordered"
    },
    // MD033: Disallow inline HTML (authorized elements can be added later)
    "no-inline-html": {
      "allowed_elements": []
    },
    // MD035: Use "---" for horizontal rules for consistency
    "hr-style": {
      "style": "---"
    },
    // MD040: Fenced code blocks must specify a language for syntax highlighting
    "fenced-code-language": {
      "language_only": true
    },
    // MD046: Always use fenced style for code blocks (not indented)
    "code-block-style": {
      "style": "fenced"
    },
    // MD048: Use backticks (not tildes) for fenced code blocks
    "code-fence-style": {
      "style": "backtick"
    },
    // MD049: Use asterisks for emphasis (*italic*) for consistency
    "emphasis-style": {
      "style": "asterisk"
    },
    // MD050: Use asterisks for strong emphasis (**bold**) for consistency
    "strong-style": {
      "style": "asterisk"
    },
    // MD055: Use pipes on both sides of table rows for readability
    "table-pipe-style": {
      "style": "leading_and_trailing"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Running the linter

First run to test:

$ npm run lint:md


> lint:md
> markdownlint-cli2

markdownlint-cli2 v0.21.0 (markdownlint v0.40.0)
Finding: articles/**/*.md
Linting: 1 file(s)
Summary: 7 error(s)
articles/20260313-setup-a-repository-to-store-blog-articles.md:6 error MD025/single-title/single-h1 Multiple top-level headings in the same document [Context: "Treating blog posts like produ..."]
articles/20260313-setup-a-repository-to-store-blog-articles.md:8:81 error MD013/line-length Line length [Expected: 80; Actual: 95]
articles/20260313-setup-a-repository-to-store-blog-articles.md:26:4 error MD009/no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 1]
articles/20260313-setup-a-repository-to-store-blog-articles.md:33 error MD032/blanks-around-lists Lists should be surrounded by blank lines [Context: "- [markdownlint](https://githu..."]
...
Enter fullscreen mode Exit fullscreen mode

It works!

Now I can analyze the errors:

  • The main error is MD013/line-length. This rule checks that lines don't exceed a certain length (80 characters by default). I can either break the long line or increase the line length limit in the configuration file or disable the rule if I don't care about it. I decided to disable this rule. Even if I prefer shorter lines and try to write one phrase per line, this rule is annoying when you have a long URL or you are just over the limit. I prefer to set a visual clue in my editor when I reach 120 characters.
  {
    "config": {
      // MD013: No limit (useful when you have urls in your articles)
      "line-length": false,
    }
  }
Enter fullscreen mode Exit fullscreen mode
  • Another error is MD024/no-duplicate-heading.
    This rule checks that there are no duplicate headings in the same document.
    In my case, I had two ## Running the linter headings after I wrote the two first linter sections.
    I thought to disable this rule, but the documentation explained that "Some Markdown parsers generate anchors for headings based on the heading name; headings with the same content can cause problems with that."
    I decided to keep this rule and change my headings to be more specific.

  • Another cryptic error I hit is MD025/single-title/single-h1.
    This rule checks that there is only one top-level heading in the document.
    In my case, I have an explicit one (a line starting with #) and a hidden one (the frontmatter title):

  ---
  title: "Treating blog posts like production code"
  tags: tooling, writing, vale, markdownlint
  ---

  # Treating blog posts like production code
Enter fullscreen mode Exit fullscreen mode

I want to keep both:

  {
    "config": {
      // MD025: Tells markdownlint not to treat the frontmatter title as an h1 to keep the explicit one
      "single-h1": {
        "front_matter_title": ""
      }
    }
  }
Enter fullscreen mode Exit fullscreen mode

After fixing errors and updating configuration, you can run the command again to check that everything is good:

$ npm run lint:md


> lint:md
> markdownlint-cli2

markdownlint-cli2 v0.21.0 (markdownlint v0.40.0)
Finding: articles/**/*.md
Linting: 1 file(s)
Summary: 0 error(s)
Enter fullscreen mode Exit fullscreen mode

Vale for writing style

Now that my article is well-formatted, I want to check that it's well-written.

Setting up vale

Install the tool with your package manager:

brew install vale
Enter fullscreen mode Exit fullscreen mode

Then, create a configuration file .vale.ini at the root of your repository:

StylesPath = .vale/styles
Vocab = blog
MinAlertLevel = suggestion
Packages = Google, proselint, write-good, Readability, alex

[*.md]
BasedOnStyles = Vale, Google, proselint, write-good, Readability, alex
Enter fullscreen mode Exit fullscreen mode

I chose these packages for the following reasons:

Package What it catches
Google Word choice, headings, contractions. Based on Google's Developer Style Guide
proselint Clichés, redundancy, jargon. Advice aggregated from great editors
write-good Passive voice, weasel words, wordy phrases
Readability Flesch-Kincaid, SMOG. Are your sentences too dense?
alex Flags insensitive or inconsiderate language: gendered terms, ableist language, racial bias. alex catches things you might not think about as a non-native English speaker

Run this command to download the packages and to create the styles directory:

vale sync
Enter fullscreen mode Exit fullscreen mode

Then, create the vocabularies for your blog and add custom words to allow or flag in your writing style.

mkdir -p .vale/styles/config/vocabularies/blog
touch .vale/styles/config/vocabularies/blog/accept.txt
touch .vale/styles/config/vocabularies/blog/reject.txt
Enter fullscreen mode Exit fullscreen mode

Running vale

$ vale articles/20260213-setup-a-repository-to-store-blog-articles.md

 articles/20260213-setup-a-repository-to-store-blog-articles.md
[...]
✖ 13 errors, 29 warnings and 17 suggestions in 1 file.
Enter fullscreen mode Exit fullscreen mode

Well… I knew that my writing style wasn't perfect, but I didn't expect that many warnings and suggestions!

Now I need to review each error and decide: fix or ignore?

I check the most common errors.
vale doesn't have a built-in way to do that.
I use jq to parse the JSON output and count the occurrences of each rule:

Install jq with your package manager:

brew install jq
Enter fullscreen mode Exit fullscreen mode
$ vale --output=JSON articles/ | jq -r '.[][].Check' | sort | uniq -c | sort -rn
  23 Google.FirstPerson
  13 write-good.E-Prime
   5 Google.EmDash
   4 Google.Parens
   3 alex.ProfanityUnlikely
   3 Vale.Spelling
   2 write-good.Weasel
   2 Google.WordList
   2 Google.Exclamation
   1 write-good.TooWordy
   1 Readability.SMOG
   1 Readability.FleschReadingEase
   1 Readability.ColemanLiau
   1 proselint.Very
   1 proselint.Typography
   1 proselint.But
   1 Google.Will
   1 Google.We
   1 Google.Spacing
   1 Google.Ellipses
   1 Google.Acronyms
Enter fullscreen mode Exit fullscreen mode

Output analysis:

  • Google.FirstPerson I use a lot of first-person pronouns in my writing. Since I write a personal blog, I don't care about this rule. I can disable it in the configuration file. I also disable Google.We for the same reason.
  # I'm writing a personal blog, not corporate docs
  Google.FirstPerson = NO
  Google.We = NO
Enter fullscreen mode Exit fullscreen mode
  • write-good.E-Prime: this one is less clear to me. Fortunately, each rule has documentation:
  $ cat .vale/styles/write-good/E-Prime.yml
  extends: existence
  message: "Try to avoid using '%s'."
  ignorecase: true
  level: suggestion
  tokens:
    - am
    - are
    - aren't
    - be
    - been
    - being
    - he's
    - here's
    - here's
    - how's
    - i'm
    - is
    - isn't
    - it's
    - she's
    - that's
    - there's
    - they're
    - was
    - wasn't
    - we're
    - were
    - weren't
    - what's
    - where's
    - who's
    - you're
Enter fullscreen mode Exit fullscreen mode

This rule flags all forms of "to be".
I don't find it useful for blog writing. I set it to NO.

  write-good.E-Prime = NO
Enter fullscreen mode Exit fullscreen mode
  • Google.EmDash: The preceding cat command displays the rule content. I also want to know where the errors are in my article. For that, I can use the --output=JSON option to get the details of each error and use jq to filter the errors:
  $ vale --output=JSON articles/ | jq -r '.[][] | select(.Check == "Google.EmDash") | "\(.Line):\(.Span[0]) \(.Message)"'
  176:47 Don't put a space before or after a dash.
  177:42 Don't put a space before or after a dash.
  179:37 Don't put a space before or after a dash.
  180:53 Don't put a space before or after a dash.
  183:87 Don't put a space before or after a dash.
Enter fullscreen mode Exit fullscreen mode

To check my articles, I need these commands often. Instead of copy/pasting it, I create three scripts:

mkdir bin

cat > bin/vstats << 'EOF'
#!/bin/bash
# Show Vale warnings count per rule
# Usage: bin/vstats

vale --output=JSON articles/ | jq -r '.[][].Check' | sort | uniq -c | sort -rn
EOF

chmod +x bin/vstats
cat > bin/vdef << 'EOF'
#!/bin/bash
# Show a Vale rule definition
# Usage: bin/vdef write-good.E-Prime

pkg="${1%%.*}"
rule="${1##*.}"
cat ".vale/styles/${pkg}/${rule}.yml"
EOF

cat > bin/vgrep << 'EOF'
#!/bin/bash
# Show occurrences of a Vale rule
# Usage: bin/vgrep Google.EmDash

vale --output=JSON articles/ | jq -r ".[][] | select(.Check == \"$1\") | \"\(.Line):\(.Span[0]) \(.Message)\""
EOF

chmod +x bin/vdef bin/vgrep
Enter fullscreen mode Exit fullscreen mode

Examples:

$ bin/vdef Google.EmDash
extends: existence
message: "Don't put a space before or after a dash."
link: "https://developers.google.com/style/dashes"
nonword: true
level: error
action:
  name: edit
  params:
    - trim
    - " "
tokens:
  - '\s[—–]\s'

$ bin/vgrep Google.EmDash
183:87 Don't put a space before or after a dash.
Enter fullscreen mode Exit fullscreen mode

Now it's easier.
I continue to check my article and fix the errors one by one.
I only comment those where I can suggest a new kind of fix.

  • write-good.Weasel: Weasel words are vague qualifiers that sound meaningful but say nothing precise. For an engineering blog, I want to be precise and avoid vague language. I'll keep this rule and fix these errors. After fixing, I agree that these sentences are better without these words.
  • Google.Parens: False positive — this triggers on Markdown link syntax. Not useful for articles. I set it to NO.
  • Google.WordList: This rule identified words that aren't recommended by Google's style guide. In my case, I used the word "disable" three times. It suggests replacing it with "turn off". I'm not fully sure if it's better since I target technical readers and "disable" feels right in this context. Using "turn off" is slightly awkward, it sounds like a light switch. Since I can't define an exception for the word "disable", it's the occasion to learn how to write custom rules.

Start by checking the rule definition:

  cat .vale/styles/Google/WordList.yml
Enter fullscreen mode Exit fullscreen mode

Then, create a custom rule by copying the existing one and modifying it:

  mkdir .vale/styles/MyBlog
  cp .vale/styles/Google/WordList.yml .vale/styles/MyBlog/WordList.yml
Enter fullscreen mode Exit fullscreen mode

Now I can remove the word "disable" from my custom rule and keep the other words.

  • alex.ProfanityUnlikely: This rule flags words that readers might misinterpret as profane. In my case, it flagged the example I used to describe alex role. I don't want to change this example, so I'm ignoring this rule for this specific case by adding an inline comment in the article:
  <!-- vale alex.ProfanityUnlikely = NO -->
  my text
  <!-- vale alex.ProfanityUnlikely = YES -->
Enter fullscreen mode Exit fullscreen mode
  • Readability.FleschReadingEase: This rule calculates the Flesch Reading Ease score of the whole text. The value is for the whole text, not only the first line. A higher score indicates more readable text. My article was just below the expected score. I improved this score by breaking long sentences into shorter ones, using simpler words, and avoiding passive voice.

After discarding rules and fixing errors, I have the following stats:

✔ 0 errors, 0 warnings and 0 suggestions in 1 file.
Enter fullscreen mode Exit fullscreen mode

I aim for zero warnings.
If you start with 10 errors, you might miss a new error lost between two existing ones.
If you start with 0 errors, any new error is immediately visible, and you can fix it right away.

Grammar and spelling mistakes

When you aren't a native English speaker, it's common to make grammar and spelling mistakes without noticing them.

I tried two different tools to catch these mistakes: ltex-ls-plus and harper. Even if harper is faster, I find
the result of ltex-ls-plus more accurate, so I decided to keep it.

Setting up ltex-ls-plus

brew install ltex-ls-plus
Enter fullscreen mode Exit fullscreen mode

Running ltex-ls-plus

ltex-cli-plus articles/20260213-setup-a-repository-to-store-blog-articles.md
Enter fullscreen mode Exit fullscreen mode

Checking links

To check that the links in my articles are valid, I use lychee, which is a fast and modern link checker.

Setting up lychee

brew install lychee
Enter fullscreen mode Exit fullscreen mode

Running lychee

$ lychee articles/20260213-setup-a-repository-to-store
5/5 ━━━━━━━━━━━━━━━━━━━━ Finished extracting links                                                                                                                                Issues found in 1 input. Find details below.

[articles/20260213-setup-a-repository-to-store-blog-articles.md]:
   [ERROR] file:///Users/david/dev/davidjulien/blog/articles/markdownlint-cli | Cannot find file: File not found. Check if file exists and path is correct

🔍 5 Total (in 1s) ✅ 4 OK 🚫 1 Error
Enter fullscreen mode Exit fullscreen mode

One link isn't working. Without a line number, using grep is necessary to find the error location.
In my case, I permuted the link with the text:

$ grep -n "\](markdownlint"
34:[https://github.com/markdownlint/markdownlint](markdownlint)
Enter fullscreen mode Exit fullscreen mode

Automating local checks

I set up four tools locally to check my articles: markdownlint, vale, ltex-ls-plus, and lychee.
I want to run these tools before each commit to ensure that I don't commit any errors.

Git hooks

To automate these checks, I use git hooks, which are scripts that run at specific points in the Git workflow.
The main problem is that the .git directory isn't versioned, so I can't track changes in the hooks and share them with my collaborators.
The solution is to store the hooks in a versioned directory (like .githooks) and tell git to use this directory.

$ mkdir .githooks
$ cat > .githooks/pre-commit << 'EOF'
#!/bin/bash
files=$(git diff --cached --name-only --diff-filter=ACM | grep 'articles/.*\.md$')

if [ -z "$files" ]; then
  exit 0
fi

echo "--- Markdown syntax ---"
markdownlint-cli2 $files || exit 1

echo "--- Check links ---"
lychee $files || exit 1

echo "--- Style ---"
vale $files || exit 1

echo "--- Grammar ---"
JAVA_OPTS="--enable-native-access=ALL-UNNAMED" ltex-cli-plus $files || exit 1

echo "Check terminated with success."
EOF

$ chmod +x .githooks/pre-commit

$ git config core.hooksPath .githooks
Enter fullscreen mode Exit fullscreen mode

Test it:

$ .githooks/pre-commit
--- Markdown syntax ---
markdownlint-cli2 v0.21.0 (markdownlint v0.40.0)
Finding: articles/20260213-setup-a-repository-to-store-blog-articles.md articles/**/*.md
Linting: 1 file(s)
Summary: 0 error(s)
--- Check links ---
6/6 ━━━━━━━━━━━━━━━━━━━━ Finished extracting links                                                                                                                                🔍 6 Total (in 1s) ✅ 6 OK 🚫 0 Errors

--- Style ---
✔ 0 errors, 0 warnings and 0 suggestions in 1 file.
--- Grammar ---
Check terminated with success.
Enter fullscreen mode Exit fullscreen mode

An alternative is lefthook.
It has more features (run checks in parallel, better output formatting, etc.) but it requires a dependency.

Neovim integration

Linters output in the editor

Running all checks thanks to a script is good, but it's even better to have these checks running in real-time while writing the article.
It allows you to fix errors right away and avoid having a long list of errors at the end.

This part depends on your editor.
Personally, I'm using Neovim with the following configuration:

In my lsp configuration:

vim.lsp.config("ltex_plus", {
  filetypes = { "markdown" },
  settings = {
    ltex = {
      language = "en-US",
    }
  }
})

vim.lsp.enable("ltex_plus")
Enter fullscreen mode Exit fullscreen mode

In my linter configuration:

return {
  "mfussenegger/nvim-lint",
  config = function()
    local lint = require("lint")

    lint.linters_by_ft = {
      markdown = { "vale", "markdownlint-cli2" },
    }

    local lint_augroup = vim.api.nvim_create_augroup("lint", { clear = true })

    vim.api.nvim_create_autocmd({ "BufEnter", "BufWritePost", "InsertLeave" }, {
      group = lint_augroup,
      callback = function()
        lint.try_lint()
      end,
    })
  end,
}
Enter fullscreen mode Exit fullscreen mode

Now I can see directly all errors in my editor while writing the article.
I can also navigate between errors with the following shortcuts:

vim.keymap.set('n', '<leader>q', vim.diagnostic.setloclist, { desc = 'Open diagnostics list' })
Enter fullscreen mode Exit fullscreen mode
map("<leader>ca", vim.lsp.buf.code_action, "[C]ode [A]ction", { "n", "x" })
Enter fullscreen mode Exit fullscreen mode

Preview in the browser

Writing Markdown is cool, but sometimes you need to check the rendering. For that, I install the markdown-preview.nvim plugin, which allows seeing a live preview of my article in the browser while writing it.

{
  "iamcco/markdown-preview.nvim",
  cmd = { "MarkdownPreviewToggle", "MarkdownPreview", "MarkdownPreviewStop" },
  ft = { "markdown" },
  build = "cd app && npm install",
}
Enter fullscreen mode Exit fullscreen mode

I just need to run :MarkdownPreview to see the preview in the browser.

Continuous integration

Since I'm the only one writing articles in my repository, I don't need to set up CI to enforce the checks. Git hooks ensure that I don't commit any changes that don't respect my quality standards. If you aren't alone, it's a good idea to set up CI to ensure that all articles go through the same checks before merging.

Conclusion

Writing code and writing prose aren't that different. Both need fast feedback loops, consistent standards, and automation that catches what you miss. With this setup, every commit to my blog repository goes through the same rigor I'd apply to commit a change in my code.

The tools here — markdownlint, vale, ltex-ls-plus, lychee — took an afternoon to set up. Writing this article took more time!
The feedback they give me every time I write is worth that investment many times over.

Top comments (0)