Compare commits

..

No commits in common. "99a981209cc6463b956ba5b194ead16e7ca4e58f" and "498155fea3856a96699b157b456e1881440adaa5" have entirely different histories.

9 changed files with 66 additions and 569 deletions

View file

@ -1,19 +0,0 @@
---
const { posts, displayDate = false, placeholder = false } = Astro.props;
---
<div style="margin-top: 1rem; margin-left: 1rem;">
{posts.map((post) => (
<p>
{displayDate && <span class="list-date">{new Date(post.data.pubDate).toISOString().split('T')[0]}</span>}
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</p>
))}
{placeholder && posts.length === 0 && (
<>
<p>
<span style="color: var(--terminal-yellow);">No posts here yet</span>
</p>
</>
)}
</div>

View file

@ -1,7 +1,6 @@
--- ---
import {siteConfig} from "../config"; import {siteConfig} from "../config";
import FediverseComments from "./helper/comments/Fediverse.astro"; import FediverseComments from "./helper/comments/Fediverse.astro";
import HatsuComments from "./helper/comments/Hatsu.astro";
const method = siteConfig.comments.type const method = siteConfig.comments.type
const ArtalkConfig = siteConfig.comments.artalk const ArtalkConfig = siteConfig.comments.artalk
@ -53,4 +52,3 @@ let { path='/' } = Astro.props;
<!-- if prerender === true is set then render from client --> <!-- if prerender === true is set then render from client -->
{(method === 'fediverse' && !FediverseConfig.renderOnServer ) && <FediverseComments path={path} /> } {(method === 'fediverse' && !FediverseConfig.renderOnServer ) && <FediverseComments path={path} /> }
{(method === 'fediverse' && FediverseConfig.renderOnServer ) && <FediverseComments server:defer path={path} ><p>Loading comments...</p></FediverseComments> } {(method === 'fediverse' && FediverseConfig.renderOnServer ) && <FediverseComments server:defer path={path} ><p>Loading comments...</p></FediverseComments> }
{method === 'hatsu' && <HatsuComments /> }

View file

@ -1,9 +0,0 @@
---
import {siteConfig} from "../config";
const navBarItems = siteConfig.navBarItems
---
<nav class="nav">
<a href="/" class="home">~</a>
<a href="/blog">Blog</a>
{navBarItems.map((item) => <a href={item.link}>{item.text}</a>)}
</nav>

View file

@ -1,35 +0,0 @@
---
import {siteConfig} from "../config";
const noscript = siteConfig.noClientJavaScript
const statisticsType = siteConfig.siteAnalytics.type
const umamiConfig = siteConfig.siteAnalytics.umami
const goatCounterConfig = siteConfig.siteAnalytics.goatcounter
---
{statisticsType === 'umami' && (
<script
is:inline
defer
src={`https://${umamiConfig.instanceDomain}/script.js`}
data-website-id={umamiConfig.websiteId}
></script>
)}
{statisticsEnabled && statisticsType === 'goatcounter' && (
<>
{noscript ? (
<img src={`https://${goatCounterConfig.instanceDomain}/count?p=/${Astro.url.pathname}`} alt="Analytics" />
) : (
<>
<script
is:inline
async
data-goatcounter={`https://${goatCounterConfig.instanceDomain}/count`}
src={`https://${goatCounterConfig.instanceDomain}/count.js`}
></script>
<noscript>
<img src={`https://${goatCounterConfig.instanceDomain}/count?p=/${Astro.url.pathname}`} alt="Analytics" />
</noscript>
</>
)}
</>
)}

View file

@ -1,494 +0,0 @@
---
import {siteConfig} from "../../../config";
const hatsuHost = `https://${siteConfig.comments.hatsu.instanceDomain}`
const {origin, pathname} = new URL(Astro.url)
const url = new URL(pathname, origin).href
import { transform } from "ultrahtml";
import sanitize from "ultrahtml/transformers/sanitize";
---
<section id="comments" class="container">
<p>Comments by <a href="https://hatsu.cli.rs">Hatsu</a></p>
<p id="mastodon-comments-list"><button id="load-comment">Load Comments</button></p>
<div id="comments-wrapper" class="mastodon-comment">
<noscript><p>JavaScript is needed for loading comments, or you copy the post's URL to your clipboard and paste it into your Fediverse instance's search box., </p></noscript>
</div>
</section>
<style is:inline>
button#load-comment, button.addComment {
background-color: var(--accent-color);
color: var(--bg-color);
border-radius: 0.25rem;
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
border: none;
}
button.button {
background-color: var(--accent-color);
color: var(--bg-color);
border-radius: 0.25rem;
padding: 0.25rem;
font-size: 1rem;
cursor: pointer;
border: none;
}
section#comments #comments-wrapper {
margin:1.5em 0;
padding:0 5px
}
section#comments .comment {
display:grid;
column-gap:1rem;
grid-template-areas:"avatar name" "avatar time" "avatar post" "...... interactions";
grid-template-columns:min-content;
justify-items:start;
margin:0 auto 0 -1em;
padding:.5em;
}
section#comments .comment.comment-reply {
margin:0 auto 0 1em;
}
section#comments .comment .avatar-link {
grid-area:avatar;
height:4rem;
position:relative;
width:4rem;
}
section#comments .comment .avatar-link .avatar {
max-height: 100%;
max-width: 100%;
border-radius: 50%;
box-sizing: border-box;
}
section#comments .comment .avatar-link.op::after {
background-color: var(--terminal-green);
border-radius:50%;
bottom:-.25rem;
color: #fafafa;
content:"✓";
display:block;
font-size:1.25rem;
font-weight:700;
height:1.5rem;
line-height:1.5rem;
position:absolute;
right:-.25rem;
text-align:center;
width:1.5rem
}
section#comments .comment .author {
align-items:center;
display:flex;
font-weight:700;
gap:.5em;
grid-area:name
}
section#comments .comment .author .instance {
background-color: var(--terminal-green);
border-radius:9999px;
color: #fafafa;
font-size:smaller;
font-weight:400;
padding:.25em .75em
}
section#comments .comment .author .instance:hover {
opacity:.8;
text-decoration:none
}
section#comments .comment .author .instance.op {
background-color: var(--terminal-green);
color: #fafafa
}
section#comments .comment .author .instance.op::before {
content:"✓";
font-weight:700;
margin-inline-end:.25em;
margin-inline-start:-.25em
}
section#comments .comment time {
grid-area:time;
line-height:1.5rem
}
section#comments .comment main {
grid-area:post
}
section#comments .comment main p:first-child {
margin-top:.25em
}
section#comments .comment main p:last-child {
margin-bottom:0
}
section#comments .comment footer {
grid-area:interactions
}
section#comments .comment footer .faves {
color:inherit
}
section#comments .comment footer .faves:hover {
opacity:.8;
text-decoration:none
}
section#comments .comment footer .faves::before {
color:yellow;
content:"☆";
font-size:2rem;
margin-inline-end:.25em
}
section#comments .comment .emoji {
display:inline;
height:1.25em;
vertical-align:middle;
width:1.25em
}
section#comments .comment .invisible {
display:none
}
section#comments .comment .ellipsis::after {
content:"…"
}
/*comment dialog*/
section#comments #comment-dialog {
width: 30em;
padding: 1em;
border-radius: 5px;
background-color: var(--bg-color);
margin: auto;
}
section#comments #comment-dialog {
color: var(--text-color);
}
section#comments #comment-dialog #close {
position: absolute;
top: 0;
right: 0;
background: none;
color: inherit;
border: none;
padding: 0.5em;
font: inherit;
outline: inherit;
}
section#comments #comment-dialog .input-row {
display: flex;
}
section#comments #comment-dialog .input-row > input {
flex-grow: 1;
margin-right: 0.5em;
}
section#comments #comment-dialog .input-row > button {
flex-basis: 3em;
}
</style>
<script define:vars={{ hatsuHost, url }}>
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
function emojify(input, emojis) {
let output = input;
emojis.forEach(emoji => {
let picture = document.createElement("picture");
let source = document.createElement("source");
source.setAttribute("srcset", escapeHtml(emoji.url));
source.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let img = document.createElement("img");
img.className = "emoji";
img.setAttribute("src", escapeHtml(emoji.static_url));
img.setAttribute("alt", `:${emoji.shortcode}:`);
img.setAttribute("title", `:${emoji.shortcode}:`);
img.setAttribute("width", "20");
img.setAttribute("height", "20");
picture.appendChild(source);
picture.appendChild(img);
output = output.replace(`:${emoji.shortcode}:`, picture.outerHTML);
});
return output;
}
function loadComments() {
// get id (base64url encode)
// aHR0cHM6Ly9leGFtcGxlLmNvbS9mb28vYmFy
const id = btoa(url).replaceAll('+', '-').replaceAll('/', '_')
let commentsWrapper = document.getElementById("comments-wrapper");
document.getElementById("load-comment").innerHTML = "Loading";
fetch(new URL(`/api/v1/statuses/${id}/context`, hatsuHost))
.then(function (response) {
return response.json();
})
.then(function (data) {
let descendants = data['descendants'];
if (
descendants &&
Array.isArray(descendants) &&
descendants.length > 0
) {
commentsWrapper.innerHTML = "";
descendants.forEach(function (status) {
console.log(descendants)
if (status.account.display_name.length > 0) {
status.account.display_name = escapeHtml(status.account.display_name);
status.account.display_name = emojify(status.account.display_name, status.account.emojis);
} else {
status.account.display_name = status.account.username;
}
;
let instance = "";
if (!status.account.id.includes("o3o.ca")) {
instance = status.account.id.split("/")[2];
} else {
instance = "o3o.ca";
}
const isReply = status.in_reply_to_id !== id;
let op = false;
if (status.account.username == "grassblock") {
op = true;
}
status.content = emojify(status.content, status.emojis);
let avatarSource = document.createElement("source");
avatarSource.setAttribute("srcset", escapeHtml(status.account.avatar));
avatarSource.setAttribute("media", "(prefers-reduced-motion: no-preference)");
let avatarImg = document.createElement("img");
avatarImg.className = "avatar";
avatarImg.setAttribute("src", escapeHtml(status.account.avatar_static));
avatarImg.setAttribute("alt", `@${status.account.username}@${instance} avatar`);
let avatarPicture = document.createElement("picture");
avatarPicture.appendChild(avatarSource);
avatarPicture.appendChild(avatarImg);
let avatar = document.createElement("a");
avatar.className = "avatar-link";
avatar.setAttribute("href", status.account.url);
avatar.setAttribute("rel", "external nofollow");
avatar.setAttribute("title", `View profile at @${status.account.username}@${instance}`);
avatar.appendChild(avatarPicture);
let instanceBadge = document.createElement("a");
instanceBadge.className = "instance";
instanceBadge.setAttribute("href", status.account.url);
instanceBadge.setAttribute("title", `@${status.account.username}@${instance}`);
instanceBadge.setAttribute("rel", "external nofollow");
instanceBadge.textContent = instance;
let display = document.createElement("span");
display.className = "display";
display.setAttribute("itemprop", "author");
display.setAttribute("itemtype", "http://schema.org/Person");
display.innerHTML = status.account.display_name;
let header = document.createElement("header");
header.className = "author";
header.appendChild(display);
header.appendChild(instanceBadge);
let permalink = document.createElement("a");
permalink.setAttribute("href", status.url);
permalink.setAttribute("itemprop", "url");
permalink.setAttribute("title", `View comment at ${instance}`);
permalink.setAttribute("rel", "external nofollow");
permalink.textContent = new Date(status.created_at).toLocaleString('en-US', {
dateStyle: "long",
timeStyle: "short",
});
let timestamp = document.createElement("time");
timestamp.setAttribute("datetime", status.created_at);
timestamp.appendChild(permalink);
let main = document.createElement("main");
main.setAttribute("itemprop", "text");
main.innerHTML = status.content;
let interactions = document.createElement("footer");
if (status.favourites_count > 0) {
let faves = document.createElement("a");
faves.className = "faves";
faves.setAttribute("href", `${status.url}/favourites`);
faves.setAttribute("title", `Favorites from ${instance}`);
faves.textContent = status.favourites_count;
interactions.appendChild(faves);
}
let comment = document.createElement("article");
//comment.id = `comment-${ status.id }`;
comment.className = isReply ? "comment comment-reply" : "comment";
comment.setAttribute("itemprop", "comment");
comment.setAttribute("itemtype", "http://schema.org/Comment");
comment.appendChild(avatar);
comment.appendChild(header);
comment.appendChild(timestamp);
comment.appendChild(main);
comment.appendChild(interactions);
if (op === true) {
comment.classList.add("op");
avatar.classList.add("op");
avatar.setAttribute(
"title",
"Blog post author; " + avatar.getAttribute("title")
);
instanceBadge.classList.add("op");
instanceBadge.setAttribute(
"title",
"Blog post author: " + instanceBadge.getAttribute("title")
);
}
// sanitize the comment
const safeComment = transform(comment.outerHTML, sanitize({
allowedTags: ['a', 'b', 'i', 'em', 'strong', 'p', 'br', 'img', 'code', 'pre', 's'],
allowedAttributes: {
a: ['href', 'title', 'rel'],
img: ['src', 'alt', 'width', 'height'],
time: ['datetime']
},
allowedSchemes: ['http', 'https']
}));
commentsWrapper.innerHTML += safeComment;
});
} else {
commentsWrapper.innerHTML = '<p>No comments yet</p>'
}
document.getElementById('load-comment').outerHTML = ''
document.getElementById('comments-wrapper').innerHTML += `<br><p><button class="addComment">进行评论</button></p>`
document.getElementById('comments-wrapper').innerHTML += `
<dialog id="comment-dialog">
<div class="dialog-title">
<b class="">Make a comment</b>
<button title="Cancel" id="close" class="">&times;</button>
</div>
<p>
Comments are powered by the <a href="https://hatsu.cli.rs">Hatsu</a>, which is a federated comment system backed by Fediverse that allows you to interact with posts across different instances.
You can use your existing Fediverse account to comment on this post, to do so enter the instance address of your Fediverse account below:
<p>
<p class="input-row">
<input type="text" inputmode="url" autocapitalize="none" autocomplete="off"
value="${localStorage.getItem("url") ?? ''}" id="instanceName"
placeholder="例如mastodon.social">
<button class="button" id="go">前往</button>
</p>
<p>或是复制帖文的地址并通过自己网站的搜索框抓取这篇帖子进行互动:</p>
<p class="input-row">
<input type="text" readonly id="copymInput" value="${url}">
<button class="button" id="copym">复制</button>
</p>
<p>账号所在的实例使用 Misskey、Pleroma 等平台的用户,上面的方式可能不工作,请手动复制下面的链接到站点搜索框手动抓取进行互动:</p>
<p class="input-row">
<input type="text" readonly id="copygInput" value="${hatsuHost}/posts/${url}">
<button class="button" id="copyg">复制</button>
</p>
</dialog>`
const dialog = document.getElementById('comment-dialog');
// open dialog on button click
Array.from(document.getElementsByClassName("addComment")).forEach(button => button.addEventListener('click', () => {
dialog.showModal();
// this is a very very crude way of not focusing the field on a mobile device.
// the reason we don't want to do this, is because that will push the modal out of view
if (dialog.getBoundingClientRect().y > 100) {
document.getElementById("instanceName").focus();
}
}));
// when click on 'Go' button: go to the instance specified by the user
document.getElementById('go').addEventListener('click', () => {
let instanceURL = document.getElementById('instanceName').value.trim();
if (instanceURL === '') {
// bail out - window.alert is not very elegant, but it works
window.alert("请填入你的实例地址!");
return;
}
// store the url in the local storage for next time
localStorage.setItem('mastodonUrl', instanceURL);
if (!instanceURL.startsWith('https://')) {
instanceURL = `https://${instanceURL}`;
}
window.open(`${instanceURL}/authorize_interaction?uri=${url}`, '_blank');
});
// also when pressing enter in the input field
document.getElementById('instanceName').addEventListener('keydown', e => {
if (e.key === 'Enter') {
document.getElementById('go').dispatchEvent(new Event('click'));
}
});
// copy tye post's url when pressing copy
document.getElementById('copym').addEventListener('click', () => {
// select the input field, both for visual feedback, and so that the user can use CTRL/CMD+C for manual copying, if they don't trust you
document.getElementById('copymInput').select();
navigator.clipboard.writeText(url);
// Confirm this by changing the button text
document.getElementById('copym').innerHTML = '已复制!';
// restore button text after a second.
window.setTimeout(() => {
document.getElementById('copym').innerHTML = '复制';
}, 1000);
});
// copy tye post's url when pressing copy
document.getElementById('copyg').addEventListener('click', () => {
// select the input field, both for visual feedback, and so that the user can use CTRL/CMD+C for manual copying, if they don't trust you
document.getElementById('copygInput').select();
navigator.clipboard.writeText(url);
// Confirm this by changing the button text
document.getElementById('copyg').innerHTML = '已复制!';
// restore button text after a second.
window.setTimeout(() => {
document.getElementById('copyg').innerHTML = '复制';
}, 1000);
});
// close dialog on button click, or escape button
document.getElementById('close').addEventListener('click', () => {
dialog.close();
});
dialog.addEventListener('keydown', e => {
if (e.key === 'Escape') dialog.close();
});
// Close dialog, if clicked on backdrop
dialog.addEventListener('click', event => {
var rect = dialog.getBoundingClientRect();
var isInDialog =
rect.top <= event.clientY
&& event.clientY <= rect.top + rect.height
&& rect.left <= event.clientX
&& event.clientX <= rect.left + rect.width;
if (!isInDialog) {
dialog.close();
}
})
}
)
}
document.getElementById("load-comment").addEventListener("click", loadComments);
</script>

View file

@ -8,8 +8,6 @@ import Meta from "../components/helper/head/Meta.astro";
import { siteConfig } from "../config"; import { siteConfig } from "../config";
import Logo from '../assets/mercury.svg' import Logo from '../assets/mercury.svg'
import Statistics from "../components/Statistics.astro";
import Navbar from "../components/Navbar.astro";
interface Props { interface Props {
title: string; title: string;
@ -22,6 +20,9 @@ interface Props {
const noscript = siteConfig.noClientJavaScript const noscript = siteConfig.noClientJavaScript
const statisticsEnabled = siteConfig.siteAnalytics.enabled const statisticsEnabled = siteConfig.siteAnalytics.enabled
const statisticsType = siteConfig.siteAnalytics.type
const umamiConfig = siteConfig.siteAnalytics.umami
const goatCounterConfig = siteConfig.siteAnalytics.goatcounter
const defaultTitle = siteConfig.title const defaultTitle = siteConfig.title
const formattedRootPath = defaultTitle.toLowerCase().replace(/\s+/g, '-'); const formattedRootPath = defaultTitle.toLowerCase().replace(/\s+/g, '-');
@ -30,6 +31,7 @@ const path = formattedRootPath + (relativePath === '/' ? '' : relativePath)
const pageTitle = (relativePath === '/' ? defaultTitle : `${Astro.props.title} - ${defaultTitle}`) const pageTitle = (relativePath === '/' ? defaultTitle : `${Astro.props.title} - ${defaultTitle}`)
const navBarItems = siteConfig.navBarItems
const customFooter = siteConfig.customFooter const customFooter = siteConfig.customFooter
const nekoType = siteConfig.neko?.type const nekoType = siteConfig.neko?.type
@ -52,7 +54,13 @@ const { title = pageTitle, author = siteConfig.defaultAuthor.name,description =
<div class="terminal-path"> <div class="terminal-path">
{path} {path}
</div> </div>
<Navbar />
<nav class="nav">
<a href="/" class="home">~</a>
<a href="/blog">Blog</a>
{navBarItems.map((item) => <a href={item.link}>{item.text}</a>)}
</nav>
<Search /> <Search />
<div class="content-box"> <div class="content-box">
@ -72,7 +80,33 @@ const { title = pageTitle, author = siteConfig.defaultAuthor.name,description =
<p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p> <p>Powered by <a href="https://git.gb0.dev/gb/mercury" target="_blank"><Logo width={16} height={16} /> mercury</a></p>
</div> </div>
</footer> </footer>
{statisticsEnabled && <Statistics/>} {statisticsEnabled && statisticsType === 'umami' && (
<script
is:inline
defer
src={`https://${umamiConfig.instanceDomain}/script.js`}
data-website-id={umamiConfig.websiteId}
></script>
)}
{statisticsEnabled && statisticsType === 'goatcounter' && (
<>
{noscript ? (
<img src={`https://${goatCounterConfig.instanceDomain}/count?p=/${Astro.url.pathname}`} alt="Analytics" />
) : (
<>
<script
is:inline
async
data-goatcounter={`https://${goatCounterConfig.instanceDomain}/count`}
src={`https://${goatCounterConfig.instanceDomain}/count.js`}
></script>
<noscript>
<img src={`https://${goatCounterConfig.instanceDomain}/count?p=/${Astro.url.pathname}`} alt="Analytics" />
</noscript>
</>
)}
</>
)}
{ (siteConfig.neko.enabled && !noscript) && { (siteConfig.neko.enabled && !noscript) &&
<> <>
<script is:inline define:vars={{ nekoType }}> <script is:inline define:vars={{ nekoType }}>

View file

@ -3,7 +3,6 @@ import Layout from '../layouts/Layout.astro';
import { getCollection } from 'astro:content'; import { getCollection } from 'astro:content';
import NewsLetter from "../components/NewsLetter.astro"; import NewsLetter from "../components/NewsLetter.astro";
import {siteConfig} from "../config"; import {siteConfig} from "../config";
import ArticleList from "../components/ArticleList.astro";
const posts = await getCollection('posts'); const posts = await getCollection('posts');
posts.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()); posts.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime());
@ -17,7 +16,22 @@ posts.sort((a, b) => new Date(b.data.pubDate).getTime() - new Date(a.data.pubDat
<div style="margin-top: 2rem;"> <div style="margin-top: 2rem;">
<span class="command">ls -la posts/</span> <span class="command">ls -la posts/</span>
<ArticleList posts={posts} displayDate={true} placeholder={true} /> <div style="margin-top: 1rem; margin-left: 1rem;">
{posts.map((post) => (
<p>
<span class="list-date">{new Date(post.data.pubDate).toISOString().split('T')[0]}</span>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</p>
))}
{posts.length === 0 && (
<>
<p>
<span style="color: var(--terminal-yellow);">No posts here yet</span>
</p>
</>
)}
</div>
</div> </div>
<div style="margin-top: 2rem;"> <div style="margin-top: 2rem;">

View file

@ -1,7 +1,6 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import {getCollection} from "astro:content"; import {getCollection} from "astro:content";
import ArticleList from "../../components/ArticleList.astro";
export async function getStaticPaths() { export async function getStaticPaths() {
const allPosts = await getCollection('posts'); const allPosts = await getCollection('posts');
@ -26,7 +25,12 @@ const { posts } = Astro.props;
<Layout title={`posts tagged with ${category}`} description={`Posts tagged with ${category}`}> <Layout title={`posts tagged with ${category}`} description={`Posts tagged with ${category}`}>
<h1 class="title">ls ~/blog | grep "{category}"</h1> <h1 class="title">ls ~/blog | grep "{category}"</h1>
<ul> <ul>
<ArticleList posts={posts} displayDate={true} /> {posts.map((post: any) =>
<p>
<span class="list-date">{new Date(post.data.pubDate).toISOString().split('T')[0]}</span>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</p>
)}
</ul> </ul>
</Layout> </Layout>

View file

@ -1,7 +1,6 @@
--- ---
import Layout from '../../layouts/Layout.astro'; import Layout from '../../layouts/Layout.astro';
import {getCollection} from "astro:content"; import {getCollection} from "astro:content";
import ArticleList from "../../components/ArticleList.astro";
export async function getStaticPaths() { export async function getStaticPaths() {
const allPosts = await getCollection('posts'); const allPosts = await getCollection('posts');
@ -22,6 +21,11 @@ const { posts } = Astro.props;
<Layout title={`posts tagged with ${tag}`} description={`Posts tagged with ${tag}`}> <Layout title={`posts tagged with ${tag}`} description={`Posts tagged with ${tag}`}>
<h1 class="title">ls ~/blog | grep "{tag}"</h1> <h1 class="title">ls ~/blog | grep "{tag}"</h1>
<ul> <ul>
<ArticleList posts={posts} displayDate={true} /> {posts.map((post: any) =>
<p>
<span class="list-date">{new Date(post.data.pubDate).toISOString().split('T')[0]}</span>
<a href={`/blog/${post.slug}`}>{post.data.title}</a>
</p>
)}
</ul> </ul>
</Layout> </Layout>