You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

8.8 KiB

jam-my-stack 🥞

A set of simple IndieWeb Jamstack publishing syndication tools

Published at

npm version

These simple scripts enrich your Jamstack-site by adding/manipulating/whatever (meta)data, such as extra posts, indexing, and so forth. A primary example of these tools in action is my own site - inspect how it's used at

Are you looking for a way to receive webmentions? See !

The tools


  1. yarn add jam-my-stack
  2. const { mastodon, goodreads } = require('jam-my-stack')

1. Mastodon

1.1 parseFeed

An async function that parses your Fediverse-compatible feed (Mastodon/Pleroma/...) and converts entries to .md Markdown files for your Jamstack to enjoy.

Usage example:

    await mastodon.parseFeed({
        notesdir: `${__dirname}/content/notes`,
        url: "",
        utcOffset: 60,
        titleCount: 50,
        titlePrefix: "Note: "

Options and their default values:

  • utcOffset: 60 (= GMT+1, that's where I am!) (in minutes, see day.js docs
  • titleCount: 50. Will add "..." and trim if title length bigger.
  • titlePrefix: "". Will add before title (e.g. "Note: ")
  • ignoreReplies: false. If true, will not process in-reply-to items.

Note that this does not delete the notes dir with every call. It simply checks if there isn't already a file with the same name (based on the publication date), and adds one if not.

Example feed entry:

  <title>I pulled the Google plug and installed LineageOS:</title>
  <content type="html">I pulled the Google plug and installed LineageOS: &lt;a href=&quot;; rel=&quot;ugc&quot;&gt;;/a&gt; Very impressed so far! Also rely on my own CalDAV server to replace GCalendar. Any others here running &lt;a class=&quot;hashtag&quot; data-tag=&quot;lineageos&quot; href=&quot;; rel=&quot;tag ugc&quot;&gt;#lineageos&lt;/a&gt; for privacy reasons?</content>
  <ostatus:conversation ref="">
  <link href="" rel="ostatus:conversation"/>
    <link type="application/atom+xml" href='' rel="self"/>
    <link type="text/html" href='' rel="alternate"/>
    <category term="lineageos"></category>
      <link rel="mentioned" ostatus:object-type="" href=""/>
        <link rel="mentioned" ostatus:object-type="" href=""/>

This generates the file (it assumes UTC times in the feed and adjusts according to specified utcOffset, such as GMT+1 in this example), with contents:

source: ""
title: "I pulled the Google plug and installed LineageOS:"
date: "2021-03-01T19:03:35"

I pulled the Google plug and installed LineageOS: <a href="" rel="ugc"></a> Very impressed so far! Also rely on my own CalDAV server to replace GCalendar. Any others here running <a class="hashtag" data-tag="lineageos" href="" rel="tag ugc">#lineageos</a> for privacy reasons?

See implementation for more details and features.

Also parsers:

  • <link rel="enclosure"/> image types (see render-enclosures.ejs) ejs template, that is appended to the Markdown file if any are found. Styling is up to you...
  • ... @ hi there - this is a in-reply-to toot which adds context frontmatter, so your html renderer can use the correct IndieWeb classes. This should also enable webmention sending since you mention the URL. If you "at" a valid Mastodon user, it will automatically do this.

2. Goodreads

2.1 createWidget

An async function that reads and modifies Goodreads JS widget embed code, converting low-res book covers to hi-res ones if possible. This omits possible Goodread cookies and cross-domain mishaps.

Usage example:

    const widget = await goodreads.createWidget("'s%20bookshelf:%20read?cover_size=medium&hide_link=&hide_title=&num_books=12&order=d&shelf=read&sort=date_added&widget_id=1496758344")
    await fsp.writeFile(`${__dirname}/static/js/goodreads.js`, widget, 'utf-8')

3. Lunr

As of version 1.0.30, Lunr functionality was removed in favor of

With Pagefind, there's no need to integrate it into jam-my-stack, greatly simplifying things and reducing the index file size.

4. Howlongtobeat

4.1 howlong

Adds game length (MainGame) and an ID to your front matter (keys howlongtobeat_id and howlongtobeat_hrs), provided you first added a property called game_name. (This gets substituted).

It also downloads a thumbnail of the cover image as cover.jpg in the same relative directory as the source article if you provided the dir as an option. The downloaded thumbnail is automatically optimized for the web using mogrify (this will emit a warning if you do not have ImageMagick installed locally).

So, Frontmatter like this:

title: Diablo 3 my Review
game_name: Diablo 3

Gets subsituted by something like this:

title: Diablo 3 my Review
howlongtobeat_id: 62129
howlongtobeat_hrs: 20.5

In your Hugo template, add a link to{howlongtobeat_id} and you're all set!

Usage example:

  await howlong({
    postDir: `${__dirname}/content`,
    downloadDir: `${__dirname}/static`)

It will print out games and metadata it found.

Suppose the above lives in content/games/switch/diablo-3, then a cover.jpg will be automatically downloaded in static/games/switch/diablo-3/ and that directory will be created if not yet existing.

Working example: (on the left side). Check out the Hugo template to use the properties at

5. Webmentions

In cooperation with

5.1 getWebmentions

Calls the get webmention endpoint, sorts by date, adds metadata such as relative date (relativeTarget, property), and returns data. Could be written in a data folder for Hugo to parse, for example.

Parameters: first domain, second the config for the endpoint and token. Usage example:

await getWebmentions("", {
  endpoint: '',
  token: 'lol'

5.1 send

Calls the set webmention endpoint using a PUT. Based on the RSS feed, see the go-jamming README.

Same as getWebmentions.

6. YouTube

Thanks to ideas from and his script. This downloads a thumbnail using youtube-dl, smacks a play button on it using convert, and stores that in the specified folder. Use in conjunction with a Hugo shortcode to get rid of YouTube's iframes!

This method will fail if you do not have ImageMagick installed locally.

Usage example:

  await download({
    postDir: 'somewhere/posts',
    downloadDir: 'static/youtube-thumbs',
    overlayImg: 'playbtn.png'

It scans all .md files in the posts dir for {{< youtube xxx >}} shortcodes.