Coder Social home page Coder Social logo

Welcome to Ben Holmes' personal site ๐Ÿ‘‹

Netlify Status Version License: MIT Twitter: bholmesdev

Charcoal self portrait with side text: teacher, blogger, UX freak, fan of charcoal

A statically generated, JAMStack-ified SPA using a custom build tool + 11ty ๐Ÿš€

โœจ Explore the live site

Table of Contents

๐Ÿƒโ€โ™‚๏ธ Running this thing locally

Make sure you have NodeJS installed first. Then, run this terminal command inside the project directory:

npm i
npm start

This will spin up a local development server using Browsersync with live reloading on file changes.

You should also notice a softly thrown exception in your console. This is totally normal! Since blog posts previewed on the homepage are pulled from DEV, you'll need an environment variable to render them properly. You can check out DEV's API docs to pull from your personal account and see what happens ๐Ÿ˜

๐Ÿ† Goals of this project

Well, this certainly ain't your grandma's Gatsby site! This thing is lightweight, framework-free, and full of custom configuration. I built this project with a few goals in mind:

  1. I did not want to lean on existing frameworks to make it work, mostly as a learning exercise. So no React, Vue, Svelte, or even JQuery to be found.
  2. I wanted a single page app feel with sexy page transitions to boot โœจ This was not easy to pull off given the first goal, but not impossible!
  3. I wanted to stay on the bleeding edge of modern browser APIs. So dynamic JS imports, ES modules, CSS grid... it's all fair game.
  4. And lastly, I have the need for speed ๐Ÿš€ Pages (and transitions between those pages) needed to stay crisp, and load times for styles, scripts, and assets should stay low. Preloading is the name of the game here.

๐Ÿ’ช Leaning on 11ty

My first iteration on this portfolio concept didn't use any existing frameworks at all. There were custom solutions for data fetching at build time, layout rendering, JS bundling... well, I reinvented every wheel in existence. This had a nasty consequence: every time I revisited the repo to tweak something, I needed to re-read my own docs to remember how it fit together ๐Ÿ˜ฌ

In the end, it's clear that 11ty can save me from this chaos. It has some nice out-of-the-box features that I can piggyback off of:

From this baseline, I set up a new system for layouts, styles, and JS that works nicely with their (experimental!) addExtension helper. You can check out that configuration in the .eleventy.js file if you're so inclined, but that's beyond the scope of this README ๐Ÿ˜

๐Ÿ—‚ General file structure

Here's a breakdown of the folder hierarchy + naming conventions:

build # Build output from src
assets # Dump for images, fonts, and icons
src # The fun zone ๐Ÿš€
  _data # ๐Ÿ—„ Data globally available to all pages
  _layouts # ๐Ÿ—‚ Templates, styles, and scripts *wrapping around* pages
  _includes # ๐ŸŽ’ Templates and SVG graphics *imported into* pages
  _main.mjs # ๐Ÿงต A magical file that makes the whole app work
  [route-name].* # Templates, styles, and scripts for *actual routes* on the site
utils # Helper JS functions used server and clientside

You'll also notice a general rhythm for all the route-name files: there's a .pug file, a scss file, and an mjs file of the same name.

This is how I "group" all my logic together by route. For instance, contact.scss applies styles to the /contact page, and contact.mjs runs some JavaScript whenever the /contact page loads. We'll explore how this works in the following sections!

๐Ÿ”– The concept of [data-page]

To understand how this system ties together, I'll need to explain one magical attribute: [data-page].

In short, this is an identifier I use to figure out how all my layout chains fit together. This lets me pull off animated page transitions, style scoping, JS scoping, and more!

Here's a simple example. Say I'm writing a post in cool-blog-post.md with a template like this:

---
layout: blog-post
---

# Very cool post!

With text and such.

Then, maybe I'll have a _layouts/blog-post.pug file that looks like this (pardon the pug syntax!):

html
  body
    nav
      a(href="/") Home

    main(data-page=slinkit.page)
    | !{content}

โ˜๏ธ Here we find our first data-page property. This should get applied to the container immediately outside of the content (aka our cool-blog-post). The actual value gets applied by that slinkit.page property, which my build tool passes in for you.

When this page gets built, we'll end up with a file that looks like this:

<html>
  <body>
    <nav>
      <a href="/">Home</a>
    </nav>
    <main data-page="very-cool-post">
      <h1>Very cool post!</h1>
      <p>With text and such.</p>
    </main>
  </body>
</html>

Pretty much what you'd expect! And as you can see, that data-page value is taken straight from the name of the file inside.

This brings us to what this property really is: the value of data-page identifies whatever you're putting inside a given layout.

Layout chaining

This remains true for layouts-within-layouts as well. Say we have a hierarchy like this:

_layouts/
  index.pug
  blog-navigation.pug
  blog-post.pug

very-cool-post.md

Where very-cool-post uses the blog-post layout, which uses the blog-navigation layout, which uses the index layout.

When we snap all these nested layouts together, we might get something like this:

<!--index layout starts at the outermost level -->
<html lang="en-US">
  <head>...</head>
  <body data-page="_layouts/blog-navigation">
    <!--blog-navigation layout starts here -->
    <aside>
      <h2>Neat table of contents</h2>
      <a href="#1">Section 1</a>
      <a href="#2">Section 2</a>
      <a href="#3">Section 3</a>
    </aside>
    <main data-page="_layouts/blog-post">
      <!--blog-post layout starts here -->
      <img src="thumbnail.jpg" alt="...">
      <section data-page="very-cool-post">
        <!--very-cool-post starts here -->
        <h1>Very cool post!</h1>
        <p>With text and such.</p>
      </section>
    </main>
  </body>
</html>

You can think of this like fitting a bunch of lego bricks together, where data-page attributes are those little teeth that hold the bricks together ๐Ÿงฑ

๐Ÿ’จ Page transitions

This feature is one of the main reasons for my "Single Page App" setup. Since I'm not reloading the browser to load a new page, I can apply whatever page transitions I want while loading new content.

As it stands, there's only one page transition across the site: sliding in from the bottom of the screen.

Page transitions clicking between home and contact pages

But as you can see, I only animate in the new page, while the navigation bar stays put. How can I pull this off if I'm not using React or something similar?

Well, it all comes down to the [data-page] property. Let's say we're animating between two pages that both use the same layout. The /build output for these pages might look like this:

<!--about.html-->
<html lang="en-US">
  <head>...</head>
  <body>
    <nav>
      <a href="/about">About Me</a>
      <a href="/contact">Get in touch</a>
    </nav>
    <main data-page="about">
      <h1>All about me</h1>
      <p>I got a lot to say lemme tell ya...</p>
    </main>
  </body>
</html>

<!--contact.html-->
<html lang="en-US">
  <head>...</head>
  <body>
    <nav>
      <a href="/about">About Me</a>
      <a href="/contact">Get in touch</a>
    </nav>
    <main data-page="contact">
      <h1>Get in touch</h1>
      <p>Fill out this shiny form!</p>
      <form>...</form>
    </main>
  </body>
</html>

These pages are obviously identical until we hit that main tag. Inside here, we have some new content to animate into view.

We could walk through the page element-by-element to figure out "what's changed" (similar to how the virtual DOM works in React). But with our data-page attributes hooking our layouts together, there's no need for all that work!

You can explore the layout diff-ing function in the following section, but the major takeaway: ๐Ÿ’ก page transitions will only animate the pieces that change, and ignore the pieces that don't (as far as layouts are concerned anyways).

Layout diffing

Source code here

Here's the multi-step process I use to find what's changed:

  1. Download the next page we're animating to using a fetch call. This is as simple as calling fetch("/about") from JavaScript, and grabbing the output as a big string of HTML.
  2. Find all the [data-page] elements in both a) our current page and b) the page we just downloaded. Just querying page.querySelectorAll('[data-page]'), we'll get all those elements in order from outermost element to innermost.
  3. Walk through the [data-page] elements, and find the first place where they differ. This lets us ignore all the nested layouts that are shared between pages.
  4. Animate between those differing elements ๐ŸŽ‰

So if we had two pages with layout chains like this:

index                     index
blog-navigation           blog-navigation
blog-post                 personal-notes
very-cool-post.md         very-cool-note.md

We'd walk through the nested layouts of each, top to bottom:

  • index vs index โœ… Those look the same
  • blog-navigation vs blog-navigation โœ… Those too
  • blog-post vs personal-notes โœ‹ Oop, those are different!

So, we'd grab the element with data-page="personal-notes", and animate it over the data-page="blog-post" container on our current page.

๐Ÿ’… .scss style scoping

Any files ending in .scss are treated as scoped styles for a given route. No, this isn't achieved with gibberish class hashes like CSS Modules or Styled Components! It's much simpler than this ๐Ÿ˜

For example, if we create some styles like this:

/* about-me.scss */
main {
  background: black;
  color: white;
}

p {
  font-family: 'Comic Sans MS';
}

It'll output a CSS file that looks like this:

[data-page="about-me"] main {
  background: black;
  color: white;
}

[data-page="about-me"] p {
  font-family: 'Comic Sans MS';
}

And that's it! Since our template layouts apply these data-page attributes already, we just use those to scope magically scope our styles.

Layout style scoping

The process is super similar for layouts. For instance, say we wanted to apply some custom styles to all our blog posts using a blog-post layout:

/* _layouts/blog-post.scss */
p {
  font-family: 'Papyrus';
}

code {
  font-family: 'Fira Code';

  span.line-highlighter {
    background: orange;
  }
}

This generates a similar output to our route-based styles:

[data-page="_layouts/blog-post"] p {
  font-family: 'Papyrus';
}

[data-page="_layouts/blog-post"] code {
  font-family: 'Fira Code';
}

[data-page="_layouts/blog-post"] code span.line-highlighter {
  background: orange;
}

๐Ÿ’ก Note: This doesn't scope your styles to the layout template alone! Expect these styles to "cascade" to the page using this layout as well.

This setup is super helpful for debugging your CSS. For instance, say I want to figure out why my fonts are getting overridden on one of my blog posts. Popping open the "styles" tab in my inpsector...

Computed styles in dev tools, showing "blog" styles overriding "_layouts/blog-post" styles

...I immediately know where all my styles are coming from! To fix my problem, I just need to remove that beautiful font family from my blog.scss file ๐Ÿ‘

โš™๏ธ .mjs scripts

Any files ending in mjs are client-side scripts the run whenever a given route is loaded. Here's a simple example:

// about-me.mjs
export default () => {
  // JS that runs on page load, once the page transition finishes
  const onClick = () => console.log('I clicked on something!')
  document.addEventListener('click', onClick)

  return () => {
    // "cleanup" code that runs just before transitioning to the next page
    document.removeEventListener('click', onClick)
  }
}

Unpacking this a bit:

  1. Whenever you visit the /about-me page, we run the default export function you created once that page has animated into view. So, once the page is fully on screen, we'll add the click event listener as shown here.
  2. We'll run the "cleanup" function returned by this function just before loading + transitioning to the next page. Detaching event listeners is a necessary evil! Since we're using clientside routing for everything (aka we never refresh the browser window), the only way to remove these listeners is through manual cleanup like this.

Note: You may notice that none of this code can run during a page transition. This is to keep framerates silky smooth during animations. In the future, page transitions could be configurable enough to run whatever JS you choose!

Scripts on layouts

This works how you might expect! If you want to run some JS on any page using a given layout... just add a .mjs file with the same layout name:

_layouts
  blog-post.pug # Template
  blog-post.mjs # Scoped JavaScript

โš ๏ธ There's just one caveat: even if the next page uses the same layout, the layout script will clean up and re-run from scratch.

Right now, there's no way to run a client script only once if that layout is used across pages. Open to suggestions on how this could work!

The _main.mjs file

Source code here

In short, this is the puppetmaster that makes everything possible. This script gets applied to all pages of the site, and manages some important functionality:

  1. It listens for link clicks across the site and prevents the "default" browser behavior (i.e. instead of refreshing the page, we want to animate the next page into view)
  2. It fetches the HTML for the next page. This is pulled off with a plane ole fetch call for the route you're visiting.
  3. It animates whatever content that's changed. Visit the layout diffing section to understand this process.
  4. It figures out which .mjs scripts to run for a given route. For instance, say we were visiting /about which has both an about.mjs and a layout with its own .mjs file. For this, we'll need to import both of those and execute them after the page transition.
  5. It throws any new styles into the document <head>. In order for our fancy scoped styles to load, we need to fetch that stylesheet and apply it.

...In other words, it does everything I've described in the previous sections ๐Ÿ˜

๐Ÿค Show your support

Give a โญ๏ธ if this project helped you!

This project is still pretty in-flux, so I won't be opening issues for newcomers just yet. Still, if any of my current issues peak your interest or you want to talk shop, feel free to DM me on Twitter or use the contact form on this very site!

โœ Author

๐Ÿ‘ค Ben Holmes


This README was generated with โค๏ธ by readme-md-generator

Ben Holmes's Projects

bholmesdev icon bholmesdev

Version 3 of my personal portfolio. This is heavily under construction using a wacky, custom build tool ๐Ÿ”ง

code-advent-cal-2020 icon code-advent-cal-2020

My Rustastic ๐Ÿฆ€ solutions to the 2020 code advent calendar https://adventofcode.com/2020

docs icon docs

Documentation site for Markdoc

dolphin-audio-visualizer icon dolphin-audio-visualizer

An audio visualization tool for dolphin audio recordings, displaying live annotations and sound clusters

eleventy icon eleventy

A simpler static site generator. An alternative to Jekyll. Transforms a directory of templates (of varying types) into HTML.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.