From Markdown to Live: A Publishing Pipeline Without a CMS

TL;DR How DAP iQ turns markdown into published pages with predictable SEO output: frontmatter, upserts, feeds, sitemaps, and local validation.

This site has no CMS. There is no admin UI. Content is Markdown in a repo.

The system is simple:

  1. Author Markdown with frontmatter.
  2. Run a publisher CLI.
  3. Store normalized content in SQL.
  4. Serve it via MVC.

You're reading Part 5 of 5 in the AI-assisted development series. Previous: Part 4: Performance Defaults That Beat Clever Optimizations Next: Series hub: AI-assisted development

This series moves from workflow -> safety -> performance -> publishing, using DAP iQ as the working system.

Common questions this answers

  • How do you publish Markdown with predictable SEO output?
  • What frontmatter rules keep a pipeline safe under AI-assisted development?
  • How do you handle slug changes without accidental SEO damage?

Definition (what this means in practice)

A deterministic publishing pipeline is one where the same Markdown and rules produce the same URLs, metadata, and rendered output locally and in production. You can validate it with commands and HTTP checks, not by inspecting a dashboard.

In practice, this means treating frontmatter as an API, failing fast on schema violations, and validating locally before every publish.

Terms used

  • Source of truth: where edits must happen (for this workflow: Markdown in git).
  • Deterministic: same input produces the same output.
  • Slug: the canonical URL key; it should not change casually.
  • Frontmatter schema: the required and optional fields the publisher accepts.
  • Fail fast: reject invalid content at publish time.
  • SEO invariants: canonicals, structured data, sitemap, and feeds remain stable.

Reader contract

This article is for:

  • Engineers who want a publishing system you can reason about in code review.
  • Teams using AI-assisted development and trying to avoid "it shipped from nowhere" incidents.

You will leave with:

  • A strict frontmatter schema you can treat like an API.
  • A deterministic pipeline model (Markdown -> normalized DB -> MVC).
  • SEO invariants: stable slugs, stable canonicals, stable feeds.

Why this exists

I want predictable publishing. If a change is not visible in git, it should not ship. If a page URL changes, it should be a deliberate migration, not an accident.

Default rule

Make publishing deterministic. Prefer small, explicit contracts over flexible systems.

Quick start (the minimum viable pipeline)

If you are building a content site without a CMS:

Verified on: ASP.NET Core (.NET 10), EF Core 10.

  1. Pick canonical routes and never generate them from titles.
  2. Define a strict frontmatter schema and fail fast on missing required fields.
  3. Normalize content at publish time (tags, series, computed reading time).
  4. Keep DB as a rendering store, not the source of truth.
  5. Make output deterministic: same Markdown + same rules -> same HTML + same SEO.

Frontmatter is the contract

Frontmatter is not "metadata". It is an API contract between content authors and your publisher.

This is the contract style that scales under AI-assisted development:

Field Required Meaning Notes
title yes On-page title Human readable
slug yes Canonical URL key Must be stable
tldr yes Summary block Used in lists
tags yes Navigation + discovery Avoid tag spam
series optional Series slug For hub pages
seriesOrder optional Ordering in series Integer
metaTitle yes HTML title Often same as title
metaDescription yes Meta description Keep it precise
published yes Publish toggle true/false
created optional Content-created date ISO string, e.g. 2026-01-09
updated optional Content-updated date Must be >= created

Why created/updated matter:

  • They let you avoid "dateModified churn" when republishing content.
  • They let you control what search engines see as a meaningful update.

Copy/paste frontmatter schema (YAML)

This is a practical starting point. Treat it like an API contract.

title: "<string>"
slug: "<kebab-case-string>"
tldr: "<string>"
tags:
  - "<string>"
metaTitle: "<string>"
metaDescription: "<string>"
published: true

# Optional series fields
series: "<series-slug>"
seriesOrder: 1

# Optional dates (ISO 8601 date)
created: "2026-01-09"
updated: "2026-01-09"

Publisher rules (fail fast)

Common failure mode: changes that look harmless but alter routing, metadata, or structured data output. Publishing needs to make harmful changes obvious.

Rules that keep the pipeline safe:

  • Required frontmatter missing -> fail.
  • Invalid dates (e.g., updated < created) -> fail.
  • Slug changes -> treat as a migration, not an edit.
  • Frontmatter schema drift (unknown keys) -> warn in local dev, fail in CI/publish.

If your publisher is permissive, your site will drift.

Deterministic SEO outputs

Deterministic means:

  • Same input -> same output.
  • Accidental changes are reviewable.

SEO invariants for this kind of site:

Output Deterministic rule
Canonical URL derived from slug, never from title
datePublished based on first publish (or created when inserting)
dateModified based on meaningful content updates (or updated when provided)
Feeds stable ordering and stable item IDs
Sitemap stable loc entries and lastmod behavior

JSON-LD note (ASP.NET Core Razor)

Preferred approach: serialize a controlled object to JSON and render it as raw text. Do not hand-roll JSON strings.

Default: render JSON-LD in a normal <script type="application/ld+json"> block. The key is that the JSON comes from a serializer, not from string concatenation.

Razor note: TagHelper suppression syntax for script tags (the !-prefixed form) is not HTML. It exists to force Razor to output a literal <script> tag without TagHelper rewriting. In plain HTML (or non-Razor rendering), it will not work and should never be copy/pasted.

@using System.Text.Json

@{
  var jsonLdObject = new
  {
    @context = "https://schema.org",
    @type = "Article"
  };

  var jsonLd = JsonSerializer.Serialize(jsonLdObject);
}

<script type="application/ld+json">
@Html.Raw(jsonLd)
</script>

Last resort (no code pattern): if you can prove your rendered HTML output is corrupting the type attribute (for example the page source shows application/ld&#x2B;json), use Razor TagHelper suppression for the script tag. Treat it as a workaround, not as the primary pattern.

CI/CD pipeline integration

Automate validation and publishing in your deployment pipeline.

GitHub Actions workflow

# .github/workflows/publish-content.yml
name: Publish Content

on:
  push:
    branches: [main]
    paths:
      - 'content/**'
  workflow_dispatch:

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Validate frontmatter
        run: dotnet run --project src/Publisher -- validate content/

      - name: Check for broken internal links
        run: dotnet run --project src/Publisher -- check-links content/

  publish:
    needs: validate
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'

      - name: Publish to database
        env:
          ConnectionStrings__Default: ${{ secrets.DB_CONNECTION_STRING }}
        run: dotnet run --project src/Publisher -- publish content/

      - name: Purge CDN cache
        run: |
          curl -X POST "${{ secrets.CDN_PURGE_URL }}" \
            -H "Authorization: Bearer ${{ secrets.CDN_TOKEN }}"

Validation gates

Check When to run Failure behavior
Frontmatter schema Every PR Block merge
Internal link validation Every PR Block merge
ASCII-only punctuation Every PR Block merge
Slug uniqueness Every PR Block merge
Image optimization Every PR Warning
Publish to staging Every PR Required for review
Publish to production Merge to main Auto-deploy

Preview deployments

For PRs, deploy to a preview environment:

preview:
  runs-on: ubuntu-latest
  if: github.event_name == 'pull_request'
  steps:
    - name: Deploy to preview
      env:
        PREVIEW_URL: pr-${{ github.event.number }}.preview.example.com
      run: |
        dotnet run --project src/Publisher -- publish content/ --preview
        echo "Preview: https://$PREVIEW_URL" >> $GITHUB_STEP_SUMMARY

Content versioning strategy

Track content changes for audit, rollback, and analytics.

Version tracking in the database

public class Article
{
    public int Id { get; set; }
    public string Slug { get; set; } = string.Empty;
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;

    // Versioning fields
    public int Version { get; set; } = 1;
    public string ContentHash { get; set; } = string.Empty;  // SHA256 of content
    public DateTimeOffset FirstPublished { get; set; }
    public DateTimeOffset LastModified { get; set; }
}

// Version history (optional, for audit)
public class ArticleVersion
{
    public int Id { get; set; }
    public int ArticleId { get; set; }
    public int Version { get; set; }
    public string Content { get; set; } = string.Empty;
    public string ContentHash { get; set; } = string.Empty;
    public DateTimeOffset PublishedAt { get; set; }
    public string? GitCommitSha { get; set; }
}

Git-based versioning

Since Markdown lives in git, leverage git history:

# Get version history for an article
git log --oneline -- content/insights/my-article.md

# Get content at specific version
git show abc123:content/insights/my-article.md

# Compare versions
git diff abc123..def456 -- content/insights/my-article.md

Content hash for change detection

public class Publisher
{
    private static string ComputeContentHash(string content)
    {
        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(content));
        return Convert.ToHexString(bytes);
    }

    public async Task PublishAsync(Article article, string newContent)
    {
        var newHash = ComputeContentHash(newContent);

        if (article.ContentHash == newHash)
        {
            // Content unchanged, skip update
            return;
        }

        article.Content = newContent;
        article.ContentHash = newHash;
        article.Version++;
        article.LastModified = DateTimeOffset.UtcNow;

        await _db.SaveChangesAsync();
    }
}

Localization considerations

If you need multi-language content, plan the structure early.

File structure for localized content

content/
  insights/
    en/
      my-article.md
    es/
      my-article.md
    de/
      my-article.md

URL patterns

Pattern Example Pros Cons
Subdomain es.example.com/insights/article Clear separation DNS/cert complexity
Path prefix example.com/es/insights/article Simple routing Longer URLs
Query param example.com/insights/article?lang=es Easy to add Poor SEO

Recommendation: Path prefix (/es/, /de/) for most sites. Subdomain for major markets with dedicated teams.

Frontmatter for localization

title: "My Article"
slug: my-article
locale: en
translations:
  es: my-article-es
  de: my-article-de
hreflang:
  - locale: en
    url: /insights/my-article
  - locale: es
    url: /es/insights/my-article

SEO for localized content

<!-- In <head> -->
<link rel="alternate" hreflang="en" href="https://example.com/insights/article" />
<link rel="alternate" hreflang="es" href="https://example.com/es/insights/article" />
<link rel="alternate" hreflang="x-default" href="https://example.com/insights/article" />

When NOT to localize

  • Technical documentation with code (code is universal)
  • Small audience in secondary languages
  • No budget for translation quality assurance
  • Content changes frequently (translation lag)

Copy/paste artifact: slug migration checklist

Treat slug changes as a migration, not an edit.

  • Confirm the old slug has been published (in prod) and is indexed.
  • Add a permanent redirect (301) from old -> new.
  • Update internal links (series nav, hub pages, related reading).
  • Update sitemap so both URLs behave correctly (new listed, old returns 301).
  • Decide how you handle canonical: old should point to new after redirect.
  • Keep the old slug reserved (do not reuse it for a new article).
  • Re-publish and validate with HTTP checks (200 for new, 301 for old).

A concrete slug change story (what actually happens)

You ship an article as /insights/ai-workflow. Weeks later you realize the phrasing is unclear, and you want /insights/ai-memory-bank-workflow.

If you change the slug in frontmatter and do nothing else:

  • links in old posts now 404
  • search engines see a missing page and you lose accumulated rank
  • your feed and sitemap behavior becomes inconsistent

If you treat it as a migration:

  • the old URL returns 301 to the new URL
  • the new URL becomes the canonical and accumulates rank
  • readers and crawlers both land on the right page

The key idea: a slug is part of your public API. Change it with the same discipline you would use for a breaking API change.

Failure modes (what breaks under AI-assisted development)

  1. Letting the publisher silently accept broken metadata.
  2. Generating URLs from titles.
  3. Changing slugs after publish without redirects.
  4. Treating DB content as the source of truth and editing it directly.
  5. Publishing changes that modify SEO output without an explicit reason.

Checklist

  • Treat frontmatter as an API.
  • Validate required fields.
  • Keep slugs stable.
  • Keep canonical URLs stable.
  • Verify JSON-LD renders as <script type="application/ld+json"> in final HTML output, and the JSON parses.
  • Make publish output deterministic.
  • Make date behavior explicit (created/updated).

Copy/paste artifact: final hardening checklist (series)

Use this before you call the series done.

Goal:

  • One canonical version per article.
  • ASCII-only punctuation.
  • Internal links consistent across all parts.
  • No invalid patterns in examples.

Canonical link map (series graph):

  • Part 1: hub /series/ai-assisted-development, next /insights/ai-memory-bank-spec-driven-workflow
  • Part 2: prev /insights/understanding-ai-code-assistants, next /insights/security-boundaries-for-ai-assisted-development
  • Part 3: prev /insights/ai-memory-bank-spec-driven-workflow, next /insights/performance-defaults-outputcache-efcore
  • Part 4: prev /insights/security-boundaries-for-ai-assisted-development, next /insights/publishing-pipeline-markdown-to-live-dapiq
  • Part 5: prev /insights/performance-defaults-outputcache-efcore, next /series/ai-assisted-development

Copy/paste blockers to eliminate (ASCII-only):

  • Replace smart quotes with ASCII quotes: U+201C and U+201D -> ", U+2019 -> '
  • Replace unicode arrows with ASCII: U+2192 -> ->

Done when:

  • The series has one file per slug (no duplicates).
  • Every file includes created, updated, and published in frontmatter.
  • The nav block links match the canonical link map.
  • Part 5 contains no Razor TagHelper suppression examples and JSON-LD renders correctly in view source.

FAQ

Why not just use a CMS?

For some teams, a CMS is correct. For a technical publishing platform, deterministic publishing is a trust signal. You can review every change in git.

Should the DB store Markdown or HTML?

Either can work. What matters is the source of truth. If Markdown in git is truth, the DB is a rendering store.

How do I avoid SEO churn when republishing?

Make dateModified meaningful. Either compute it from content diffs or allow an explicit updated field.

How strict should the frontmatter schema be?

Strict enough that typos cannot ship silently. Warn locally, fail in CI or publish mode.

Where should series metadata live?

In a single series metadata file (for example content/series/<slug>.yml). Treat per-article overrides as escape hatches, not the default.

How do I validate JSON-LD without guessing?

Check the rendered HTML output and validate the JSON. If you are targeting rich results, also validate with the Rich Results Test.

How do I keep slugs stable over time?

Treat slugs as public API. If you need a change, do a migration (301 redirect, updated internal links, and sitemap behavior).

What about unknown frontmatter keys?

Unknown keys are usually typos, and typos create silent SEO drift.

Many YAML parsers ignore unknown keys by default. That is convenient, but it is not safe for publishing.

  • Unknown keys in local dev -> warn.
  • Unknown keys in CI/publish -> fail.

What to do next

Browse the AI-assisted development series for the full sequence. If you want the start-here entry point, read Part 1: Understanding AI Code Assistants. If you want to review or implement a pipeline like this, reach out via Contact.

References

Author notes

Decisions:

  • Markdown in git is the source of truth. Rationale: reviewability.
  • Publisher normalizes content into SQL. Rationale: fast rendering.
  • Slugs are immutable without a migration plan. Rationale: SEO stability.
  • Optional created/updated control date semantics. Rationale: avoid churn.

Observations:

  • When publishing is deterministic, reviews catch breaking changes.
  • When frontmatter is strict, typos do not silently ship.
  • When slugs are stable, the site accumulates rank instead of resetting it.