Last June, I provided code for statically embedding Mastodon content in the Hugo static site generator (SSG). A couple of months later, I followed up with similar code for use in the JavaScript-based Eleventy SSG. In each case, the styling was Sass-based. Then, yesterday, I provided Hugo code using styling from Tailwind CSS; so, today, here is a Tailwind-based version for Eleventy, too.
I will spare you a repetition of the info from the earlier Eleventy-related post about these embeds, so please consult that post as necessary before proceeding.
Other than normal Tailwind itself, the code I present below — which as of Tailwind 3.x produces static embeds with pretty much the same appearance as did the code in the original post — needs a little extra help . . .
First, in your Tailwind configuration, you should install the tailwind-fluid-typography
plugin. It adds a number of fluid-
classes, each of which makes use of CSS’s clamp
function to provide more responsive text sizing. The code makes use of several of these classes.
Second, because of a bit of the CSS that Mastodon content includes, you’ll need to compensate with a @layer
addition after bringing in1 Tailwind’s own regular styling:
/*
This is your project's main CSS file.
*/
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.toot-content {
@apply text-gray-900 dark:text-gray-100;
& .invisible{
@apply text-[0pt] leading-[0] block w-0 h-0;
}
& .ellipsis::after {
content: "...";
}
}
}
Finally, as was true for the Eleventy code from the earlier post, this code assumes you have the eleventy-fetch
, luxon
, and md5
packages installed in your project.
With all of that understood, here’s the Eleventy shortcode I call stoot.js
(not stoot.njk
, as I erroneously called it in the original version of this post):
stoot.js
const EleventyFetch = require("@11ty/eleventy-fetch")
const md5 = require('md5')
const { DateTime } = require("luxon")
module.exports = async (instance, id) => {
let stringToRet = ``
let tootLink, handleInst, mediaMD5, urlToGet, mediaStuff, videoStuff, gifvStuff, cardStuff, pollStuff = ''
let imageCount, votesCount = 0
urlToGet = `https://` + instance + `/api/v1/statuses/` + id
async function GetToot(tootURL) {
const response = await EleventyFetch(tootURL, {
duration: "2w",
type: "json"
});
return response
}
let Json = await GetToot(urlToGet);
if (Json.account) {
tootLink = `https://` + instance + `@` + Json.account.acct + `/status/` + id
handleInst = `@` + Json.account.acct + `@` + instance
}
if (Json.media_attachments.length !== 0) {
mediaMD5 = md5(Json.media_attachments[0].url)
Json.media_attachments.forEach((type) => {
if (Json.media_attachments[0].type == "image") {
imageCount = ++imageCount;
}
})
Json.media_attachments.forEach((type, meta) => {
if (Json.media_attachments[0].type == "image") {
mediaStuff = ``;
mediaStuff = mediaStuff + `<div class="mt-2 rounded-xl overflow-hidden grid grid-cols-1 gap-[2px]"><style>.img-${mediaMD5} {aspect-ratio: ${Json.media_attachments[0].meta.original.width} / ${Json.media_attachments[0].meta.original.height}}</style>`;
mediaStuff = mediaStuff + `<img src="${Json.media_attachments[0].url}" alt="Image ${Json.media_attachments[0].id} from toot ${id} on ${instance}" class="tweet-media-img img-${mediaMD5}`;
if (Json.sensitive) {
mediaStuff = mediaStuff + ` blur-2xl relative`;
}
mediaStuff = mediaStuff + `" loading="lazy"`;
if (Json.sensitive) {
mediaStuff = mediaStuff + ` onclick="this.classList.toggle('!blur-none !z-[9999] relative')"`;
}
mediaStuff = mediaStuff + `/>`;
if (Json.sensitive) {
mediaStuff = mediaStuff + `<div class="absolute font-bold w-full top-[40%] text-white text-center text-2xl leading-tight">Sensitive content<br />(flagged at origin)</div>`;
}
mediaStuff = mediaStuff + `</div>`;
}
if (Json.media_attachments[0].type == "video") {
videoStuff = ``;
videoStuff = videoStuff + `<style>.img-${mediaMD5} {aspect-ratio: ${Json.media_attachments[0].meta.original.width} / ${Json.media_attachments[0].meta.original.height}}</style>`;
videoStuff = videoStuff + `<div class="text-center mt-2 rounded-xl overflow-hidden grid grid-cols-1 gap-[2px]"><video muted playsinline controls class="text-center w-full h-auto aspect-square object-cover img-${mediaMD5}`;
if (Json.sensitive) {
videoStuff = videoStuff + ` blur-2xl relative`;
}
videoStuff = videoStuff + `"`;
if (Json.sensitive) {
videoStuff = videoStuff + ` onclick="this.classList.toggle('!blur-none !z-[9999] relative')"`;
}
videoStuff = videoStuff + `><source src="${Json.media_attachments[0].url}"><p class="fluid-xs text-center">(Your browser doesn’t support the <code>video</code> tag.)</p></video>`;
if (Json.sensitive) {
videoStuff = videoStuff + `<div class="absolute font-bold w-full top-[40%] text-white text-center text-2xl leading-tight">Sensitive content<br />(flagged at origin)</div>`;
}
videoStuff = videoStuff + `</div>`
}
if (Json.media_attachments[0].type == "gifv") {
gifvStuff = ``;
gifvStuff = gifvStuff + `<style>.img-${mediaMD5} {aspect-ratio: ${Json.media_attachments[0].meta.original.width} / ${Json.media_attachments[0].meta.original.height}}</style>`;
gifvStuff = gifvStuff + `<div class="text-center mt-2 rounded-xl overflow-hidden grid grid-cols-1 gap-[2px]"><video loop autoplay muted playsinline controls controlslist="nofullscreen" class="text-center w-full h-auto aspect-square object-cover img-${mediaMD5}`;
if (Json.sensitive) {
gifvStuff = gifvStuff + ` blur-2xl relative`;
}
gifvStuff = gifvStuff + `"`;
if (Json.sensitive) {
gifvStuff = gifvStuff + ` onclick="this.classList.toggle('!blur-none !z-[9999] relative')"`;
}
gifvStuff = gifvStuff + `><source src="${Json.media_attachments[0].url}"><p class="fluid-xs text-center">(Your browser doesn’t support the <code>video</code> tag.)</p></video>`;
if (Json.sensitive) {
gifvStuff = gifvStuff + `<div class=""absolute font-bold w-full top-[40%] text-white text-center text-2xl leading-tight">Sensitive content<br />(flagged at origin)</div>`;
}
gifvStuff = gifvStuff + `</div>`
}
})
if(Json.card !== null) {
cardStuff = ``;
cardStuff = cardStuff + `<a href="${Json.card.url}" class="no-underline decoration-transparent text-gray-700 dark:text-gray-300" rel="noopener"><div class="relative md:flex border border-gray-700 dark:border-gray-200 rounded-md mt-4 decoration-transparent overflow-hidden"><div class="flex-100 md:flex-200 relative"><img src="${Json.card.image}" alt="Card image from ${instance} toot ${id}" loading="lazy" class="block m-0 w-full h-full object-cover bg-cover bg-[50%]" /></div><div class="flex-auto overflow-hidden p-3 leading-normal"><p class="font-bold fluid-sm !tracking-normal !leading-normal">${Json.card.title}</p><p class="fluid-xs !leading-normal !tracking-normal">${Json.card.description}</p></div></div></a>`;
}
if (Json.poll !== null) {
votesCount = Json.poll.votes_count;
let pollIterator = 0;
pollStuff = ``;
pollStuff = pollStuff + `<div class="grid grid-cols-[3.5em 0.5fr 1fr] gap-[1em]leading-none">`;
Json.poll.options.forEach(( options ) => {
pollStuff = pollStuff + `<div class="col-start-1 text-right"><strong>${((Json.poll.options[pollIterator].votes_count)/(votesCount)).toLocaleString("en", {style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1})}</strong></div><div class="col-start-2"><meter class="w-full" id="vote-count" max="${votesCount}" value=${Json.poll.options[pollIterator].votes_count}></meter></div><div class="col-start-3">${Json.poll.options[pollIterator].title}</div>`;
pollIterator = ++pollIterator;
})
pollStuff = pollStuff + `</div><p class="fluid-xs pt-4">${votesCount} people</p>`;
}
if (Json.content) {
stringToRet = `<blockquote class="fluid-base mx-auto my-auto p-4 border-2 border-gray-700 dark:border-gray-200 rounded-xl bg-white dark:bg-gray-900 w-full md:w-[80%]" cite="${tootLink}" data-pagefind-ignore>
<div class="flex">
<a class="mr-2 flex-shrink-0 no-underline" href="https://${instance}/@${Json.account.acct}" rel="noopener"><img class="w-[48px] h-auto rounded-full" src="${Json.account.avatar}" alt="Mastodon avatar for ${handleInst}" loading="lazy" /></a>
<div class="flex flex-col flex-grow">
<a class="font-bold text-black dark:text-white fluid-sm lg:fluid-base !tracking-normal no-underline" href="https://${instance}/@${Json.account.acct}" rel="noopener">${Json.account.display_name}</a>
<a class="text-gray-700 dark:text-gray-200 fluid-xs lg:fluid-sm !leading-none !tracking-normal no-underline" href="https://${instance}/@${Json.account.acct}" rel="noopener">${handleInst}</a>
</div>
</div>
<div class="toot-content py-4">${Json.content}</div>`
if (mediaStuff) {
stringToRet += `<div>${mediaStuff}</div>`
}
if (videoStuff) {
stringToRet += `<div>${videoStuff}</div>`
}
if (gifvStuff) {
stringToRet += `<div>${gifvStuff}</div>`
}
if (cardStuff) {
stringToRet += `<div>${cardStuff}</div>`
}
if (pollStuff) {
stringToRet += `<div>${pollStuff}</div>`
}
let timeToFormat = Json.created_at
let formattedTime = DateTime.fromISO(timeToFormat, { zone: "utc" }).toFormat("h:mm a • MMMM d, yyyy")
stringToRet += `<div class="mt-4 flex items-center text-gray-500 dark:text-gray-300 fluid-sm !tracking-normal">
<a href="https://${instance}/@${Json.account.acct}/${Json.id}" class="text-gray-600 dark:text-gray-300 no-underline" rel="noopener">${formattedTime}</a> <span class="fluid-xs">(UTC)</span>
</div>
</blockquote>`
}
return stringToRet
}
If you’re using
@import
to bring in other CSS files in addition to the Tailwind styling, that’ll be necessary for the Tailwind stuff as well. ↩︎
Latest commit (ddfbbdb6
) for page file:
2023-09-22 at 10:57:57 AM CDT.
Page history