Blog #1
@@ -1,12 +1,21 @@
|
||||
import { config } from '@keystatic/core'
|
||||
|
||||
import { contentCollections } from '@/keystatic/collections/general'
|
||||
import { siteCollections } from '@/keystatic/collections/site'
|
||||
import { ARTICLES, PAGES, RELEASES, TAGS } from '@/lib/constants/collections'
|
||||
|
||||
export default config({
|
||||
storage: {
|
||||
kind: 'local',
|
||||
},
|
||||
ui: {
|
||||
navigation: {
|
||||
Content: [ARTICLES, PAGES, TAGS],
|
||||
Site: [RELEASES],
|
||||
},
|
||||
},
|
||||
collections: {
|
||||
...contentCollections,
|
||||
...siteCollections,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
const { article } = Astro.props
|
||||
|
||||
const { cover, excerpt } = article.summary
|
||||
const date = article.published
|
||||
const date = article.publishedAt
|
||||
---
|
||||
|
||||
<article class="card">
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
---
|
||||
import type { Sequence } from '@/lib/site/navigation'
|
||||
import type { Article } from '@/lib/site/types'
|
||||
|
||||
import RevisionCard from './RevisionCard.astro'
|
||||
import SequenceNav from './SequenceNav.astro'
|
||||
|
||||
type Props = {
|
||||
article: Article
|
||||
sequence: Sequence | null
|
||||
}
|
||||
|
||||
const { sequence } = Astro.props
|
||||
const { article, sequence } = Astro.props
|
||||
const { title, changes } = article
|
||||
---
|
||||
|
||||
{sequence && (
|
||||
{(changes.length > 0 || sequence) && (
|
||||
<footer class="article-footer">
|
||||
<SequenceNav sequence={sequence} />
|
||||
{changes.length > 0 && <RevisionCard title={title} changes={changes} />}
|
||||
{sequence && <SequenceNav sequence={sequence} />}
|
||||
</footer>
|
||||
)}
|
||||
|
||||
38
src/components/post/Footer/RevisionCard.astro
Normal file
38
src/components/post/Footer/RevisionCard.astro
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
import { CHANGE_TYPES } from '@/lib/constants/changelog'
|
||||
import { toMilitaryDTG } from '@/lib/content/dates'
|
||||
import type { Change } from '@/lib/site/types'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
changes: Change[]
|
||||
}
|
||||
|
||||
const LIMIT = 5
|
||||
|
||||
const { title, changes } = Astro.props
|
||||
|
||||
const labelOf = (kind: Change['kind']) =>
|
||||
CHANGE_TYPES.find((option) => option.value === kind)?.label ?? kind
|
||||
---
|
||||
|
||||
<section class="revision-card" aria-labelledby="revisions-heading">
|
||||
<div class="revision-card__head">
|
||||
<h2 id="revisions-heading" class="revision-card__heading">
|
||||
Revisions
|
||||
</h2>
|
||||
<p class="revision-card__subject">{title}</p>
|
||||
</div>
|
||||
<ol class="revision-card__list">
|
||||
{changes.slice(0, LIMIT).map(({ kind, text, release }) => (
|
||||
<li class="revision-card__row">
|
||||
<span class="revision-card__version">v{release.version}</span>
|
||||
<time class="revision-card__date" datetime={release.date.toISOString()}>
|
||||
{toMilitaryDTG(release.date)}
|
||||
</time>
|
||||
<span class="revision-card__kind">{labelOf(kind)}</span>
|
||||
<p class="revision-card__text">{text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
@@ -15,7 +15,7 @@ type Props = {
|
||||
const { article } = Astro.props
|
||||
|
||||
const crumbs = trailOf(article)
|
||||
const published = article.published
|
||||
const published = article.publishedAt
|
||||
---
|
||||
|
||||
<header class="article-header">
|
||||
|
||||
@@ -11,8 +11,10 @@ type Props = {
|
||||
const { article } = Astro.props
|
||||
const site = await getSite()
|
||||
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
|
||||
const date = article.updated ?? article.published
|
||||
const dateLabel = article.updated ? 'Updated' : 'Published'
|
||||
const latest = article.changes[0]?.release.date
|
||||
const updated = latest && article.publishedAt && latest > article.publishedAt ? latest : null
|
||||
const date = updated ?? article.publishedAt
|
||||
const dateLabel = updated ? 'Updated' : 'Published'
|
||||
---
|
||||
|
||||
<dl class="article-header__meta">
|
||||
|
||||
@@ -12,8 +12,7 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
tags:
|
||||
- alchemical-materialism
|
||||
- genesis
|
||||
|
||||
@@ -12,8 +12,7 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
tags:
|
||||
- alchemical-materialism
|
||||
- genesis
|
||||
|
||||
@@ -12,8 +12,7 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
tags:
|
||||
- alchemical-materialism
|
||||
- genesis
|
||||
|
||||
@@ -12,8 +12,7 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
tags:
|
||||
- alchemical-materialism
|
||||
- genesis
|
||||
|
||||
@@ -12,8 +12,7 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
tags:
|
||||
- alchemical-materialism
|
||||
- genesis
|
||||
|
||||
@@ -15,8 +15,7 @@ summary:
|
||||
credit: John Martin – The Great Day of His Wrath
|
||||
effect: xeroxed
|
||||
ratio: 21-9
|
||||
dates:
|
||||
published: 2026-09-21T12:35:00.000Z
|
||||
published: 2026-09-21T12:35:00.000Z
|
||||
tags:
|
||||
- the-crucible
|
||||
- alchemical-materialism
|
||||
|
||||
@@ -10,7 +10,6 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-21T12:34:00.000Z
|
||||
published: 2026-09-21T12:34:00.000Z
|
||||
tags: []
|
||||
---
|
||||
|
||||
@@ -12,8 +12,7 @@ summary:
|
||||
cover:
|
||||
effect: none
|
||||
ratio: original
|
||||
dates:
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
published: 2026-09-30T12:27:00.000Z
|
||||
tags:
|
||||
- alchemical-materialism
|
||||
- genesis
|
||||
|
||||
@@ -8,8 +8,7 @@ summary:
|
||||
Where we explore the crucible – a materialistic alchemist Worldbuilding
|
||||
Framework
|
||||
cover: {}
|
||||
dates:
|
||||
published: 2026-09-21T12:33:00.000Z
|
||||
published: 2026-09-21T12:33:00.000Z
|
||||
tags:
|
||||
- the-crucible
|
||||
---
|
||||
|
||||
6
src/content/site/releases/v0-12.yaml
Normal file
6
src/content/site/releases/v0-12.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
version: '0.12'
|
||||
date: 2026-09-30T13:00:00.000Z
|
||||
changes:
|
||||
- kind: added
|
||||
article: prima-materia
|
||||
text: Section on the ground state; first table of correspondences
|
||||
12
src/content/site/releases/v0-13.yaml
Normal file
12
src/content/site/releases/v0-13.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
version: '0.13'
|
||||
date: 2026-09-30T13:00:00.000Z
|
||||
changes:
|
||||
- kind: added
|
||||
article: calcination
|
||||
text: Test
|
||||
- kind: added
|
||||
article: fermentation
|
||||
text: Big test with a longer sentence
|
||||
- kind: changed
|
||||
article: coagulation
|
||||
text: Lorem ipsum, blablabla ipbsum whorum
|
||||
19
src/content/site/releases/v0-14.yaml
Normal file
19
src/content/site/releases/v0-14.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: '0.14'
|
||||
date: 2026-09-30T13:00:00.000Z
|
||||
changes:
|
||||
- kind: added
|
||||
article: coagulation
|
||||
text: >-
|
||||
s the raw release exactly as Keystatic reads it, inferred from the schema
|
||||
like the other three
|
||||
- kind: changed
|
||||
article: prima-materia
|
||||
text: >-
|
||||
releases are loaded, resolved and attached to articles. Six files; nothing
|
||||
renders yet.
|
||||
- kind: fixed
|
||||
article: fermentation
|
||||
text: We don't burn the priests anymore, we burn the gods
|
||||
- kind: removed
|
||||
article: the-crucible
|
||||
text: AND EVERYTHING GOES BOOOOOOOM
|
||||
@@ -1,8 +1,7 @@
|
||||
import { collection, fields } from '@keystatic/core'
|
||||
|
||||
import { base, stage } from '@/keystatic/fields/content/base'
|
||||
import { base, published, stage } from '@/keystatic/fields/content/base'
|
||||
import { content } from '@/keystatic/fields/content/content'
|
||||
import { dates } from '@/keystatic/fields/content/dates'
|
||||
import { articleRoute } from '@/keystatic/fields/content/route'
|
||||
import { articleSummary } from '@/keystatic/fields/content/summary'
|
||||
import { ARTICLES, TAGS } from '@/lib/constants/collections'
|
||||
@@ -21,7 +20,7 @@ export const articles = collection({
|
||||
stage,
|
||||
route: articleRoute,
|
||||
summary: articleSummary(ARTICLES),
|
||||
dates,
|
||||
published,
|
||||
tags: fields.multiRelationship({
|
||||
label: 'Tags',
|
||||
collection: TAGS,
|
||||
|
||||
7
src/keystatic/collections/site/index.ts
Normal file
7
src/keystatic/collections/site/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { RELEASES } from '@/lib/constants/collections'
|
||||
|
||||
import { releases } from './releases'
|
||||
|
||||
export const siteCollections = {
|
||||
[RELEASES]: releases,
|
||||
}
|
||||
66
src/keystatic/collections/site/releases.ts
Normal file
66
src/keystatic/collections/site/releases.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { collection, fields } from '@keystatic/core'
|
||||
|
||||
import { CHANGE_TYPES } from '@/lib/constants/changelog'
|
||||
import { ARTICLES, RELEASES } from '@/lib/constants/collections'
|
||||
|
||||
const VERSION = /^\d+\.\d+(\.\d+)?$/
|
||||
|
||||
export const releases = collection({
|
||||
label: 'Releases',
|
||||
slugField: 'version',
|
||||
path: `src/content/site/${RELEASES}/*`,
|
||||
columns: ['version', 'date'],
|
||||
schema: {
|
||||
version: fields.slug({
|
||||
name: {
|
||||
label: 'Version',
|
||||
description: 'Without the v: 0.12, 1.0, 1.0.3',
|
||||
validation: {
|
||||
isRequired: true,
|
||||
pattern: {
|
||||
regex: VERSION,
|
||||
message: 'Numbers separated by dots, like 0.12 or 1.0.3',
|
||||
},
|
||||
},
|
||||
},
|
||||
slug: {
|
||||
label: 'File ID',
|
||||
generate: (version) => `v${version.replaceAll('.', '-')}`,
|
||||
},
|
||||
}),
|
||||
date: fields.datetime({
|
||||
label: 'Date',
|
||||
defaultValue: {
|
||||
kind: 'now',
|
||||
},
|
||||
validation: {
|
||||
isRequired: true,
|
||||
},
|
||||
}),
|
||||
changes: fields.array(
|
||||
fields.object({
|
||||
kind: fields.select({
|
||||
label: 'Kind',
|
||||
options: CHANGE_TYPES,
|
||||
defaultValue: 'added',
|
||||
}),
|
||||
article: fields.relationship({
|
||||
label: 'Article',
|
||||
description: 'Empty for site-wide changes',
|
||||
collection: ARTICLES,
|
||||
}),
|
||||
text: fields.text({
|
||||
label: 'Text',
|
||||
validation: {
|
||||
isRequired: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
{
|
||||
label: 'Changes',
|
||||
itemLabel: (props) =>
|
||||
`${props.fields.kind.value} · ${props.fields.article.value ?? 'site'} · ${props.fields.text.value}`,
|
||||
},
|
||||
),
|
||||
},
|
||||
})
|
||||
@@ -45,6 +45,13 @@ export const noindex = fields.checkbox({
|
||||
label: 'Hide from search engines',
|
||||
})
|
||||
|
||||
export const published = fields.datetime({
|
||||
label: 'Published',
|
||||
defaultValue: {
|
||||
kind: 'now',
|
||||
},
|
||||
})
|
||||
|
||||
export const base = {
|
||||
title,
|
||||
subtitle,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { fields } from '@keystatic/core'
|
||||
|
||||
export const dates = fields.object(
|
||||
{
|
||||
published: fields.datetime({
|
||||
label: 'Published',
|
||||
defaultValue: {
|
||||
kind: 'now',
|
||||
},
|
||||
}),
|
||||
updated: fields.datetime({
|
||||
label: 'Updated',
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Dates',
|
||||
},
|
||||
)
|
||||
@@ -38,7 +38,7 @@ const sequence = sequenceOf(article)
|
||||
<FootnoteList footnotes={rendered.footnotes} />
|
||||
</div>
|
||||
</div>
|
||||
<ArticleFooter sequence={sequence} />
|
||||
<ArticleFooter sequence={sequence} article={article} />
|
||||
</article>
|
||||
<DevBanner
|
||||
collection={article.collection}
|
||||
@@ -58,12 +58,10 @@ const sequence = sequenceOf(article)
|
||||
gap: var(--layout-gutter);
|
||||
|
||||
@media (--bp-desktop) {
|
||||
@mixin layout-columns;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
[rail-start] minmax(var(--layout-rail-width), 1fr)
|
||||
[rail-end body-start] var(--layout-content-width)
|
||||
[body-end margin-start] var(--layout-margin-width) [margin-end];
|
||||
gap: var(--layout-gutter);
|
||||
row-gap: var(--layout-gutter);
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
20
src/lib/constants/changelog.ts
Normal file
20
src/lib/constants/changelog.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const CHANGE_TYPES = [
|
||||
{
|
||||
label: 'Added',
|
||||
value: 'added',
|
||||
},
|
||||
{
|
||||
label: 'Changed',
|
||||
value: 'changed',
|
||||
},
|
||||
{
|
||||
label: 'Fixed',
|
||||
value: 'fixed',
|
||||
},
|
||||
{
|
||||
label: 'Removed',
|
||||
value: 'removed',
|
||||
},
|
||||
] as const
|
||||
|
||||
export type ChangeType = (typeof CHANGE_TYPES)[number]['value']
|
||||
@@ -3,3 +3,6 @@
|
||||
export const ARTICLES = 'articles'
|
||||
export const PAGES = 'pages'
|
||||
export const TAGS = 'tags'
|
||||
|
||||
/* === SITE COLLECTIONS === */
|
||||
export const RELEASES = 'releases'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ARTICLES, PAGES, TAGS } from '../constants/collections'
|
||||
import { ARTICLES, PAGES, RELEASES, TAGS } from '../constants/collections'
|
||||
import { createDiagnostics } from '../diagnostics'
|
||||
import type { Diagnostics } from '../diagnostics/types'
|
||||
import { createContentReader } from './reader'
|
||||
import type { ArticleEntry, Content, PageEntry, TagEntry } from './types'
|
||||
import type { ArticleEntry, Content, PageEntry, ReleaseEntry, TagEntry } from './types'
|
||||
|
||||
interface CollectionReader<T> {
|
||||
list(): Promise<string[]>
|
||||
@@ -36,10 +36,11 @@ async function readAll<T>(
|
||||
export async function loadContent(): Promise<Content> {
|
||||
const { collections } = createContentReader()
|
||||
const problems = createDiagnostics()
|
||||
const [articles, pages, tags] = await Promise.all([
|
||||
const [articles, pages, tags, releases] = await Promise.all([
|
||||
readAll<ArticleEntry>(collections.articles, ARTICLES, problems),
|
||||
readAll<PageEntry>(collections.pages, PAGES, problems),
|
||||
readAll<TagEntry>(collections.tags, TAGS, problems),
|
||||
readAll<ReleaseEntry>(collections.releases, RELEASES, problems),
|
||||
])
|
||||
return { articles, pages, tags, diagnostics: problems.list }
|
||||
return { articles, pages, tags, releases, diagnostics: problems.list }
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ type Collections = typeof config.collections
|
||||
export type ArticleEntry = Entry<Collections['articles']>
|
||||
export type PageEntry = Entry<Collections['pages']>
|
||||
export type TagEntry = Entry<Collections['tags']>
|
||||
export type ReleaseEntry = Entry<Collections['releases']>
|
||||
|
||||
export type Content = {
|
||||
articles: Map<string, ArticleEntry>
|
||||
pages: Map<string, PageEntry>
|
||||
tags: Map<string, TagEntry>
|
||||
releases: Map<string, ReleaseEntry>
|
||||
diagnostics: Diagnostic[]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { loadContent } from '../content/load'
|
||||
import type { Content } from '../content/types'
|
||||
import { createDiagnostics } from '../diagnostics'
|
||||
import type { Diagnostics } from '../diagnostics/types'
|
||||
import { collectReleases } from './releases'
|
||||
import {
|
||||
isBuilt,
|
||||
newestFirst,
|
||||
@@ -58,6 +59,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
|
||||
const articles = placeArticles(content, visibility, problems)
|
||||
const pages = placePages(content, visibility, problems)
|
||||
const tags = collectTags(content, articles)
|
||||
const releases = collectReleases(content, articles, problems)
|
||||
validate(articles.values(), content, problems)
|
||||
const byPath = indexUrls([...articles.values(), ...pages.values()], problems)
|
||||
const roots = [...articles.values()]
|
||||
@@ -68,6 +70,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
|
||||
articles,
|
||||
pages,
|
||||
tags,
|
||||
releases,
|
||||
byPath,
|
||||
known: new Set(content.articles.keys()),
|
||||
roots,
|
||||
|
||||
59
src/lib/site/releases.ts
Normal file
59
src/lib/site/releases.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { RELEASES } from '../constants/collections'
|
||||
import { parseDate } from '../content/dates'
|
||||
import type { Content } from '../content/types'
|
||||
import type { Diagnostics } from '../diagnostics/types'
|
||||
import type { Article, Release } from './types'
|
||||
|
||||
const partsOf = (version: string) => version.split('.').map(Number)
|
||||
|
||||
function compareVersions(a: string, b: string): number {
|
||||
const [left, right] = [partsOf(a), partsOf(b)]
|
||||
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
||||
const difference = (left[i] ?? 0) - (right[i] ?? 0)
|
||||
if (difference) return difference
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const releaseOrder = (a: Release, b: Release) => compareVersions(b.version, a.version)
|
||||
|
||||
export function collectReleases(
|
||||
content: Content,
|
||||
articles: Map<string, Article>,
|
||||
problems: Diagnostics,
|
||||
): Release[] {
|
||||
const releases: Release[] = []
|
||||
|
||||
for (const [id, entry] of content.releases) {
|
||||
const date = parseDate(entry.date)
|
||||
if (!date) {
|
||||
problems.error(RELEASES, id, 'Needs a date')
|
||||
continue
|
||||
}
|
||||
|
||||
const release: Release = { id, version: entry.version, date, changes: [] }
|
||||
for (const { kind, article: target, text } of entry.changes) {
|
||||
const article = target ? articles.get(target) : null
|
||||
if (target && !article) {
|
||||
if (!content.articles.has(target))
|
||||
problems.error(RELEASES, id, `Article "${target}" does not exist`)
|
||||
continue
|
||||
}
|
||||
release.changes.push({ kind, article: article ?? null, text, release })
|
||||
}
|
||||
|
||||
if (release.changes.length) releases.push(release)
|
||||
}
|
||||
|
||||
releases.sort(releaseOrder)
|
||||
releases.forEach((release, i) => {
|
||||
const older = releases[i + 1]
|
||||
if (older && release.date < older.date)
|
||||
problems.warning(RELEASES, release.id, `Dated before v${older.version}`)
|
||||
})
|
||||
|
||||
for (const release of releases)
|
||||
for (const change of release.changes) change.article?.changes.push(change)
|
||||
|
||||
return releases
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function segmentProblem(segment: string, atRoot: boolean): string | 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 newestFirst = (a: Article, b: Article) => time(b.publishedAt) - time(a.publishedAt)
|
||||
|
||||
export const siblingOrder = (a: Article, b: Article) =>
|
||||
(a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) ||
|
||||
|
||||
@@ -54,10 +54,10 @@ export function placeArticles(
|
||||
id,
|
||||
path: `${parent?.path ?? ''}/${segment}`,
|
||||
listed: isListed(entry.status, visibility),
|
||||
published: parseDate(entry.dates.published),
|
||||
updated: parseDate(entry.dates.updated),
|
||||
publishedAt: parseDate(entry.published),
|
||||
parent,
|
||||
children: [],
|
||||
changes: [],
|
||||
}
|
||||
placed.set(id, article)
|
||||
return article
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChangeType } from '../constants/changelog'
|
||||
import { ARTICLES, PAGES, TAGS } from '../constants/collections'
|
||||
import type { ArticleEntry, PageEntry, TagEntry } from '../content/types'
|
||||
import type { Diagnostic } from '../diagnostics/types'
|
||||
@@ -7,10 +8,10 @@ export type Article = ArticleEntry & {
|
||||
id: string
|
||||
path: string
|
||||
listed: boolean
|
||||
published: Date | null
|
||||
updated: Date | null
|
||||
publishedAt: Date | null
|
||||
parent: Article | null
|
||||
children: Article[]
|
||||
changes: Change[]
|
||||
}
|
||||
|
||||
export type Page = PageEntry & {
|
||||
@@ -26,12 +27,27 @@ export type Tag = TagEntry & {
|
||||
articles: Article[]
|
||||
}
|
||||
|
||||
export type Release = {
|
||||
id: string
|
||||
version: string
|
||||
date: Date
|
||||
changes: Change[]
|
||||
}
|
||||
|
||||
export type Change = {
|
||||
kind: ChangeType
|
||||
article: Article | null
|
||||
text: string
|
||||
release: Release
|
||||
}
|
||||
|
||||
export type Routed = Article | Page
|
||||
|
||||
export type Site = {
|
||||
articles: Map<string, Article>
|
||||
pages: Map<string, Page>
|
||||
tags: Map<string, Tag>
|
||||
releases: Release[]
|
||||
byPath: Map<string, Routed>
|
||||
known: Set<string>
|
||||
roots: Article[]
|
||||
|
||||
@@ -4,17 +4,16 @@ 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) {
|
||||
for (const { id, status, summary, publishedAt, 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 (status !== 'draft' && !publishedAt) 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`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,17 @@
|
||||
|
||||
display: grid;
|
||||
gap: var(--size-10);
|
||||
|
||||
@media (--bp-desktop) {
|
||||
@mixin layout-columns;
|
||||
|
||||
& > .revision-card {
|
||||
grid-column: body-start / body-end;
|
||||
}
|
||||
|
||||
& > .sequence-nav {
|
||||
grid-column: rail-start / margin-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,17 +109,10 @@
|
||||
.article-header__tag {
|
||||
@mixin py var(--size-1);
|
||||
@mixin px var(--size-3);
|
||||
|
||||
@mixin typo_tag;
|
||||
display: block;
|
||||
|
||||
font-size: var(--type--2);
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
line-height: var(--leading--2);
|
||||
color: var(--color-text-inverse);
|
||||
letter-spacing: var(--tracking-tag);
|
||||
|
||||
background-color: var(--color-surface-inverse);
|
||||
|
||||
transition: background-color var(--motion-fast) var(--ease-out);
|
||||
|
||||
&:hover {
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
@import url("stage-badge.css");
|
||||
@import url("article-footer.css");
|
||||
@import url("sequence-nav.css");
|
||||
@import url("revision-card.css");
|
||||
|
||||
92
src/styles/components/post/revision-card.css
Normal file
92
src/styles/components/post/revision-card.css
Normal file
@@ -0,0 +1,92 @@
|
||||
@layer components.blocks {
|
||||
.revision-card {
|
||||
@mixin border-t var(--border-boundary), solid, var(--color-border-strong);
|
||||
@mixin pt var(--size-8);
|
||||
}
|
||||
|
||||
.revision-card__head {
|
||||
@mixin mb var(--size-6);
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--size-2) var(--size-6);
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.revision-card__heading {
|
||||
@mixin typo_heading-2;
|
||||
@mixin ma var(--size-0);
|
||||
}
|
||||
|
||||
.revision-card__subject {
|
||||
@mixin typo_eyebrow;
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.revision-card__list {
|
||||
@mixin ma var(--size-0);
|
||||
@mixin pa var(--size-0);
|
||||
|
||||
list-style: none;
|
||||
|
||||
@media (--bp-tablet-up) {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto minmax(0, 1fr);
|
||||
column-gap: var(--size-10);
|
||||
}
|
||||
}
|
||||
|
||||
.revision-card__row {
|
||||
@mixin border-b var(--border-hairline), solid, var(--color-border-subtle);
|
||||
@mixin py var(--size-6);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
gap: var(--size-2) var(--size-8);
|
||||
align-items: baseline;
|
||||
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type-0);
|
||||
line-height: var(--leading-0);
|
||||
|
||||
@media (--bp-tablet-up) {
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: subgrid;
|
||||
}
|
||||
}
|
||||
|
||||
.revision-card__version {
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.revision-card__date {
|
||||
color: var(--color-text-quiet);
|
||||
}
|
||||
|
||||
.revision-card__kind {
|
||||
@mixin typo_tag;
|
||||
@mixin py var(--size-1);
|
||||
@mixin px var(--size-3);
|
||||
|
||||
justify-self: start;
|
||||
color: var(--color-text-inverse);
|
||||
background-color: var(--color-surface-inverse);
|
||||
}
|
||||
|
||||
.revision-card__text {
|
||||
@mixin ma var(--size-0);
|
||||
|
||||
grid-column: 1 / -1;
|
||||
font-family: var(--font-body);
|
||||
line-height: var(--leading-prose-tight);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
@media (--bp-tablet-up) {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,11 @@
|
||||
|
||||
width: min(100% - 2 * var(--layout-page-margin), var(--layout-wrapper-width));
|
||||
}
|
||||
|
||||
@define-mixin layout-columns {
|
||||
grid-template-columns:
|
||||
[rail-start] minmax(var(--layout-rail-width), 1fr)
|
||||
[rail-end body-start] var(--layout-content-width)
|
||||
[body-end margin-start] var(--layout-margin-width) [margin-end];
|
||||
column-gap: var(--layout-gutter);
|
||||
}
|
||||
|
||||
@@ -108,3 +108,12 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-eyebrow);
|
||||
}
|
||||
|
||||
@define-mixin typo_tag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--type--2);
|
||||
font-weight: var(--font-weight-emphasis);
|
||||
line-height: var(--leading--2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: var(--tracking-tag);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user