Blog #1
5
src/lib/content/dates.ts
Normal file
5
src/lib/content/dates.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
84
src/lib/site/index.ts
Normal 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
35
src/lib/site/rules.ts
Normal 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
76
src/lib/site/tree.ts
Normal 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
38
src/lib/site/types.ts
Normal 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
20
src/lib/site/validate.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user