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 }, + ], +}; +--- + + +