Original post: Page history and credits on a static blog
Series: Part of How this blog was built: documenting every decision that shaped this site.
Most blog posts operate on an implicit contract: once published, they don't change.
Or if they do change, the change is invisible. This is fine for minor edits, but
when you correct something meaningful — a wrong date, a misattributed quote, broken
code — readers who've already seen the post have no way of knowing.
This blog has two optional features that address this: a page history log and a
credits section. Both are schema-validated fields in the content collection and
rendered at the bottom of post pages.
Architecture overview
Diagram fallback for Dev.to. View the canonical article for the full version: https://sourcier.uk/blog/page-history-credits
UI mockup
The wireframe below shows the presentation intent for both metadata surfaces: a
timeline-style history block and compact, pill-style credits.
Diagram fallback for Dev.to. View the canonical article for the original SVG: https://sourcier.uk/blog/page-history-credits
Page history
The history field in a post's frontmatter is an optional array of revision entries:
history:
- datetime: 2026-03-26T00:00:00
note: Initial publish.
- datetime: 2026-03-27T12:00:00
note: >-
Corrected the HMAC algorithm description — it's SHA-256, not SHA-1.
Each entry has a datetime (coerced to a Date by Zod) and a note string.
Notes support inline HTML, so links to related pages and emphasis are possible.
The Zod schema definition:
history: z
.array(
z.object({
datetime: z.coerce.date(),
note: z.string(),
}),
)
.optional(),
PageHistory.astro renders the entries as an <ol> — a chronological list where
each <li> pairs a <time> element with a <span> for the note:
<div class="page-history">
<p class="page-history__heading">Page history</p>
<ol class="page-history__log">
{entries.map((entry) => (
<li class="page-history__entry">
<time
class="page-history__time"
datetime={entry.datetime.toISOString()}
>
{formatDatetime(entry.datetime)}
</time>
<span class="page-history__note" set:html={entry.note} />
</li>
))}
</ol>
</div>
The <time> element carries the machine-readable ISO 8601 datetime in its
datetime attribute. The human-readable text is formatted with toLocaleDateString
using the en-GB locale.
set:html is used for the note rather than {entry.note} because notes can
contain inline HTML. This is an intentional tradeoff — the content is
author-controlled in a static repository, not user-submitted, so the XSS risk
is the same as any other HTML in the site.
Visually, the history block is rendered at reduced opacity (0.65) and with
a left border — it's clearly secondary information, present for transparency
rather than as a primary content element.
Credits
The credits field follows the same pattern — an optional array, validated by Zod,
with label, text, and an optional URL:
credits:
- label: Cover image
text: Kelly Sikkema on Unsplash
url: https://unsplash.com/@kellysikkema
- label: Diagram library
text: Mermaid
url: https://mermaid.js.org/
credits: z
.array(
z.object({
label: z.string(),
text: z.string(),
url: z.string().url().optional(),
}),
)
.optional(),
PageCredits.astro renders them as a <ul>. Each <li> pairs the label with
either an anchor or a plain <span> depending on whether a URL is present. URLs
use target="_blank" with rel="noopener noreferrer".
Attribution is a first-class concern here, not an afterthought. Every Unsplash cover
image has its photographer credited. Libraries and tools that made a feature possible
are listed. When a post is directly inspired by another person's work, that's
acknowledged explicitly. This isn't just good etiquette — it's consistent with how
I'd want my own work credited.
Both components share the same visual treatment: muted, compact, below the main
content and the share widget. They're there for the reader who cares about the
detail, invisible to the reader who doesn't.
Why both belong in the schema
It would be easy to treat history and credits as presentational concerns — markdown
at the bottom of a post, maintained by hand. Putting them in the schema instead
means they're validated on every build, available to any component or page that
needs them, and impossible to malform silently. The discipline of typing them enforces
consistency: every credit has a label, every history entry has a datetime.
Neither field is required. A post with no meaningful revision history doesn't need
a history block. A post with no external sources doesn't need credits. The optionality
is intentional — adding boilerplate entries just to fill a section would dilute the
signal these features are meant to carry.

Top comments (0)