DEV Community

Roger Rajaratnam
Roger Rajaratnam

Posted on • Originally published at sourcier.uk

Page history and credits on a static blog

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

Mermaid diagram

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.

Wireframe mockup showing the page history timeline and credits chip list rendered below a blog post, including annotation callouts for semantic tags and styling intent

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.
Enter fullscreen mode Exit fullscreen mode

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(),
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode
credits: z
  .array(
    z.object({
      label: z.string(),
      text: z.string(),
      url: z.string().url().optional(),
    }),
  )
  .optional(),
Enter fullscreen mode Exit fullscreen mode

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)