DEV Community

孫昊
孫昊

Posted on

YAML Frontmatter for Content Pipelines: 50+ Articles Without an INDEX.md

TL;DR: Stop maintaining INDEX.md by hand. Add YAML frontmatter to every paste-ready file, then a 30-line scanner generates the index automatically. I have 50+ articles, 14 newsletters, and 6 SKU files all auto-indexed via this pattern.


The problem with INDEX.md

You start writing article 1. You add it to INDEX.md. Article 2 — you add it. Article 3, 4, 5...

By article 30, INDEX.md is out of sync. You forgot to add 3 articles. You added one with the wrong status. The status: ready flag is wrong because you published it but didn't update INDEX.md.

This is the classic "human-maintained source of truth" anti-pattern. Don't do this.

The frontmatter pattern

Every paste-ready file gets a YAML block at the top:

---
id: devto-55-manifest-frontmatter-content
title: "dev.to #55 - YAML Frontmatter for Content Pipelines"
category: content
priority: P1
status: ready
eta_min: 5
actions: [preview, copy-clipboard, open-devto]
tags: [content, yaml, automation]
created: 2026-05-07
publish_target_date: 2026-05-12
---

# YAML Frontmatter for Content Pipelines

(article body)
Enter fullscreen mode Exit fullscreen mode

The frontmatter has 9 fields:

  1. id — unique kebab-case ID
  2. title — display title
  3. category — content / product / roadmap / kit
  4. priority — P0 (today) / P1 (this week) / P2 (next month)
  5. status — draft / ready / live / published / archived
  6. eta_min — how long to publish/use it
  7. actions — array of dashboard buttons to render
  8. tags — for searchability
  9. created / publish_target_date — calendar tracking

The 30-line scanner

import yaml
from pathlib import Path

def scan_assets(root: Path):
    assets = []
    for md_file in root.rglob("*.md"):
        content = md_file.read_text(encoding='utf-8-sig')
        if not content.startswith("---"):
            continue
        try:
            _, yaml_block, _ = content.split("---", 2)
        except ValueError:
            continue
        try:
            meta = yaml.safe_load(yaml_block)
        except yaml.YAMLError:
            continue
        if not isinstance(meta, dict) or "id" not in meta:
            continue
        meta['_path'] = str(md_file.relative_to(root))
        assets.append(meta)
    return sorted(assets, key=lambda a: a.get('publish_target_date', '0'))

# Usage
assets = scan_assets(Path("reports"))
for a in assets:
    print(f"[{a['category']:8}] {a['priority']} {a['id']:40}{a['_path']}")
Enter fullscreen mode Exit fullscreen mode

Output:

[content ] P0 devto-50-50-articles-checkpoint            → devto-article-50-paste-ready.md
[content ] P0 substack-23-day-60-milestone               → substack-issue-23-day-60-milestone-paste-ready.md
[content ] P1 devto-55-manifest-frontmatter-content      → devto-article-55-paste-ready.md
...
Enter fullscreen mode Exit fullscreen mode

No manual updates. Adding a new file with frontmatter automatically appears in the index.

Dashboard rendering

The dashboard reads the frontmatter and renders it as a table:

from flask import render_template_string
@app.route('/assets')
def assets():
    rows = scan_assets(Path("reports"))
    return render_template_string(TABLE_HTML, rows=rows)

TABLE_HTML = """
<table>
{% for r in rows %}
  <tr class="{{r.priority}}">
    <td>{{r.category}}</td>
    <td>{{r.id}}</td>
    <td>{{r.status}}</td>
    <td>{{r.publish_target_date}}</td>
    <td>
      {% if 'preview' in r.actions %}<a href="/preview/{{r._path}}">preview</a>{% endif %}
      {% if 'copy-clipboard' in r.actions %}<button onclick="copy('{{r._path}}')">copy</button>{% endif %}
      {% if 'open-devto' in r.actions %}<a href="https://dev.to/new" target="_blank">dev.to</a>{% endif %}
    </td>
  </tr>
{% endfor %}
</table>
"""
Enter fullscreen mode Exit fullscreen mode

Three columns: ID, status, publish target date. Three buttons: preview, copy clipboard, open editor. Auto-generated from frontmatter.

Status transitions

The status field has 5 values that transition:

draft → ready → live (Substack/Gumroad) | published (dev.to) → archived
Enter fullscreen mode Exit fullscreen mode

Update via shell one-liner:

sed -i 's/^status: ready$/status: published/' reports/devto-article-50-paste-ready.md
Enter fullscreen mode Exit fullscreen mode

Or your dashboard exposes a button per row that does it.

What this saves

Manual INDEX.md update flow:

  • Write article → save file → open INDEX.md → add row → fix sort → save → commit

Frontmatter flow:

  • Write article (with frontmatter at top) → save file → done

Per-article time saved: 60 seconds.
For 50 articles: 50 minutes saved + zero index drift.

Bonus: stale-file detection

Once frontmatter is in place, you can write a stale-check:

from datetime import datetime, timedelta

stale = []
for a in scan_assets(Path("reports")):
    target = datetime.strptime(a.get('publish_target_date', '2099-01-01'), '%Y-%m-%d')
    if a['status'] in ['draft', 'ready'] and target < datetime.now() - timedelta(days=7):
        stale.append(a)

if stale:
    print(f"{len(stale)} stale assets:")
    for s in stale:
        print(f"  {s['id']} (target {s['publish_target_date']})")
Enter fullscreen mode Exit fullscreen mode

Run this in cron daily. Flags any draft you forgot to publish.

Source

Full manifest scanner + dashboard + stale checker:

AutoApp Dashboard ($39) includes:

  • manifest_scan.py (this article)
  • dashboard/app.py (Flask + frontmatter rendering)
  • stale_checker.py (cron-friendly)
  • 60+ paste-ready files with frontmatter as examples

If you have 10+ markdown files and a hand-maintained INDEX.md, you have a frontmatter-shaped problem. Solve it once.

Top comments (0)