Added RevisionCard
This commit is contained in:
@@ -1,12 +1,21 @@
|
|||||||
import { config } from '@keystatic/core'
|
import { config } from '@keystatic/core'
|
||||||
|
|
||||||
import { contentCollections } from '@/keystatic/collections/general'
|
import { contentCollections } from '@/keystatic/collections/general'
|
||||||
|
import { siteCollections } from '@/keystatic/collections/site'
|
||||||
|
import { ARTICLES, PAGES, RELEASES, TAGS } from '@/lib/constants/collections'
|
||||||
|
|
||||||
export default config({
|
export default config({
|
||||||
storage: {
|
storage: {
|
||||||
kind: 'local',
|
kind: 'local',
|
||||||
},
|
},
|
||||||
|
ui: {
|
||||||
|
navigation: {
|
||||||
|
Content: [ARTICLES, PAGES, TAGS],
|
||||||
|
Site: [RELEASES],
|
||||||
|
},
|
||||||
|
},
|
||||||
collections: {
|
collections: {
|
||||||
...contentCollections,
|
...contentCollections,
|
||||||
|
...siteCollections,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type Props = {
|
|||||||
const { article } = Astro.props
|
const { article } = Astro.props
|
||||||
|
|
||||||
const { cover, excerpt } = article.summary
|
const { cover, excerpt } = article.summary
|
||||||
const date = article.published
|
const date = article.publishedAt
|
||||||
---
|
---
|
||||||
|
|
||||||
<article class="card">
|
<article class="card">
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
---
|
---
|
||||||
import type { Sequence } from '@/lib/site/navigation'
|
import type { Sequence } from '@/lib/site/navigation'
|
||||||
|
import type { Article } from '@/lib/site/types'
|
||||||
|
|
||||||
|
import RevisionCard from './RevisionCard.astro'
|
||||||
import SequenceNav from './SequenceNav.astro'
|
import SequenceNav from './SequenceNav.astro'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
article: Article
|
||||||
sequence: Sequence | null
|
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">
|
<footer class="article-footer">
|
||||||
<SequenceNav sequence={sequence} />
|
{changes.length > 0 && <RevisionCard title={title} changes={changes} />}
|
||||||
|
{sequence && <SequenceNav sequence={sequence} />}
|
||||||
</footer>
|
</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 { article } = Astro.props
|
||||||
|
|
||||||
const crumbs = trailOf(article)
|
const crumbs = trailOf(article)
|
||||||
const published = article.published
|
const published = article.publishedAt
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class="article-header">
|
<header class="article-header">
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ type Props = {
|
|||||||
const { article } = Astro.props
|
const { article } = Astro.props
|
||||||
const site = await getSite()
|
const site = await getSite()
|
||||||
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
|
const tags = article.tags.map((id) => site.tags.get(id)).filter((tag) => tag !== undefined)
|
||||||
const date = article.updated ?? article.published
|
const latest = article.changes[0]?.release.date
|
||||||
const dateLabel = article.updated ? 'Updated' : 'Published'
|
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">
|
<dl class="article-header__meta">
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-30T12:27:00.000Z
|
||||||
published: 2026-09-30T12:27:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
- genesis
|
- genesis
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-30T12:27:00.000Z
|
||||||
published: 2026-09-30T12:27:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
- genesis
|
- genesis
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-30T12:27:00.000Z
|
||||||
published: 2026-09-30T12:27:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
- genesis
|
- genesis
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-30T12:27:00.000Z
|
||||||
published: 2026-09-30T12:27:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
- genesis
|
- genesis
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-30T12:27:00.000Z
|
||||||
published: 2026-09-30T12:27:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
- genesis
|
- genesis
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ summary:
|
|||||||
credit: John Martin – The Great Day of His Wrath
|
credit: John Martin – The Great Day of His Wrath
|
||||||
effect: xeroxed
|
effect: xeroxed
|
||||||
ratio: 21-9
|
ratio: 21-9
|
||||||
dates:
|
published: 2026-09-21T12:35:00.000Z
|
||||||
published: 2026-09-21T12:35:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- the-crucible
|
- the-crucible
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-21T12:34:00.000Z
|
||||||
published: 2026-09-21T12:34:00.000Z
|
|
||||||
tags: []
|
tags: []
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ summary:
|
|||||||
cover:
|
cover:
|
||||||
effect: none
|
effect: none
|
||||||
ratio: original
|
ratio: original
|
||||||
dates:
|
published: 2026-09-30T12:27:00.000Z
|
||||||
published: 2026-09-30T12:27:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- alchemical-materialism
|
- alchemical-materialism
|
||||||
- genesis
|
- genesis
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ summary:
|
|||||||
Where we explore the crucible – a materialistic alchemist Worldbuilding
|
Where we explore the crucible – a materialistic alchemist Worldbuilding
|
||||||
Framework
|
Framework
|
||||||
cover: {}
|
cover: {}
|
||||||
dates:
|
published: 2026-09-21T12:33:00.000Z
|
||||||
published: 2026-09-21T12:33:00.000Z
|
|
||||||
tags:
|
tags:
|
||||||
- the-crucible
|
- 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 { 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 { content } from '@/keystatic/fields/content/content'
|
||||||
import { dates } from '@/keystatic/fields/content/dates'
|
|
||||||
import { articleRoute } from '@/keystatic/fields/content/route'
|
import { articleRoute } from '@/keystatic/fields/content/route'
|
||||||
import { articleSummary } from '@/keystatic/fields/content/summary'
|
import { articleSummary } from '@/keystatic/fields/content/summary'
|
||||||
import { ARTICLES, TAGS } from '@/lib/constants/collections'
|
import { ARTICLES, TAGS } from '@/lib/constants/collections'
|
||||||
@@ -21,7 +20,7 @@ export const articles = collection({
|
|||||||
stage,
|
stage,
|
||||||
route: articleRoute,
|
route: articleRoute,
|
||||||
summary: articleSummary(ARTICLES),
|
summary: articleSummary(ARTICLES),
|
||||||
dates,
|
published,
|
||||||
tags: fields.multiRelationship({
|
tags: fields.multiRelationship({
|
||||||
label: 'Tags',
|
label: 'Tags',
|
||||||
collection: 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',
|
label: 'Hide from search engines',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const published = fields.datetime({
|
||||||
|
label: 'Published',
|
||||||
|
defaultValue: {
|
||||||
|
kind: 'now',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const base = {
|
export const base = {
|
||||||
title,
|
title,
|
||||||
subtitle,
|
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} />
|
<FootnoteList footnotes={rendered.footnotes} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArticleFooter sequence={sequence} />
|
<ArticleFooter sequence={sequence} article={article} />
|
||||||
</article>
|
</article>
|
||||||
<DevBanner
|
<DevBanner
|
||||||
collection={article.collection}
|
collection={article.collection}
|
||||||
@@ -58,12 +58,10 @@ const sequence = sequenceOf(article)
|
|||||||
gap: var(--layout-gutter);
|
gap: var(--layout-gutter);
|
||||||
|
|
||||||
@media (--bp-desktop) {
|
@media (--bp-desktop) {
|
||||||
|
@mixin layout-columns;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns:
|
row-gap: var(--layout-gutter);
|
||||||
[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);
|
|
||||||
align-items: start;
|
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 ARTICLES = 'articles'
|
||||||
export const PAGES = 'pages'
|
export const PAGES = 'pages'
|
||||||
export const TAGS = 'tags'
|
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 { createDiagnostics } from '../diagnostics'
|
||||||
import type { Diagnostics } from '../diagnostics/types'
|
import type { Diagnostics } from '../diagnostics/types'
|
||||||
import { createContentReader } from './reader'
|
import { createContentReader } from './reader'
|
||||||
import type { ArticleEntry, Content, PageEntry, TagEntry } from './types'
|
import type { ArticleEntry, Content, PageEntry, ReleaseEntry, TagEntry } from './types'
|
||||||
|
|
||||||
interface CollectionReader<T> {
|
interface CollectionReader<T> {
|
||||||
list(): Promise<string[]>
|
list(): Promise<string[]>
|
||||||
@@ -36,10 +36,11 @@ async function readAll<T>(
|
|||||||
export async function loadContent(): Promise<Content> {
|
export async function loadContent(): Promise<Content> {
|
||||||
const { collections } = createContentReader()
|
const { collections } = createContentReader()
|
||||||
const problems = createDiagnostics()
|
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<ArticleEntry>(collections.articles, ARTICLES, problems),
|
||||||
readAll<PageEntry>(collections.pages, PAGES, problems),
|
readAll<PageEntry>(collections.pages, PAGES, problems),
|
||||||
readAll<TagEntry>(collections.tags, TAGS, 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 ArticleEntry = Entry<Collections['articles']>
|
||||||
export type PageEntry = Entry<Collections['pages']>
|
export type PageEntry = Entry<Collections['pages']>
|
||||||
export type TagEntry = Entry<Collections['tags']>
|
export type TagEntry = Entry<Collections['tags']>
|
||||||
|
export type ReleaseEntry = Entry<Collections['releases']>
|
||||||
|
|
||||||
export type Content = {
|
export type Content = {
|
||||||
articles: Map<string, ArticleEntry>
|
articles: Map<string, ArticleEntry>
|
||||||
pages: Map<string, PageEntry>
|
pages: Map<string, PageEntry>
|
||||||
tags: Map<string, TagEntry>
|
tags: Map<string, TagEntry>
|
||||||
|
releases: Map<string, ReleaseEntry>
|
||||||
diagnostics: Diagnostic[]
|
diagnostics: Diagnostic[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { loadContent } from '../content/load'
|
|||||||
import type { Content } from '../content/types'
|
import type { Content } from '../content/types'
|
||||||
import { createDiagnostics } from '../diagnostics'
|
import { createDiagnostics } from '../diagnostics'
|
||||||
import type { Diagnostics } from '../diagnostics/types'
|
import type { Diagnostics } from '../diagnostics/types'
|
||||||
|
import { collectReleases } from './releases'
|
||||||
import {
|
import {
|
||||||
isBuilt,
|
isBuilt,
|
||||||
newestFirst,
|
newestFirst,
|
||||||
@@ -58,6 +59,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
|
|||||||
const articles = placeArticles(content, visibility, problems)
|
const articles = placeArticles(content, visibility, problems)
|
||||||
const pages = placePages(content, visibility, problems)
|
const pages = placePages(content, visibility, problems)
|
||||||
const tags = collectTags(content, articles)
|
const tags = collectTags(content, articles)
|
||||||
|
const releases = collectReleases(content, articles, problems)
|
||||||
validate(articles.values(), content, problems)
|
validate(articles.values(), content, problems)
|
||||||
const byPath = indexUrls([...articles.values(), ...pages.values()], problems)
|
const byPath = indexUrls([...articles.values(), ...pages.values()], problems)
|
||||||
const roots = [...articles.values()]
|
const roots = [...articles.values()]
|
||||||
@@ -68,6 +70,7 @@ export function buildSite(content: Content, visibility: Visibility): Site {
|
|||||||
articles,
|
articles,
|
||||||
pages,
|
pages,
|
||||||
tags,
|
tags,
|
||||||
|
releases,
|
||||||
byPath,
|
byPath,
|
||||||
known: new Set(content.articles.keys()),
|
known: new Set(content.articles.keys()),
|
||||||
roots,
|
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 time = (date: Date | null) => date?.getTime() ?? 0
|
||||||
const UNINDEXED = Number.MAX_SAFE_INTEGER
|
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) =>
|
export const siblingOrder = (a: Article, b: Article) =>
|
||||||
(a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) ||
|
(a.route.index ?? UNINDEXED) - (b.route.index ?? UNINDEXED) ||
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ export function placeArticles(
|
|||||||
id,
|
id,
|
||||||
path: `${parent?.path ?? ''}/${segment}`,
|
path: `${parent?.path ?? ''}/${segment}`,
|
||||||
listed: isListed(entry.status, visibility),
|
listed: isListed(entry.status, visibility),
|
||||||
published: parseDate(entry.dates.published),
|
publishedAt: parseDate(entry.published),
|
||||||
updated: parseDate(entry.dates.updated),
|
|
||||||
parent,
|
parent,
|
||||||
children: [],
|
children: [],
|
||||||
|
changes: [],
|
||||||
}
|
}
|
||||||
placed.set(id, article)
|
placed.set(id, article)
|
||||||
return article
|
return article
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ChangeType } from '../constants/changelog'
|
||||||
import { ARTICLES, PAGES, TAGS } from '../constants/collections'
|
import { ARTICLES, PAGES, TAGS } from '../constants/collections'
|
||||||
import type { ArticleEntry, PageEntry, TagEntry } from '../content/types'
|
import type { ArticleEntry, PageEntry, TagEntry } from '../content/types'
|
||||||
import type { Diagnostic } from '../diagnostics/types'
|
import type { Diagnostic } from '../diagnostics/types'
|
||||||
@@ -7,10 +8,10 @@ export type Article = ArticleEntry & {
|
|||||||
id: string
|
id: string
|
||||||
path: string
|
path: string
|
||||||
listed: boolean
|
listed: boolean
|
||||||
published: Date | null
|
publishedAt: Date | null
|
||||||
updated: Date | null
|
|
||||||
parent: Article | null
|
parent: Article | null
|
||||||
children: Article[]
|
children: Article[]
|
||||||
|
changes: Change[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Page = PageEntry & {
|
export type Page = PageEntry & {
|
||||||
@@ -26,12 +27,27 @@ export type Tag = TagEntry & {
|
|||||||
articles: Article[]
|
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 Routed = Article | Page
|
||||||
|
|
||||||
export type Site = {
|
export type Site = {
|
||||||
articles: Map<string, Article>
|
articles: Map<string, Article>
|
||||||
pages: Map<string, Page>
|
pages: Map<string, Page>
|
||||||
tags: Map<string, Tag>
|
tags: Map<string, Tag>
|
||||||
|
releases: Release[]
|
||||||
byPath: Map<string, Routed>
|
byPath: Map<string, Routed>
|
||||||
known: Set<string>
|
known: Set<string>
|
||||||
roots: Article[]
|
roots: Article[]
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import type { Diagnostics } from '../diagnostics/types'
|
|||||||
import type { Article } from './types'
|
import type { Article } from './types'
|
||||||
|
|
||||||
export function validate(articles: Iterable<Article>, content: Content, problems: Diagnostics) {
|
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 { excerpt, cover } = summary
|
||||||
const error = (message: string) => problems.error(ARTICLES, id, message)
|
const error = (message: string) => problems.error(ARTICLES, id, message)
|
||||||
const warn = (message: string) => problems.warning(ARTICLES, id, message)
|
const warn = (message: string) => problems.warning(ARTICLES, id, message)
|
||||||
|
|
||||||
if (status !== 'draft' && !excerpt) error('Needs an excerpt')
|
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) error('Cover image needs alt text')
|
||||||
if (!cover.image && (cover.alt || cover.caption || cover.credit))
|
if (!cover.image && (cover.alt || cover.caption || cover.credit))
|
||||||
warn('The cover has text but not image!')
|
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`)
|
for (const tag of tags) if (!content.tags.has(tag)) error(`Tag "${tag}" does not exist`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,17 @@
|
|||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--size-10);
|
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 {
|
.article-header__tag {
|
||||||
@mixin py var(--size-1);
|
@mixin py var(--size-1);
|
||||||
@mixin px var(--size-3);
|
@mixin px var(--size-3);
|
||||||
|
@mixin typo_tag;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
font-size: var(--type--2);
|
|
||||||
font-weight: var(--font-weight-emphasis);
|
|
||||||
line-height: var(--leading--2);
|
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
letter-spacing: var(--tracking-tag);
|
|
||||||
|
|
||||||
background-color: var(--color-surface-inverse);
|
background-color: var(--color-surface-inverse);
|
||||||
|
|
||||||
transition: background-color var(--motion-fast) var(--ease-out);
|
transition: background-color var(--motion-fast) var(--ease-out);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
@import url("stage-badge.css");
|
@import url("stage-badge.css");
|
||||||
@import url("article-footer.css");
|
@import url("article-footer.css");
|
||||||
@import url("sequence-nav.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));
|
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;
|
text-transform: uppercase;
|
||||||
letter-spacing: var(--tracking-eyebrow);
|
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