DEV Community

孫昊
孫昊

Posted on

I Use YAML Frontmatter as a Kanban Board (No Trello, No Notion)

TL;DR: Each markdown file's frontmatter has a status field with values draft → ready → live → archived. A 30-line scanner reads all files and renders a kanban view by status column. No SaaS subscriptions, no syncing issues, files stay in git.


The kanban states

Every paste-ready content file uses one of:

Status Meaning
draft Started writing, not ready to publish
ready Self-verified, ready to publish
live Published (Substack/Gumroad)
published Published (dev.to — the platform uses different terms)
archived Done, kept for reference

Other fields:

  • priority: P0 (today) / P1 (this week) / P2 (this month)
  • category: content / product / roadmap / kit
  • publish_target_date: ISO 8601 date

The scanner

import yaml
from pathlib import Path
from collections import defaultdict

def kanban_view(root: Path):
    """Render kanban view across all markdown files with frontmatter."""
    columns = defaultdict(list)
    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)
            meta = yaml.safe_load(yaml_block) or {}
        except (ValueError, yaml.YAMLError):
            continue
        if "status" not in meta:
            continue
        meta["_file"] = str(md_file.relative_to(root))
        columns[meta["status"]].append(meta)
    return columns


def render_kanban(columns):
    print(f"\n{'DRAFT':12} {'READY':12} {'LIVE':12} {'ARCHIVED':12}")
    print("-" * 64)
    max_rows = max(len(items) for items in columns.values())
    for i in range(max_rows):
        row = []
        for status in ['draft', 'ready', 'live', 'archived']:
            items = columns.get(status, [])
            if i < len(items):
                row.append(items[i].get('id', '?')[:12])
            else:
                row.append("")
        print(" | ".join(f"{c:12}" for c in row))


if __name__ == "__main__":
    cols = kanban_view(Path("reports"))
    render_kanban(cols)
    for status, items in cols.items():
        print(f"\n{status} ({len(items)}):")
        for item in items[:5]:
            print(f"  {item.get('id', '?')} [{item.get('priority', '?')}]")
Enter fullscreen mode Exit fullscreen mode

Output:

DRAFT        READY        LIVE         ARCHIVED
----------------------------------------------------------------
             devto-50     substack-21
             devto-51     substack-22
             devto-52     substack-23
             ...

draft (0):

ready (24):
  devto-30 [P1]
  devto-31 [P0]
  ...

live (12):
  substack-21 [P0]
  substack-22 [P0]
  ...
Enter fullscreen mode Exit fullscreen mode

Status transitions

You move a file between columns by editing its frontmatter:

sed -i 's/^status: draft$/status: ready/' file.md
Enter fullscreen mode Exit fullscreen mode

Or via your dashboard's "ship" button:

@app.route('/ship/<path>')
def ship(path):
    md = Path(f"reports/{path}.md").read_text()
    md = md.replace("status: ready", "status: live")
    Path(f"reports/{path}.md").write_text(md)
    return "ok"
Enter fullscreen mode Exit fullscreen mode

Why this beats Trello/Notion

1. Files stay in git

Every status change is a commit. git log is your audit trail. No "Trello deleted my card."

2. No SaaS lock-in

Trello / Notion can rate-limit, change their API, raise prices. Your filesystem won't.

3. CDP automation reads the same source

Your CDP publish script can read status: ready files only. Once published, status flips to live. Self-documenting.

4. Search is grep

"Find all P0 priority articles" = grep -l "priority: P0" reports/. No SaaS query language.

5. Cross-references are real

Article #50 references article #45. In Notion, those are 2 different "pages" with backlinks. In files, it's just a markdown link.

Bonus: 1-line stats

def stats(columns):
    total = sum(len(items) for items in columns.values())
    return {
        "total": total,
        "ready": len(columns.get("ready", [])),
        "live": len(columns.get("live", [])),
        "draft": len(columns.get("draft", [])),
        "by_priority": {
            "P0": sum(1 for items in columns.values() for i in items if i.get("priority") == "P0"),
            "P1": sum(1 for items in columns.values() for i in items if i.get("priority") == "P1"),
        }
    }
Enter fullscreen mode Exit fullscreen mode

Output:

{"total": 60, "ready": 24, "live": 36, "draft": 0, "by_priority": {"P0": 5, "P1": 28}}
Enter fullscreen mode Exit fullscreen mode

What this lets you do

  • See at a glance: "I have 24 articles ready to ship"
  • Filter by priority: "What's P0 today?"
  • Track throughput: "I shipped 12 articles this month"
  • Identify backlog: "0 drafts means I'm not writing enough"

All from cat reports/*.md | grep status and friends.

What I'd skip

  • Don't add sub-statuses (ready-for-review, in-review). Keep it 4 columns max.
  • Don't track effort estimates (eta_min: 30). Self-deception territory.
  • Don't add assignee. You're an indie. There's only you.

What I'd add:

  • tags for category grouping (already in my frontmatter spec)
  • last_modified (auto-updated on save)
  • dependencies (article #50 depends on #45 being LIVE first)

Source

Full kanban scanner + dashboard integration:

AutoApp Dashboard ($39) includes:

  • kanban_scan.py (this article)
  • dashboard/templates/kanban.html (Flask kanban view)
  • 60+ paste-ready content files with status fields as examples

If you have 50+ markdown files and you're using Trello to track them, you're paying SaaS for what grep can do. 30 lines of Python solves it.

Top comments (0)