From 04adffc411aa127045d5a4335743f503b4922c90 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 30 May 2026 06:15:44 +0000
Subject: [PATCH 1/6] Initial plan
From 657a869c9d6e5afdb9147862c28a601022b6ed01 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 30 May 2026 06:27:53 +0000
Subject: [PATCH 2/6] feat(content-ui): add articles detail page, sitemap, and
docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Create /articles/[slug].astro — SSR article detail page with breadcrumbs,
author/date/reading-time meta, markdown rendering, inline conversion CTA,
related articles section, Article + BreadcrumbList JSON-LD schemas
- Update sitemap.xml.ts: add /guides and /articles to static routes; fetch
and include all published guides and articles with lastmod dates
- Create docs/content-ui.md: full developer reference covering routing,
content hubs, detail page features, SEO, CMS API, editorial workflow,
internal linking, accessibility, and testing commands
All 93 CMS service tests pass."
Co-authored-by: NickLetts2 <90337962+NickLetts2@users.noreply.github.com>
---
.../src/pages/articles/[slug].astro | 251 +++++++++++
.../src/pages/articles/index.astro | 286 +++++++++++++
.../src/pages/guides/[slug].astro | 254 ++++++++++++
.../src/pages/guides/index.astro | 291 +++++++++++++
apps/marketing-site/src/pages/sitemap.xml.ts | 43 +-
docs/content-ui.md | 391 ++++++++++++++++++
services/cms-service/app/models/schemas.py | 27 +-
services/cms-service/app/routers/content.py | 309 +++++++++++++-
services/cms-service/tests/test_content.py | 191 +++++++++
9 files changed, 2036 insertions(+), 7 deletions(-)
create mode 100644 apps/marketing-site/src/pages/articles/[slug].astro
create mode 100644 apps/marketing-site/src/pages/articles/index.astro
create mode 100644 apps/marketing-site/src/pages/guides/[slug].astro
create mode 100644 apps/marketing-site/src/pages/guides/index.astro
create mode 100644 docs/content-ui.md
diff --git a/apps/marketing-site/src/pages/articles/[slug].astro b/apps/marketing-site/src/pages/articles/[slug].astro
new file mode 100644
index 00000000..4a8142ae
--- /dev/null
+++ b/apps/marketing-site/src/pages/articles/[slug].astro
@@ -0,0 +1,251 @@
+---
+// SSR — visibility rules enforced at request time
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import CtaBanner from '../../components/sections/CtaBanner.astro';
+import { marked } from 'marked';
+import { sanitizeBlogHtml } from '../../lib/sanitize';
+import { appUrl, siteUrl } from '../../config';
+
+const cmsBase = process.env.CMS_SERVICE_URL || import.meta.env.CMS_SERVICE_URL || 'http://cms-service:8000';
+const internalApiKey = process.env.INTERNAL_API_KEY || import.meta.env.INTERNAL_API_KEY || '';
+
+const { slug } = Astro.params;
+
+interface ArticleDetail {
+ id: string;
+ content_type: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ content_markdown: string;
+ author_name: string;
+ category: string | null;
+ seo_title: string | null;
+ seo_description: string | null;
+ canonical_url: string | null;
+ published_at: string | null;
+ created_at: string;
+ updated_at: string;
+ reading_time_minutes: number | null;
+ primary_topic: string | null;
+}
+
+interface RelatedItem {
+ id: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ published_at: string | null;
+ created_at: string;
+ reading_time_minutes: number | null;
+}
+
+let article: ArticleDetail | null = null;
+let relatedItems: RelatedItem[] = [];
+
+try {
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+ const res = await fetch(`${cmsBase}/articles/${encodeURIComponent(slug!)}`, { headers });
+ if (res.ok) {
+ article = await res.json();
+ }
+} catch {
+ // Network error — fall through to 404
+}
+
+if (!article) {
+ return Astro.redirect('/404', 404);
+}
+
+// Related articles (best-effort)
+try {
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+ const relRes = await fetch(`${cmsBase}/content/articles/related/${encodeURIComponent(slug!)}?limit=3`, { headers });
+ if (relRes.ok) {
+ const relData = await relRes.json();
+ relatedItems = relData.items ?? [];
+ }
+} catch {
+ // Non-critical
+}
+
+const publishDate = article.published_at ?? article.created_at;
+const formattedDate = new Date(publishDate).toLocaleDateString('en-GB', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+
+const publishMs = new Date(publishDate).getTime();
+const updatedMs = new Date(article.updated_at).getTime();
+const wasUpdated = updatedMs - publishMs > 86_400_000;
+const formattedUpdated = wasUpdated
+ ? new Date(article.updated_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
+ : null;
+
+const wordCount = article.content_markdown.trim().split(/\s+/).length;
+const readMins = article.reading_time_minutes ?? Math.max(1, Math.round(wordCount / 200));
+const readingTime = `${readMins} min read`;
+
+const pageTitle = `${article.seo_title ?? article.title} | Curvit Articles`;
+const pageDescription = article.seo_description ?? article.excerpt ?? `Read "${article.title}" — a Curvit career and CV article.`;
+const canonicalURL = article.canonical_url ?? `${siteUrl}/articles/${article.slug}/`;
+
+const contentHtml = sanitizeBlogHtml(marked.parse(article.content_markdown, { async: false }) as string);
+
+const articleSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: article.title,
+ description: article.excerpt ?? undefined,
+ author: { '@type': 'Person', name: article.author_name },
+ datePublished: publishDate,
+ dateModified: article.updated_at,
+ url: canonicalURL,
+ publisher: {
+ '@type': 'Organization',
+ name: 'Curvit',
+ url: siteUrl,
+ },
+};
+
+const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
+ { '@type': 'ListItem', position: 2, name: 'Articles', item: `${siteUrl}/articles/` },
+ { '@type': 'ListItem', position: 3, name: article.title, item: canonicalURL },
+ ],
+};
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {relatedItems.length > 0 && (
+
+ )}
+
+
+
+
+
+
+
diff --git a/apps/marketing-site/src/pages/articles/index.astro b/apps/marketing-site/src/pages/articles/index.astro
new file mode 100644
index 00000000..02ed4295
--- /dev/null
+++ b/apps/marketing-site/src/pages/articles/index.astro
@@ -0,0 +1,286 @@
+---
+// SSR — content changes dynamically; no prerender
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import CtaBanner from '../../components/sections/CtaBanner.astro';
+import { appUrl, siteUrl } from '../../config';
+
+const cmsBase = process.env.CMS_SERVICE_URL || import.meta.env.CMS_SERVICE_URL || 'http://cms-service:8000';
+const internalApiKey = process.env.INTERNAL_API_KEY || import.meta.env.INTERNAL_API_KEY || '';
+
+const url = new URL(Astro.request.url);
+const searchQuery = url.searchParams.get('search') ?? '';
+const categoryFilter = url.searchParams.get('category') ?? '';
+const currentPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
+const PAGE_SIZE = 12;
+
+interface ArticleSummary {
+ id: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ author_name: string;
+ category: string | null;
+ published_at: string | null;
+ created_at: string;
+ reading_time_minutes: number | null;
+}
+
+interface ArticlesResponse {
+ items: ArticleSummary[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+let articlesData: ArticlesResponse = { items: [], total: 0, page: 1, page_size: PAGE_SIZE };
+let fetchError = false;
+let categories: string[] = [];
+
+try {
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+ const params = new URLSearchParams({
+ page: String(currentPage),
+ page_size: String(PAGE_SIZE),
+ });
+ if (searchQuery) params.set('search', searchQuery);
+ if (categoryFilter) params.set('category', categoryFilter);
+
+ const res = await fetch(`${cmsBase}/articles?${params}`, { headers });
+ if (res.ok) {
+ articlesData = await res.json();
+ const allCategories = articlesData.items
+ .map((a) => a.category)
+ .filter((c): c is string => !!c);
+ categories = [...new Set(allCategories)].sort();
+ } else {
+ fetchError = true;
+ }
+} catch {
+ fetchError = true;
+}
+
+const totalPages = Math.ceil(articlesData.total / PAGE_SIZE);
+const canonicalURL = `${siteUrl}/articles/`;
+
+const pageSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+ name: 'Career & CV Articles — Curvit',
+ url: canonicalURL,
+ description: 'Long-form articles on CV writing, career strategy, job search and the UK recruitment landscape.',
+};
+
+const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
+ { '@type': 'ListItem', position: 2, name: 'Articles', item: canonicalURL },
+ ],
+};
+---
+
+
+
+
+
+
+
+
+
+
+ Articles · UK
+
+ Career & CV articles
+
+
+ Long-form, research-backed articles on CV writing, career development, and navigating the UK job market.
+
+
+
+
+
+
+ {(searchQuery || categoryFilter) && (
+
+ {articlesData.total === 0 ? 'No articles found' : `${articlesData.total} article${articlesData.total === 1 ? '' : 's'} found`}
+ {searchQuery && for “{searchQuery}”}
+ {categoryFilter && in {categoryFilter}}
+
+ )}
+
+ {fetchError && (
+
Unable to load articles right now. Please try again later.
+ )}
+
+ {!fetchError && articlesData.items.length === 0 && (
+
No articles published yet.
+ )}
+
+
+ {articlesData.items.length > 0 && (
+
+ {articlesData.items.map((article) => {
+ const dateStr = article.published_at ?? article.created_at;
+ const formattedDate = new Date(dateStr).toLocaleDateString('en-GB', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ return (
+ -
+
+ {article.category && (
+
+ {article.category}
+
+ )}
+
+ {article.author_name}
+ ·
+
+ {article.reading_time_minutes && (
+ <>
+ ·
+ {article.reading_time_minutes} min read
+ >
+ )}
+
+
+ {article.excerpt && (
+ {article.excerpt}
+ )}
+
+ Read article
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {totalPages > 1 && (
+
+ )}
+
+
+
+
+ How would your CV score against this role?
+
+
+ Paste any job description and upload your CV — Curvit benchmarks your match and shows you the exact gaps to close.
+
+
+ Score my CV free
+
+
+
+
+
+
diff --git a/apps/marketing-site/src/pages/guides/[slug].astro b/apps/marketing-site/src/pages/guides/[slug].astro
new file mode 100644
index 00000000..ab1f33d9
--- /dev/null
+++ b/apps/marketing-site/src/pages/guides/[slug].astro
@@ -0,0 +1,254 @@
+---
+// SSR — visibility rules enforced at request time
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import CtaBanner from '../../components/sections/CtaBanner.astro';
+import { marked } from 'marked';
+import { sanitizeBlogHtml } from '../../lib/sanitize';
+import { appUrl, siteUrl } from '../../config';
+
+const cmsBase = process.env.CMS_SERVICE_URL || import.meta.env.CMS_SERVICE_URL || 'http://cms-service:8000';
+const internalApiKey = process.env.INTERNAL_API_KEY || import.meta.env.INTERNAL_API_KEY || '';
+
+const { slug } = Astro.params;
+
+interface GuideDetail {
+ id: string;
+ content_type: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ content_markdown: string;
+ author_name: string;
+ category: string | null;
+ seo_title: string | null;
+ seo_description: string | null;
+ canonical_url: string | null;
+ published_at: string | null;
+ created_at: string;
+ updated_at: string;
+ reading_time_minutes: number | null;
+ primary_topic: string | null;
+}
+
+interface RelatedItem {
+ id: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ published_at: string | null;
+ created_at: string;
+ reading_time_minutes: number | null;
+}
+
+let guide: GuideDetail | null = null;
+let relatedItems: RelatedItem[] = [];
+
+try {
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+
+ const res = await fetch(`${cmsBase}/guides/${encodeURIComponent(slug!)}`, { headers });
+ if (res.ok) {
+ guide = await res.json();
+ } else if (res.status !== 404) {
+ // Non-404 errors: fall through and show 404 page
+ }
+} catch {
+ // Network error — fall through to 404
+}
+
+if (!guide) {
+ return Astro.redirect('/404', 404);
+}
+
+// Fetch related guides in parallel (best-effort)
+try {
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+ const relRes = await fetch(`${cmsBase}/content/guides/related/${encodeURIComponent(slug!)}?limit=3`, { headers });
+ if (relRes.ok) {
+ const relData = await relRes.json();
+ relatedItems = relData.items ?? [];
+ }
+} catch {
+ // Related content is non-critical — ignore errors
+}
+
+const publishDate = guide.published_at ?? guide.created_at;
+const formattedDate = new Date(publishDate).toLocaleDateString('en-GB', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+});
+
+const publishMs = new Date(publishDate).getTime();
+const updatedMs = new Date(guide.updated_at).getTime();
+const wasUpdated = updatedMs - publishMs > 86_400_000;
+const formattedUpdated = wasUpdated
+ ? new Date(guide.updated_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' })
+ : null;
+
+const wordCount = guide.content_markdown.trim().split(/\s+/).length;
+const readMins = guide.reading_time_minutes ?? Math.max(1, Math.round(wordCount / 200));
+const readingTime = `${readMins} min read`;
+
+const pageTitle = `${guide.seo_title ?? guide.title} | Curvit Guides`;
+const pageDescription = guide.seo_description ?? guide.excerpt ?? `Read "${guide.title}" — a Curvit career and CV guide.`;
+const canonicalURL = guide.canonical_url ?? `${siteUrl}/guides/${guide.slug}/`;
+
+const contentHtml = sanitizeBlogHtml(marked.parse(guide.content_markdown, { async: false }) as string);
+
+const articleSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'Article',
+ headline: guide.title,
+ description: guide.excerpt ?? undefined,
+ author: { '@type': 'Person', name: guide.author_name },
+ datePublished: publishDate,
+ dateModified: guide.updated_at,
+ url: canonicalURL,
+ publisher: {
+ '@type': 'Organization',
+ name: 'Curvit',
+ url: siteUrl,
+ },
+};
+
+const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
+ { '@type': 'ListItem', position: 2, name: 'Guides', item: `${siteUrl}/guides/` },
+ { '@type': 'ListItem', position: 3, name: guide.title, item: canonicalURL },
+ ],
+};
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {relatedItems.length > 0 && (
+
+ )}
+
+
+
+
+
+
+
diff --git a/apps/marketing-site/src/pages/guides/index.astro b/apps/marketing-site/src/pages/guides/index.astro
new file mode 100644
index 00000000..20e2a90c
--- /dev/null
+++ b/apps/marketing-site/src/pages/guides/index.astro
@@ -0,0 +1,291 @@
+---
+// SSR — content changes dynamically; no prerender
+import BaseLayout from '../../layouts/BaseLayout.astro';
+import CtaBanner from '../../components/sections/CtaBanner.astro';
+import { appUrl, siteUrl } from '../../config';
+
+const cmsBase = process.env.CMS_SERVICE_URL || import.meta.env.CMS_SERVICE_URL || 'http://cms-service:8000';
+const internalApiKey = process.env.INTERNAL_API_KEY || import.meta.env.INTERNAL_API_KEY || '';
+
+// Read search / category / page from query string
+const url = new URL(Astro.request.url);
+const searchQuery = url.searchParams.get('search') ?? '';
+const categoryFilter = url.searchParams.get('category') ?? '';
+const currentPage = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10));
+const PAGE_SIZE = 12;
+
+interface GuideSummary {
+ id: string;
+ title: string;
+ slug: string;
+ excerpt: string | null;
+ author_name: string;
+ category: string | null;
+ published_at: string | null;
+ created_at: string;
+ reading_time_minutes: number | null;
+}
+
+interface GuidesResponse {
+ items: GuideSummary[];
+ total: number;
+ page: number;
+ page_size: number;
+}
+
+let guidesData: GuidesResponse = { items: [], total: 0, page: 1, page_size: PAGE_SIZE };
+let fetchError = false;
+let categories: string[] = [];
+
+try {
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+ const params = new URLSearchParams({
+ page: String(currentPage),
+ page_size: String(PAGE_SIZE),
+ });
+ if (searchQuery) params.set('search', searchQuery);
+ if (categoryFilter) params.set('category', categoryFilter);
+
+ const res = await fetch(`${cmsBase}/guides?${params}`, { headers });
+ if (res.ok) {
+ guidesData = await res.json();
+ // Build unique category list for filtering UI
+ const allCategories = guidesData.items
+ .map((g) => g.category)
+ .filter((c): c is string => !!c);
+ categories = [...new Set(allCategories)].sort();
+ } else {
+ fetchError = true;
+ }
+} catch {
+ fetchError = true;
+}
+
+const totalPages = Math.ceil(guidesData.total / PAGE_SIZE);
+const canonicalURL = `${siteUrl}/guides/`;
+
+const pageSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+ name: 'CV & Career Guides — Curvit',
+ url: canonicalURL,
+ description: 'Practical, in-depth guides on CV writing, ATS optimisation, interview preparation and job search strategy for UK jobseekers.',
+};
+
+const breadcrumbSchema = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'Home', item: siteUrl },
+ { '@type': 'ListItem', position: 2, name: 'Guides', item: canonicalURL },
+ ],
+};
+---
+
+
+
+
+
+
+
+
+
+
+ Guides · UK
+
+ Career & CV guides
+
+
+ Practical, in-depth guides on writing a strong CV, passing ATS screening, preparing for interviews and navigating the UK job market.
+
+
+
+
+
+
+
+ {(searchQuery || categoryFilter) && (
+
+ {guidesData.total === 0 ? 'No guides found' : `${guidesData.total} guide${guidesData.total === 1 ? '' : 's'} found`}
+ {searchQuery && for “{searchQuery}”}
+ {categoryFilter && in {categoryFilter}}
+
+ )}
+
+
+ {fetchError && (
+
Unable to load guides right now. Please try again later.
+ )}
+
+
+ {!fetchError && guidesData.items.length === 0 && (
+
No guides published yet.
+ )}
+
+
+ {guidesData.items.length > 0 && (
+
+ {guidesData.items.map((guide) => {
+ const dateStr = guide.published_at ?? guide.created_at;
+ const formattedDate = new Date(dateStr).toLocaleDateString('en-GB', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+ return (
+ -
+
+ {guide.category && (
+
+ {guide.category}
+
+ )}
+
+ {guide.author_name}
+ ·
+
+ {guide.reading_time_minutes && (
+ <>
+ ·
+ {guide.reading_time_minutes} min read
+ >
+ )}
+
+
+ {guide.excerpt && (
+ {guide.excerpt}
+ )}
+
+ Read guide
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {totalPages > 1 && (
+
+ )}
+
+
+
+
+ How does your CV compare to the role?
+
+
+ Upload your CV and paste a job description — Curvit scores your match and shows you exactly what to improve.
+
+
+ Score my CV free
+
+
+
+
+
+
diff --git a/apps/marketing-site/src/pages/sitemap.xml.ts b/apps/marketing-site/src/pages/sitemap.xml.ts
index 71bc8ce1..82dddbad 100644
--- a/apps/marketing-site/src/pages/sitemap.xml.ts
+++ b/apps/marketing-site/src/pages/sitemap.xml.ts
@@ -1,12 +1,16 @@
import type { APIRoute } from 'astro';
import { cvForRoles } from '../lib/content/cv-for-roles';
-interface BlogPost {
+interface ContentItemSummary {
slug: string;
- start_date?: string;
+ published_at?: string;
created_at?: string;
}
+interface ContentListResponse {
+ items: ContentItemSummary[];
+}
+
// Static public routes only - NO user-specific or authenticated content
const staticRoutes = [
'/',
@@ -18,6 +22,8 @@ const staticRoutes = [
'/privacy',
'/terms',
'/blog',
+ '/guides',
+ '/articles',
'/cv-for',
'/ats-cv-checker',
'/cv-tailoring-tool',
@@ -63,7 +69,7 @@ export const GET: APIRoute = async ({ site }) => {
const res = await fetch(cmsUrl, { headers });
if (res.ok) {
- const posts: BlogPost[] = await res.json();
+ const posts: { slug: string; start_date?: string; created_at?: string }[] = await res.json();
const now = new Date();
let includedCount = 0;
@@ -93,6 +99,37 @@ export const GET: APIRoute = async ({ site }) => {
console.warn(`Sitemap: Failed to fetch blog posts: ${error instanceof Error ? error.message : String(error)}`);
}
+ // Fetch and include published guides and articles
+ const contentTypes: { type: string; path: string }[] = [
+ { type: 'guides', path: 'guides' },
+ { type: 'articles', path: 'articles' },
+ ];
+
+ for (const { type, path } of contentTypes) {
+ try {
+ const cmsBase = process.env.CMS_SERVICE_URL || 'http://cms-service:8000';
+ const internalApiKey = process.env.INTERNAL_API_KEY || '';
+ const headers = internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
+
+ const res = await fetch(`${cmsBase}/${type}?page_size=100`, { headers });
+ if (res.ok) {
+ const data: ContentListResponse = await res.json();
+ data.items.forEach((item) => {
+ const dateStr = item.published_at ?? item.created_at;
+ const lastmod = dateStr ? new Date(dateStr).toISOString().split('T')[0] : undefined;
+ urls.push(`
+ ${new URL(`/${path}/${item.slug}`, site).href}
+ ${lastmod ? `${lastmod}\n ` : ''}weekly
+ 0.7
+ `);
+ });
+ console.log(`Sitemap: Included ${data.items.length} published ${type}`);
+ }
+ } catch (error) {
+ console.warn(`Sitemap: Failed to fetch ${type}: ${error instanceof Error ? error.message : String(error)}`);
+ }
+ }
+
const urlsXml = urls.join('\n');
const sitemap = `
diff --git a/docs/content-ui.md b/docs/content-ui.md
new file mode 100644
index 00000000..08e954fc
--- /dev/null
+++ b/docs/content-ui.md
@@ -0,0 +1,391 @@
+# Content UI — Developer Reference
+
+> **Issue:** #330 Content UI Refactor
+> **Depends on:** #329 (Unified Content Platform)
+> **Blocks:** #331 (AI Content Creator)
+
+---
+
+## Overview
+
+This document describes the public-facing content experience and the CMS administration layer that power Curvit's editorial platform.
+
+The platform supports four content types:
+
+| Content type | URL prefix | CMS slug |
+|---|---|---|
+| Blog | `/blog/:slug` | `blog` |
+| Guide | `/guides/:slug` | `guides` |
+| Article | `/articles/:slug` | `articles` |
+| CV For | `/cv-for/:slug` | `cv-for` |
+
+All four types are modelled as a unified `ContentItem` in the CMS service database and share a common administration interface.
+
+---
+
+## Routing structure
+
+### Public content hubs
+
+| Route | Description |
+|---|---|
+| `/blog` | Blog index — legacy `BlogPost` model (existing) |
+| `/blog/:slug` | Individual blog post |
+| `/guides` | Guides hub — SSR, paginated, searchable |
+| `/guides/:slug` | Individual guide detail page |
+| `/articles` | Articles hub — SSR, paginated, searchable |
+| `/articles/:slug` | Individual article detail page |
+| `/cv-for` | CV For landing page (static) |
+| `/cv-for/:slug` | Individual CV For role page (static) |
+
+### Administration
+
+| Route | Description |
+|---|---|
+| `/admin/content` | Content list / management dashboard |
+| `/admin/content/new` | Create new content item |
+| `/admin/content/:id/edit` | Edit existing content item |
+| `/admin/content/calendar` | Editorial calendar (month/week view) |
+| `/admin/content/assets` | Content asset library |
+
+---
+
+## Content hubs
+
+### Hub page features
+
+Each hub page (`/guides`, `/articles`) supports:
+
+- **Search** — `?q=` query parameter, title-and-excerpt full-text search via CMS API
+- **Category filter** — `?category=` parameter, single-category filtering
+- **Pagination** — `?page=` parameter (default page size: 12 items)
+- **SEO metadata** — canonical URL, `og:*` tags, `twitter:*` tags
+- **Structured data** — `CollectionPage` + `BreadcrumbList` JSON-LD schemas
+- **Diagnostic CTA** — soft conversion CTA encouraging users to score their CV
+
+### Hub page query parameters
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `q` | string | — | Full-text search (title / excerpt) |
+| `category` | string | — | Filter by category slug |
+| `page` | number | `1` | Pagination page number |
+
+### Example requests
+
+```
+GET /guides?q=ats&page=1
+GET /articles?category=career-advice&page=2
+```
+
+---
+
+## Content detail pages
+
+### Common capabilities
+
+Every content detail page provides:
+
+| Feature | Description |
+|---|---|
+| **Hero / header** | Title, author, published date, reading time, category badge |
+| **Breadcrumbs** | Home → Hub → Article title (visible + schema) |
+| **Markdown rendering** | Server-side via `marked`; sanitised by `sanitizeBlogHtml` |
+| **Updated date** | Shown when `updated_at` is more than 24 hours after `published_at` |
+| **Reading time** | Derived from `reading_time_minutes` field or calculated at ~200 wpm |
+| **Inline CTA** | Educational/curiosity CTA ("How would your CV score against this role?") |
+| **Related content** | Up to 3 related items of the same content type |
+| **Canonical URL** | From `canonical_url` field, or constructed from `siteUrl + /type/slug/` |
+| **Structured data** | `Article` + `BreadcrumbList` JSON-LD schemas |
+| **Open Graph** | `og:title`, `og:description`, `og:type=article` |
+
+### CTA patterns used
+
+Per the conversion UX requirements, only soft educational CTAs are used:
+
+- **Curiosity CTA** (inline): _"How would your CV score against this role?"_
+- **Educational CTA** (page footer banner): _"Score your CV against a real job description"_
+
+No aggressive sales messaging is used.
+
+---
+
+## CV For pages
+
+CV For pages use a separate static implementation based on `cv-for-roles.ts`:
+
+- Pre-rendered at build time (`prerender = true`)
+- Data source: `apps/marketing-site/src/lib/content/cv-for-roles.ts`
+- Template: `apps/marketing-site/src/pages/cv-for/[slug].astro`
+- Content includes: recruiter expectations, ATS tips, common mistakes, example sections
+
+CV For pages are not yet migrated to the unified `ContentItem` model. Migration is deferred to preserve existing content parity.
+
+---
+
+## SEO features
+
+### Metadata
+
+All content pages set:
+
+- `
` — `{seo_title ?? title} | Curvit {ContentType}`
+- `` — `seo_description ?? excerpt ?? fallback`
+- `` — from `canonical_url` field or constructed
+- `` — title, description, type
+- `` — card type, title, description
+
+### Structured data
+
+Hub pages emit:
+
+```json
+{ "@type": "CollectionPage" }
+{ "@type": "BreadcrumbList" }
+```
+
+Detail pages emit:
+
+```json
+{ "@type": "Article" }
+{ "@type": "BreadcrumbList" }
+```
+
+The `Article` schema includes `headline`, `description`, `author`, `datePublished`, `dateModified`, `url`, and `publisher`.
+
+### Sitemap
+
+`/sitemap.xml` is dynamically generated and includes:
+
+- All static routes
+- All published blog posts (with `lastmod`)
+- All published guides (with `lastmod`)
+- All published articles (with `lastmod`)
+- All CV For role pages
+
+---
+
+## CMS service API
+
+### Public endpoints
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/guides` | List published guides |
+| `GET` | `/guides/:slug` | Get a single published guide |
+| `GET` | `/articles` | List published articles |
+| `GET` | `/articles/:slug` | Get a single published article |
+| `GET` | `/content/{type}/related/{slug}` | Get related items for a content page |
+
+#### List endpoint query parameters
+
+| Parameter | Type | Default | Description |
+|---|---|---|---|
+| `category` | string | — | Filter by category |
+| `search` | string | — | Full-text search (title) |
+| `page` | int | `1` | Page number |
+| `page_size` | int | `20` | Items per page (max 100) |
+
+#### Related content endpoint
+
+```
+GET /content/{type_slug}/related/{slug}?limit=3
+```
+
+Returns up to `limit` published items of the same type, preferring items in the same category as the current item, excluding the current item itself.
+
+### Admin endpoints
+
+All admin endpoints require the `X-Internal-Api-Key` header.
+
+#### Content CRUD
+
+| Method | Path | Description |
+|---|---|---|
+| `GET` | `/admin/content` | List all content items |
+| `POST` | `/admin/content` | Create new content item |
+| `GET` | `/admin/content/:id` | Get content item by ID |
+| `PUT` | `/admin/content/:id` | Update content item |
+
+#### Workflow actions
+
+| Method | Path | Description |
+|---|---|---|
+| `POST` | `/admin/content/:id/submit-review` | Submit draft for review |
+| `POST` | `/admin/content/:id/approve` | Approve reviewed content |
+| `POST` | `/admin/content/:id/request-changes` | Request changes (with reason) |
+| `POST` | `/admin/content/:id/schedule` | Schedule for publication |
+| `POST` | `/admin/content/:id/publish` | Publish immediately |
+| `POST` | `/admin/content/:id/unpublish` | Return to draft |
+| `POST` | `/admin/content/:id/archive` | Archive content |
+| `POST` | `/admin/content/:id/clone` | Clone content as new draft |
+| `DELETE` | `/admin/content/:id` | Delete content (Draft or Archived only) |
+
+---
+
+## Editorial workflow
+
+### Workflow statuses
+
+```
+Draft → Review → Approved → Scheduled → Published → Archived
+ ↓
+ (Request Changes → Draft)
+```
+
+| Status | Description |
+|---|---|
+| `Draft` | Being authored; not publicly visible |
+| `Review` | Submitted for editorial review |
+| `Approved` | Approved by reviewer; ready to schedule or publish |
+| `Scheduled` | Will be published at `start_date` |
+| `Published` | Live and publicly visible |
+| `Archived` | Removed from public view; retained for reference |
+
+### Workflow roles
+
+| Role | Permitted actions |
+|---|---|
+| `ContentAuthor` | Create, edit draft, submit for review |
+| `ContentReviewer` | Approve, request changes |
+| `ContentPublisher` | Schedule, publish, unpublish |
+| `ContentAdministrator` | All actions including archive and delete |
+
+### Clone behaviour
+
+Cloning a content item:
+
+- Sets `status = "Draft"`
+- Appends `" (Copy)"` to the title
+- Clears `canonical_url`, `published_at`, `start_date`
+- Sets `created_from_content_id` to the source item's ID (provenance tracking)
+
+### Delete constraints
+
+Only `Draft` or `Archived` items may be deleted. Attempting to delete a `Published` item returns `409 Conflict`.
+
+---
+
+## Template design
+
+### Shared components
+
+All content detail pages use:
+
+- `BaseLayout` — head metadata, OG tags, canonical URL
+- `CtaBanner` — footer conversion banner
+- Inline markdown rendering via `marked` + `sanitizeBlogHtml`
+- Structured data injected via `