From fb972bc2227fea75f0c3644a1b80fe4f31938dff Mon Sep 17 00:00:00 2001 From: Ashelyn Rose Date: Mon, 31 Jul 2023 18:13:05 -0600 Subject: [PATCH] Add game of life post --- app/Appearance.tsx | 5 + app/[slug]/PageScript.tsx | 15 + app/[slug]/page.tsx | 20 +- posts/2023-07-31_wasm-game-of-life-1.md | 459 +++++++++++++++++++++++ public/scripts/wasm-life-1/controller.js | 142 +++++++ public/scripts/wasm-life-1/game.wat | 337 +++++++++++++++++ styles/layout.css | 47 ++- styles/post.module.css | 10 + utils/post.ts | 30 +- 9 files changed, 1054 insertions(+), 11 deletions(-) create mode 100644 posts/2023-07-31_wasm-game-of-life-1.md create mode 100644 public/scripts/wasm-life-1/controller.js create mode 100644 public/scripts/wasm-life-1/game.wat diff --git a/app/Appearance.tsx b/app/Appearance.tsx index d42d2e0..8c18d0c 100644 --- a/app/Appearance.tsx +++ b/app/Appearance.tsx @@ -1,8 +1,11 @@ 'use client' +import EventEmitter from 'events' import { useState, useEffect, MouseEvent } from 'react' type AppearanceMode = undefined | 'light' | 'dark' +export const themeSignal = new EventEmitter() + export default function Appearance() { const [appearance, setAppearance] = useState(undefined) @@ -11,6 +14,8 @@ export default function Appearance() { document.body.parentElement?.classList.remove('light') document.body.parentElement?.classList.remove('dark') document.body.parentElement?.classList.add(appearance) + + themeSignal.emit('change') } }, [appearance]) diff --git a/app/[slug]/PageScript.tsx b/app/[slug]/PageScript.tsx index d3d1572..547e662 100644 --- a/app/[slug]/PageScript.tsx +++ b/app/[slug]/PageScript.tsx @@ -1,10 +1,12 @@ 'use client' import { useEffect, useRef } from "react" +import { themeSignal } from "../Appearance" interface Module { setup?: (wabt?: any) => Promise cleanup?: () => Promise + onThemeChange?: () => void } export default function PageScript({ script, wasm }: { script?: string, wasm?: Uint8Array }) { @@ -35,5 +37,18 @@ export default function PageScript({ script, wasm }: { script?: string, wasm?: U } }, []) + useEffect(() => { + function onThemeChange() { + const mod = moduleRef.current + + if (mod?.onThemeChange && typeof mod?.onThemeChange === 'function') { + mod.onThemeChange() + } + } + + themeSignal.addListener('change', onThemeChange) + return () => { themeSignal.removeListener('change', onThemeChange) } + }, []) + return null } diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx index d92a704..9fcba9e 100644 --- a/app/[slug]/page.tsx +++ b/app/[slug]/page.tsx @@ -34,7 +34,23 @@ export default async function Post({ params: { slug } }: { params: { slug: strin

{post.subtitle}

{post.body} - {post.script && } + + {post.script && } + + {(post.script || post.wasm) && ( + <> +
+

Post Resources:

+ + + )} ); @@ -60,8 +76,6 @@ Code.theme = { } function CodeBlock({ children }: { children: ReactElement }) { - console.log(children) - // extract props normally passed to pre element const { className: langKey, children: sourceText, ...rest } = children?.props ?? {} const language = langKey?.replace(/^lang-/i, '') diff --git a/posts/2023-07-31_wasm-game-of-life-1.md b/posts/2023-07-31_wasm-game-of-life-1.md new file mode 100644 index 0000000..175d4eb --- /dev/null +++ b/posts/2023-07-31_wasm-game-of-life-1.md @@ -0,0 +1,459 @@ +--- +title: Pure Wasm Game of Life +subtitle: Because I'm truly a pedant at heart +script: wasm-life-1/controller.js +wasm: wasm-life-1/game.wat +unlisted: true +--- + +Lately in doing research on WebAssembly I've been lookig around for examples +of things implemented in it, and I've come across several blog posts based on +the fantastic *Rust And WebAssembly* tutorial for +[Conway's Game of Life](https://rustwasm.github.io/docs/book/game-of-life/introduction.html). + +And I mean no shade towards the folks who wrote those, but I feel it's mildly +disingenuous to say that those are "Conway's Game of Life in WebAssembly" when +the actual game code was written entirely in Rust. WebAssembly is in that case +really no more than a compilation target, not actually the language used. + +So of course I knew what I had to do . . . + +Welcome to Conway's Game of Life, *actually implemented* in WebAssembly: + + +

+ + +

+ +What you're looking at here is Conway's Game of Life where ***all of the game code*** +is written in pure, raw, not-a-compiler-in-sight WebAssembly. Its performance is +pretty comparable to the Rust versions, and I'm glad to say the code for it isn't +even that much of a mess! + +Let's dive into how it works together, shall we? + +## Overview + +As with most things in wasm we need to decide ahead of time what we are going +to implement directly in it, and what things are going to stay in the Javascript +part. I went with implementing the core game logic in wasm, leaving the initialization +and display code in JS. + +I chose the first because I didn't want to have to deal with getting a float +value back from `Math.random()` in wasm, and the second because DOM manipulation, +canvas, and WebGL are all a bit of a pain to do manually from WebAssembly. + +For convenience in this implementation I am using a byte per cell. Packing a bit +per cell into less memory space would be great, but I'm trying to keep it +*relatively* simple at first. I'm planning to revisit that in a later post though. + +## Code samples + +For now, let's look at a few selections from the code (links to full source will be +at the [bottom of the page](#resources)). + +### Globals and Initialization + +```wasm +(module + (memory (export "shared_memory") 1) + + (global $boardWidth (mut i32) (i32.const 0)) + (global $boardHeight (mut i32) (i32.const 0)) + (global $boardBufferLength (mut i32) (i32.const 0)) + (global $buffer0ptr (mut i32) (i32.const -1)) + (global $buffer1ptr (mut i32) (i32.const -1)) + (global $currentBuffer (mut i32) (i32.const -1)) +``` + +I start the wasm module out with 1 page of memory (64 KiB), and define global +variables for the board dimensions, the length of each board buffer (in bytes), +the locations of each buffer, and which one is currently selected. + +You can see that each of these gets initialized in the next function: + +```wasm + (func (export "initializeBoard") (param $width i32) (param $height i32) + ;; Store width and height for later + (global.set $boardWidth (local.get $width)) + (global.set $boardHeight (local.get $height)) + + ;; Compute total cells per board + local.get $width + local.get $height + i32.mul + global.set $boardBufferLength + + ;; Request enough memory for both boards + global.get $boardBufferLength + i32.const 2 + i32.mul + call $growMemoryForBoards + + ;; Set pointer locations for our two boards + (global.set $buffer0ptr (i32.const 0)) + (global.set $buffer1ptr (global.get $boardBufferLength)) + + ;; Set current board + (global.set $currentBuffer (i32.const 0)) + ) +``` + +In the case that `$growMemoryForBoards` fails it will crash the WebAssembly +module, but considering I don't have a backup plan for how to make do with +less memory, that's acceptable to me. + +### Manipulating the board + +Next let's check out some of the basic board manipulation functions that our +Javascript code calls during initialization and display: + +```wasm + (func $getValueAtPosition (export "getValueAtPosition") (param $row i32) (param $column i32) (result i32) + (local $position i32) + local.get $row + local.get $column + call $getIndexForPosition + + local.tee $position + i32.const 0 + i32.lt_s + + if + i32.const 0 + return + end + + local.get $position + call $getBoardPtr + i32.add + i32.load8_u + ) + + (func $setValueAtPosition (export "setValueAtPosition") (param $row i32) (param $column i32) (param $value i32) + (local $position i32) + local.get $row + local.get $column + call $getIndexForPosition + + local.tee $position + i32.const 0 + i32.lt_s + + if + return + end + + local.get $position + call $getBoardPtr + i32.add + local.get $value + i32.store8 + ) +``` + +As you can see both rely on another function called `$getIndexForPosition`, check +its return value to make sure it didn't give -1, and then add that position to +the current board pointer. Not too bad so far! + +That helper function `$getIndexForPosition` is also relatively simple: + +```wasm + (func $getIndexForPosition (param $row i32) (param $column i32) (result i32) + local.get $row + i32.const 0 + global.get $boardHeight + call $positionInRange + + local.get $column + i32.const 0 + global.get $boardWidth + call $positionInRange + + i32.and + i32.eqz + + if + i32.const -1 + return + end + + global.get $boardWidth + local.get $row + i32.mul + local.get $column + i32.add + ) + +``` + +It again does some basic bounds checking, then some math with the board with, +row and column. Generally this all matches so far to how you might implement +this in any other language. + +### Updating the board + +Okay so this is where stuff starts to get a bit messy. WebAssembly *ostensibly* +has loops, but they're really more just a conditional jump. So the main function +for updating the board (which has to iterate through every position) gets to +be a bit verbose: + +```wasm + (func $tick (export "tick") + (local $row i32) + (local $column i32) + (local $value i32) + + i32.const 0 + local.set $row + + loop $rows + + ;; start at the beginning of a row + i32.const 0 + local.set $column + + ;; for every column in the row + loop $columns + + ;; compute new value + local.get $row + local.get $column + call $getNewValueAtPosition + local.set $value + + ;; place in next board + call $swapBoards + local.get $row + local.get $column + local.get $value + call $setValueAtPosition + call $swapBoards + + ;; increment column + local.get $column + i32.const 1 + i32.add + local.tee $column + + ;; loop back if less than width + global.get $boardWidth + i32.lt_s + br_if $columns + end + + ;;increment row + local.get $row + i32.const 1 + i32.add + local.tee $row + + ;; loop back if less than heeight + global.get $boardHeight + i32.lt_s + br_if $rows + end + + ;; swap to the new board + call $swapBoards + ) +``` + +The `$swapBoards` function here is not that important to look at, it just +changes the current board flag so that `$getBoardPtr` returns the correct one. +I am kind of annoyed that I have to swap the board back and forth all the time, +but we'll see if that becomes an issue later. + +But what's this `$getNewValueAtPosition` function? Let's have a look at that! + +. . . prepare yourself, this one's a doozy. + +```wasm + (func $getNewValueAtPosition (param $row i32) (param $column i32) (result i32) + (local $count i32) + + local.get $row + i32.const 1 + i32.sub + local.get $column + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + call $getValueAtPosition + + local.get $row + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.sub + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.sub + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + i32.add + i32.add + i32.add + i32.add + i32.add + i32.add + i32.add + + ;; Exactly 3 neighbors + local.tee $count + i32.const 3 + i32.eq + + if + ;; becomes or stays alive + i32.const 1 + return + end + + ;; If currently dead + local.get $row + local.get $column + call $getValueAtPosition + i32.eqz + if + ;; Stay dead + i32.const 0 + return + end + + ;; 2 neighbors + local.get $count + i32.const 2 + i32.eq + if + i32.const 1 + return + end + + i32.const 0 + return + ) +``` + +This is (effectively) an unrolled loop. I could make this code shorter +but un-unrolling my loop, but I couldn't find a way to do that which didn't +immediately result in more instructions being run overall, so *for the moment* +I'm leaving it like this. + +But once you know what each chunk is doing, yeah it's pretty simple! Each of the +`$getValueAtPosition` calls adds either a 0 or a 1 to the stack, and then we add +all of those up, store it in a variable, and check it against our various possible +outcomes. + +Not that bad really, it's just rather verbose. + +### And the glue + +Lastly let's look at some of the JS that ties this together. I'm not going to +look that closely at the bit that loads the WebAssembly and initializes the +module - I assume most ~~(sane)~~ folks are using a bindings generator or bundler +or something else that does that for them. But let's look at the board initialization +and drawing code. + +Starting with the board initialization, you can see it's rather short: + +```js +function initialize() { + const { gameExports, width, height } = gameState + + gameExports.initializeBoard(width, height) + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const filled = Math.random() > .5; + gameExports.setValueAtPosition(row, column, filled ? 1 : 0) + } + } +} +``` + +Oh the pleasures of a high-level language - the conciseness is just lovely isn't it? +And hopefully you can see why I didn't want to import `Math.random()` into my +WebAssembly - then I'd have to deal with floats, and more iteration, and it'd just +not be fun. + +Okay now for the drawing code: + +```js +function drawBoard() { + const { gameExports, width, height, pixelSize, ctx, canvas } = gameState + + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'currentColor' + ctx.beginPath() + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const alive = gameExports.getValueAtPosition(row, column) + + if (!alive) continue + + const x = column * pixelSize + const y = row * pixelSize + + ctx.moveTo(x, y) + ctx.lineTo(x + pixelSize, y) + ctx.lineTo(x + pixelSize, y + pixelSize) + ctx.lineTo(x, y + pixelSize) + ctx.lineTo(x, y) + } + } + ctx.fill() +} +``` + +This is a pretty simple use of the `` element, I think maybe in the +future if I want to optimize this I'd probably look into only updating the +changed cells or something like that, so that it doesn't need to redraw +the *entire* board each time. But on anything up to about a 400x300 grid +this was staying at roughly 5ms per frame on my machine, which should be +suitable for keeping about 60 frames per second - particularly if I can +optimize the board update function a bit as well. diff --git a/public/scripts/wasm-life-1/controller.js b/public/scripts/wasm-life-1/controller.js new file mode 100644 index 0000000..10ff37d --- /dev/null +++ b/public/scripts/wasm-life-1/controller.js @@ -0,0 +1,142 @@ +let gameState = { + running: false, + pixelSize: 0, + lastReported: null, + frameTimes: [], + frames: 0, + width: 0, + height: 0, + canvas: null, + ctx: null, + gameExports: null +} + +const initialMessage = "Click the board above to start simulation" + +export async function setup(wasmModule) { + gameState.gameExports = wasmModule.exports + + const canvas = gameState.canvas = document.querySelector('#game') + gameState.ctx = gameState.canvas.getContext("2d") + + const pixelSize = gameState.pixelSize = parseInt(canvas.getAttribute('data-pixelsize') || '2') + gameState.width = Math.floor(parseInt(canvas.width) / pixelSize) + gameState.height = Math.floor(parseInt(canvas.height) / pixelSize) + + initialize() + drawBoard() + + const frameTimesElem = document.getElementById('frameTimes') + const resetButton = document.getElementById('reset') + + frameTimesElem.innerText = initialMessage + + gameState.canvas.addEventListener('click', () => { + if (!gameState.running) { + if (frameTimesElem.innerText === initialMessage) + frameTimesElem.innerText = 'Starting...' + + gameState.running = true + frameLoop() + } else { + gameState.running = false; + gameState.frameTimes = [] + gameState.lastReported = null + } + }) + + resetButton.addEventListener('click', () => { + gameState.running = false + gameState.frameTimes = [] + gameState.lastReported = null + frameTimesElem.innerText = initialMessage + initialize() + drawBoard() + }) +} + +export async function onThemeChange() { + drawBoard() +} + +export async function cleanup() { + gameState.running = false +} + +function initialize() { + const { gameExports, width, height } = gameState + + gameExports.initializeBoard(width, height) + + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const filled = Math.random() > .5; + gameExports.setValueAtPosition(row, column, filled ? 1 : 0) + } + } +} + +function frameLoop() { + const { gameExports, running, frameTimes } = gameState + + if (!gameState.lastReported) + gameState.lastReported = performance.now() + + if (!running) return + + const beforeTick = performance.now() + gameExports.tick() + const afterTick = performance.now() + drawBoard() + const afterDraw = performance.now() + + // Push raw frame time + frameTimes.push(afterDraw - beforeTick) + + if (frameTimes.length >= 10) { + const averageFrame = (frameTimes.reduce((a, b) => a + b) / frameTimes.length).toFixed(2); + + const lastReported = gameState.lastReported + const current = gameState.lastReported = performance.now() + const tenFrameTime = (current - lastReported) / 1000 + const fps = Math.floor(10 / tenFrameTime).toFixed(0) + gameState.frameTimes = [] + + const pagesUsed = gameExports.getPagesUsed() + + document.getElementById('frameTimes').innerHTML = + `Frames per second: ${fps}
+ Milliseconds per frame: ${averageFrame}
+ Memory allocated: ${pagesUsed} pages (${pagesUsed * 64} KiB)` + } + + console.log(`tick took ${afterTick - beforeTick}ms`) + console.log(`draw took ${afterDraw - afterTick}ms`) + + requestAnimationFrame(frameLoop) +} + +function drawBoard() { + const { gameExports, width, height, pixelSize, ctx, canvas } = gameState + + ctx.clearRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = 'currentColor' + ctx.beginPath() + for (let row = 0; row < height; row++) { + for (let column = 0; column < width; column++) { + const alive = gameExports.getValueAtPosition(row, column) + + if (!alive) continue + + const x = column * pixelSize + const y = row * pixelSize + + ctx.moveTo(x, y) + ctx.lineTo(x + pixelSize, y) + ctx.lineTo(x + pixelSize, y + pixelSize) + ctx.lineTo(x, y + pixelSize) + ctx.lineTo(x, y) + } + } + ctx.fill() +} diff --git a/public/scripts/wasm-life-1/game.wat b/public/scripts/wasm-life-1/game.wat new file mode 100644 index 0000000..bc7edbf --- /dev/null +++ b/public/scripts/wasm-life-1/game.wat @@ -0,0 +1,337 @@ +(module + (memory (export "shared_memory") 1) + + (global $boardWidth (mut i32) (i32.const 0)) + (global $boardHeight (mut i32) (i32.const 0)) + (global $boardBufferLength (mut i32) (i32.const 0)) + (global $buffer0ptr (mut i32) (i32.const -1)) + (global $buffer1ptr (mut i32) (i32.const -1)) + (global $currentBuffer (mut i32) (i32.const -1)) + + (func (export "initializeBoard") (param $width i32) (param $height i32) + ;; Store width and height for later + (global.set $boardWidth (local.get $width)) + (global.set $boardHeight (local.get $height)) + + ;; Compute total cells per board + local.get $width + local.get $height + i32.mul + global.set $boardBufferLength + + ;; Request enough memory for both boards + global.get $boardBufferLength + i32.const 2 + i32.mul + call $growMemoryForBoards + + ;; Set pointer locations for our two boards + (global.set $buffer0ptr (i32.const 0)) + (global.set $buffer1ptr (global.get $boardBufferLength)) + + ;; Set current board + (global.set $currentBuffer (i32.const 0)) + ) + + (func (export "getPagesUsed") (result i32) + memory.size + ) + + (func $growMemoryForBoards (param $totalBytes i32) + (local $targetPages i32) + + ;; figure out target page size + local.get $totalBytes + i32.const 1 + i32.sub + i32.const 65536 ;; size of a page + i32.div_u + i32.const 1 + i32.add + + ;; get difference + memory.size + i32.sub + + ;; grow + memory.grow + drop ;; ignore result, we're gonna crash anyways + ;; perhaps we should have a way to report errors back to JS next time + ) + + (func $getBoardPtr (result i32) + global.get $currentBuffer + i32.eqz + if (result i32) + global.get $buffer0ptr + else + global.get $buffer1ptr + end + ) + + (func $swapBoards + global.get $currentBuffer + i32.eqz + if (result i32) + i32.const 1 + else + i32.const 0 + end + global.set $currentBuffer + ) + + (func $positionInRange (param $position i32) (param $min i32) (param $max i32) (result i32) + local.get $position + local.get $min + i32.lt_s + + if + i32.const 0 + return + end + + local.get $position + local.get $max + i32.ge_s + + if + i32.const 0 + return + end + + i32.const 1 + return + ) + + (func $getIndexForPosition (param $row i32) (param $column i32) (result i32) + local.get $row + i32.const 0 + global.get $boardHeight + call $positionInRange + + local.get $column + i32.const 0 + global.get $boardWidth + call $positionInRange + + i32.and + i32.eqz + + if + i32.const -1 + return + end + + global.get $boardWidth + local.get $row + i32.mul + local.get $column + i32.add + ) + + (func $getValueAtPosition (export "getValueAtPosition") (param $row i32) (param $column i32) (result i32) + (local $position i32) + local.get $row + local.get $column + call $getIndexForPosition + + local.tee $position + i32.const 0 + i32.lt_s + + if + i32.const 0 + return + end + + local.get $position + + call $getBoardPtr + i32.add + i32.load8_u + ) + + (func $setValueAtPosition (export "setValueAtPosition") (param $row i32) (param $column i32) (param $value i32) + (local $position i32) + local.get $row + local.get $column + call $getIndexForPosition + + local.tee $position + i32.const 0 + i32.lt_s + + if + return + end + + local.get $position + + call $getBoardPtr + i32.add + local.get $value + i32.store8 + ) + + (func $getNewValueAtPosition (param $row i32) (param $column i32) (result i32) + (local $count i32) + + local.get $row + i32.const 1 + i32.sub + local.get $column + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + call $getValueAtPosition + + local.get $row + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.sub + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + i32.const 1 + i32.sub + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.sub + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + local.get $row + i32.const 1 + i32.add + local.get $column + i32.const 1 + i32.add + call $getValueAtPosition + + i32.add + i32.add + i32.add + i32.add + i32.add + i32.add + i32.add + + ;; Exactly 3 neighbors + local.tee $count + i32.const 3 + i32.eq + + if + ;; becomes or stays alive + i32.const 1 + return + end + + ;; If currently dead + local.get $row + local.get $column + call $getValueAtPosition + i32.eqz + if + ;; Stay dead + i32.const 0 + return + end + + ;; 2 neighbors + local.get $count + i32.const 2 + i32.eq + if + i32.const 1 + return + end + + i32.const 0 + return + ) + + (func $tick (export "tick") + (local $row i32) + (local $column i32) + (local $value i32) + + i32.const 0 + local.set $row + + loop $rows + + ;; start at the beginning of a row + i32.const 0 + local.set $column + + ;; for every column in the row + loop $columns + + ;; compute new value + local.get $row + local.get $column + call $getNewValueAtPosition + local.set $value + + ;; place in next board + call $swapBoards + local.get $row + local.get $column + local.get $value + call $setValueAtPosition + call $swapBoards + + ;; increment column + local.get $column + i32.const 1 + i32.add + local.tee $column + + ;; loop back if less than width + global.get $boardWidth + i32.lt_s + br_if $columns + end + + ;;increment row + local.get $row + i32.const 1 + i32.add + local.tee $row + + ;; loop back if less than heeight + global.get $boardHeight + i32.lt_s + br_if $rows + end + + ;; swap to the new board + call $swapBoards + ) +) + diff --git a/styles/layout.css b/styles/layout.css index d9e9979..9ad7e99 100644 --- a/styles/layout.css +++ b/styles/layout.css @@ -1,23 +1,30 @@ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@100;200&display=swap'); :root, :root.dark { - --background:#2F2536; - --foreground:#E1B8FF; + --backgroundDark:#2F2536; + --foregroundDark:#E1B8FF; + --illustrationDark: white; --backgroundLight: #f5eaff; --foregroundLight: #49332d; + --illustrationLight: #8c002e; --padding: 16px; + --background: var(--backgroundDark); + --foreground: var(--foregroundDark); + --illustration: var(--illustrationDark); } @media (prefers-color-scheme: light) { :root { --background: var(--backgroundLight); --foreground: var(--foregroundLight); + --illustration: var(--illustrationLight); } } :root.light { --background: var(--backgroundLight); --foreground: var(--foregroundLight); + --illustration: var(--illustrationLight); } @@ -71,3 +78,39 @@ body { min-width: 300px; margin: 0 auto; } + +#container main canvas { + color: var(--illustration); +} + +#container main p > code { + padding: 2px 4px; + position: relative; + font-style: italic; +} + +#container main p > code::before { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: currentColor; + border-radius: 8px; + opacity: .15; + pointer-events: none; +} + +#container main p > code::after { + content: ' '; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border: solid 1px currentColor; + opacity: .8; + border-radius: 8px; + pointer-events: none; +} diff --git a/styles/post.module.css b/styles/post.module.css index fc762c7..4257c7e 100644 --- a/styles/post.module.css +++ b/styles/post.module.css @@ -21,3 +21,13 @@ .post p a { color: inherit; } + +hr.postFooter { + margin-top: calc(3 * var(--padding)); + color: inherit; + opacity: .4; +} + +.postFooter ~ ul li a { + color: inherit; +} diff --git a/utils/post.ts b/utils/post.ts index 864325b..002bccf 100644 --- a/utils/post.ts +++ b/utils/post.ts @@ -15,8 +15,17 @@ export interface PostMeta { export interface Post extends Omit { body: string, - script?: string, - wasm?: Uint8Array, + script?: Script, + wasm?: Wasm +} + +export interface Script { + name: string, + path: string, +} + +export interface Wasm extends Script { + contents: Uint8Array } type Attributes = { [key: string]: any } @@ -75,6 +84,7 @@ export async function loadSinglePage(slug: string): Promise { const { attributes, body } = frontmatter(fileContents) const data: Attributes = attributes as Attributes + let script: Script | undefined = undefined; if (data.script) { const scriptPath = path.join(process.cwd(), 'public/scripts', data.script) try { @@ -82,10 +92,14 @@ export async function loadSinglePage(slug: string): Promise { } catch { throw new Error(`Could not find script ${scriptPath} referenced in post ${slug}`) } finally { - data.script = `/scripts/${data.script}` + script = { + name: data.script.replace(/^.*\//, ''), + path: `/scripts/${data.script}` + } } } + let wasm: Wasm | undefined = undefined; if (data.wasm) { const wabt = await wabtModule() const wasmPath = path.join(process.cwd(), 'public/scripts', data.wasm) @@ -95,15 +109,19 @@ export async function loadSinglePage(slug: string): Promise { const wasmIntermed = wabt.parseWat(fileName, wasmText) const { buffer: wasmBinary } = wasmIntermed.toBinary({ write_debug_names: true }) - data.wasm = wasmBinary + wasm = { + name: fileName, + path: `/scripts/${data.wasm}`, + contents: wasmBinary + } } const { filePath, ...otherMeta } = postMeta return { ...otherMeta, - script: data.script, - wasm: data.wasm, + script, + wasm, body } }