From 5cad56000cf3dc1c803d6f5ef389c17527221867 Mon Sep 17 00:00:00 2001 From: Dave Date: Tue, 22 Sep 2026 13:37:08 +0200 Subject: [PATCH] Added site functions --- src/lib/content/dates.ts | 5 +++ src/lib/content/types.ts | 2 +- src/lib/diagnostics/types.ts | 4 +- src/lib/site/index.ts | 84 ++++++++++++++++++++++++++++++++++++ src/lib/site/rules.ts | 35 +++++++++++++++ src/lib/site/tree.ts | 76 ++++++++++++++++++++++++++++++++ src/lib/site/types.ts | 38 ++++++++++++++++ src/lib/site/validate.ts | 20 +++++++++ 8 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 src/lib/content/dates.ts create mode 100644 src/lib/site/index.ts create mode 100644 src/lib/site/rules.ts create mode 100644 src/lib/site/tree.ts create mode 100644 src/lib/site/types.ts create mode 100644 src/lib/site/validate.ts diff --git a/src/lib/content/dates.ts b/src/lib/content/dates.ts new file mode 100644 index 0000000..771a166 --- /dev/null +++ b/src/lib/content/dates.ts @@ -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 +} diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index 76fb8ab..6c409df 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -10,7 +10,7 @@ export type ArticleEntry = Entry export type PageEntry = Entry export type TagEntry = Entry -export interface Content { +export type Content = { articles: Map pages: Map tags: Map diff --git a/src/lib/diagnostics/types.ts b/src/lib/diagnostics/types.ts index 55645e0..597de5f 100644 --- a/src/lib/diagnostics/types.ts +++ b/src/lib/diagnostics/types.ts @@ -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 diff --git a/src/lib/site/index.ts b/src/lib/site/index.ts new file mode 100644 index 0000000..236f892 --- /dev/null +++ b/src/lib/site/index.ts @@ -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() + 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) { + const listed = [...articles.values()].filter((article) => article.listed) + const tags = new Map() + 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() + 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 | null = null + +export function getSite(): Promise { + const build = async () => + buildSite(await loadContent(), { drafts: import.meta.env?.DEV ?? false }) + if (!import.meta.env?.PROD) return build() + return (cached ??= build()) +} diff --git a/src/lib/site/rules.ts b/src/lib/site/rules.ts new file mode 100644 index 0000000..f6afafa --- /dev/null +++ b/src/lib/site/rules.ts @@ -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) diff --git a/src/lib/site/tree.ts b/src/lib/site/tree.ts new file mode 100644 index 0000000..ded37ec --- /dev/null +++ b/src/lib/site/tree.ts @@ -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 { + const placed = new Map() + const visiting = new Set() + + 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() + 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 +} diff --git a/src/lib/site/types.ts b/src/lib/site/types.ts new file mode 100644 index 0000000..6fb393c --- /dev/null +++ b/src/lib/site/types.ts @@ -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 + pages: Map + tags: Map + byPath: Map + roots: Article[] + diagnostics: Diagnostic[] +} diff --git a/src/lib/site/validate.ts b/src/lib/site/validate.ts new file mode 100644 index 0000000..6e4e181 --- /dev/null +++ b/src/lib/site/validate.ts @@ -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
, 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`) + } +}