diff --git a/assets/styles/twitter.css b/assets/styles/twitter.css
new file mode 100644
index 0000000..d0387c6
--- /dev/null
+++ b/assets/styles/twitter.css
@@ -0,0 +1,49 @@
+:root {
+ --tweet-separator-color: rgb(var(--lt-gray));
+}
+
+article.tweet {
+ border: 1px solid var(--tweet-separator-color);
+ border-radius: 6px;
+ padding: 4rem;
+}
+
+ul.tweets {
+ list-style-type: none;
+ margin-inline-start: 0;
+}
+
+li.tweet {
+ border-bottom: 1px solid var(--tweet-separator-color);
+ margin: 0;
+ padding-block: 2rem;
+}
+
+li.tweet:first-of-type {
+ border-top: 1px solid var(--tweet-separator-color);
+}
+
+.metadata {
+ display: flex;
+ list-style-type: none;
+ margin: 0;
+ margin-block-start: 1rem;
+}
+
+.metadata .timestamp {
+ color: rgb(var(--mid-gray));
+ font-size: 90%;
+ margin: 0;
+}
+
+.metadata :is(.rt, .fav) {
+ gap: 0.5rem;
+ display: flex;
+ align-items: center;
+ position: relative;
+}
+
+.metadata .rt img {
+ position: relative;
+ top: 1px;
+}
diff --git a/config/_default/permalinks.yaml b/config/_default/permalinks.yaml
index 8efe757..7d4b0bc 100644
--- a/config/_default/permalinks.yaml
+++ b/config/_default/permalinks.yaml
@@ -1,2 +1,3 @@
blog: blog/:year/:month/:slug/
photos: photos/:year/:month/:slug/
+twitter: twitter/:year/:month/:slug/
diff --git a/layouts/twitter/li_grid_with_date.html b/layouts/twitter/li_grid_with_date.html
new file mode 100644
index 0000000..a58625a
--- /dev/null
+++ b/layouts/twitter/li_grid_with_date.html
@@ -0,0 +1,7 @@
+
diff --git a/layouts/twitter/list.html b/layouts/twitter/list.html
new file mode 100644
index 0000000..0d5d420
--- /dev/null
+++ b/layouts/twitter/list.html
@@ -0,0 +1,26 @@
+{{ define "header" }}
+ {{ partial "header.html" . }}
+{{ end }}
+
+{{ define "main" }}
+ {{ .Title }}
+
+ {{ .Content }}
+
+ {{ $paginator := .Paginator 100 }}
+ {{ template "_internal/pagination.html" . }}
+
+
+
+ {{ template "_internal/pagination.html" . }}
+{{ end }}
+
+{{ define "footer" }}
+ {{ partial "footer.html" . }}
+{{ end }}
diff --git a/layouts/twitter/section.atom.atom b/layouts/twitter/section.atom.atom
new file mode 100644
index 0000000..e69de29
diff --git a/layouts/twitter/section.rss.rss b/layouts/twitter/section.rss.rss
new file mode 100644
index 0000000..e69de29
diff --git a/layouts/twitter/single.html b/layouts/twitter/single.html
new file mode 100644
index 0000000..bde1ecc
--- /dev/null
+++ b/layouts/twitter/single.html
@@ -0,0 +1,15 @@
+{{ define "main" }}
+
+{{ end }}
diff --git a/scripts/import-twitter-archive.js b/scripts/import-twitter-archive.js
new file mode 100755
index 0000000..0f82141
--- /dev/null
+++ b/scripts/import-twitter-archive.js
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+
+"use strict";
+
+const fsPromises = require("node:fs/promises");
+const path = require("node:path");
+
+class TweetProcessingError extends Error {
+ tweet;
+
+ constructor(tweet, message) {
+ super(`[${tweet.id}]: ${message}`);
+ this.tweet = tweet;
+ }
+}
+
+class MediaEntity {
+ tweet;
+ entity;
+
+ fileName;
+ mediaFilePath;
+
+ constructor(tweet, entity) {
+ this.tweet = tweet;
+ this.entity = entity;
+ }
+
+ process(mediaPath) {
+ const url = new URL(this.entity.media_url);
+ this.fileName = path.basename(url.pathname);
+ this.mediaFilePath = path.join(mediaPath, `${this.tweet.id}-${this.fileName}`);
+ }
+
+ async copyMediaFile(contentDirectory) {
+ const destinationPath = path.join(contentDirectory, this.fileName);
+ console.log(`[${this.tweet.id}]: Copying media file from ${this.mediaFilePath} to ${destinationPath}`);
+
+ try {
+ await fsPromises.copyFile(this.mediaFilePath, destinationPath);
+ } catch (error) {
+ console.error(error);
+ throw error;
+ }
+ }
+}
+
+class Tweet {
+ tweet;
+ mediaEntities = [];
+
+ constructor(tweet) {
+ this.tweet = tweet;
+ }
+
+ get id() {
+ return this.#innerTweet.id_str;
+ }
+
+ get dateCreated() {
+ return new Date(this.#innerTweet.created_at);
+ }
+
+ get text() {
+ return this.#innerTweet.full_text;
+ }
+
+ contentPath(asDirectory = true) {
+ const innerTweet = this.#innerTweet;
+
+ const idString = innerTweet.id_str;
+ const timestamp = this.dateCreated;
+
+ return path.join(
+ timestamp.getFullYear().toString().padStart(4, '0'),
+ // Months are 0-11.
+ (timestamp.getMonth() + 1).toString().padStart(2, '0'),
+ asDirectory ? idString : `${idString}.md`
+ );
+ }
+
+ get #innerTweet() { return this.tweet.tweet; }
+
+ async process(hugoContentPath, mediaPath) {
+ const dateCreated = this.dateCreated;
+ const indexObject = {
+ date: dateCreated.toISOString(),
+ title: this.text,
+ slug: this.id,
+ tweet: this.tweet,
+ };
+
+
+ let textOfTweet = this.text;
+ let references = [];
+
+ let doTextSubstitution = (startIndex, endIndex, replacementText) => {
+ const textBefore = textOfTweet.substring(0, startIndex);
+ const textAfter = textOfTweet.substring(endIndex);
+ textOfTweet = `${textBefore}${replacementText}${textAfter}`;
+ };
+
+ let processTextEntitySubstitution = (_, item, i) => {
+ const [startIndex, endIndex] = item.indices;
+ const reference = `[${textOfTweet.substring(startIndex, endIndex)}][entity${i}]`;
+ doTextSubstitution(startIndex, endIndex, reference);
+
+ references.push(`[entity${i}]: https://twitter.com/${item.screen_name}`);
+ };
+
+ let processMediaEntitySubstitution = (_, item, __) => {
+ const entity = new MediaEntity(this, item);
+ this.mediaEntities.push(entity);
+ entity.process(mediaPath);
+
+ const [startIndex, endIndex] = item.indices;
+ const reference = `\n\n{{< figures/image name="${entity.fileName}" >}}\n\n`;
+ doTextSubstitution(startIndex, endIndex, reference);
+ };
+
+ let shouldMakeDirectory = false;
+
+ Object.entries(this.#innerTweet.entities)
+ .map(([entityType, entities]) => entities.map(e => [entityType, e]))
+ .flatMap(e => e)
+ .sort(([, a], [, b]) => {
+ const startIndexOfA = a.indices[0];
+ const startIndexOfB = b.indices[0];
+
+ if (startIndexOfA === startIndexOfB) {
+ console.assert(false);
+ return 0;
+ }
+
+ // Reverse sort by start index of the entity.
+ return startIndexOfA < startIndexOfB ? 1 : -1;
+ }).forEach(([typeOfItem, item], i) => {
+ switch (typeOfItem) {
+ case "hashtags":
+ case "user_mentions":
+ processTextEntitySubstitution(typeOfItem, item, i);
+ break;
+ case "media":
+ shouldMakeDirectory = true;
+ processMediaEntitySubstitution(typeOfItem, item, i);
+ break;
+ case "symbols":
+ // Symbols appear to be stock ticker symbols. They may be other things.
+ console.log(`[${this.id}]: Encountered symbol entity. Ignoring.`, item);
+ break;
+ }
+ });
+
+
+ const frontMatter = JSON.stringify(indexObject, null, 4);
+ const joinedReferences = references.reverse().join("\n");
+ let contentsOfIndexFile = `${frontMatter}\n\n${textOfTweet}\n\n${joinedReferences}`;
+
+ let tweetFilePath;
+ if (shouldMakeDirectory) {
+ const contentPath = path.join(hugoContentPath, this.contentPath());
+ tweetFilePath = path.join(contentPath, "index.md");
+
+ console.log(`[${this.id}]: Writing tweet file to ${tweetFilePath}`);
+ await fsPromises.mkdir(contentPath, { recursive: true });
+
+ for (const mediaEntity of this.mediaEntities) {
+ await mediaEntity.copyMediaFile(contentPath);
+ }
+ } else {
+ tweetFilePath = path.join(hugoContentPath, this.contentPath(false));
+ const containingDirectory = path.dirname(tweetFilePath);
+
+ console.log(`[${this.id}]: Writing tweet file to ${tweetFilePath}`);
+ await fsPromises.mkdir(containingDirectory, { recursive: true });
+ }
+
+ try {
+ await fsPromises.writeFile(tweetFilePath, contentsOfIndexFile);
+ } catch (error) {
+ console.error(error);
+ throw error;
+ }
+
+ return this;
+ }
+}
+
+function* take(iterable, length) {
+ const iterator = iterable[Symbol.iterator]();
+ while (length-- > 0) {
+ yield iterator.next().value;
+ }
+}
+
+function* readTweets(tweetsJSONPath) {
+ tweetsJSONPath = path.resolve(tweetsJSONPath);
+
+ console.log("Loading tweets from", tweetsJSONPath);
+ const tweets = require(tweetsJSONPath);
+
+ for (const tweetObject of tweets) {
+ yield new Promise(resolve => {
+ resolve(new Tweet(tweetObject));
+ });
+ }
+}
+
+async function main() {
+ const programArguments = process.argv;
+
+ // The first two arguments are always `node` and then the script.
+ if (programArguments.length !== 4) {
+ console.error("Invalid number of program arguments. Expected 3.");
+ return -1;
+ }
+
+ const twitterArchivePath = programArguments[2];
+ const hugoContentPath = programArguments[3];
+
+ try {
+ await fsPromises.access(twitterArchivePath);
+ } catch (error) {
+ console.error(error);
+ return -1;
+ }
+
+ const twitterArchiveDataPath = path.join(twitterArchivePath, "data");
+ try {
+ await fsPromises.access(twitterArchiveDataPath);
+ } catch (error) {
+ console.error(`${twitterArchivePath} doesn't appear to be a valid Twitter archive. It's missing a data directory!`);
+ console.error(error);
+ return -1;
+ }
+
+ try {
+ await fsPromises.access(hugoContentPath);
+ } catch (error) {
+ console.error(error);
+ return -1;
+ }
+
+ const tweetsFilePath = path.join(twitterArchiveDataPath, "tweets.json");
+ const tweetsMediaPath = path.join(twitterArchiveDataPath, "tweets_media");
+
+ let numberOfTweetsProcessed = 0;
+
+ await Promise.all(
+ [...take(readTweets(tweetsFilePath), 1000)].map(tweetPromise => {
+ return tweetPromise
+ .then(tweet => {
+ numberOfTweetsProcessed++;
+ return tweet.process(hugoContentPath, tweetsMediaPath);
+ })
+ .catch(error => console.error(error));
+ }));
+
+ console.log(`\nSuccessfully processed ${numberOfTweetsProcessed} tweets.`);
+
+ return 0;
+}
+
+main();
diff --git a/static/images/retweet.png b/static/images/retweet.png
new file mode 100644
index 0000000..61af3a9
Binary files /dev/null and b/static/images/retweet.png differ
diff --git a/static/images/star-unfilled.png b/static/images/star-unfilled.png
new file mode 100644
index 0000000..8e88a0a
Binary files /dev/null and b/static/images/star-unfilled.png differ