Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions apps/marketing-site/src/lib/cms-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> | undefined {
return internalApiKey ? { 'X-Internal-Api-Key': internalApiKey } : undefined;
}
55 changes: 55 additions & 0 deletions apps/marketing-site/src/lib/content/content-utils.ts
Original file line number Diff line number Diff line change
@@ -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;
}
248 changes: 248 additions & 0 deletions apps/marketing-site/src/pages/articles/[slug].astro
Original file line number Diff line number Diff line change
@@ -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 },
],
};
---

<BaseLayout
title={pageTitle}
description={pageDescription}
canonicalURL={canonicalURL}
ogType="article"
>
<script slot="head" type="application/ld+json" is:inline set:html={JSON.stringify(articleSchema)} />
<script slot="head" type="application/ld+json" is:inline set:html={JSON.stringify(breadcrumbSchema)} />

<div class="mx-auto max-w-4xl px-4 py-16 sm:px-6 sm:py-24">
<!-- Breadcrumb -->
<nav aria-label="Breadcrumb" class="mb-8">
<ol class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-stone-500">
<li>
<a href="/" class="hover:text-teal-400 transition-colors">Home</a>
</li>
<li aria-hidden="true"><span class="text-stone-700">/</span></li>
<li>
<a href="/articles/" class="hover:text-teal-400 transition-colors">Articles</a>
</li>
<li aria-hidden="true"><span class="text-stone-700">/</span></li>
<li class="truncate text-stone-400" aria-current="page">{article.title}</li>
</ol>
</nav>

<!-- Article header -->
<header class="mb-10">
{article.category && (
<span class="mb-3 inline-block rounded-full bg-teal-900/50 px-3 py-1 text-xs font-semibold text-teal-300">
{article.category}
</span>
)}
<h1
class="mb-5 text-3xl font-bold tracking-tight text-stone-50 sm:text-4xl"
style="font-family: 'DM Serif Display', serif;"
>
{article.title}
</h1>

<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-stone-500">
<span class="font-medium text-stone-400">{article.author_name}</span>
<span aria-hidden="true" class="text-stone-700">·</span>
<time datetime={publishDate}>{formattedDate}</time>
<span aria-hidden="true" class="text-stone-700">·</span>
<span>{readingTime}</span>
</div>

{formattedUpdated && (
<p class="mt-2 text-xs text-stone-400">
Updated <time datetime={article.updated_at}>{formattedUpdated}</time>
</p>
)}

{article.excerpt && (
<p class="mt-6 text-base leading-relaxed text-stone-300 border-l-2 border-teal-600 pl-4">
{article.excerpt}
</p>
)}

<hr class="mt-8 border-stone-800" />
</header>

<!-- Article content -->
<article
class="prose prose-invert prose-stone max-w-none prose-headings:font-bold prose-a:text-teal-400 prose-a:no-underline hover:prose-a:underline prose-img:rounded-xl"
set:html={contentHtml}
/>

<!-- Inline educational CTA -->
<aside class="my-12 rounded-xl border border-teal-800/50 bg-teal-950/30 p-6">
<p class="text-sm font-semibold text-teal-200">How would your CV score against this role?</p>
<p class="mt-1 text-xs text-stone-400">Upload your CV and paste any job description — Curvit gives you an instant match score and actionable suggestions.</p>
<a
href={`${appUrl}/signup`}
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-teal-500"
data-analytics="article-inline-cta"
>
Score my CV free
</a>
</aside>

<!-- Related articles -->
{relatedItems.length > 0 && (
<aside class="mt-16 border-t border-stone-800 pt-10" aria-label="Related articles">
<h2 class="mb-6 text-lg font-bold text-stone-100" style="font-family: 'DM Serif Display', serif;">
Related articles
</h2>
<ul role="list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{relatedItems.map((item) => {
const itemDate = item.published_at ?? item.created_at;
return (
<li>
<a
href={`/articles/${item.slug}/`}
class="block rounded-xl border border-stone-800 bg-stone-900/70 p-5 transition-colors hover:border-stone-700"
>
<p class="font-semibold text-sm text-stone-100 leading-snug">{item.title}</p>
{item.excerpt && (
<p class="mt-1.5 text-xs leading-relaxed text-stone-400 line-clamp-2">{item.excerpt}</p>
)}
<p class="mt-3 text-xs font-medium text-teal-400">Read article →</p>
</a>
</li>
);
})}
</ul>
</aside>
)}

<!-- Footer: back link -->
<div class="mt-16 border-t border-stone-800 pt-8">
<a
href="/articles/"
class="inline-flex items-center gap-1.5 text-sm text-stone-500 hover:text-teal-400 transition-colors"
>
<svg viewBox="0 0 20 20" fill="currentColor" class="h-4 w-4" aria-hidden="true">
<path fill-rule="evenodd" d="M17 10a.75.75 0 01-.75.75H5.612l4.158 3.96a.75.75 0 11-1.04 1.08l-5.5-5.25a.75.75 0 010-1.08l5.5-5.25a.75.75 0 111.04 1.08L5.612 9.25H16.25A.75.75 0 0117 10z" clip-rule="evenodd" />
</svg>
Back to articles
</a>
</div>
</div>

<CtaBanner
heading="Put these insights to work"
body="Score your CV against a real job description — get your match score and targeted suggestions in seconds."
cta={{ label: 'Start free', href: `${appUrl}/signup` }}
/>
</BaseLayout>
Loading
Loading