This is the personal website of Brian Anders — an Engineering Manager, YouTuber, Podcaster, and Musician. The site is a custom-built Node.js static site generator that produces pages from EJS templates, SCSS stylesheets, and Browserify-bundled JavaScript. Content is primarily posts and experiments — interactive demos, code explorations, music data visualizations, and personal projects — rather than traditional blog articles.
| Environment | URL |
|---|---|
| Production | https://briananders.com |
| Staging | http://staging.briananders.com.s3-website-us-east-1.amazonaws.com |
| Local dev | http://localhost:3000 |
| Repository | Purpose |
|---|---|
| briananders/briananders.com | This repo — the main website |
| briananders/briananders.com-visual-diffs | Visual regression testing (git submodule at visual-diffs/) |
| briananders/sublime-text-dublicate | VSCode extension: Sublime Duplicate Text |
| briananders/pageweight | NPM CLI tool for measuring webpage weight |
| briananders/two-way-merge | NPM CLI tool for two-way directory sync |
These paths are preserved during deploys via s3-upload-allowlist.json and are not built from this repo. In local dev, they are proxied from the staging S3 bucket.
| S3 Path | Description | Consumer |
|---|---|---|
/last-fm-history/ |
Pre-processed Last.fm scrobble data (JSON files + images) for the Music Listening History page | src/js/posts/last-fm-scrobbles.js |
/band-news/ |
Aggregated music news articles matching followed bands (articles.json) |
src/js/posts/music-news.js |
/data/ |
Static JSON datasets (word lists for Wordle/Wordscapes solvers) | Various post scripts |
/movies/ |
Movie/IMDb ratings data | src/js/posts/imdb-ratings.js |
The site also calls the Last.fm API directly from the browser for the real-time scrobbles page:
- API endpoint:
https://ws.audioscrobbler.com/2.0/ - User:
imbanders - Configuration:
src/js/_modules/last-fm/config.js
| Layer | Technology |
|---|---|
| Runtime | Node.js 22.21.0 (see .nvmrc) — must use nvm |
| Templates | EJS with gray-matter YAML front matter |
| Styles | SCSS (Dart Sass via sass package), minified with CleanCSS |
| JavaScript | CommonJS modules bundled with Browserify + Babel (preset-env, preset-react) |
| Hosting | AWS S3 + CloudFront |
| Deployment | Custom s3-uploader.js using AWS SDK v3 (@aws-sdk/client-s3, @aws-sdk/lib-storage, @aws-sdk/client-cloudfront) |
| Dev server | Express with express-static and http-proxy-middleware |
| Linting | ESLint 8 (airbnb-base config — pinned to v8, airbnb does not support ESLint 9/10) |
| Build optimization | HTML minification (html-minifier-terser), JS minification (uglify-js), SVG optimization (svgo), WebP/AVIF conversion (sharp), gzip compression, content-hash asset naming (xxhash) |
| Analytics | Google Tag Manager (production only) |
├── index.js # Main entry: build pipeline + dev server
├── s3-uploader.js # AWS S3 deploy script
├── s3-upload-allowlist.json # S3 paths preserved during deploy
├── scaffold.js # New page scaffolding tool
├── preview-production.js # Serve production build locally
├── package.json
├── .nvmrc # Node 22.21.0
├── .eslintrc.json # Airbnb-base ESLint config
│
├── build/ # Build pipeline (all files have full JSDoc as of March 2026)
│ ├── bundlers/
│ │ ├── bundle-ejs.js # EJS → HTML renderer (two-pass: template then layout)
│ │ ├── bundle-js.js # Browserify + Babelify bundler; watchify in dev
│ │ ├── bundle-scss.js # Dart Sass compiler + CleanCSS minifier
│ │ └── sitemap.js # Renders sitemap.json.ejs + sitemap.xml.ejs
│ ├── constants/
│ │ ├── build-events.js # String constants for all EventEmitter event names
│ │ ├── completion-flags.js # Shared mutable booleans tracking stage completion
│ │ ├── directories.js # Path factory (package/ vs golden/ depending on mode)
│ │ ├── file-formats.js # Image/video extension lists and raster format lists
│ │ └── site-data.js # Author metadata, social links, domain, commitHash
│ ├── helpers/
│ │ ├── check-done.js # Gate: exits process when all completion flags are true
│ │ ├── clean.js # Empties the output directory (returns Promise)
│ │ ├── ejs-functions.js # 15 template helper functions available in all EJS templates
│ │ ├── exit-message.js # ASCII-art success banner printed on build completion
│ │ ├── generate-build-txt.js # Writes build.txt (date + commit hash) to output dir
│ │ ├── production.js # Boolean: NODE_ENV === 'production'
│ │ └── timestamp.js # Returns grey [HH:MM:SS] stamp for build log lines
│ ├── hashing/
│ │ ├── asset-hashing.js # XXHash JS files + image/video files; emits two events
│ │ ├── finish-hashing.js # Rewrites HTML+JSON to reference hashed filenames
│ │ ├── hash-css.js # XXHash CSS files (runs after CSS is updated with image hashes)
│ │ └── update-css-with-image-hashes.js # Rewrites CSS url() before CSS is hashed
│ ├── optimize/
│ │ ├── convert-to-webp.js # Sharp WebP conversion
│ │ ├── convert-to-avif.js # Sharp AVIF conversion at quality 80
│ │ ├── gzip-files.js # zlib gzip on html/xml/css/js/txt/json
│ │ ├── minify-html.js # html-minifier-terser (async minify())
│ │ ├── minify-js.js # UglifyJS
│ │ └── optimize-svgs.js # svgo preset-default; getSVG() for inline use
│ ├── move-assets.js # Copies images (SVG→SVGO, raster→WebP+AVIF+copy), videos, txt, downloads
│ ├── page-mapping-data.js # Front-matter index compiled from all EJS templates
│ ├── prod-builder.js # Production event listener wiring
│ ├── preview-builder.js # Dev mode: chokidar watchers + previewReady gate
│ └── golden-builder.js # Golden build: minify only, hashing/gzip flags pre-set
│
├── src/
│ ├── templates/ # EJS page templates (one per output page)
│ │ ├── index.html.ejs # Homepage
│ │ ├── about.ejs # About page
│ │ ├── drums.ejs # Banders Drums page
│ │ ├── 404.html.ejs # 404 page
│ │ ├── posts/ # Blog-style posts and experiments
│ │ └── example/ # Example/demo pages
│ ├── partials/ # Reusable EJS partials (header, footer, nav, etc.)
│ ├── layout/ # Layout templates (base.ejs, blank.ejs, empty.ejs)
│ ├── styles/ # SCSS source
│ │ ├── system/ # Design system (variables, mixins, functions)
│ │ │ ├── _utilities.scss # @forward aggregator — import this in all consumer files
│ │ │ ├── variables/ # Colors, fonts, viewports, spacing, grid (all.scss aggregates)
│ │ │ ├── mixins/ # Media queries, typography, elements, grid, colors (all.scss aggregates)
│ │ │ └── functions/ # Sass utility functions (all.scss aggregates)
│ │ ├── modules/ # Shared module styles
│ │ └── posts/ # Per-post styles
│ ├── js/ # JavaScript source
│ │ ├── _modules/ # Shared modules (analytics, dark-mode, etc.)
│ │ ├── _components/ # Reusable components (album-listing, year-listing, etc.)
│ │ └── posts/ # Per-post entry scripts
│ ├── images/ # Source images
│ ├── videos/ # Source videos
│ ├── data/ # Static JSON data files
│ ├── downloads/ # Downloadable files
│ ├── sitemap.json.ejs # Sitemap template (receives pageMappingData + siteData)
│ ├── sitemap.xml.ejs # Sitemap XML template
│ ├── robots.txt
│ └── humans.txt
│
├── .github/workflows/ # CI/CD
│ ├── deploy-production.yml # Push to main → build → deploy → tag
│ ├── deploy-staging.yml # Push to staging → build → deploy
│ ├── build-validation.yml # PRs → build + test
│ ├── claude-code-review.yml # AI-powered PR reviews
│ └── claude.yml # Claude workflow
│
├── test/ # Tests (build.test.mjs, golden.test.mjs)
├── scaffold/ # Templates for `npm run scaffold`
└── visual-diffs/ # Git submodule for visual regression
Each page is an EJS template in src/templates/ with YAML front matter:
---
title: "Page Title"
description: "Page description shown at the top and in meta tags."
date: 2024-12-31
priority: 0.8
pageClasses:
- 'page-class-name'
layout: base
styles:
- 'main'
- 'posts/page-name'
scripts:
- 'posts/page-name'
---- Layout is required:
base(standard with header/footer),blank(HTML shell only), orempty - Styles array references SCSS files in
src/styles/(without extension) - Scripts array references JS entry files in
src/js/(without extension) - Output paths are derived from template paths:
posts/my-post.ejs→/posts/my-post/index.html - Files named
*.html.ejsoutput directly (e.g.,index.html.ejs→index.html) - Templates prefixed with
_are excluded from the build
npm run scaffold -- --path=/posts/my-new-postCreates three files: the EJS template, SCSS stylesheet, and JS entry with boilerplate.
Available in all templates via build/helpers/ejs-functions.js:
| Helper | Description |
|---|---|
partial(name, data) |
Include an EJS partial from src/partials/ |
img({ src, alt, classes, width, height }) |
<img> tag with auto-dimensions from image-size |
lazyImage({ src, alt, classes }) |
Lazy-loaded image with inline SVG placeholder |
lazyVideo({ srcs, placeholders, attributes }) |
Lazy-loaded responsive video with aspect-ratio box |
code(block, { language }) |
Syntax-highlighted <pre><code> block (highlight.js) |
link(str, locals) |
<a> tag with automatic external/rel handling |
inlineLink(str, { href }) |
Inline anchor (.inline-link) |
blockLink(str, { href }) |
Block nav link with > arrow (.block-link) |
cardLink(str, { href }) |
Card-style link (.card-link) |
buttonLink(str, { href }) |
Button-style link (.button) |
formattedDate(dateString) |
Formats as YYYY-MM-DD |
getChildPages(parentPath) |
Returns direct children from pageMappingData |
defaultLastFMModule(albums) |
Last.fm loading placeholder markup |
inlineScss(src) |
Compiles SCSS file to CSS string for inline <style> use |
getFileContents(src) |
Returns file as string; SVGs are run through svgo first |
dasherize(str) |
fooBar → foo-bar |
camelize(str) |
foo-bar → fooBar |
Important: img(), lazyImage(), and lazyVideo() read from the output directory (dir.package) not the source. This is why bundleEJS cannot start until both imagesMoved and videosMoved flags are true.
- CommonJS modules (
require/module.exports), bundled by Browserify - Underscore-prefixed directories (
_modules/,_components/) are shared/private — not entry points - Entry scripts (no underscore) in
src/js/orsrc/js/posts/map to pagescriptsfront matter document-readymodule wraps DOM-ready callbacks:require('../_modules/document-ready').document(() => { ... })
- Use
@use "system/utilities" as *at the top of every SCSS file (migrated from@importin March 2026) - Design system variables in
src/styles/system/variables/(colors, fonts, viewports, spacing, grid) - Mixins in
src/styles/system/mixins/(media queries, typography, elements, grid, colors) - Page classes in front matter scope styles (e.g.,
.posts.last-fm { ... }) - Use
sass:color,sass:map,sass:math,sass:stringmodules for built-in functions
Always activate Node 22 before running any build command:
source ~/.nvm/nvm.sh && nvm use v22.21.0Chokidar v5 and other packages require Node 22. Running under Node 18 will fail with ERR_REQUIRE_ESM.
| Mode | Command | Output dir | Minify | Hash | Gzip | Watchers |
|---|---|---|---|---|---|---|
| Dev | npm start |
package/ |
No | No | No | Yes (chokidar + live reload) |
| Production | npm run build |
package/ |
Yes | Yes | Yes | No |
| Golden | npm run build:golden |
golden/ |
Yes | No | No | No |
The golden build is used for visual regression tests — it produces realistic HTML without hashes or gzip so the output can be diff'd against a reference snapshot.
All build modules share a single configs object passed by reference from index.js. This is the central nervous system of the pipeline:
const configs = {
BUILD_EVENTS, // { assetHashCssListed, imagesMoved, ... } — event name constants
buildEvents, // Node.js EventEmitter instance — the pipeline message bus
completionFlags, // Shared mutable booleans tracking which stages have finished
debug, // true if --verbose flag passed
dir, // { root, src, package, build, nodeModules } — all absolute paths
hashingFileNameList, // {} — populated during hashing: originalPath → hashedPath
pageMappingData, // [] — populated by compilePageMappingData: [{ url, data }]
isGoldenBuild, // true if --golden flag passed
};Because it's passed by reference, mutations to completionFlags, hashingFileNameList, and pageMappingData are visible to every module without any import coupling.
build/constants/completion-flags.js defines the shared state object:
{
JS_IS_MINIFIED: false,
CSS_IS_MINIFIED: false,
HTML_IS_MINIFIED: false,
IMAGES_ARE_MOVED: false,
VIDEOS_ARE_MOVED: false,
ASSET_HASH: {
IMAGES: false, // images + videos renamed with hashes
CSS: false, // CSS files renamed with hashes
JS: false, // JS files renamed with hashes
DONE: false, // HTML + JSON rewritten to use hashed filenames
},
SITE_MAP: false,
GZIP: false,
PREVIEW_READY: false, // dev only — not checked by checkDone
}check-done.js checks all flags except PREVIEW_READY. The golden builder pre-sets ASSET_HASH.* and GZIP to true so those stages are skipped without modifying checkDone.
index.js: clean() resolves
├── compilePageMappingData() → emits: pageMappingDataCompiled
│ ├── compileSitemap() → emits: sitemapDone → checkDone
│ └── shouldBundleEjs() [gate: waits for imagesMoved + videosMoved]
├── bundleJS() → emits: jsMoved
│ └── (prod) minifyJS() → sets JS_IS_MINIFIED → emits: jsMinified
│ └── assetHashing() [gate: all 5 prereq flags must be true]
├── bundleSCSS() → sets CSS_IS_MINIFIED → emits: stylesMoved
│ └── assetHashing() [gate]
└── moveAssets()
├── moveAllImages() → sets IMAGES_ARE_MOVED → emits: imagesMoved
│ ├── shouldBundleEjs() [gate]
│ └── assetHashing() [gate]
└── moveAllVideos() → sets VIDEOS_ARE_MOVED → emits: videosMoved
└── shouldBundleEjs() [gate: both flags now true → bundleEJS()]
bundleEJS() → emits: templatesMoved
└── (prod) minifyHTML() → sets HTML_IS_MINIFIED → emits: htmlMinified
└── assetHashing() [gate: all 5 now true → runs]
├── hash JS files → sets ASSET_HASH.JS → emits: assetHashJsListed
│ └── finishHashing() [gate: all 3 hash flags]
└── hash image files → sets ASSET_HASH.IMAGES → emits: assetHashImagesListed
├── updateCSSwithImageHashes() → emits: indexCssForHashing
│ └── hashCSS() → sets ASSET_HASH.CSS → emits: assetHashCssListed
│ └── finishHashing() [gate: all 3 now true → runs]
│ → sets ASSET_HASH.DONE → emits: hashingDone
│ ├── checkDone()
│ └── gzipFiles() → sets GZIP → emits: gzipDone
│ └── checkDone() [all flags true → exit()]
└── finishHashing() [gate: already run above, exits early]
CSS files reference images via url(). If CSS were hashed before images, the CSS hash would be based on the pre-updated content and the final HTML rewrite would produce mismatched references. The order is enforced:
- Hash images and JS simultaneously (
assetHashing) - Update CSS
url()references with the new image hashes (updateCSSwithImageHashes) - Hash CSS (now its content reflects the hashed image paths) (
hashCSS) - Rewrite HTML + JSON with all three sets of hashes (
finishHashing)
bundle-ejs.js renders each template in two passes:
Pass 1: The template's content (YAML front matter stripped by gray-matter) is rendered with ejs.render(). Template data = ejsFunctions + siteData + frontMatter.data + { path: pagePath }.
Pass 2: The rendered content is injected as {{ content }} into the layout file (e.g. src/layout/base.ejs) and that combined string is rendered again.
Recursive detection: If the output of pass 2 contains YAML front matter (detected by matter.test()), the process repeats — this supports layouts that extend other layouts.
Error handling: In dev mode, template errors produce an in-browser red error page (via handleTemplateError) and a desktop notification, rather than crashing the server.
move-assets.js dispatches based on file extension:
| Extension | Processing |
|---|---|
.svg |
Passed through svgo preset-default before writing to output |
.jpg, .jpeg, .png |
Converted to a sibling .webp AND copied as-is (both formats kept) |
.webp and others |
Copied as-is |
favicon_base.png |
Also generates favicon.ico via png-to-ico |
Note: { nodir: true } is required on the downloads glob — glob v13's ** pattern matches the base directory itself.
index.js sets up an Express server (port 3000) in dev mode with:
/livereload— Server-Sent Events (SSE) endpoint. Browser tabs connect and wait fordata: reload\n\nmessages. AfterpreviewReady, a chokidar watcher ondir.packagesends reload events on every file change.- Proxy routes —
/last-fm-history,/band-news,/data,/moviesare proxied to the staging S3 bucket so external data is available locally without downloading. - Static serving —
express-staticservesdir.packagefor all other paths.
The live-reload script in HTML pages opens a persistent EventSource('/livereload') connection and calls location.reload() on each message.
preview-builder.js runs three chokidar watchers:
| Watcher | Target | On change |
|---|---|---|
buildDirWatcher |
build/ directory |
process.exit() — forces manual restart |
indexWatcher |
index.js |
process.exit() — forces manual restart |
sourceWatcher |
src/ directory |
Incremental rebuild of changed asset type only |
Source file changes dispatch by path:
src/js/**→bundleJS(configs)src/styles/**→bundleSCSS+compilePageMappingData(both, because inlined SCSS may change)src/templates/**,src/partials/**,src/layout/**→compilePageMappingDatasrc/images/**→moveOneImagesrc/videos/**→moveOneVideosrc/downloads/**→moveOneDownloadsrc/data/**or.txt→moveOneTxtFile
- Add the extension to
build/constants/file-formats.jsin theimagesorvideosarray. - Raster image sources automatically receive
.webpand.avifsiblings during preview and production builds.
- Add a
moveAllFonts/moveOneFontpair tomove-assets.jsfollowing the same pattern asmoveAllVideos. - Call
moveAllFonts(configs)from themoveAssetsexport. - If downstream stages need to wait for it: add a
FONTS_ARE_MOVEDcompletion flag, afontsMovedbuild event, and updatecheckDoneto include the new flag.
- Write the module in
build/optimize/— acceptconfigs, reset a completion flag, do work, set flag, emit an event. - Add the new event name to
build/constants/build-events.js. - Add the new flag to
build/constants/completion-flags.js. - Wire the listener in
build/prod-builder.js(andgolden-builder.jsif applicable). - Update
checkDone.jsto include the new flag inflagsToCheck.
- Add the function to the object returned by
build/helpers/ejs-functions.js. - It's immediately available in all templates — no other changes needed.
- Add JSDoc with
@param/@returns.
| Package | Role | Version note |
|---|---|---|
browserify |
JS bundler — resolves require() for browsers |
— |
babelify |
Babel transform for Browserify | Presets: preset-env + preset-react |
watchify |
Incremental rebuild plugin for Browserify | Dev mode only |
sass |
Dart Sass compiler | Uses compileAsync() with loadPaths |
clean-css |
CSS minifier | Production only |
ejs |
Template engine | v5 — CJS compat |
gray-matter |
YAML front matter parser | — |
glob |
File globbing | v13 — use const { globSync } = require('glob') |
xxhash |
Fast content hashing for asset filenames | Native addon, compiles via node-gyp |
html-minifier-terser |
HTML minification | v7 — minify() is async (returns Promise) |
uglify-js |
JS minification | — |
svgo |
SVG optimization | v4 — use preset-default only, no overrides |
sharp |
PNG/JPG/WebP → WebP/AVIF conversion | AVIF output uses quality 80 |
image-size |
Read image dimensions at template render time | v2 — sizeOf(fs.readFileSync(path)) |
png-to-ico |
favicon.ico generation | v3 ESM — call pngToIco.default(path) |
chokidar |
File system watcher | v5 ESM-compat — require('chokidar').watch() works |
express |
Dev server | v5 |
http-proxy-middleware |
Proxy to staging S3 in dev | — |
merge |
Deep merge for EJS template data | — |
colors |
ANSI colors in build log output | — |
| Script | Description |
|---|---|
npm start |
Dev server at localhost:3000 with file watching and proxy to staging for external data |
npm run build |
Production build to /package |
npm run build:golden |
Golden build (no gzip/hash) to /golden for visual diff |
npm run deploy |
Deploy production build to S3 |
npm run stage |
Deploy staging build to S3 |
npm test |
Run tests (requires Node 22 — use nvm first) |
npm run preview:production |
Serve the production build locally |
npm run scaffold -- --path=/path |
Create new page boilerplate |
npm run visual-diff |
Run visual regression tests |
- Production: Push to
mainbranch triggers GitHub Actions → builds → deploys towww.briananders.comS3 bucket → invalidates CloudFront cache → creates a deploy tag - Staging: Push to
stagingbranch triggers GitHub Actions → builds → deploys tostaging.briananders.comS3 bucket - PR validation: All PRs run build + tests
- The
s3-upload-allowlist.jsonpreserves/band-news/,/last-fm-history/,/data/, and/movies/paths during deploy
- Interactive experiments — Canvas animations (lissajous curves, cellular automata, moire patterns, polar clock), browser games (minesweeper, yahtzee, coin flip), audio visualizations (polyrhythm, sound frequency slider)
- Developer tools — Wordle solver, Wordscapes solver, browser diagnostics
- Music features — Last.fm scrobble visualizations (real-time API + historical data), music listening history breakdowns, music news aggregation from followed bands
- CSS/HTML demos — Layout examples, animation techniques, design system documentation
- Personal content — Drum cover videos (Banders Drums YouTube channel), podcast links (Bat Lessons), about/contributions page
- Developer notes — Git tips, aliases, configuration guides
| Platform | Handle/URL |
|---|---|
| GitHub | briananders |
| Last.fm | imbanders |
| YouTube (drums) | @bandersdrums |
| Bat Lessons | batlessons.com / @batlessons |
| Bluesky | imbanders.bsky.social |
| Mastodon | @banders@mastodon.social |
| andersbrian | |
| imbanders |