Blog #1

Merged
Dave merged 22 commits from blog into main 2026-09-30 16:48:38 +02:00
8 changed files with 261 additions and 3 deletions
Showing only changes of commit 5cad56000c - Show all commits

5
src/lib/content/dates.ts Normal file
View File

@@ -0,0 +1,5 @@
export function parseDate(value: string | null): Date | null {
if (!value) return null
const date = new Date(/(z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`)
return Number.isNaN(date.getTime()) ? null : date
}

View File

@@ -10,7 +10,7 @@ export type ArticleEntry = Entry<Collections['articles']>
export type PageEntry = Entry<Collections['pages']>
export type TagEntry = Entry<Collections['tags']>
export interface Content {
export type Content = {
articles: Map<string, ArticleEntry>
pages: Map<string, PageEntry>
tags: Map<string, TagEntry>

View File

@@ -1,13 +1,13 @@
export type Level = 'error' | 'warning'
export interface Diagnostic {
export type Diagnostic = {
level: Level
collection: string
id: string | null
message: string
}
export interface Diagnostics {
export type Diagnostics = {
list: Diagnostic[]
error(collection: string, id: string | null, message: string): void
warning(collection: string, id: string | null, message: string): void

84
src/lib/site/index.ts Normal file
View File

@@ -0,0 +1,84 @@
import { PAGES, TAGS } from '../constants/collections'
import { loadContent } from '../content/load'
import type { Content } from '../content/types'
import { createDiagnostics } from '../diagnostics'
import type { Diagnostics } from '../diagnostics/types'
import {
isBuilt,
newestFirst,
segmentOf,
segmentProblem,
siblingOrder,
type Visibility,
} from './rules'
import { placeArticles } from './tree'
import type { Article, Page, Routed, Site, Tag } from './types'
import { validate } from './validate'
function placePages(content: Content, visibility: Visibility, problems: Diagnostics) {
const pages = new Map<string, Page>()
for (const [id, entry] of content.pages) {
if (!isBuilt(entry.status, visibility)) continue
const segment = segmentOf(entry)
const problem = segmentProblem(segment, true)
if (problem) problems.error(PAGES, id, problem)
else pages.set(id, { ...entry, collection: PAGES, id, path: `/${segment}` })
}
return pages
}
function collectTags(content: Content, articles: Map<string, Article>) {
const listed = [...articles.values()].filter((article) => article.listed)
const tags = new Map<string, Tag>()
for (const [id, entry] of content.tags) {
const tagged = listed.filter((article) => article.tags.includes(id)).sort(newestFirst)
if (tagged.length)
tags.set(id, { ...entry, collection: TAGS, id, path: `/${TAGS}/${id}`, articles: tagged })
}
return tags
}
function indexUrls(routed: Routed[], problems: Diagnostics) {
const byPath = new Map<string, Routed>()
for (const node of routed) {
const taken = byPath.get(node.path)
if (!taken) byPath.set(node.path, node)
else
problems.error(
node.collection,
node.id,
`URL ${node.path} is taken by ${taken.collection}/${taken.id}.`,
)
}
return byPath
}
export function buildSite(content: Content, visibility: Visibility): Site {
const problems = createDiagnostics()
const articles = placeArticles(content, visibility, problems)
const pages = placePages(content, visibility, problems)
const tags = collectTags(content, articles)
validate(articles.values(), content, problems)
const byPath = indexUrls([...articles.values(), ...pages.values()], problems)
const roots = [...articles.values()]
.filter((article) => article.listed && !article.parent)
.sort(siblingOrder)
return {
articles,
pages,
tags,
byPath,
roots,
diagnostics: [...content.diagnostics, ...problems.list],
}
}
let cached: Promise<Site> | null = null
export function getSite(): Promise<Site> {
const build = async () =>
buildSite(await loadContent(), { drafts: import.meta.env?.DEV ?? false })
if (!import.meta.env?.PROD) return build()
return (cached ??= build())
}

35
src/lib/site/rules.ts Normal file
View File

@@ -0,0 +1,35 @@
import { TAGS } from '../constants/collections'
import type { Status } from '../constants/status'
import { slugify } from '../content/slug'
import type { Article } from './types'
export type Visibility = {
drafts: boolean
}
export const isBuilt = (status: Status, { drafts }: Visibility) => status !== 'draft' || drafts
export const isListed = (status: Status, { drafts }: Visibility) =>
status === 'published' || (drafts && status === 'draft')
export const segmentOf = (entry: { title: string; route: { segment: string } }) =>
entry.route.segment || slugify(entry.title)
const RESERVED = [TAGS, 'keystatic']
export function segmentProblem(segment: string, atRoot: boolean): string | null {
if (!segment) return 'The title has no URL-safe character. Set an URL segment.'
if (atRoot && RESERVED.includes(segment))
return `"/${segment}" is reserved. Set an URL segment`
return null
}
const time = (date: Date | null) => date?.getTime() ?? 0
const UNINDEXED = Number.MAX_SAFE_INTEGER
export const newestFirst = (a: Article, b: Article) => time(b.published) - time(a.published)
export const siblingOrder = (a: Article, b: Article) =>
(a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) ||
newestFirst(a, b) ||
a.title.localeCompare(b.title)

76
src/lib/site/tree.ts Normal file
View File

@@ -0,0 +1,76 @@
import { ARTICLES } from '../constants/collections'
import { parseDate } from '../content/dates'
import type { Content } from '../content/types'
import type { Diagnostics } from '../diagnostics/types'
import {
isBuilt,
isListed,
segmentOf,
segmentProblem,
siblingOrder,
type Visibility,
} from './rules'
import type { Article } from './types'
export function placeArticles(
content: Content,
visibility: Visibility,
problems: Diagnostics,
): Map<string, Article> {
const placed = new Map<string, Article | null>()
const visiting = new Set<string>()
const skip = (id: string, level: 'error' | 'warning', message: string): Article | null => {
problems[level](ARTICLES, id, message)
placed.set(id, null)
return null
}
const place = (id: string): Article | null => {
if (placed.has(id)) return placed.get(id) ?? null
const entry = content.articles.get(id)
if (!entry || !isBuilt(entry.status, visibility)) return null
if (visiting.has(id)) return skip(id, 'error', 'Its parent chain loops back to it')
const parentId = entry.route.parent
visiting.add(id)
const parent = parentId ? place(parentId) : null
visiting.delete(id)
if (placed.has(id)) return placed.get(id) ?? null
if (parentId && !parent) {
const parentEntry = content.articles.get(parentId)
if (!parentEntry) return skip(id, 'error', `Parent "${parentId}" does not exist.`)
const why = isBuilt(parentEntry.status, visibility) ? 'has no URL' : 'is a draft'
return skip(id, 'warning', `Not build: parent "${parentId}" ${why}`)
}
const segment = segmentOf(entry)
const problem = segmentProblem(segment, !parent)
if (problem) return skip(id, 'error', problem)
const article: Article = {
...entry,
collection: ARTICLES,
id,
path: `${parent?.path ?? ''}/${segment}`,
listed: isListed(entry.status, visibility),
published: parseDate(entry.dates.published),
updated: parseDate(entry.dates.updated),
parent,
children: [],
}
placed.set(id, article)
return article
}
for (const id of content.articles.keys()) place(id)
const articles = new Map<string, Article>()
for (const [id, article] of placed) if (article) articles.set(id, article)
for (const article of articles.values())
if (article.listed) article.parent?.children.push(article)
for (const article of articles.values()) article.children.sort(siblingOrder)
return articles
}

38
src/lib/site/types.ts Normal file
View File

@@ -0,0 +1,38 @@
import { ARTICLES, PAGES, TAGS } from '../constants/collections'
import type { ArticleEntry, PageEntry, TagEntry } from '../content/types'
import type { Diagnostic } from '../diagnostics/types'
export type Article = ArticleEntry & {
collection: typeof ARTICLES
id: string
path: string
listed: boolean
published: Date | null
updated: Date | null
parent: Article | null
children: Article[]
}
export type Page = PageEntry & {
collection: typeof PAGES
id: string
path: string
}
export type Tag = TagEntry & {
collection: typeof TAGS
id: string
path: string
articles: Article[]
}
export type Routed = Article | Page
export type Site = {
articles: Map<string, Article>
pages: Map<string, Page>
tags: Map<string, Tag>
byPath: Map<string, Routed>
roots: Article[]
diagnostics: Diagnostic[]
}

20
src/lib/site/validate.ts Normal file
View File

@@ -0,0 +1,20 @@
import { ARTICLES } from '../constants/collections'
import type { Content } from '../content/types'
import type { Diagnostics } from '../diagnostics/types'
import type { Article } from './types'
export function validate(articles: Iterable<Article>, content: Content, problems: Diagnostics) {
for (const { id, status, summary, published, updated, tags } of articles) {
const { excerpt, cover } = summary
const error = (message: string) => problems.error(ARTICLES, id, message)
const warn = (message: string) => problems.warning(ARTICLES, id, message)
if (status !== 'draft' && !excerpt) error('Needs an excerpt')
if (status !== 'draft' && !published) error('Needs a published date')
if (cover.image && !cover.alt) error('Cover image needs alt text')
if (!cover.image && (cover.alt || cover.caption || cover.credit))
warn('The cover has text but not image!')
if (published && updated && updated < published) warn('Updated before it was published')
for (const tag of tags) if (!content.tags.has(tag)) error(`Tag "${tag}" does not exist`)
}
}