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
Linters
Linters ensure that articles are well-formatted and consistent.
Here are the linters I set up:
-
markdownlintto check Markdown files and flag style issues. -
valefor writing style. -
ltex-ls-plusto check for grammar and spelling mistakes. -
lycheeto 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:
-
jsoncfor configuration, which allows me to add comments and explanations in the configuration file. - Written by the
markdownlintauthor himself - Has an official GitHub Action
Setting up markdownlint-cli2
Install the tool as a development dependency:
npm install markdownlint-cli2 --save-dev
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"
}
}
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": {
}
}
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"
}
}
}
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..."]
...
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,
}
}
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 linterheadings 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 (thefrontmattertitle):
---
title: "Treating blog posts like production code"
tags: tooling, writing, vale, markdownlint
---
# Treating blog posts like production code
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": ""
}
}
}
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)
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
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
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
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
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.
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
$ 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
Output analysis:
-
Google.FirstPersonI 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 disableGoogle.Wefor the same reason.
# I'm writing a personal blog, not corporate docs
Google.FirstPerson = NO
Google.We = NO
-
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
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
-
Google.EmDash: The precedingcatcommand displays the rule content. I also want to know where the errors are in my article. For that, I can use the--output=JSONoption to get the details of each error and usejqto 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.
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
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.
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 toNO. -
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
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
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 describealexrole. 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 -->
-
Readability.FleschReadingEase: This rule calculates theFlesch Reading Easescore 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.
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
Running ltex-ls-plus
ltex-cli-plus articles/20260213-setup-a-repository-to-store-blog-articles.md
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
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
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)
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
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.
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")
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,
}
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' })
map("<leader>ca", vim.lsp.buf.code_action, "[C]ode [A]ction", { "n", "x" })
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",
}
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)