Files
Jakub Zych 783426c5d8 feat: Journal author and tag pages, SEO, sitemap source and RSS route
- author pages resolve slug or login and canonicalise to the slug
- tag pages, per-page SEO meta and canonicals that keep ?page=N
- sitemap source pages the API and builds locs from journal.routes
- /rss.xml proxies the API feed with x-forwarded-for
- neutral README; version 0.1.0
2026-10-09 18:06:20 +02:00

71 lines
2.5 KiB
TypeScript

// SEO values for Journal pages, fed to useSeoMeta. The site name suffix comes
// from the app's site.name through the SEO title template, not from here.
import type { JournalPost } from './types.ts'
/** Plain text of an HTML fragment: tags dropped, common entities decoded, whitespace collapsed. */
export function htmlToText(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, ' ')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/\s+/g, ' ')
.trim()
}
/** Cuts `text` to at most `max` characters, at a word boundary when one is near, with an ellipsis. */
export function truncateText(text: string, max: number = 160): string {
if (text.length <= max) return text
const cut = text.slice(0, max - 1)
const space = cut.lastIndexOf(' ')
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`
}
/** Makes `url` absolute against `siteUrl`; already absolute URLs and a missing siteUrl pass through. */
export function absoluteUrl(url: string, siteUrl?: string | null): string {
if (/^https?:\/\//i.test(url) || !siteUrl) return url
return `${siteUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`
}
export interface JournalPostSeo {
title: string
ogTitle: string
description: string
ogDescription: string
ogType: 'article'
ogImage?: string
articlePublishedTime?: string
articleModifiedTime?: string
}
/**
* Meta for a post page: the post title, the excerpt (or the first 160
* characters of the body text) as description, the featured image as
* og:image, and the publish and update times.
*/
export function postSeoMeta(post: JournalPost, siteUrl?: string | null): JournalPostSeo {
const description = truncateText(htmlToText(post.excerpt) || htmlToText(post.content_html), 160)
const meta: JournalPostSeo = {
title: post.title,
ogTitle: post.title,
description,
ogDescription: description,
ogType: 'article',
}
if (post.featured_image_url) meta.ogImage = absoluteUrl(post.featured_image_url, siteUrl)
if (post.published_at) meta.articlePublishedTime = post.published_at
if (post.updated_at) meta.articleModifiedTime = post.updated_at
return meta
}
/** A listing title; pages after the first append the "Page N" string. */
export function listTitle(base: string, page: number, pageTitle: string): string {
return page > 1 ? `${base} · ${pageTitle}` : base
}