This site has no CMS. There is no admin UI. Content is Markdown in a repo.
The system is simple:
- Author Markdown with frontmatter.
- Run a publisher CLI.
- Store normalized content in SQL.
- 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.
- Pick canonical routes and never generate them from titles.
- Define a strict frontmatter schema and fail fast on missing required fields.
- Normalize content at publish time (tags, series, computed reading time).
- Keep DB as a rendering store, not the source of truth.
- 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+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)
- Letting the publisher silently accept broken metadata.
- Generating URLs from titles.
- Changing slugs after publish without redirects.
- Treating DB content as the source of truth and editing it directly.
- 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, andpublishedin 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
- System.Text.Json Overview
- ASP.NET Core Output Caching Middleware
- Configuration in ASP.NET Core (providers and precedence)
- URL Rewriting Middleware in ASP.NET Core
- Tag Helpers in ASP.NET Core (Tag Helper opt-out character)
- Google Search Central Structured Data Intro (JSON-LD)
- schema.org Article Type
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/updatedcontrol 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.