Everything Old is New Again

Oct 3, 2020   ◦   ~ 1,350 words / 7 minutes   ◦   thoughts tech

Worldwide pandemic. Extreme political incompetence. Election tampering. Partisan stupidity. There are tons of things I could write about. But instead of those blood-pressure inflating topics, how about something completely different?

I reworked my blog. Let’s talk about that.1

Static Site Generator (still)

Previously, my site was built with Metalsmith and Handlebars. Parts of which I really liked. Specifically, I like Metalsmith. Over time, most of the third-party plugins I used have migrated to custom plugins2. And that process was nice and easy. But Handlebars… Man. The whole layout/partials discovery is just a mess compared to the nice component style that I’m used to. Plus, I’ve never been a fan of templates like that because there’s generally no tooling support. Things just inexplicably fail to work.

I briefly looked into using React for templates, but that wasn’t ideal. Or, more precisely, it was hacky as hell.

A few weeks ago, I came across an article by Jim Nielsen about using tagged template literals to render HTML. The more I thought about it, the more I liked it. For this purpose, anyway.

I took his notion of using tagged template literals as a ‘component’3 and added a few more things on top to make it feel a little more familiar. I like single-file components, so I added functions for registering global CSS and client-side JS. Also, simple Context support and hook-like functions.

So after some iterations, here’s an example of what a simple component looks like:

import { css, html, registerStyles } from "../../packages/tilt/index.js"

export function Tag({ tag, selected }, children) {
  const tagName = children || tag.name
  const cls = (tag.slug == 'draft')
    ? 'bd-error text-error'
    : ''

  return html`
    <a href="/topics/${tag.slug}" class="Tag ${cls} ${selected && 'is-selected'}">
      ${tagName}
    </a>
  `
}

export default Tag

registerStyles(Tag, css`
  .Tag.is-selected {
    background: dodgerblue;
    color: white;
  }
`)

Nice simple HTML “component” there, pal, but what about when you need info from the site metadata or the current page?

OK, smartass. Here’s the theme/components/Nav.js component, it uses both of the things you mentioned. If you’re familiar with React (or it’s ilk), you’ll notice that it looks like it’s using hooks… Well, we’ll get to that. Hold your horses.

import { css, html, registerScript, registerStyles, usePage, useSite } from "../../packages/tilt/index.js"

export function Nav(props, children) {
  const site = useSite()
  const page = usePage()
  const hasTitle = page.title && page.title != ""

  return html`
    <nav class="nav Nav">
      <div class="nav-left">
        ${Link({ href: '/', cls: 'site-title', page }, html`
          <img src="/media/images/site/Small.png" alt="${site.title}">
          <span>${page.link !== "/" ? site.title : ""}</span>
        `)}
        ${hasTitle && html`
          ${Breadcrumbs({ page })}
          ${Divider()}
          <a class="page-title">${page.title}</a>
        `}
      </div>
      <div class="nav-right">
        ${Link({ href: '/storytime/', page }, 'Stories')}
        ${Link({ href: '/posts/', page }, 'Articles')}
        ${Link({ href: '/pages/mailing-list/', page }, 'Newsletter')}
        ${Link({ href: '/pages/about/', page }, 'About')}
        <a onclick="toggleTheme()">🌗</a>
        &nbsp;
      </div>
    </nav>
  `
}

const Link = ({ href, page, cls = '' }, children) => html`
  <a class="${page.link == href ? 'active' : 'inactive'} ${cls}" href="${href}">
    ${children}
  </a>
`

const Divider = (props, children) => html`
  <div class="Divider"></div>
`

function Breadcrumbs({ page }, children) {
  const hasTrail = 'categories' in page && page.categories.length
  if (!hasTrail) return ''

  let href = '/taxonomy/#'
  const main_category = page.categories[0]

  return html`
    ${main_category.map(cat => {
      href += `/${cat.slug}`
      return html`
          ${Divider()}
          ${Link({ href, page }, cat.name)}
        `
    })}
  `
}

// This script gets collected into a `/theme/js/generated.js` file
registerScript(Nav, html`<script>
  function toggleTheme() {
    const cl = document.documentElement.classList
    if(cl.contains('theme-dark')) {
      cl.remove('theme-dark')
      cl.add('theme-light')
      localStorage.setItem('selected-theme', 'theme-light')
    }
    else {
      cl.remove('theme-light')
      cl.add('theme-dark')
      localStorage.setItem('selected-theme', 'theme-dark')
    }
  }
</script>`)

// These styles get collected into a `/theme/css/generated.css` file
registerStyles(Nav, css`
  .Nav {
    position: fixed;
    background: var(--color-lightGrey);
    top: 0px;
    left: 0px;
    right: 0px;
    box-shadow: 0px 2px 2px rgba(0,0,0, 0.3);
    z-index: 10;
    white-space: nowrap;
    padding-top: 3px;
    flex-wrap: wrap;
  }
  .Nav a {
    padding: 1rem 1.25rem;
    border-bottom: 3px solid var(--color-lightGrey);
  }
  .Nav a.active {
    color: var(--color-primary);
    border-color: var(--color-primary);
  }
  .Nav a.inactive {
    color: var(--color-font);
  }
  .Nav a.page-title {
    font-weight: 900;
    white-space: wrap;
  }
  .Nav a.page-title:hover {
    opacity: 1;
  }
  .Nav a.site-title {
    font-weight: 100;
    font-size: 1em;
  }
  .Nav a.site-title img {

  }
  .Nav a.site-title span{
    padding-left: 1em;  
  }

  .Nav .Divider {
    text-decoration: none;
    display: flex;
    align-items: center;
    opacity: 0.3;
    padding: 0; 
    color: var(--color-darkGrey);
  }

  .Nav .nav-left { flex: unset; }
  .Nav .nav-right { flex: 1; }

  @media (max-width: 415px)  {
    .Nav { font-size: 75%;  }
    .Nav a { padding: 0.75rem; }
  }
`)

export default Nav

The “use” functions, the hook-like functions, are actually using the simplest (and dumbest) implementation of a Context ever.

let _stack = [{}]

export function getContext(key) {
  return _stack[0][key]
}

export function setContext(key, value) {
  _stack[0][key] = value
}

export function inNestedContext(fn) {
  _pushContextStack()
  const results = fn()
  _popContextStack()
  return results
}


export function _pushContextStack() {
  _stack.unshift(Object.create(_stack[0]))
}

export function _popContextStack() {
  if (_stack.length === 1) return // Don't remove the root context
  _stack.shift()
}

My ‘hooks’ are just grabbing data from the context:

/** @type {Page} */
function usePage() {
  return getContext('page')
}
// Helper method so I don't typo the key name
usePage.set = (val) => setContext('page', val)

Any content file that ends with .tilt.js is treated as a simple component; its output is set as the page.contents. Useful for, say, /feed.xmlSee that code here.

I also added support for what I call generators.

The only difference between generators and simple components is that the generator gets removed from the output and returns an object containing new files to merge into the current output tree.

I use this feature for dynamically creating the tag pages.

import { css, html, registerStyles, usePage, useTaxonomy } from "../packages/tilt/index.js"
import PostListItem from "../theme/components/PostListItem.js"
import Page from "../theme/layouts/Page.js"

export const frontmatter = {
  generator: true,
}

// Generators return a new fileset...
export default function TopicsGenerator(props, children) {
  const tax = useTaxonomy()

  return tax.tagList.reduce((files, tag) => {
    const filepath = `topics/${tag.slug}/index.html`
    files[filepath] = {
      filepath,
      contents: TopicPage({ tag, tags: tax.tagList })
    }
    return files
  }, {})
}


function TopicPage({ tag, tags }, children) {
  usePage.set({
    title: `Tag: ${tag.name}`,
    image: '/media/images/covers/old-books.jpg'
  })
  return Page({ cls: 'TopicPage' }, html`
    <div class="row">
      <div class="col-8 PostListColumn">

      ${tag.pages.map(page => html`
        ${PostListItem({ post: page })}
      `)}
      
      </div>
      <div class="col TagColumn">
        
        <h3>Tags</h3>
        ${tags.map(t => html`
          ${Tag({ tag:t, selected: tag.name === t.name })}
        `)}

      </div>
    </div>
  `)
}

registerStyles(TopicPage, css`
  .TopicPage .TagColumn .tag {
    margin-bottom: 1rem;
  }
  .TopicPage .TagColumn .tag.selected {
    background: dodgerblue;
    color: silver !important;
    cursor: default;
  }
  .TopicPage .TagColumn .tag.selected a {
    background: dodgerblue;
    color: white!important;
    cursor: default;
  }
  .TopicPage .TagColumn .tag + .tag {
    margin-left: 0;
    margin-right: 1rem;
  }
`)

CSS

I was using Bulma in the previous iteration. But hacking in a dark theme was a pain. So I’ve moved over to Chota, a tiny CSS framework that leverages CSS variables. Thus, I can now easily support a switchable light/dark theme. If you have a system setting, it will use that. Otherwise, it defaults to dark. You can toggle it by clicking the little square in the header.

Tilt

I wrapped all this into a local package called tilt. Eventually I’ll probably clean it up a bit and put it on GitHub. I’ll definitely want to use this same stack for creating my upcoming author website. 😬

In the meantime, if you have any questions or want me to hurry up and put it on GH, hit me up in the comments or on twitter. Or Github, I guess.


Technologies in use:

  • Build
    • Node – JavaScript runtime built on Chrome’s V8 JavaScript engine
    • Metalsmith – An extremely simple, pluggable static site generator
    • markdown-it – Fast and easy to extend Markdown parser
    • highlight-js – Syntax highlighting for the Web
    • lodash – JavaScript utility library
  • Client
    • Echo – Lazy loading images
    • Fountain – Rendering screenplay formats
    • Hybrids – Small UI library for creating web components
      • Used in my custom tags: mc-spoilers, mc-slideshow, mc-code, and mc-book
    • Svelte – Cybernetically enhanced web apps
      • Used in my custom tags: comic-viewer and mc-gallery
    • chota.css – Tiny CSS framework
    • atom-one-dark – Code highlighting themes

  1. A blog about a blog. How recursive. ↩︎

  2. With the notable exceptions of:

    Looks like metalsmith-assets is deprecated now, but it still works fine. ↩︎

  3. Technically, I suppose it’s an encapsulated string builder? ↩︎

Previous

Nexus Necrominder
Mar 31, 2020  ○  ~ 300 words / 1 minute