diff --git a/apps/marketing-site/src/lib/cms-client.ts b/apps/marketing-site/src/lib/cms-client.ts new file mode 100644 index 00000000..e4f8c068 --- /dev/null +++ b/apps/marketing-site/src/lib/cms-client.ts @@ -0,0 +1,19 @@ +/** + * Shared CMS client configuration for server-side Astro pages. + * + * Centralises the CMS service URL and internal API key resolution so that + * all content pages read from the same environment variables. + */ + +export const cmsBase = + process.env.CMS_SERVICE_URL || + import.meta.env.CMS_SERVICE_URL || + 'http://cms-service:8000'; + +export const internalApiKey = + process.env.INTERNAL_API_KEY || import.meta.env.INTERNAL_API_KEY || ''; + +/** Returns headers for authenticated CMS requests, or undefined if no key is configured. */ +export function cmsHeaders(): Record | undefined { + return internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined; +} diff --git a/apps/marketing-site/src/lib/content/content-utils.ts b/apps/marketing-site/src/lib/content/content-utils.ts new file mode 100644 index 00000000..ac1db89d --- /dev/null +++ b/apps/marketing-site/src/lib/content/content-utils.ts @@ -0,0 +1,55 @@ +/** + * Shared content utility helpers used by content hub and detail pages. + */ + +/** Words-per-minute used for reading time estimation. */ +const WORDS_PER_MINUTE = 200; + +/** + * Milliseconds in one day — used to detect meaningful update timestamps. + * Exported so detail pages can determine whether an updated_at date is + * materially different from published_at without duplicating the constant. + */ +export const MS_PER_DAY = 86_400_000; + +/** + * Returns estimated reading time in minutes from markdown text. + * Falls back to the CMS-provided value when available. + * + * Note: word count splits on whitespace and does not strip markdown syntax + * (e.g. code fences, link text), so the estimate is approximate. + */ +export function calcReadingTime(markdown: string, cmsValue?: number | null): number { + if (cmsValue != null && cmsValue > 0) return cmsValue; + const wordCount = markdown.trim().split(/\s+/).length; + return Math.max(1, Math.round(wordCount / WORDS_PER_MINUTE)); +} + +/** + * Extracts a sorted, deduplicated list of category strings from a list of + * content items. Items with a null or empty category are ignored. + */ +export function extractCategories(items: { category?: string | null }[]): string[] { + const cats = items.map((i) => i.category).filter((c): c is string => !!c); + return [...new Set(cats)].sort(); +} + +/** + * Builds a paginated hub URL, preserving search and category query params. + * + * @param basePath Hub path, e.g. `/guides/` + * @param page Target page number + * @param search Current search query (empty string = omit) + * @param category Current category filter (empty string = omit) + */ +export function buildPaginationUrl( + basePath: string, + page: number, + search: string, + category: string, +): string { + let url = `${basePath}?page=${page}`; + if (search) url += `&search=${encodeURIComponent(search)}`; + if (category) url += `&category=${encodeURIComponent(category)}`; + return url; +} 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..042aff31 --- /dev/null +++ b/apps/marketing-site/src/pages/articles/[slug].astro @@ -0,0 +1,248 @@ +--- +// 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'; +import { calcReadingTime, MS_PER_DAY } from '../../lib/content/content-utils'; +import { cmsBase, cmsHeaders } from '../../lib/cms-client'; + +const { slug } = Astro.params; +if (!slug) return Astro.redirect('/404', 404); + +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 res = await fetch(`${cmsBase}/articles/${encodeURIComponent(slug)}`, { headers: cmsHeaders() }); + 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 relRes = await fetch(`${cmsBase}/content/articles/related/${encodeURIComponent(slug)}?limit=3`, { headers: cmsHeaders() }); + 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 > MS_PER_DAY; +const formattedUpdated = wasUpdated + ? new Date(article.updated_at).toLocaleDateString('en-GB', { year: 'numeric', month: 'long', day: 'numeric' }) + : null; + +const readMins = calcReadingTime(article.content_markdown, article.reading_time_minutes); +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 }, + ], +}; +--- + + +