feat: created basic blog layout

This commit is contained in:
Leyla Becker 2026-02-10 11:02:43 -06:00
parent 42fd071301
commit 0d41db36ef
13 changed files with 935 additions and 3 deletions

25
_includes/base.njk Normal file
View file

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} | Volpe</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/prism.css">
</head>
<body>
<header>
<nav>
<a href="/">Home</a>
</nav>
</header>
<main>
{{ content | safe }}
</main>
<footer>
<p>&copy; {{ page.date.getFullYear() }} Volpe</p>
</footer>
</body>
</html>

30
_includes/post.njk Normal file
View file

@ -0,0 +1,30 @@
---
layout: base.njk
---
<article class="blog-post">
<header class="post-header">
<h1>{{ title }}</h1>
<time datetime="{{ createdAt | dateFormat }}">{{ createdAt | dateFormat }}</time>
{% if updatedAt %}
<p class="updated-at">Updated: <time datetime="{{ updatedAt | dateFormat }}">{{ updatedAt | dateFormat }}</time></p>
{% endif %}
</header>
<div class="post-content">
{{ content | safe }}
</div>
{% set postTags = page.inputPath | extractTagsFromFile %}
{% if postTags.length > 0 %}
<section class="post-tags">
<h2>Tags</h2>
<ul class="tag-list">
{% for tag in postTags %}
<li><a href="/tags/{{ tag | lower }}/">#{{ tag }}</a></li>
{% endfor %}
</ul>
</section>
{% endif %}
</article>

149
css/prism.css Normal file
View file

@ -0,0 +1,149 @@
/* PrismJS 1.29.0 - One Dark Theme */
code[class*="language-"],
pre[class*="language-"] {
color: #abb2bf;
background: none;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 1rem 0;
overflow: auto;
border-radius: 5px;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #282c34;
}
:not(pre) > code[class*="language-"] {
padding: 0.1em 0.3em;
border-radius: 0.3em;
white-space: normal;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #5c6370;
}
.token.punctuation {
color: #abb2bf;
}
.token.selector,
.token.tag {
color: #e06c75;
}
.token.property,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.attr-name,
.token.deleted {
color: #d19a66;
}
.token.string,
.token.char,
.token.attr-value,
.token.builtin,
.token.inserted {
color: #98c379;
}
.token.operator,
.token.entity,
.token.url,
.language-css .token.string,
.style .token.string {
color: #56b6c2;
}
.token.atrule,
.token.keyword {
color: #c678dd;
}
.token.function,
.token.class-name {
color: #61afef;
}
.token.regex,
.token.important,
.token.variable {
color: #c678dd;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
/* Line numbers */
pre[class*="language-"].line-numbers {
position: relative;
padding-left: 3.8em;
counter-reset: linenumber;
}
pre[class*="language-"].line-numbers > code {
position: relative;
white-space: inherit;
}
.line-numbers .line-numbers-rows {
position: absolute;
pointer-events: none;
top: 0;
font-size: 100%;
left: -3.8em;
width: 3em;
letter-spacing: -1px;
border-right: 1px solid #5c6370;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.line-numbers-rows > span {
display: block;
counter-increment: linenumber;
}
.line-numbers-rows > span:before {
content: counter(linenumber);
color: #5c6370;
display: block;
padding-right: 0.8em;
text-align: right;
}

259
css/style.css Normal file
View file

@ -0,0 +1,259 @@
/* Base styles */
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--text-color: #333;
--background-color: #fff;
--border-color: #ddd;
--code-background: #f5f5f5;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
line-height: 1.6;
color: var(--text-color);
background-color: var(--background-color);
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
/* Header and Navigation */
header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
header nav a {
color: var(--primary-color);
text-decoration: none;
font-weight: bold;
font-size: 1.2rem;
}
header nav a:hover {
color: var(--secondary-color);
}
/* Main content */
main {
min-height: calc(100vh - 200px);
}
h1, h2, h3, h4, h5, h6 {
color: var(--primary-color);
margin: 1.5rem 0 1rem;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
p {
margin-bottom: 1rem;
}
a {
color: var(--secondary-color);
}
/* Blog post list */
.post-list {
list-style: none;
}
.post-list li {
margin-bottom: 1.5rem;
}
.post-list li:last-child {
margin-bottom: 0;
}
.post-card {
display: block;
text-decoration: none;
padding: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 8px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.post-card:hover {
border-color: var(--secondary-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-card h2 {
margin: 0 0 0.5rem;
color: var(--primary-color);
}
.post-card:hover h2 {
color: var(--secondary-color);
}
.post-card time {
color: #666;
font-size: 0.9rem;
}
.post-card p {
color: var(--text-color);
margin-top: 0.5rem;
margin-bottom: 0;
}
/* Blog post */
.blog-post {
margin-bottom: 2rem;
}
.post-header {
margin-bottom: 2rem;
}
.post-header h1 {
margin-bottom: 0.5rem;
}
.post-header time {
color: #666;
font-size: 0.9rem;
}
.post-content {
margin-bottom: 2rem;
}
.post-content img {
max-width: 100%;
height: auto;
}
.post-content code {
background-color: var(--code-background);
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
}
.post-content pre {
background-color: var(--code-background);
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
margin: 1rem 0;
}
.post-content pre code {
padding: 0;
background: none;
}
/* Expandable sections */
.expandable {
border: 1px solid var(--border-color);
border-radius: 5px;
margin: 1rem 0;
}
.expandable summary {
padding: 0.75rem 1rem;
cursor: pointer;
font-weight: bold;
background-color: var(--code-background);
border-radius: 5px 5px 0 0;
}
.expandable[open] summary {
border-bottom: 1px solid var(--border-color);
}
.expandable > *:not(summary) {
padding: 0 1rem;
}
.expandable > p:last-child {
padding-bottom: 1rem;
}
/* Tags */
.post-tags {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
}
.post-tags h2 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.tag-list {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1rem;
}
.tag-list li {
display: inline;
}
.tag-list a {
color: var(--secondary-color);
text-decoration: none;
}
.tag-list a:hover {
text-decoration: underline;
}
/* Footnotes */
.footnotes {
margin-top: 2rem;
padding-top: 1rem;
border-top: 2px solid var(--border-color);
font-size: 0.9rem;
}
.footnotes h2 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.footnotes ol {
padding-left: 1.5rem;
}
.footnotes li {
margin-bottom: 0.5rem;
}
.footnote-ref a {
color: var(--secondary-color);
text-decoration: none;
}
.footnote-backref {
margin-left: 0.5rem;
text-decoration: none;
}
/* Footer */
footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
text-align: center;
color: #666;
font-size: 0.9rem;
}

210
eleventy.config.js Normal file
View file

@ -0,0 +1,210 @@
const markdownIt = require("markdown-it");
const markdownItContainer = require("markdown-it-container");
const markdownItFootnote = require("markdown-it-footnote");
const syntaxHighlight = require("@11ty/eleventy-plugin-syntaxhighlight");
const fs = require("fs");
const tagPattern = /(?<=^|\s)#([a-zA-Z][a-zA-Z0-9_]*)(?![a-zA-Z0-9_-])/g;
// TODO: is there any reasonable way to make this use real markdown parsing because right now this is sketchy
const extractTags = (content) => {
if (!content) return [];
const matches = content.match(tagPattern);
if (!matches) return [];
const tags = [...new Set(matches.map(m => m.slice(1)))];
return tags;
}
const getPostTags = (post) => {
const filePath = post.inputPath;
try {
const content = fs.readFileSync(filePath, 'utf-8');
const tags = extractTags(content);
return tags.map(tag => {
const normalizedTag = tag.toLowerCase();
return normalizedTag
});
} catch (e) {
// Skip if file can't be read
return []
}
}
const isReleased = (post) => {
return post.data.unreleased !== true;
}
const markdownItHashtag = (md) => {
const hashtagRegex = /^#([a-zA-Z][a-zA-Z0-9_]*)(?![a-zA-Z0-9_-])/;
md.inline.ruler.push('hashtag', function(state, silent) {
const pos = state.pos;
const ch = state.src.charCodeAt(pos);
if (ch !== '#') return false;
if (pos > 0) {
const prevCh = state.src.charCodeAt(pos - 1);
if (prevCh !== ' ' && prevCh !== '\t' && prevCh !== '\n' && prevCh !== '\r') {
return false;
}
}
const match = state.src.slice(pos).match(hashtagRegex);
if (!match) return false;
if (!silent) {
const token = state.push('hashtag', 'a', 0);
token.content = match[1];
token.markup = '#';
}
state.pos += match[0].length;
return true;
});
md.renderer.rules.hashtag = function(tokens, idx) {
const tagName = tokens[idx].content;
const slug = tagName.toLowerCase();
return `<a href="/tags/${slug}/" class="inline-tag">#${md.utils.escapeHtml(tagName)}</a>`;
};
}
const md = markdownIt({
html: true,
breaks: true,
linkify: true
});
md.use(markdownItContainer, 'details', {
validate: function (params) {
return params.trim().match(/^(.*)$/);
},
render: function (tokens, idx) {
const m = tokens[idx].info.trim().match(/^(.*)$/);
if (tokens[idx].nesting === 1) {
const title = md.utils.escapeHtml(m[1]);
return `<details class="expandable">\n<summary>${title}</summary>\n`;
} else {
return '</details>\n';
}
}
});
md.use(markdownItFootnote);
md.use(markdownItHashtag);
module.exports = (eleventyConfig) => {
eleventyConfig.addPlugin(syntaxHighlight);
eleventyConfig.addFilter("extractTags", extractTags);
eleventyConfig.addFilter("extractTagsFromFile", (filePath) => {
try {
const content = fs.readFileSync(filePath, 'utf-8');
return extractTags(content);
} catch (e) {
return [];
}
});
eleventyConfig.setLibrary("md", md);
eleventyConfig.addFilter("dateFormat", (date) => {
const d = new Date(date);
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC'
});
});
eleventyConfig.addFilter("isoDate", (date) => {
const d = new Date(date);
return d.toISOString().split('T')[0];
});
eleventyConfig.addCollection("posts", (collectionApi) => {
return collectionApi.getFilteredByGlob("posts/**/*.md")
.filter(isReleased)
.sort((a, b) => {
const aDate = a.data.createdAt || a.date;
const bDate = b.data.createdAt || b.date;
return aDate - bDate;
});
});
eleventyConfig.addCollection("allPosts", (collectionApi) => {
return collectionApi.getFilteredByGlob("posts/**/*.md").sort((a, b) => {
const aDate = a.data.createdAt || a.date;
const bDate = b.data.createdAt || b.date;
return aDate - bDate;
});
});
eleventyConfig.addCollection("postsBySlug", (collectionApi) => {
const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased);
const slugify = eleventyConfig.getFilter("slugify");
const grouped = {};
posts.forEach(post => {
const slug = slugify(post.data.title);
if (!grouped[slug]) {
grouped[slug] = [];
}
grouped[slug].push(post);
});
Object.values(grouped).forEach(postList => {
postList.sort((a, b) => {
const aDate = a.data.createdAt || a.date;
const bDate = b.data.createdAt || b.date;
return aDate - bDate;
});
});
return grouped;
});
eleventyConfig.addCollection("contentTags", (collectionApi) => {
const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased);
return [...new Set(posts.flatMap(getPostTags))].sort();
});
eleventyConfig.addCollection("postsByTag", (collectionApi) => {
const posts = collectionApi.getFilteredByGlob("posts/**/*.md").filter(isReleased);
const tagMap = {};
posts.forEach(post => {
const tags = getPostTags(post)
tags.forEach((tag) => {
tagMap[tag] = {
name: tag,
posts: [post, ...(tagMap[tag]?.posts ?? [])],
}
})
});
Object.values(tagMap).forEach(tagData => {
tagData.posts.sort((a, b) => {
const aDate = a.data.createdAt || a.date;
const bDate = b.data.createdAt || b.date;
return aDate - bDate;
});
});
return tagMap;
});
eleventyConfig.addPassthroughCopy("css");
return {
dir: {
input: ".",
includes: "_includes",
output: "_site"
},
markdownTemplateEngine: "njk",
htmlTemplateEngine: "njk"
};
};

View file

@ -1 +0,0 @@
<!doctype html><title>Page title</title><p>Hi</p>

24
index.njk Normal file
View file

@ -0,0 +1,24 @@
---
layout: base.njk
title: Blog
---
<h1>Blog Posts</h1>
<ul class="post-list">
{% for post in collections.posts %}
<li>
<a href="{{ post.url }}" class="post-card">
<h2>{{ post.data.title }}</h2>
<time datetime="{{ post.data.createdAt | dateFormat }}">{{ post.data.createdAt | dateFormat }}</time>
{% if post.data.description %}
<p>{{ post.data.description }}</p>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% if collections.posts.length == 0 %}
<p>No blog posts yet.</p>
{% endif %}

View file

@ -5,13 +5,17 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "pnpm exec eleventy", "build": "pnpm exec eleventy",
"develop": "pnpm exec eleventy --serve" "dev": "pnpm exec eleventy --serve"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.28.0",
"dependencies": { "dependencies": {
"@11ty/eleventy": "^3.1.2" "@11ty/eleventy": "^3.1.2",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"markdown-it": "^14.1.0",
"markdown-it-container": "^4.0.0",
"markdown-it-footnote": "^4.0.0"
} }
} }

35
pnpm-lock.yaml generated
View file

@ -11,6 +11,18 @@ importers:
'@11ty/eleventy': '@11ty/eleventy':
specifier: ^3.1.2 specifier: ^3.1.2
version: 3.1.2 version: 3.1.2
'@11ty/eleventy-plugin-syntaxhighlight':
specifier: ^5.0.2
version: 5.0.2
markdown-it:
specifier: ^14.1.0
version: 14.1.0
markdown-it-container:
specifier: ^4.0.0
version: 4.0.0
markdown-it-footnote:
specifier: ^4.0.0
version: 4.0.0
packages: packages:
@ -29,6 +41,9 @@ packages:
resolution: {integrity: sha512-QK1tRFBhQdZASnYU8GMzpTdsMMFLVAkuU0gVVILqNyp09xJJZb81kAS3AFrNrwBCsgLxTdWHJ8N64+OTTsoKkA==} resolution: {integrity: sha512-QK1tRFBhQdZASnYU8GMzpTdsMMFLVAkuU0gVVILqNyp09xJJZb81kAS3AFrNrwBCsgLxTdWHJ8N64+OTTsoKkA==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@11ty/eleventy-plugin-syntaxhighlight@5.0.2':
resolution: {integrity: sha512-T6xVVRDJuHlrFMHbUiZkHjj5o1IlLzZW+1IL9eUsyXFU7rY2ztcYhZew/64vmceFFpQwzuSfxQOXxTJYmKkQ+A==}
'@11ty/eleventy-utils@2.0.7': '@11ty/eleventy-utils@2.0.7':
resolution: {integrity: sha512-6QE+duqSQ0GY9rENXYb4iPR4AYGdrFpqnmi59tFp9VrleOl0QSh8VlBr2yd6dlhkdtj7904poZW5PvGr9cMiJQ==} resolution: {integrity: sha512-6QE+duqSQ0GY9rENXYb4iPR4AYGdrFpqnmi59tFp9VrleOl0QSh8VlBr2yd6dlhkdtj7904poZW5PvGr9cMiJQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -351,6 +366,12 @@ packages:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'} engines: {node: '>=12'}
markdown-it-container@4.0.0:
resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==}
markdown-it-footnote@4.0.0:
resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==}
markdown-it@14.1.0: markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true hasBin: true
@ -454,6 +475,10 @@ packages:
resolution: {integrity: sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==} resolution: {integrity: sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
prr@1.0.1: prr@1.0.1:
resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==}
@ -586,6 +611,10 @@ snapshots:
- posthtml - posthtml
- supports-color - supports-color
'@11ty/eleventy-plugin-syntaxhighlight@5.0.2':
dependencies:
prismjs: 1.30.0
'@11ty/eleventy-utils@2.0.7': {} '@11ty/eleventy-utils@2.0.7': {}
'@11ty/eleventy@3.1.2': '@11ty/eleventy@3.1.2':
@ -903,6 +932,10 @@ snapshots:
luxon@3.7.2: {} luxon@3.7.2: {}
markdown-it-container@4.0.0: {}
markdown-it-footnote@4.0.0: {}
markdown-it@14.1.0: markdown-it@14.1.0:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@ -994,6 +1027,8 @@ snapshots:
posthtml-parser: 0.11.0 posthtml-parser: 0.11.0
posthtml-render: 3.0.0 posthtml-render: 3.0.0
prismjs@1.30.0: {}
prr@1.0.1: {} prr@1.0.1: {}
punycode.js@2.3.1: {} punycode.js@2.3.1: {}

36
post-redirects.njk Normal file
View file

@ -0,0 +1,36 @@
---
pagination:
data: collections.postsBySlug
size: 1
alias: slugData
resolve: keys
permalink: /post/{{ slugData }}/
layout: base.njk
---
{% set posts = collections.postsBySlug[slugData] %}
{% if posts.length == 1 %}
{# Single post - redirect to the dated version #}
<meta http-equiv="refresh" content="0; url=/post/{{ slugData }}/{{ posts[0].data.createdAt | isoDate }}/">
<script>window.location.href = "/post/{{ slugData }}/{{ posts[0].data.createdAt | isoDate }}/";</script>
<p>Redirecting to <a href="/post/{{ slugData }}/{{ posts[0].data.createdAt | isoDate }}/">{{ posts[0].data.title }}</a>...</p>
{% else %}
{# Multiple posts with same title - show disambiguation page #}
<h1>{{ posts[0].data.title }}</h1>
<p>There are multiple posts with this title. Please select the one you're looking for:</p>
<ul class="post-list">
{% for post in posts %}
<li>
<a href="/post/{{ slugData }}/{{ post.data.createdAt | isoDate }}/" class="post-card">
<h2>{{ post.data.title }}</h2>
<time datetime="{{ post.data.createdAt | dateFormat }}">{{ post.data.createdAt | dateFormat }}</time>
{% if post.data.description %}
<p>{{ post.data.description }}</p>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
{% endif %}

122
posts/posts.11tydata.js Normal file
View file

@ -0,0 +1,122 @@
const path = require("path");
const fs = require("fs");
const { execSync } = require("child_process");
const getGitCreatedTime = (filePath) => {
try {
const result = execSync(
`git log --diff-filter=A --follow --format=%aI -- "${filePath}" | tail -1`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
).trim();
if (result) {
return new Date(result);
}
} catch (e) {
// Git command failed, return null
}
return null;
};
const getGitModifiedTime = (filePath) => {
try {
const result = execSync(
`git log -1 --format=%aI -- "${filePath}"`,
{ encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
).trim();
if (result) {
return new Date(result);
}
} catch (e) {
// Git command failed, return null
}
return null;
};
const getTitleFromFilename = (filePath) => {
const basename = path.basename(filePath, '.md');
return basename
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const getFileCreatedTime = (filePath) => {
try {
const stats = fs.statSync(filePath);
return stats.birthtime || stats.mtime;
} catch (e) {
return null;
}
}
const getFileModifiedTime = (filePath) => {
try {
const stats = fs.statSync(filePath);
return stats.mtime;
} catch (e) {
return null;
}
}
const parseDate = (value) => {
if (!value) return null;
if (value instanceof Date) return value;
const parsed = new Date(value);
return isNaN(parsed.getTime()) ? null : parsed;
}
const getPostCreatedAt = (data) => {
const frontmatterDate = parseDate(data.createdAt);
if (frontmatterDate) {
return frontmatterDate;
}
const gitDate = getGitCreatedTime(data.page.inputPath);
if (gitDate) {
return gitDate;
}
const fileDate = getFileCreatedTime(data.page.inputPath);
if (fileDate) {
return fileDate;
}
return data.page.date;
}
module.exports = {
layout: "post.njk",
tags: ["posts"],
unreleased: false,
eleventyComputed: {
title: (data) => {
if (data.title && data.title !== data.page.fileSlug) {
return data.title;
}
return getTitleFromFilename(data.page.inputPath);
},
createdAt: getPostCreatedAt,
updatedAt: (data) => {
const modifiedDate = parseDate(data.updatedAt) ??
getGitModifiedTime(data.page.inputPath) ??
getFileModifiedTime(data.page.inputPath);
const createdDate = getPostCreatedAt(data);
if (modifiedDate) {
// Only return updatedAt if it's AFTER created date (by more than a day)
const dayInMs = 24 * 60 * 60 * 1000;
if (modifiedDate > createdDate && (modifiedDate - createdDate) > dayInMs) {
return modifiedDate;
}
}
return createdDate;
},
permalink: (data) => {
const title = data.title || getTitleFromFilename(data.page.inputPath);
const slug = title.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
const createdDate = getPostCreatedAt(data);
const isoDate = createdDate.toISOString().split('T')[0];
return `/post/${slug}/${isoDate}/`;
}
}
};

12
tags-index.njk Normal file
View file

@ -0,0 +1,12 @@
---
permalink: /tags/
layout: base.njk
---
<h1>All Tags</h1>
<ul class="tag-list">
{% for tag in collections.contentTags %}
<li><a href="/tags/{{ tag }}/">#{{ tag }}</a></li>
{% endfor %}
</ul>

27
tags.njk Normal file
View file

@ -0,0 +1,27 @@
---
pagination:
data: collections.postsByTag
size: 1
alias: tag
addAllPagesToCollections: true
permalink: /tags/{{ tag }}/
layout: base.njk
---
<h1>Posts tagged "#{{ collections.postsByTag[tag].name }}"</h1>
<ul class="post-list">
{% for post in collections.postsByTag[tag].posts %}
<li>
<a href="{{ post.url }}" class="post-card">
<h2>{{ post.data.title }}</h2>
<time datetime="{{ post.date | dateFormat }}">{{ post.date | dateFormat }}</time>
{% if post.data.description %}
<p>{{ post.data.description }}</p>
{% endif %}
</a>
</li>
{% endfor %}
</ul>
<p><a href="/tags/"><3E> All tags</a></p>