Source-code for Duncan’s Childhood Blog

The Git repository for this blog is not publicly visible, because it contains HTML for articles that I don’t wish to publish. Instead, I’ve set up this page so that some of my code can be seen.

The blog is made with TypeScript in the Astro framework. Astro (or Vite, the bundler it uses under the hood) doesn’t seem to like importing .astro files as raw text, so I have a pre-build script to copy all .astro files to .astro.txt files, which can then be imported in this page. If you can see Astro code below, that works!

I also have a page rendering the readme for this project.

/prebuild.sh
# Why does this prebuild script exist?
# On the /source page, the contents of much of the Childhood Blog’s source-code is displayed.
# For TypeScript, Json, etc, the files can be imported as raw strings using Vite’s `?raw` feature.
# This doesn’t work for Astro files — Astro/Vite sees the .astro extension and imports the Astro component, even with `?raw`.
# So this file copies all Astro files to text files in a /text-dist folder.
# The /source page can then import those text files raw.

# Start from a blank slate
rm -r text-dist

# All folders need to be hardcoded, for now.
mkdir text-dist
mkdir text-dist/src
mkdir text-dist/src/components
mkdir text-dist/src/layouts
mkdir text-dist/src/pages

# Copy every Astro file to a new text file.
for file in src/**/*.astro; do
  cp -a $file text-dist/${file}.txt
done

echo "All .astro files in /src have been copied to .astro.txt files."
/src/components/ArticleCard.astro
---
import { addLinkBase, prettyPrintDate } from '../lib/helpers'
import { getSeriesForOneArticle } from '../lib/series-array'

interface Props {
	date: string
}

const { date } = Astro.props

const series = getSeriesForOneArticle(date)
---

<a href={addLinkBase(date)}>
	{
		series ? (
			<img src={addLinkBase(series.image)} alt={series.name} width="24" />
		) : (
			<img src={addLinkBase('/images/in-articles/cb5_gradeastarbox_30.png')} alt="(not in a series)" width="24" />
		)
	}
	{prettyPrintDate(date)}</a
>

<style>
	img {
		vertical-align: middle;
		width: 1.125em;
		aspect-ratio: 1 / 1;
	}
</style>
/src/components/ArticleStatCard.astro
---
import ArticleCard from './ArticleCard.astro'

interface Props {
	article: {
		date: string
		wordCount: number
		synopsis: string
	}
}

const { date, synopsis, wordCount } = Astro.props.article
---

<!-- Bizarrely, <i>words</i> <small></small> gets interpreted as <i>words <small></small></i>
 So there needs to be a closing tag (but not <br/>) after the </i> for the italics to end in the correct place. -->
<div>
	<ArticleCard {date} />
	<i>{wordCount}&nbsp;words</i>
</div>
<small>
	{synopsis}
</small>

<style>
	i {
		font-size: smaller;
	}
</style>
/src/components/AuthorialFooter.astro
---
import YearPhoto, { type Year } from './YearPhoto.astro'

export interface Props {
	year: Year
}

const { year } = Astro.props
---

<aside>
	<div class="bracket-divider"></div>
	<YearPhoto {year} />
	<p>That was some writing from me when I looked like this.</p>
</aside>

<style>
	aside {
		display: flex;
		flex-direction: column;
		align-items: center;
		text-align: center;
		width: calc(100vw - 2rem);
		max-width: 48rem;
	}
	.bracket-divider {
		margin-top: 3rem;
		width: 100%;
		height: 4rem;
		background: var(--section-border);
		clip-path: polygon(
			0% 0%,
			0.25rem 0%,
			8% 41%,
			46% 46%,
			50% 70%,
			54% 46%,
			92% 41%,
			calc(100% - 0.25rem) 0%,
			100% 0%,
			92% calc(41% + 0.25rem),
			54% calc(46% + 0.25rem),
			50% calc(70% + 0.25rem),
			46% calc(46% + 0.25rem),
			8% calc(41% + 0.25rem)
		);
	}
	p {
		width: 67%;
		text-wrap: balance;
	}
</style>
/src/components/CodeInDetails.astro
---
import { Code } from 'astro:components'
import type { CodeLang } from '../lib/defs'

export interface Props {
	summary: string
	code: string
	lang?: CodeLang
}

const { summary, code, lang } = Astro.props
---

<details open>
	<summary>{summary}</summary>
	<Code {code} lang={lang || 'ts'} theme="slack-dark" />
</details>

<style>
	details {
		margin-block: 0.5rem;
	}
</style>
/src/components/PhotosHeader.astro
---
import YearPhoto, { type Year } from './YearPhoto.astro'
---

<div class="photos-header">
	{
		[2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014].map((year) => (
			<div class={'year-photo year-photo-' + year}>
				<YearPhoto year={year as Year} style="width: var(--year-photo-width);" />
			</div>
		))
	}
</div>

<style>
	.photos-header {
		display: grid;
		grid-template-columns: repeat(8, 1fr);
		--row-fr: calc(0.125vw + 0.125rem);
		grid-template-rows:
			calc(10 * var(--row-fr))
			calc(7 * var(--row-fr))
			calc(4 * var(--row-fr))
			calc(2 * var(--row-fr));
		justify-items: center;
		gap: 0;
		--year-photo-width: 100%;
		margin-top: 1rem;
		margin-bottom: calc(6.25% + 2rem);
	}
	.year-photo {
		place-self: center;
		display: flex;
		place-content: center;
		width: calc(100% + 1rem);
	}
	.year-photo-2007 {
		grid-row: 1 / span 1;
		grid-column: 1 / span 1;
	}
	.year-photo-2008 {
		grid-row: 2 / span 1;
		grid-column: 2 / span 1;
	}
	.year-photo-2009 {
		grid-row: 3 / span 1;
		grid-column: 3 / span 1;
	}
	.year-photo-2010 {
		grid-row: 4 / span 1;
		grid-column: 4 / span 1;
	}
	.year-photo-2011 {
		grid-row: 4 / span 1;
		grid-column: 5 / span 1;
	}
	.year-photo-2012 {
		grid-row: 3 / span 1;
		grid-column: 6 / span 1;
	}
	.year-photo-2013 {
		grid-row: 2 / span 1;
		grid-column: 7 / span 1;
	}
	.year-photo-2014 {
		grid-row: 1 / span 1;
		grid-column: 8 / span 1;
	}
</style>
/src/components/SiteFooter.astro
---
import { addLinkBase } from '../lib/helpers'

export interface Props {
	showBlogHomeLink: boolean
	showSourceCodeLink: boolean
}

const { showBlogHomeLink, showSourceCodeLink } = Astro.props
---

<footer>
	<ul>
		{
			showBlogHomeLink && (
				<li>
					<a href={addLinkBase()}>Childhood Blog home</a>
				</li>
			)
		}

		{
			showSourceCodeLink && (
				<li>
					<a href={addLinkBase('source')}>See the code</a>
				</li>
			)
		}

		<li>
			<a href="https://www.duncanritchie.co.uk/"> By Duncan Ritchie </a>
		</li>
	</ul>
</footer>

<style>
	ul {
		justify-content: space-between;
	}
</style>
/src/components/YearPhoto.astro
---
import { addLinkBase } from '../lib/helpers'

export type Year = 2007 | 2008 | 2009 | 2010 | 2011 | 2012 | 2013 | 2014

export interface Props {
	year: Year
	style?: string
}

const { year, style } = Astro.props

const altTexts = {
	2007: 'Me aged ten in front of a tree',
	2008: 'Me aged twelve holding a sprig of heather',
	2009: 'Me aged thirteen in front of the White Cliffs of Dover',
	2010: 'Me aged fourteen behind a sign for Duncanston village',
	2011: 'Me aged fourteen sitting on a grassy hillside',
	2012: 'Me aged fifteen overlooking the Seine in Paris',
	2013: 'Me aged sixteen in front of a blue screen',
	2014: 'Me aged eighteen in a student’s dormitory',
}
const altText = altTexts[year] ?? `Me in ${year}`
---

<img src={addLinkBase(`/images/me-by-year/${year}.webp`)} alt={altText} width="150" style={style ?? ''} />

<style>
	img {
		aspect-ratio: 1 / 1;
		border-radius: 50%;
	}
</style>
/src/layouts/Layout.astro
---
import SiteFooter from '../components/SiteFooter.astro'

interface Props {
	title: string
	description: string
	showBlogHomeLink?: boolean
	showSourceCodeLink?: boolean
}

const { title, description, showBlogHomeLink, showSourceCodeLink } = Astro.props
const canonicalURL = new URL(Astro.url.pathname, Astro.site)
---

<!doctype html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="description" content={description} />
		<meta name="viewport" content="width=device-width" />
		<meta name="generator" content={Astro.generator} />
		<title>{title}</title>
		<link rel="canonical" href={canonicalURL} />
		<link rel="stylesheet" href="https://www.duncanritchie.co.uk/css/main.css" />
		<link rel="stylesheet" href="https://www.duncanritchie.co.uk/css/no-js.css" />
		<link rel="stylesheet" media="print" href="https://www.duncanritchie.co.uk/css/print.css" />
		<link rel="stylesheet" href="https://www.duncanritchie.co.uk/css/subsites.css" />
		<link rel="stylesheet" href="https://fonts.duncanritchie.co.uk/subsites.css" />
		<link rel="icon" href="https://www.duncanritchie.co.uk/favicon.ico" type="image/x-icon" />
		<link rel="icon" href="https://www.duncanritchie.co.uk/favicon-96x96.png" type="image/png" />
	</head>
	<body>
		<slot />
		<SiteFooter showBlogHomeLink={showBlogHomeLink ?? true} showSourceCodeLink={showSourceCodeLink ?? true} />
	</body>
</html>
<style is:global>
	:root {
		--light-blue: hsl(180, 32%, 40%);
		--mid-blue: hsl(180, 32%, 25%);
	}
	html {
		font-family: system-ui, sans-serif;
	}
	code,
	kbd,
	samp,
	pre {
		font-size: 0.9em;
	}
	code {
		font-family: monospace;
	}
	pre:has(> code) {
		padding: 0.5rem;
		border: 3px solid var(--colour6);
	}

	/* Fun fact: elements with `hidden` attribute are treated as having `display: none` CSS by default,
	but this gets overridden when a class is applied. */
	.auto-grid:not([hidden]) {
		/* Autosizing grid columns CSS is adapted from https://css-tricks.com/look-ma-no-media-queries-responsive-layouts-using-css-grid/#aa-the-article-list */
		display: grid;
		--min-column-width: 12ch;
		grid-template-columns: repeat(auto-fit, minmax(min(var(--min-column-width), 100%), 1fr));
		gap: calc(1rem + 1.25vw);
		margin-top: 1.75rem;
	}

	/* Reset browser styles for <ul> */
	ul.auto-grid {
		list-style: none;
		padding-left: 0;
	}

	.page-header {
		border: 0.25rem solid var(--colour6);
		border-top: none;
		padding: 1rem 1rem 0;
		margin-top: -1rem;
	}

	th {
		border: 1px solid var(--colour6);
	}

	fieldset {
		border: 3px solid var(--colour6);
		padding: 0.5rem;
		accent-color: var(--colour6);
	}

	.prev-next {
		margin-inline: 2rem;
	}
	@media (min-width: 54rem) {
		.prev-next {
			width: calc(100% - 4rem);
			max-width: 48rem;
			display: flex;
			gap: 1rem;
			justify-content: space-between;
		}
		.prev-next .next {
			margin-left: auto;
		}
	}
</style>
/src/lib/articles.ts
import EntireBlog from '../data/EntireBlog.html?raw'
import { adjustLinks, deleteHtmlComments, getIdFromHtmlLine } from './helpers'
import { headingRegex, seriesHeaderRegex, monthHeadingRegex, yearHeadingRegex, seriesFooterRegex } from './regexes'

const allHtmlLines = deleteHtmlComments(EntireBlog).split(/[\r\n]+/)

function getAllHeadingsAndArticles(): string[] {
	const articles = []
	let currentLineIndex = 0
	let currentArticle = ''
	while (currentLineIndex < allHtmlLines.length) {
		const currentLine = allHtmlLines[currentLineIndex]
		if (headingRegex.test(currentLine) || seriesHeaderRegex.test(currentLine)) {
			articles.push(currentArticle)
			currentArticle = currentLine
		} else {
			currentArticle += currentLine + '\n'
		}
		currentLineIndex++
	}
	articles.push(currentArticle)
	return articles
}
const allHeadingsAndArticles = getAllHeadingsAndArticles()

function getDateArticleMap(): { [key: string]: string } {
	const headings = allHeadingsAndArticles.filter((line) => headingRegex.test(line))

	const datedArticles: { [key: string]: string } = {}

	headings.forEach((heading) => (datedArticles[getIdFromHtmlLine(heading) || 'null'] = heading))
	return datedArticles
}
const dateArticleMap = getDateArticleMap()

/**
 * Returns the year heading, month heading, and series header (if the article is in a series),
 * in the order they appear in the HTML.
 */
function getHeadingsForArticle(date: string): string[] {
	let articleFound = false
	let hasSeriesBeenDetermined = false
	let monthHeadingFound = false
	let yearHeadingFound = false
	let headings: string[] = []

	// Loop backwards through the headings and articles.
	// When the article matching `date` is found, then we can look for the headings.
	for (let i = allHeadingsAndArticles.length - 1; i >= 0; i--) {
		const current = allHeadingsAndArticles[i]

		if (current.includes(` id='${date}'`) || current.includes(` id="${date}"`)) {
			articleFound = true
		} else if (articleFound) {
			// Once the month heading has been found, we don’t need to find another.
			if (!monthHeadingFound && monthHeadingRegex.test(current)) {
				headings.unshift(current)
				monthHeadingFound = true
			}

			// Once the year heading has been found, we don’t need to find another.
			if (!yearHeadingFound && yearHeadingRegex.test(current)) {
				headings.unshift(current)
				yearHeadingFound = true
			}

			// If a series footer is found before any series header can be found, we know the article is not in a series.
			// If a series header is found before any series footer, we know the article is in that series.
			// Once the series (or lack thereof) has been determined, we don’t need to find another series.
			if (!hasSeriesBeenDetermined) {
				if (seriesHeaderRegex.test(current)) {
					headings.unshift(current)
					hasSeriesBeenDetermined = true
				} else if (seriesFooterRegex.test(current)) {
					hasSeriesBeenDetermined = true
				}
			}

			if (hasSeriesBeenDetermined && monthHeadingFound && yearHeadingFound) {
				break
			}
		}
	}
	if (!articleFound) {
		throw Error('No article was found for ' + date)
	}
	if (!yearHeadingFound) {
		throw Error('Year HTML not found for ' + date)
	}
	if (!monthHeadingFound) {
		throw Error('Month HTML not found for ' + date)
	}
	return headings
}

function getArticleWithHeadings(date: string): string {
	const dayHtml = dateArticleMap[date]
	if (!dayHtml) {
		throw Error('No HTML found for day ' + date)
	}

	const headings = getHeadingsForArticle(date)
	if (!headings) {
		throw Error('No HTML found for headings of day ' + date)
	}
	const concatenated = headings.join('') + dayHtml

	return adjustLinks(concatenated)
}

const poemCount = allHtmlLines.filter((line) => / data-is-poem="true"/.test(line)).length

export { dateArticleMap, getHeadingsForArticle, getArticleWithHeadings, allHeadingsAndArticles, poemCount }
/src/lib/defs.ts
interface Series {
	name: string
	slug: string
	image: string
	dates: string[]
	content: string
	type: 'Series'
}

interface Article {
	date: string
	type: 'Article'
}

// Used in CodeInDetails component
type CodeLang = 'astro' | 'bash' | 'ts'

export type { Series, Article, CodeLang }
/src/lib/helpers.ts
import { dateRegex } from './regexes'

function getIdFromHtmlLine(line: string): string | undefined {
	const matches = /(?<=id=['"])[^'"]+(?=['"])/.exec(line)
	return matches?.[0]
}

function isPlausibleDate(date: string) {
	return dateRegex.test(date)
}

/**
 * Adds the base URL to `url` for use in hrefs.
 * If no parameter is passed, returns the base URL.
 */
function addLinkBase(url?: string): string {
	const base = import.meta.env.BASE_URL
	const concatenated = base + '/' + (url ?? '')
	const cleaned = concatenated.replace('../', '/').replaceAll(/\/+/g, '/')
	return cleaned
}

/**
 * The source HTML contains several hyperlinks to locations within the blog, such as #2007Jun05.
 * When parts of the HTML are extracted to make the page for one article or series, the hyperlinks may no longer work,
 * since the 2007Jun05 article (etc) might not be on the same page.
 * So the href may need to be changed to point to the page {base}/2007Jun05.
 * This function performs this change, for every href that needs it.
 *
 * If an ID matching the href exists on the page (the input `html`), no change is made:
 * it’s fine to link to #2007Jun05 if the 2007Jun05 article is on the same page.
 *
 * Images also get their `src` attributes changed here.
 */
function adjustLinks(html: string): string {
	if (!html) {
		throw Error('html in adjustLinks is ' + html)
	}
	// Get all the relative hrefs in the HTML.
	const hrefsRegex = /((?<= href=['"]#)[^'"]+(?=['"]))/g

	const hrefs = html.match(hrefsRegex) || []

	// For each href, check if it matches an ID in the HTML.
	// If it doesn’t, replace the # of the href with the base URL.
	let changedHtml = html

	hrefs.forEach((href) => {
		if (!href) {
			return
		}

		const idRegex = new RegExp(` id=['"]${href}['"]`)
		if (!idRegex.test(html)) {
			const hrefRegex = new RegExp(`(?<= href=['"])#(?=${href}['"])`, 'g')

			changedHtml = changedHtml.replaceAll(hrefRegex, addLinkBase(''))
		}
	})

	// Also add the base URL to src attributes that refer to images.
	changedHtml = changedHtml.replaceAll(/(?<= src=['"])(\.\.)?\/images\/[^'"]+(?=['"])/g, addLinkBase)

	return changedHtml
}

function deleteHtmlComments(html: string): string {
	// Regex fun fact: *? is like * but non-greedy, so .*? will not include -->
	// so this function will preserve “ Not a comment ” in
	// <!-- Comment 1 --> Not a comment <!-- Comment 2 -->
	// Another regex fun fact: . doesn’t match line-breaks, so we need (.|\n|\r)
	return html.replaceAll(/<!--(.|\n|\r)*?-->/g, '')
}

function prettyPrintDate(date: string): string {
	if (!isPlausibleDate(date)) {
		throw Error(`Date ${date} cannot be pretty-printed. It must be in 2007Apr04 format.`)
	}
	const year = date.slice(0, 4)
	const month = date.slice(4, 7)
	const day = date.slice(7, 9)
	return `${year} ${month} ${day}`
}

/**
 * For example: Scotland &amp; Hallowe&#39;een -> Scotland & Hallowe'en
 *
 * From https://www.30secondsofcode.org/js/s/escape-unescape-html/
 */
function unescapeHtml(html: string): string {
	return html.replace(
		/&amp;|&lt;|&gt;|&#39;|&quot;|&nbsp;/g,
		(tag) =>
			({
				'&amp;': '&',
				'&lt;': '<',
				'&gt;': '>',
				'&#39;': "'",
				'&quot;': '"',
				'&nbsp;': ' ', // Non-breaking space
			})[tag] || tag,
		// If more tags are to be added, they should be added to both the regex and the mapping object.
	)
}

// Caveat: any HTML tag splits a word here. So "ho<u>meow</u>ner" is 3 words, not 1.
function countWordsInHtml(html: string): number {
	const words = unescapeHtml(html)
		.replaceAll(/<style.*?>(.|\r|\n)*?<\/style>/g, ' ') // Remove <style> elements.
		.replaceAll(/<[^>]*>/g, ' ') // Remove all other HTML tags.
		.replaceAll(/&#[\d]+;/g, ' ') // Remove special characters that `unescapeHtml` overlooked.
		.split(/[\s"\.:;,\/-]+/i) // Split on runs of spaces/punctuation.
		.filter(Boolean) // Remove empty strings.

	return words.length
}

export {
	getIdFromHtmlLine,
	isPlausibleDate,
	addLinkBase,
	deleteHtmlComments,
	adjustLinks,
	prettyPrintDate,
	unescapeHtml,
	countWordsInHtml,
}
/src/lib/regexes.ts
const headingRegex = /<h[1234]/
const yearHeadingRegex = /<h2/
const monthHeadingRegex = /<h3/
const seriesHeaderRegex = /<img .*src="..\/images\/series-headers\/(?:.+_)?header/
const seriesFooterRegex = /<img .*src="..\/images\/series-headers\/(?:.+_)?footer/

/**
 * Matches "2007Jul28" etc
 */
const dateRegex =
	/^20(?:07|08|09|10|11|12|13|14)(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?:(?:[012][0-9])|30|31)$/

export { headingRegex, yearHeadingRegex, monthHeadingRegex, seriesHeaderRegex, seriesFooterRegex, dateRegex }
/src/lib/series-array.ts
import { allHeadingsAndArticles, getHeadingsForArticle } from './articles'
import type { Article, Series } from './defs'
import { adjustLinks, getIdFromHtmlLine, isPlausibleDate } from './helpers'
import { seriesFooterRegex, seriesHeaderRegex } from './regexes'

function getArrayOfSeriesAndSingleArticles(): (Series | Article)[] {
	const arrayOfSeries: (Series | Article)[] = []
	let currentSeriesName = ''
	let currentSeriesSlug = ''
	let currentSeriesDates = ''
	let currentSeriesContent = ''
	let currentSeriesImage = ''

	for (let i = 0; i < allHeadingsAndArticles.length; i++) {
		const current = allHeadingsAndArticles[i]
		if (seriesHeaderRegex.test(current)) {
			currentSeriesSlug = getIdFromHtmlLine(current) ?? ''
			if (!currentSeriesSlug) {
				throw Error('No ID was found in HTML ' + current)
			}

			currentSeriesName = /(?<=alt="Header: &quot;)[^"]+(?=&quot;[^"]+")/.exec(current)?.[0] ?? ''
			if (!currentSeriesName) {
				throw Error('No name was found in alt text in HTML ' + current)
			}

			currentSeriesImage =
				/(?<=<img ([a-z]+="[^"]*" )*src="\.\.)\/images\/series-headers\/[^"]+(?=")/.exec(current)?.[0] ?? ''
			if (!currentSeriesImage) {
				throw Error('No image source was found in alt text in HTML ' + current)
			}

			currentSeriesDates = ''
			currentSeriesContent = ''
		} else if (currentSeriesName === '') {
			const articleId = getIdFromHtmlLine(current)
			if (articleId && isPlausibleDate(articleId)) {
				arrayOfSeries.push({ date: articleId, type: 'Article' })
			}
		} else {
			currentSeriesContent += current + '\n'

			const articleId = getIdFromHtmlLine(current)
			if (articleId && isPlausibleDate(articleId)) {
				currentSeriesDates += articleId + ' '
			}
		}
		if (seriesFooterRegex.test(current)) {
			const seriesDates = currentSeriesDates.trimEnd().split(' ')
			const firstArticleHeadings = getHeadingsForArticle(seriesDates[0])
			if (!firstArticleHeadings) {
				throw Error(`Article ${seriesDates[0]} has no headings so can’t give headings to ${currentSeriesName}.`)
			}
			// If there’s a month heading between the series header and the first article,
			// it appears in both firstArticleHeadings and articlesContent.
			// To prevent the heading being duplicated, we remove the heading from firstArticleHeadings.
			if (currentSeriesContent.startsWith(firstArticleHeadings.at(-1) || '')) {
				firstArticleHeadings.pop()
			}
			const totalContent = adjustLinks(firstArticleHeadings.join('\n') + currentSeriesContent)
			arrayOfSeries.push({
				name: currentSeriesName,
				slug: currentSeriesSlug,
				image: currentSeriesImage,
				dates: seriesDates,
				content: totalContent,
				type: 'Series',
			})
			currentSeriesName = ''
		}
	}

	return arrayOfSeries
}
const arrayOfSeriesAndSingleArticles: (Series | Article)[] = getArrayOfSeriesAndSingleArticles()

const arrayOfSeries: Series[] = arrayOfSeriesAndSingleArticles
	.filter((item) => item.type === 'Series')
	.map((series) => series as Series) // Keep TypeScript happy by making type explicit.

function getSeriesForOneArticle(date: string): Series | undefined {
	return arrayOfSeries.find((series) => {
		return series.dates.includes(date)
	})
}

export { arrayOfSeries, arrayOfSeriesAndSingleArticles, getSeriesForOneArticle }
/src/pages/404.astro
---
import Layout from '../layouts/Layout.astro'
---

<Layout title="404 — Duncan’s Childhood Blog" description="Page not found">
	<main>
		<h1>Page not found <small>(404 error)</small></h1>
		<!-- <p>
			The page you wanted doesn’t exist. I may have moved or deleted it, so
			sorry about that!
		</p> -->
		<div class="overflow">
			<div class="paper">
				<p>Sorry but this page is currently unavailable.</p>
				<p>
					A cobra probably ate it. <span title="Ascii art of a cobra"
						><span aria-hidden="true">:()) ==&lt;&gt;======================================&gt;</span></span
					>
				</p>
				<p>
					Either that or a magpie stole it to its nest somewhere. <span title="Ascii art of a magpie"
						><span aria-hidden="true">&lt;(• )( w )&lt;&lt;&lt;&lt;</span></span
					>
				</p>
			</div>
		</div>

		<p>
			You might like to go back to
			<a href={import.meta.env.BASE_URL}>my Childhood Blog’s homepage</a>.
		</p>
	</main>
</Layout>

<style>
	.overflow {
		overflow-x: auto;
		padding: 1rem;
	}
	.paper {
		font-family: 'Comic Sans', cursive, sans-serif;
		--font-size: 20px;
		font-size: var(--font-size);
		line-height: 2;
		--line-colour: #004dff40;
		--margin-colour: #004dff80;
		--background-gradients: linear-gradient(
				90deg,
				transparent,
				transparent 2.5em,
				var(--margin-colour) 2.5em,
				var(--margin-colour) calc(2.5em + 2px),
				transparent calc(2.5em + 2px),
				transparent 100%
			),
			repeating-linear-gradient(
				180deg,
				transparent,
				transparent calc(1 * var(--font-size) - 2px),
				var(--line-colour) calc(1 * var(--font-size) - 2px),
				var(--line-colour) calc(1 * var(--font-size) - 1px),
				transparent calc(1 * var(--font-size) - 1px),
				transparent calc(1 * var(--font-size))
			),
			linear-gradient(352deg, bisque, #fefcf6);
		background-image: var(--background-gradients);
		background-size:
			auto 100%,
			100% 100%,
			auto;
		background-attachment: local;
		background-position: top left;
		padding: calc(0.4 * var(--font-size)) 1rem calc(0.8 * var(--font-size)) 3rem;
		transform: rotate(359deg);
		box-shadow: 0.25rem 0.3333rem 0.3333rem #00000040;
	}
	.paper p {
		margin-top: var(--font-size);
		margin-bottom: var(--font-size);
	}
	.paper [aria-hidden='true'] {
		word-break: keep-all;
		white-space: nowrap;
	}
</style>
/src/pages/[date].astro
---
import AuthorialFooter from '../components/AuthorialFooter.astro'
import type { Year } from '../components/YearPhoto.astro'
import Layout from '../layouts/Layout.astro'
import { dateArticleMap, getArticleWithHeadings } from '../lib/articles'
import { addLinkBase, isPlausibleDate, prettyPrintDate } from '../lib/helpers'
import { getSeriesForOneArticle } from '../lib/series-array'

export async function getStaticPaths() {
	const dates = Object.keys(dateArticleMap).filter(isPlausibleDate)

	return [...dates].map((date, index, dates) => ({
		params: { date },
		props: {
			date: date,
			previous: dates[index - 1],
			next: dates[index + 1],
		},
	}))
}

const { date, previous, next } = Astro.props
const prettyDate = prettyPrintDate(date)
const year = Number(date.slice(0, 4)) as Year

const series = getSeriesForOneArticle(date)
const previousDateSeries = getSeriesForOneArticle(previous)
const nextDateSeries = getSeriesForOneArticle(next)
---

<Layout title={prettyDate + ' — Duncan’s Childhood Blog'} description="An article that Duncan Ritchie wrote years ago">
	<main>
		<header class="page-header">
			<h1>
				<img
					src={addLinkBase('images/logos/CBlog-logo.webp')}
					alt="Childhood Blog"
					width="150"
					style="vertical-align: middle;"
				/>
				Article for {prettyDate}
			</h1>
			<p>
				<em>
					{
						series ? (
							<>
								Part of the{' '}
								<a href={addLinkBase(series.slug)}>
<Fragment set:html={series.name} />
								</a>{' '}
								series.
							</>
						) : (
							<>This article is not in a series.</>
						)
					}
				</em>
			</p>
		</header>
		<Fragment set:html={getArticleWithHeadings(date)} />
	</main>

	<AuthorialFooter {year} />

	<section class="prev-next">
		{
			previous && (
				<p class="previous">
					Previous:
					<a href={addLinkBase(previous)}>{prettyPrintDate(previous)}</a>
					{previousDateSeries && (
						<>
							(in the
							<a href={addLinkBase(previousDateSeries.slug) + '#' + previous}>
<Fragment set:html={previousDateSeries.name} />
							</a>
							series)
						</>
					)}
				</p>
			)
		}
		{
			next && (
				<p class="next">
					Next:
					<a href={addLinkBase(next)}>{prettyPrintDate(next)}</a>
					{nextDateSeries && (
						<>
							(in the
							<a href={addLinkBase(nextDateSeries.slug) + '#' + next}>
<Fragment set:html={nextDateSeries.name} />
							</a>
							series)
						</>
					)}
				</p>
			)
		}
	</section>
</Layout>
/src/pages/[series].astro
---
import AuthorialFooter from '../components/AuthorialFooter.astro'
import type { Year } from '../components/YearPhoto.astro'
import Layout from '../layouts/Layout.astro'
import { addLinkBase, prettyPrintDate, unescapeHtml } from '../lib/helpers'
import { arrayOfSeries, arrayOfSeriesAndSingleArticles } from '../lib/series-array'

export async function getStaticPaths() {
	return [...arrayOfSeries].map((series) => ({
		params: { series: series.slug },
		props: {
			series,
		},
	}))
}

const { series } = Astro.props

const indexInSeriesAndArticles = arrayOfSeriesAndSingleArticles.findIndex(
	(item) => item.type === 'Series' && item.slug === series.slug,
)
const previousSeriesOrArticle = arrayOfSeriesAndSingleArticles[indexInSeriesAndArticles - 1]
const nextSeriesOrArticle = arrayOfSeriesAndSingleArticles[indexInSeriesAndArticles + 1]
---

<Layout
	title={unescapeHtml(series.name) + ' — Duncan’s Childhood Blog'}
	description="An article that Duncan Ritchie wrote years ago"
>
	<main>
		<header class="page-header">
			<h1>
<Fragment set:html={series.name} />” series of
				<img
					src={addLinkBase('images/logos/CBlog-logo.webp')}
					alt="Childhood Blog"
					width="150"
					style="vertical-align: middle"
				/>
				articles
			</h1>
		</header>
		<Fragment set:html={series.content} />
	</main>

	<AuthorialFooter year={Number(series.dates[0].slice(0, 4)) as Year} />

	<section class="prev-next">
		{
			previousSeriesOrArticle && (
				<p class="previous">
					Previous:{' '}
					{previousSeriesOrArticle.type === 'Article' ? (
						<a href={addLinkBase(previousSeriesOrArticle.date)}>{prettyPrintDate(previousSeriesOrArticle.date)}</a>
					) : (
						<a href={addLinkBase(previousSeriesOrArticle.slug)}>
<Fragment set:html={previousSeriesOrArticle.name} />
						</a>
					)}
				</p>
			)
		}
		{
			nextSeriesOrArticle && (
				<p class="next">
					Next:{' '}
					{nextSeriesOrArticle.type === 'Article' ? (
						<a href={addLinkBase(nextSeriesOrArticle.date)}>{prettyPrintDate(nextSeriesOrArticle.date)}</a>
					) : (
						<a href={addLinkBase(nextSeriesOrArticle.slug)}>
<Fragment set:html={nextSeriesOrArticle.name} />
						</a>
					)}
				</p>
			)
		}
	</section>
</Layout>
/src/pages/all.astro
---
import Layout from '../layouts/Layout.astro'
import EntireBlog from '../data/EntireBlog.html?raw'
import { adjustLinks, deleteHtmlComments, addLinkBase } from '../lib/helpers'

const blogWithoutHtmlComments = adjustLinks(deleteHtmlComments(EntireBlog))
---

<Layout
	title="All articles — Duncan’s Childhood Blog"
	description="All the online content of the blog that Duncan Ritchie created as a child"
>
	<main>
		<h1>Duncan’s Childhood Blog — all online articles</h1>
		<p><a href={addLinkBase()}>Blog homepage</a></p>
		<Fragment set:html={blogWithoutHtmlComments} />
	</main>
</Layout>
/src/pages/index.astro
---
import ArticleCard from '../components/ArticleCard.astro'
import PhotosHeader from '../components/PhotosHeader.astro'
import Layout from '../layouts/Layout.astro'
import { dateArticleMap } from '../lib/articles'
import { isPlausibleDate, addLinkBase } from '../lib/helpers'
import { arrayOfSeries } from '../lib/series-array'

const dates = Object.keys(dateArticleMap).filter(isPlausibleDate)
---

<Layout
	title="Home — Duncan’s Childhood Blog"
	description="Blog created by Duncan Ritchie for articles he wrote as a child"
	showBlogHomeLink={false}
>
	<main>
		<header>
			<PhotosHeader />
			<img
				class="bird chick"
				src={addLinkBase('images/logos/CB-bird-in-nest.webp')}
				alt="Illustration of a chick in a nest in 3D vector shapes"
				width="380"
				style={`aspect-ratio: 1 / 1; --url: url("${addLinkBase('images/logos/CB-bird-in-nest.webp')}"`}
			/>
			<h1>
				<span> Duncan’s Childhood Blog </span>
			</h1>
			<p>
				For eight years of my adolescence, I kept a diary for narrating goings-on in my life, and I typed it up with
				photos and maps (and&nbsp;drawings of birds). Many of those articles are excruciatingly tedious. Below are some
				of the articles that are slightly less excruciatingly tedious.
			</p>
		</header>
		<p>
			The date of each article is (probably) the date of events mentioned in the article, and might not be the date I
			wrote it. Moreover, some of the content is exaggerated or downright fictitious. Even my pen-name of Duncan Sailor
			is meant as a joke. However, some articles have additional notes <ins>formatted like this</ins>, which were added
			later and are probably accurate.
		</p>
		<p>
			I have a <a href="https://www.duncanritchie.co.uk/blog">newer blog</a> for articles I’m writing as an adult. There’s
			an article there about <a href="https://www.duncanritchie.co.uk/blog/childhood-blog"
				>how I made this Childhood Blog</a
			>.
		</p>
		<p>
			I also have a page of <a href={addLinkBase('stats')}>statistics</a> relating to my childhood articles.
		</p>

		<img
			src={addLinkBase('images/in-articles/CB5_graphicCuckooProfile_300.png')}
			alt="Illustration of a cuckoo in flat vector shapes"
			width="330"
			style={`aspect-ratio: 300 / 152; --url: url("${addLinkBase('images/in-articles/CB5_graphicCuckooProfile_300.png')}"`}
			class="bird cuckoo"
		/>
		<h2 id="series-of-articles">Series of articles</h2>
		<p>
			I divided the articles into series, since several articles are from the same holiday or school term. Some articles
			are not in any series, however.
		</p>
		<ul class="auto-grid" style="--min-column-width: 12ch;">
			{
				arrayOfSeries.map((series) => (
					<li>
						<a href={addLinkBase(series.slug)}>
							<img src={addLinkBase(series.image)} alt="" width="150" style="aspect-ratio: 1 / 1; width: 100%;" />
							<br />
							<Fragment set:html={series.name} />
						</a>
					</li>
				))
			}
		</ul>

		<img
			src={addLinkBase('images/in-articles/DB6_drawingJay.png')}
			alt="Drawing of a jay in pencil and crayon"
			width="250"
			style={`aspect-ratio: 360 / 244; --url: url("${addLinkBase('images/in-articles/DB6_drawingJay.png')}"`}
			class="bird jay"
		/>
		<h2 id="all-articles">All {dates.length} online articles</h2>
		<p>Articles that are not in a series are marked with a star.</p>
		<p><a href={addLinkBase('stats#all-articles')}>Synopses and word counts</a> are on the statistics page.</p>
		<ul class="auto-grid" style="--min-column-width: 12ch;">
			{
				dates.map((date) => (
					<li>
						<ArticleCard date={date} />
					</li>
				))
			}
		</ul>

		<img
			src={addLinkBase('images/in-articles/Eagle.png')}
			alt="Illustration of an eagle in curved vector shapes"
			width="250"
			style="aspect-ratio: 859 / 706;"
			class="bird eagle"
		/>
	</main>
</Layout>

<style>
	h1 {
		position: relative;
		margin-bottom: 1rem;
	}
	header p {
		font-size: 1.125em;
		color: var(--nav-text);
	}
	.bird {
		display: block;
		margin-inline: auto;
		margin-top: 2rem;
		margin-bottom: -1rem;
		max-width: 50%;
		shape-margin: 1.5rem;
	}
	.cuckoo {
		margin-top: 2rem;
		max-width: 67%;
	}
	.eagle {
		margin-top: 1.75rem;
	}
	h2 {
		margin-top: 2.75rem;
	}
	.auto-grid {
		clear: both;
	}
	@media (min-width: 48rem) {
		.bird {
			shape-outside: var(--url);
		}
		.chick {
			float: right;
			margin-inline: 1rem 0;
			margin-bottom: 1rem;
			shape-margin: 2rem;
		}
		.cuckoo {
			float: left;
			margin-inline: 0rem 1rem;
			margin-bottom: 1rem;
		}
		.jay {
			float: right;
			margin-inline: 1rem 0;
			margin-bottom: 1rem;
		}
	}
</style>
/src/pages/readme.astro
---
import Layout from '../layouts/Layout.astro'
import * as readme from '../../README.md'
---

<Layout title="Readme — Duncan’s Childhood Blog" description="Readme file for Duncan Ritchie’s Childhood Blog">
	<main>
		<Fragment set:html={readme.compiledContent()} />
	</main>
</Layout>
/src/pages/source.astro
---
import Layout from '../layouts/Layout.astro'
import CodeInDetails from '../components/CodeInDetails.astro'
import type { CodeLang } from '../lib/defs'
import { addLinkBase } from '../lib/helpers'

const tsFileImports = import.meta.glob(`../*/*.ts`, {
	query: '?raw',
	import: 'default',
})

// Astro files are not imported directly, because they can’t be imported raw.
// Instead, we import the .astro.txt files created by the prebuild script.
const astroFileImports = import.meta.glob<string>(`../../text-dist/**/*.astro.txt`, {
	query: '?raw',
	import: 'default',
})

const shellFileImports = import.meta.glob<string>(`../../*.sh`, {
	query: '?raw',
	import: 'default',
})

const tsFiles = Object.entries(tsFileImports).map(async ([path, code]) => ({
	path,
	code: (await code()) as string,
	lang: 'ts',
}))
const astroFiles = Object.entries(astroFileImports).map(async ([path, code]) =>
	// The filepaths displayed should be the paths of the Astro source files, not the .astro.txt copies.
	({ path: path.replace('../text-dist/src/', '').replace('.txt', ''), code: (await code()) as string, lang: 'astro' }),
)
const shellFiles = Object.entries(shellFileImports).map(async ([path, code]) => ({
	path,
	code: (await code()) as string,
	lang: 'shell',
}))

function makePathAbsolute(path: string) {
	// This file source.astro is inside /src/pages/, therefore the relative paths created above are relative to that.
	// The “C://” doesn’t matter, it’s just to make the URL constructor work.
	const url = new URL(path, 'C://Code/src/pages/')
	return url.href.slice(8)
}

const allFiles = await Promise.all([...tsFiles, ...astroFiles, ...shellFiles]).then((files) =>
	files.map((file) => ({ ...file, path: makePathAbsolute(file.path) })).sort((a, b) => (a.path > b.path ? 1 : -1)),
)
---

<Layout
	title="Source-code — Duncan’s Childhood Blog"
	description="Source-code for Duncan Ritchie’s Childhood Blog"
	showSourceCodeLink={false}
>
	<main>
		<h1>Source-code for Duncan’s Childhood Blog</h1>
		<p>
			The Git repository for this blog is not publicly visible, because it contains HTML for articles that I don’t wish
			to publish. Instead, I’ve set up this page so that some of my code can be seen.
		</p>
		<p>
			The blog is made with TypeScript in the Astro framework. Astro (or Vite, the bundler it uses under the hood)
			doesn’t seem to like importing .astro files as raw text, so I have a pre-build script to copy all .astro files to
			.astro.txt files, which can then be imported in this page. If you can see Astro code below, that works!
		</p>
		<p>
			I also have a page rendering the <a href={addLinkBase('readme')}>readme for this project</a>.
		</p>

		<div id="buttons" hidden>
			<button id="openAll" aria-controls="all-details" disabled>Show all files</button>
			<button id="closeAll" aria-controls="all-details">Hide all files</button>
		</div>

		<div id="all-details">
			{allFiles.map(({ path, code, lang }) => <CodeInDetails summary={path} code={code} lang={lang as CodeLang} />)}
		</div>
	</main>
</Layout>

<style>
	#buttons {
		margin-bottom: 1rem;
	}
</style>

<script>
	const detailsElements = document.getElementsByTagName('details')
	const showAllButton = document.getElementById('openAll')
	const hideAllButton = document.getElementById('closeAll')
	document.getElementById('buttons')?.removeAttribute('hidden')

	showAllButton?.addEventListener('click', () => {
		for (const detailsElement of detailsElements) {
			detailsElement.setAttribute('open', '')
		}
		showAllButton?.setAttribute('disabled', '')
		hideAllButton?.removeAttribute('disabled')
	})
	hideAllButton?.addEventListener('click', () => {
		for (const detailsElement of detailsElements) {
			detailsElement.removeAttribute('open')
		}
		hideAllButton?.setAttribute('disabled', '')
		showAllButton?.removeAttribute('disabled')
	})

	// Clicking on any <summary> element should make the “Show/Hide All” buttons disabled or non-disabled as appropriate.
	document.addEventListener('click', (e) => {
		const target = e.target as HTMLElement
		if (!target || target.tagName !== 'SUMMARY') {
			return
		}

		let areAllDetailsOpen = true
		let areAllDetailsClosed = true
		for (const element of detailsElements) {
			// If the <details> contains the <summary> that was clicked on,
			// its state is going to switch from open to closed or vice versa.
			// So the value of element.open is the opposite to what it’s going to be.
			const hasEventTarget = target === element.getElementsByTagName('summary')[0]

			// !== means exclusive-or for booleans.
			if (element.open !== hasEventTarget) {
				// This element will be open, so not all elements are closed.
				areAllDetailsClosed = false
			} else {
				// This element will be closed, so not all elements are open.
				areAllDetailsOpen = false
			}
		}

		if (areAllDetailsClosed) {
			hideAllButton?.setAttribute('disabled', '')
		} else {
			hideAllButton?.removeAttribute('disabled')
		}
		if (areAllDetailsOpen) {
			showAllButton?.setAttribute('disabled', '')
		} else {
			showAllButton?.removeAttribute('disabled')
		}
	})
</script>
/src/pages/stats.astro
---
import ArticleStatCard from '../components/ArticleStatCard.astro'
import Layout from '../layouts/Layout.astro'
import { addLinkBase, countWordsInHtml, isPlausibleDate, prettyPrintDate } from '../lib/helpers'
import type { Article } from '../lib/defs'
import ByArticle from '../data/by-article.json'
import { dateArticleMap, poemCount } from '../lib/articles'
import { arrayOfSeries, arrayOfSeriesAndSingleArticles } from '../lib/series-array'

const articles = Object.entries(dateArticleMap)
	.filter(([key, _]) => isPlausibleDate(key))
	.map(([date, html]) => {
		const wordCount = countWordsInHtml(html)

		const foundData = ByArticle.find((article) => article.Date === date)
		const synopsisLong = foundData?.Synopsis
		const synopsisShort = foundData?.SynShort
		const synopsis = synopsisShort || synopsisLong || ''
		if (!synopsis) {
			console.warn(`No synopsis for ${date}`)
		} else if (synopsis.length > 40) {
			// Ideally the synopsis should fit on one line without wrapping or truncation.
			console.warn(
				`Synopsis for ${date} is ${synopsis.length} characters long: please make SynShort shorter. ${synopsis}`,
			)
		}

		return { date, html, synopsis, wordCount }
	})

const medianIndex = Math.floor(articles.length / 2)
const articlesByWordCount = articles.toSorted((a, b) => b.wordCount - a.wordCount)

const series = arrayOfSeries.map((series) => ({
	...series,
	wordCount: countWordsInHtml(series.content),
	articleCount: series.dates.length,
}))
const articlesNotInAnySeries = arrayOfSeriesAndSingleArticles
	.filter((item) => item.type === 'Article') // Filter to the single articles.
	.map((a) => a as Article) // Keep TypeScript happy.
const summaryOfArticlesNotInAnySeries = {
	wordCount: articlesNotInAnySeries
		.map((article) => countWordsInHtml(dateArticleMap[article.date]))
		.reduce((a, b) => a + b),
	articleCount: articlesNotInAnySeries.length,
	first: articlesNotInAnySeries[0],
	last: articlesNotInAnySeries[articlesNotInAnySeries.length - 1],
}

const mapRegex = /<img [^>]*src="..\/images\/maps\//

const summaryOfBlog = {
	wordCount: articles.map((article) => countWordsInHtml(dateArticleMap[article.date])).reduce((a, b) => a + b),
	mapCount: articles.filter((article) => mapRegex.test(article.html)).length,
}
---

<Layout
	title="Statistics — Duncan’s Childhood Blog"
	description="Statistics about articles that Duncan Ritchie wrote as a child"
>
	<main>
		<header class="page-header">
			<h1>
				Statistics — Duncan’s Childhood Blog <img
					src={addLinkBase('images/logos/CBlog-stats-logo.webp')}
					alt="Logo for Childhood Blog stats"
					width="250"
					style="aspect-ratio: 600 / 363;"
				/>
			</h1>
			<p>These figures do not include articles (or extracts of articles) that are not publicly visible.</p>
			<p>
				The word count for a series (or for the entire blog) may be more than the sum of the word counts for the
				articles it contains, since a series may have headings that do not belong to any one article.
			</p>
			<p>Articles that are not in a series are marked with a star.</p>
		</header>

		<h2 id="totals">Totals</h2>
		<p>
			Duncan’s Childhood Blog contains <strong>{summaryOfBlog.wordCount}</strong> words,
			<strong>{articles.length}</strong> articles (of which <strong>{summaryOfBlog.mapCount}</strong> have maps),
			<strong>{poemCount}</strong> poems, and <strong>{series.length}</strong> series.
		</p>

		<h2 id="exteme-articles">Extreme articles</h2>
		<dl class="auto-grid" style="--min-column-width: 24ch;">
			<div>
				<dt>Earliest</dt>
				<dd>
					<ArticleStatCard article={articles[0]} />
				</dd>
			</div>
			<div>
				<dt>Median date</dt>
				<dd>
					<ArticleStatCard article={articles[medianIndex]} />
				</dd>
			</div>
			<div>
				<dt>Latest</dt>
				<dd>
					<ArticleStatCard article={articles[articles.length - 1]} />
				</dd>
			</div>

			<div>
				<dt>Shortest</dt>
				<dd>
					<ArticleStatCard article={articlesByWordCount[articles.length - 1]} />
				</dd>
			</div>
			<div>
				<dt>Median length</dt>
				<dd>
					<ArticleStatCard article={articlesByWordCount[medianIndex]} />
				</dd>
			</div>
			<div>
				<dt>Longest</dt>
				<dd>
					<ArticleStatCard article={articlesByWordCount[0]} />
				</dd>
			</div>
		</dl>

		<h2 id="all-series">All series</h2>
		<div>
			<table>
				<thead>
					<tr>
						<th>#</th>
						<th>Series</th>
						<th>Words</th>
						<th>Articles</th>
						<th>First article</th>
						<th>Last article</th>
					</tr>
				</thead>
				<tbody>
					{
						series.map((series, i) => (
							<tr>
								<td>{i + 1}</td>
								<td>
									<a href={addLinkBase(series.slug)}>
										<img src={addLinkBase(series.image)} alt="" width="30" />
										<Fragment set:html={series.name} />
									</a>
								</td>
								<td style="text-align: right;">{series.wordCount}</td>
								<td style="text-align: right;">{series.articleCount}</td>
								<td>{prettyPrintDate(series.dates[0])}</td>
								<td>{prettyPrintDate(series.dates[series.dates.length - 1])}</td>
							</tr>
						))
					}
					<tr>
						<td>-</td>
						<td>
							<img src={addLinkBase('/images/in-articles/cb5_gradeastarbox_30.png')} alt="" width="30" />
							<em>(articles not in a series)</em>
						</td>
						<td style="text-align: right;">{summaryOfArticlesNotInAnySeries.wordCount}</td>
						<td style="text-align: right;">{summaryOfArticlesNotInAnySeries.articleCount}</td>
						<td>
							<a href={addLinkBase(summaryOfArticlesNotInAnySeries.first.date)}>
								{prettyPrintDate(summaryOfArticlesNotInAnySeries.first.date)}
							</a>
						</td>
						<td>
							<a href={addLinkBase(summaryOfArticlesNotInAnySeries.last.date)}>
								{prettyPrintDate(summaryOfArticlesNotInAnySeries.last.date)}
							</a>
						</td>
					</tr>
				</tbody>
			</table>
			<!--
				Closing </a> tag here is because of a weird HTML/Astro bug. The </a> closing tags inside the table don’t seem to be recognised, which leads to an unwanted <a> element appearing outside of the table! This unwanted element needs to be closed to prevent the following content (the “All articles” heading) becoming a link, so that’s the purpose of the </a> here!
				The opening <a> tag is to prevent Prettier from deleting the “extraneous” </a> tag.
				I would use a Prettier comment (https://prettier.io/docs/en/ignore#html) but that only works on whole DOM nodes, not lone closing tags. 
			-->
			<a hidden></a>
		</div>
		<h2 id="all-articles">All articles</h2>

		<!-- The sorting requires client-side JS to work, so it’s hidden until the JS loads. -->
		<fieldset id="sort" hidden>
			<legend>Sort</legend>
			<input type="radio" name="sort" value="default" id="sort-default" checked />
			<label for="sort-default">Default sort</label>
			<input type="radio" name="sort" value="length" id="sort-length" />
			<label for="sort-length">Sort by length</label>
			<!-- To add another sorting option, add another <input> and <label> here, add another <ul> with class "articles-sorted" (and "hidden" attribute) below, then add the new input’s ID and the new <ul> element’s ID to the array of tuples in the JavaScript. -->
		</fieldset>

		<!-- A <ul> element is given for each sorting option; all are hidden except the one selected. -->
		<ul id="articles-sorted-by-default" class="articles-sorted auto-grid" style="--min-column-width: 24ch;">
			{
				articles.map((article) => (
					<li>
						<ArticleStatCard article={article} />
					</li>
				))
			}
		</ul>

		<ul id="articles-sorted-by-length" class="articles-sorted auto-grid" style="--min-column-width: 24ch;" hidden>
			{
				articlesByWordCount.map((article) => (
					<li>
						<ArticleStatCard article={article} />
					</li>
				))
			}
		</ul>
	</main>

	<style>
		h1 img {
			margin-inline: auto;
		}
		@media (min-width: 30rem) {
			h1 img {
				float: right;
				margin-left: 1rem;
				margin-right: 0;
			}
		}
		dt {
			font-weight: bold;
			color: var(--nav-text);
		}

		div:has(> table) {
			overflow-x: auto;
			margin-bottom: 2rem;
		}

		table {
			width: max-content;
		}

		table img {
			vertical-align: top;
			width: 1.5em;
			aspect-ratio: 1 / 1;
			margin-top: -1px;
			display: inline-block;
			position: sticky;
			left: 0;
		}
	</style>

	<script>
		document.getElementById('sort')?.removeAttribute('hidden')

		const sortRadios = [...document.getElementsByName('sort')]
		const articlesULs = [...document.getElementsByClassName('articles-sorted')]

		function showArticlesUl(ulId: string) {
			articlesULs.forEach((element) => {
				if (element.id === ulId) {
					element.removeAttribute('hidden')
				} else {
					element.setAttribute('hidden', '')
				}
			})
		}

		// If another sorting option should be added, another tuple should be added to the array.
		const radioIdsAndUlIds = [
			['sort-default', 'articles-sorted-by-default'],
			['sort-length', 'articles-sorted-by-length'],
		]

		// If the webpage is refreshed after a non-default radio is checked,
		// the non-default radio may continue to be checked (depending on the browser).
		// This would cause the <ul> of articles to not match the radio.
		// The function below simply corrects for this.
		function showArticlesUlAccordingToRadios() {
			sortRadios.forEach((radio) => {
				if (document.querySelector(`#${radio.id}[checked]`)) {
					const ulToShow = radioIdsAndUlIds.find(([radioId, _]) => radioId === radio.id)?.[1]
					if (ulToShow) {
						showArticlesUl(ulToShow)
						// In Firefox the default radio is said to have the "checked" attribute on page-load,
						// even if another radio is actually checked!
						// If we simulate a click on the radio, we can be sure that the radio and the UL are in sync,
						// but it might not be the radio that was actually checked beforehand.
						radio.click()
					}
				}
			})
		}
		showArticlesUlAccordingToRadios()

		// Actual event-listeners for responding to radios being checked.
		function addListenerToSortingRadio(radioId: string, ulId: string) {
			document.getElementById(radioId)?.addEventListener('change', () => showArticlesUl(ulId))
		}
		radioIdsAndUlIds.forEach(([radioId, ulId]) => addListenerToSortingRadio(radioId, ulId))
	</script>
</Layout>