Compare commits
22 commits
main
...
asch/rewri
| Author | SHA1 | Date | |
|---|---|---|---|
| 070a03142f | |||
| 82a241ddee | |||
| 53ef4d9147 | |||
| 728dcdb3d9 | |||
| cdca57012a | |||
| 84543184e7 | |||
| d384ecfa9d | |||
| c86d8052c5 | |||
| 0e78c506a4 | |||
| d14fd083f3 | |||
| 1ea3024e4e | |||
| 283df32afe | |||
| 1cdc202731 | |||
| 82c723c192 | |||
| 11796a8869 | |||
| c65114888c | |||
| 4ac244b3ef | |||
| 8064972c15 | |||
| 0d65f01797 | |||
| 16c606c3e8 | |||
| 4ce8a4f41d | |||
| 3441a7e4af |
|
|
@ -3,8 +3,10 @@ name: Deploy to Pages
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: ['main']
|
branches: ['main']
|
||||||
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
|
|
@ -29,21 +31,26 @@ jobs:
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: |
|
run: |
|
||||||
|
set -eo pipefail
|
||||||
npm run lint
|
npm run lint
|
||||||
git diff
|
npm run typecheck
|
||||||
if [[ `git status --porcelain` ]]; then
|
npm run qa:no-em-dashes
|
||||||
exit 1
|
npm run qa:spelling
|
||||||
fi
|
npm run qa:no-github-links
|
||||||
|
|
||||||
- name: Typecheck
|
- name: Build
|
||||||
run: npm run typecheck
|
run: npm run build
|
||||||
|
|
||||||
- name: Build, Astro Audit & QA
|
- name: QA build
|
||||||
run: |
|
run: |
|
||||||
npx playwright install chromium
|
set -eo pipefail
|
||||||
npm run qa
|
npm run qa:astro-audit
|
||||||
|
npm run qa:links
|
||||||
|
npm run qa:no-js
|
||||||
|
npm run qa:overflow
|
||||||
|
npm run qa:preview-cropping
|
||||||
|
|
||||||
- name: Copy build to host pages mount
|
- name: On main, Copy build to host pages mount
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
uses: http://forgejo:3000/andras/ci-actions/deploy-pages@main
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
39
.vscode/settings.json
vendored
|
|
@ -1,42 +1,9 @@
|
||||||
{
|
{
|
||||||
"cSpell.words": [
|
|
||||||
"andras",
|
|
||||||
"Andras",
|
|
||||||
"astra",
|
|
||||||
"colour",
|
|
||||||
"colours",
|
|
||||||
"datetime",
|
|
||||||
"decla",
|
|
||||||
"EEPROM",
|
|
||||||
"favicons",
|
|
||||||
"favourite",
|
|
||||||
"forex",
|
|
||||||
"froms",
|
|
||||||
"Glsl",
|
|
||||||
"leds",
|
|
||||||
"linkedin",
|
|
||||||
"mesmerising",
|
|
||||||
"microcontrollers",
|
|
||||||
"mixins",
|
|
||||||
"noopener",
|
|
||||||
"optimisations",
|
|
||||||
"optimised",
|
|
||||||
"organiser",
|
|
||||||
"playsinline",
|
|
||||||
"realised",
|
|
||||||
"schmelczer",
|
|
||||||
"serialisation",
|
|
||||||
"synchronised",
|
|
||||||
"tabindex",
|
|
||||||
"utilised",
|
|
||||||
"utilising",
|
|
||||||
"webm",
|
|
||||||
"webp",
|
|
||||||
"youtube"
|
|
||||||
],
|
|
||||||
"files.exclude": {
|
"files.exclude": {
|
||||||
"node_modules": true
|
"node_modules": true
|
||||||
},
|
},
|
||||||
"editor.rulers": [90],
|
"editor.rulers": [
|
||||||
|
90
|
||||||
|
],
|
||||||
"editor.wordWrap": "on"
|
"editor.wordWrap": "on"
|
||||||
}
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript.
|
Engineering writeups by Andras Schmelczer: finished projects with the design constraints left in. Built with Astro, no required client JavaScript.
|
||||||
|
|
||||||
Articles live in `src/content/posts`, project index entries in `src/content/projects`, and normal pages are rendered as static HTML.
|
All content lives in the single `src/content/work` collection: each entry can carry an `article` facet (a page under `/articles/`), a `project` facet (a card on `/projects/`), or both. Normal pages are rendered as static HTML.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
|
@ -11,6 +11,8 @@ npm ci
|
||||||
npx playwright install --with-deps chromium # required before Playwright QA checks
|
npx playwright install --with-deps chromium # required before Playwright QA checks
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `overrides.yaml` entry in `package.json` forces every transitive `yaml` dependency to 2.9+, ahead of what some Astro tooling requests on its own. Drop it once `npm ls yaml` shows nothing older without it.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -23,11 +25,11 @@ npm run qa
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- `src/content/posts`: Markdown articles
|
- `src/content/work`: Markdown entries (article and/or project facets)
|
||||||
- `src/content/projects`: project index entries
|
|
||||||
- `src/pages`: static routes
|
- `src/pages`: static routes
|
||||||
- `src/layouts`: page and post layouts
|
- `src/layouts`: page and post layouts
|
||||||
- `src/components`: reusable UI pieces
|
- `src/components`: reusable UI pieces
|
||||||
- `src/styles/global.css`: the visual system
|
- `src/styles/global.css`: the visual system
|
||||||
|
- `scripts`: QA checks run by `npm run qa` (shared helpers in `scripts/lib`)
|
||||||
- `public/media/downloads`: CV and thesis PDFs
|
- `public/media/downloads`: CV and thesis PDFs
|
||||||
- `public/media/video`: project videos
|
- `public/media/video`: project videos
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,15 @@ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||||
import rehypeSlug from 'rehype-slug';
|
import rehypeSlug from 'rehype-slug';
|
||||||
import { visit } from 'unist-util-visit';
|
import { visit } from 'unist-util-visit';
|
||||||
|
|
||||||
// Build a lookup of post slugs to their last modification dates so the sitemap
|
// Build a lookup of article slugs to their last modification dates so the
|
||||||
// can advertise accurate <lastmod> values to crawlers. astro:content isn't
|
// sitemap can advertise accurate <lastmod> values to crawlers. astro:content
|
||||||
// available inside the config, so we read post frontmatter directly. Our posts
|
// isn't available inside the config, so we read entry frontmatter directly.
|
||||||
// always use single-line scalar `date:` / `updated:` keys, so a small regex
|
// Each entry uses a single-line top-level scalar `date:` key, so a small regex
|
||||||
// extraction is sufficient and intentional.
|
// extraction is sufficient and intentional. (`updated:` now lives nested under
|
||||||
|
// `article:`; no entry sets it, so falling back to `date` is correct.)
|
||||||
const postsDir = path.resolve(
|
const postsDir = path.resolve(
|
||||||
path.dirname(fileURLToPath(import.meta.url)),
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
'src/content/posts'
|
'src/content/work'
|
||||||
);
|
);
|
||||||
|
|
||||||
function extractScalar(frontmatter, key) {
|
function extractScalar(frontmatter, key) {
|
||||||
|
|
|
||||||
116
cspell.json
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
|
||||||
|
"version": "0.2",
|
||||||
|
"language": "en,en-GB",
|
||||||
|
"ignorePaths": [
|
||||||
|
"node_modules/**",
|
||||||
|
"dist/**",
|
||||||
|
".astro/**",
|
||||||
|
"package-lock.json",
|
||||||
|
"*.svg",
|
||||||
|
"public/**",
|
||||||
|
"todo.md"
|
||||||
|
],
|
||||||
|
"words": [
|
||||||
|
"acked",
|
||||||
|
"acks",
|
||||||
|
"adblocker",
|
||||||
|
"apos",
|
||||||
|
"backpressure",
|
||||||
|
"backtest",
|
||||||
|
"bgsave",
|
||||||
|
"binaryen",
|
||||||
|
"bindgen",
|
||||||
|
"bitpacking",
|
||||||
|
"blockedbyclient",
|
||||||
|
"borgmann",
|
||||||
|
"btrfs",
|
||||||
|
"cheatable",
|
||||||
|
"collab",
|
||||||
|
"conveyal",
|
||||||
|
"crewmates",
|
||||||
|
"datasheet",
|
||||||
|
"datasheets",
|
||||||
|
"decla",
|
||||||
|
"dequant",
|
||||||
|
"desync",
|
||||||
|
"domcontentloaded",
|
||||||
|
"emelt",
|
||||||
|
"érettségi",
|
||||||
|
"extensionless",
|
||||||
|
"extrapolators",
|
||||||
|
"fizika",
|
||||||
|
"forex",
|
||||||
|
"fouc",
|
||||||
|
"funimation",
|
||||||
|
"gameboy",
|
||||||
|
"glsl",
|
||||||
|
"greatai",
|
||||||
|
"hanning",
|
||||||
|
"healthcheck",
|
||||||
|
"immich",
|
||||||
|
"immich's",
|
||||||
|
"importmap",
|
||||||
|
"incrementality",
|
||||||
|
"janky",
|
||||||
|
"jemalloc",
|
||||||
|
"keepalives",
|
||||||
|
"laion",
|
||||||
|
"loggable",
|
||||||
|
"malloc",
|
||||||
|
"metas",
|
||||||
|
"mispredictions",
|
||||||
|
"mixolydian",
|
||||||
|
"mlockall",
|
||||||
|
"mosfets",
|
||||||
|
"nixplay's",
|
||||||
|
"ntfy",
|
||||||
|
"numba",
|
||||||
|
"numpy",
|
||||||
|
"nums",
|
||||||
|
"permacomputing",
|
||||||
|
"physarum",
|
||||||
|
"piskel",
|
||||||
|
"pocketbase",
|
||||||
|
"powerups",
|
||||||
|
"proptest",
|
||||||
|
"refetches",
|
||||||
|
"reflashing",
|
||||||
|
"reimplementable",
|
||||||
|
"requantised",
|
||||||
|
"rgba",
|
||||||
|
"scalex",
|
||||||
|
"scaley",
|
||||||
|
"schmelczer",
|
||||||
|
"scrollables",
|
||||||
|
"serde",
|
||||||
|
"setpoints",
|
||||||
|
"shiki",
|
||||||
|
"slugifies",
|
||||||
|
"speculationrules",
|
||||||
|
"sqlx",
|
||||||
|
"stdlib",
|
||||||
|
"stft",
|
||||||
|
"strobing",
|
||||||
|
"stucki",
|
||||||
|
"subvolume",
|
||||||
|
"syncer",
|
||||||
|
"szintű",
|
||||||
|
"tamagotchi",
|
||||||
|
"tamagotchis",
|
||||||
|
"tarjan's",
|
||||||
|
"tidepool",
|
||||||
|
"tmpfs",
|
||||||
|
"tweakpane",
|
||||||
|
"unsets",
|
||||||
|
"vaultlink",
|
||||||
|
"vtable",
|
||||||
|
"waveshare",
|
||||||
|
"waveshare's",
|
||||||
|
"webform",
|
||||||
|
"webgpu",
|
||||||
|
"wgsl",
|
||||||
|
"writeups",
|
||||||
|
"zstd"
|
||||||
|
]
|
||||||
|
}
|
||||||
2046
package-lock.json
generated
22
package.json
|
|
@ -10,22 +10,24 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev --host 0.0.0.0 --port 5173",
|
"dev": "astro dev --host 0.0.0.0 --port 5173",
|
||||||
"typecheck": "astro check",
|
"typecheck": "astro check",
|
||||||
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/**/*.mjs\" \"*.md\" \"*.json\"",
|
||||||
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
|
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/**/*.mjs\" \"*.md\" \"*.json\"",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"audit:astro": "npm run build && node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs",
|
"audit:astro": "npm run build && node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs",
|
||||||
"qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues",
|
"qa:astro-audit": "node scripts/install-playwright-deps.mjs && node scripts/export-astro-audit.mjs --fail-on-issues",
|
||||||
"qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
|
"qa:no-em-dashes": "node scripts/check-no-em-dashes.mjs",
|
||||||
|
"qa:spelling": "cspell --no-progress \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/**/*.mjs\" \"*.md\"",
|
||||||
|
"qa:no-github-links": "node scripts/check-no-github-links.mjs",
|
||||||
"qa:links": "node scripts/check-links.mjs",
|
"qa:links": "node scripts/check-links.mjs",
|
||||||
"qa:no-js": "node scripts/check-no-js.mjs",
|
"qa:no-js": "node scripts/check-no-js.mjs",
|
||||||
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
|
||||||
"qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs",
|
"qa:preview-cropping": "node scripts/install-playwright-deps.mjs && node scripts/check-preview-cropping.mjs",
|
||||||
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
|
"qa": "npm run typecheck && npm run lint && npm run qa:no-em-dashes && npm run qa:spelling && npm run qa:no-github-links && npm run build && npm run qa:astro-audit && npm run qa:links && npm run qa:no-js && npm run qa:overflow && npm run qa:preview-cropping"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/schmelczer/schmelczer.github.io.git"
|
"url": "git+https://git.schmelczer.dev/andras/schmelczer.dev.git"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"blog",
|
"blog",
|
||||||
|
|
@ -36,27 +38,29 @@
|
||||||
"author": "Andras Schmelczer",
|
"author": "Andras Schmelczer",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/schmelczer/schmelczer.github.io/issues"
|
"url": "https://git.schmelczer.dev/andras/schmelczer.dev/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
|
"homepage": "https://git.schmelczer.dev/andras/schmelczer.dev#readme",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/check": "^0.9.9",
|
"@astrojs/check": "^0.9.9",
|
||||||
"@astrojs/rss": "^4.0.18",
|
"@astrojs/rss": "^4.0.18",
|
||||||
"@astrojs/sitemap": "^3.7.2",
|
"@astrojs/sitemap": "^3.7.2",
|
||||||
|
"@types/node": "^22.13.0",
|
||||||
"astro": "^6.3.1",
|
"astro": "^6.3.1",
|
||||||
|
"cspell": "^10.0.1",
|
||||||
"playwright": "^1.59.1",
|
"playwright": "^1.59.1",
|
||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.14.1",
|
||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"unist-util-visit": "^5.1.0",
|
"unist-util-visit": "^5.1.0"
|
||||||
"sharp": "^0.34.5"
|
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@plausible-analytics/tracker": "^0.4.5"
|
"@plausible-analytics/tracker": "^0.4.5",
|
||||||
|
"sharp": "^0.34.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
WEBVTT
|
|
||||||
|
|
||||||
00:00.000 --> 00:04.000
|
|
||||||
No spoken dialogue. Game audio only.
|
|
||||||
|
|
||||||
00:04.000 --> 00:35.000
|
|
||||||
The Ad Astra handheld board runs the game on a small OLED display.
|
|
||||||
|
|
||||||
00:35.000 --> 01:05.000
|
|
||||||
The player controls the game through the IR input while the engine updates the display in real time.
|
|
||||||
|
|
||||||
01:05.000 --> 01:34.600
|
|
||||||
The clip continues showing gameplay on the custom ATtiny85-based board.
|
|
||||||
|
|
@ -1,59 +1,17 @@
|
||||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { dist, requireDist, resolveFile } from './lib/dist.mjs';
|
||||||
|
import { walk } from './lib/walk.mjs';
|
||||||
|
|
||||||
const dist = path.resolve('dist');
|
|
||||||
const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']);
|
const allowedPreservedRoutes = new Set(['/fleeting/', '/reconcile/']);
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
async function walk(dir) {
|
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await walk(fullPath)));
|
|
||||||
} else {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function exists(file) {
|
|
||||||
try {
|
|
||||||
return (await stat(file)).isFile();
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function targetExists(pathname) {
|
async function targetExists(pathname) {
|
||||||
if (allowedPreservedRoutes.has(pathname)) return true;
|
if (allowedPreservedRoutes.has(pathname)) return true;
|
||||||
|
return (await resolveFile(pathname)) !== null;
|
||||||
const safePath = path
|
|
||||||
.normalize(decodeURIComponent(pathname))
|
|
||||||
.replace(/^\/+/, '')
|
|
||||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
|
||||||
const candidate = path.join(dist, safePath);
|
|
||||||
const candidates = [
|
|
||||||
candidate,
|
|
||||||
path.join(candidate, 'index.html'),
|
|
||||||
path.join(dist, `${safePath}.html`),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of candidates) {
|
|
||||||
if (await exists(file)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await requireDist();
|
||||||
await stat(dist);
|
|
||||||
} catch {
|
|
||||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await walk(dist);
|
const files = await walk(dist);
|
||||||
const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
|
const checkedFiles = files.filter((file) => /\.(html|xml|css|webmanifest)$/.test(file));
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { walk } from './lib/walk.mjs';
|
||||||
|
|
||||||
const forbidden = String.fromCodePoint(0x2014);
|
const forbidden = String.fromCodePoint(0x2014);
|
||||||
const root = process.cwd();
|
const root = process.cwd();
|
||||||
|
|
@ -14,7 +15,6 @@ const textExtensions = new Set([
|
||||||
'.mjs',
|
'.mjs',
|
||||||
'.ts',
|
'.ts',
|
||||||
'.txt',
|
'.txt',
|
||||||
'.vtt',
|
|
||||||
'.webmanifest',
|
'.webmanifest',
|
||||||
'.xml',
|
'.xml',
|
||||||
]);
|
]);
|
||||||
|
|
@ -37,25 +37,6 @@ async function exists(filePath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walk(entryPath) {
|
|
||||||
const entryStat = await stat(entryPath);
|
|
||||||
if (entryStat.isFile()) return [entryPath];
|
|
||||||
|
|
||||||
const entries = await readdir(entryPath, { withFileTypes: true });
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(entryPath, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await walk(fullPath)));
|
|
||||||
} else if (entry.isFile()) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineAndColumn(text, index) {
|
function lineAndColumn(text, index) {
|
||||||
const before = text.slice(0, index);
|
const before = text.slice(0, index);
|
||||||
const lines = before.split('\n');
|
const lines = before.split('\n');
|
||||||
|
|
|
||||||
99
scripts/check-no-github-links.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// cspell:ignore githubusercontent streetsidesoftware forgejo
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { walk } from './lib/walk.mjs';
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
|
||||||
|
// GitHub is no longer linked from anywhere on the site: project source links,
|
||||||
|
// the social links, and the repo metadata all point at the self-hosted git
|
||||||
|
// host instead. Matching the host substrings (rather than the bare word
|
||||||
|
// "github") keeps the Shiki `github-light`/`github-dark` themes and the
|
||||||
|
// Forgejo Actions `github.*` context variables from tripping the check.
|
||||||
|
const bannedHosts = [/github\.com/i, /github\.io/i, /githubusercontent\.com/i];
|
||||||
|
|
||||||
|
// Upstream tooling URLs that genuinely live on GitHub and have no self-hosted
|
||||||
|
// equivalent (e.g. the cspell config JSON Schema). A flagged URL is permitted
|
||||||
|
// only when it exactly equals one of these.
|
||||||
|
const permitted = new Set([
|
||||||
|
'https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const textExtensions = new Set([
|
||||||
|
'.astro',
|
||||||
|
'.css',
|
||||||
|
'.html',
|
||||||
|
'.js',
|
||||||
|
'.json',
|
||||||
|
'.md',
|
||||||
|
'.mjs',
|
||||||
|
'.ts',
|
||||||
|
'.txt',
|
||||||
|
'.webmanifest',
|
||||||
|
'.xml',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roots = [
|
||||||
|
'src',
|
||||||
|
'public',
|
||||||
|
'scripts',
|
||||||
|
'README.md',
|
||||||
|
'package.json',
|
||||||
|
'astro.config.mjs',
|
||||||
|
'cspell.json',
|
||||||
|
].map((entry) => path.resolve(root, entry));
|
||||||
|
|
||||||
|
async function exists(filePath) {
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grows a match outward to the whole whitespace/quote-delimited URL token so it
|
||||||
|
// can be compared against the permitted set.
|
||||||
|
function urlToken(line, index, length) {
|
||||||
|
const boundary = /[\s"'`<>()[\]{}]/;
|
||||||
|
let start = index;
|
||||||
|
while (start > 0 && !boundary.test(line[start - 1])) start -= 1;
|
||||||
|
let end = index + length;
|
||||||
|
while (end < line.length && !boundary.test(line[end])) end += 1;
|
||||||
|
return line.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of roots) {
|
||||||
|
if (!(await exists(entry))) continue;
|
||||||
|
files.push(...(await walk(entry)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const textFiles = files.filter((file) => textExtensions.has(path.extname(file)));
|
||||||
|
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
for (const file of textFiles) {
|
||||||
|
const lines = (await readFile(file, 'utf8')).split('\n');
|
||||||
|
|
||||||
|
lines.forEach((line, lineIndex) => {
|
||||||
|
for (const pattern of bannedHosts) {
|
||||||
|
for (const match of line.matchAll(new RegExp(pattern, 'gi'))) {
|
||||||
|
const token = urlToken(line, match.index, match[0].length);
|
||||||
|
if (permitted.has(token)) continue;
|
||||||
|
const location = `${path.relative(root, file)}:${lineIndex + 1}:${match.index + 1}`;
|
||||||
|
failures.push(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error(
|
||||||
|
`GitHub links are not allowed; link the self-hosted git host instead:\n${failures.join('\n')}`
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('No GitHub links found.');
|
||||||
|
|
@ -1,30 +1,10 @@
|
||||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import { dist, requireDist } from './lib/dist.mjs';
|
||||||
|
import { walk } from './lib/walk.mjs';
|
||||||
|
|
||||||
const dist = path.resolve('dist');
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
async function walk(dir) {
|
await requireDist();
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await walk(fullPath)));
|
|
||||||
} else {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(dist);
|
|
||||||
} catch {
|
|
||||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await walk(dist);
|
const files = await walk(dist);
|
||||||
const ALLOWED_JS_ASSET_PATTERNS = [
|
const ALLOWED_JS_ASSET_PATTERNS = [
|
||||||
|
|
|
||||||
|
|
@ -1,200 +1,32 @@
|
||||||
import { createServer } from 'node:http';
|
|
||||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { chromium } from 'playwright';
|
import {
|
||||||
|
CONTEXT_TIMEOUT_MS,
|
||||||
|
PAGE_TIMEOUT_MS,
|
||||||
|
cleanupBrowserTmp,
|
||||||
|
openBrowser,
|
||||||
|
safeCloseBrowser,
|
||||||
|
safeCloseContext,
|
||||||
|
safeClosePage,
|
||||||
|
setupBrowserTmp,
|
||||||
|
shouldRetryNavigation,
|
||||||
|
withTimeout,
|
||||||
|
} from './lib/browser.mjs';
|
||||||
|
import { discoverRoutes, requireDist, startDistServer } from './lib/dist.mjs';
|
||||||
|
|
||||||
const dist = path.resolve('dist');
|
|
||||||
const browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
|
const browserTmp = path.resolve('.astro', 'playwright-overflow-tmp');
|
||||||
const INDEX_FILE = 'index.html';
|
|
||||||
const MAX_NAV_RETRIES = 4;
|
const MAX_NAV_RETRIES = 4;
|
||||||
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
||||||
// iPad landscape / common laptop / full HD desktop.
|
// iPad landscape / common laptop / full HD desktop.
|
||||||
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||||
const CLOSE_TIMEOUT_MS = 3000;
|
|
||||||
const LAUNCH_TIMEOUT_MS = 10000;
|
|
||||||
const CONTEXT_TIMEOUT_MS = 8000;
|
|
||||||
const PAGE_TIMEOUT_MS = 15000;
|
|
||||||
const MEASURE_TIMEOUT_MS = 25000;
|
const MEASURE_TIMEOUT_MS = 25000;
|
||||||
|
|
||||||
const MIME = {
|
await requireDist();
|
||||||
'.html': 'text/html; charset=utf-8',
|
await setupBrowserTmp(browserTmp);
|
||||||
'.css': 'text/css; charset=utf-8',
|
|
||||||
'.js': 'text/javascript; charset=utf-8',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.avif': 'image/avif',
|
|
||||||
'.ico': 'image/x-icon',
|
|
||||||
'.woff': 'font/woff',
|
|
||||||
'.woff2': 'font/woff2',
|
|
||||||
'.mp4': 'video/mp4',
|
|
||||||
'.webm': 'video/webm',
|
|
||||||
'.vtt': 'text/vtt; charset=utf-8',
|
|
||||||
'.pdf': 'application/pdf',
|
|
||||||
};
|
|
||||||
|
|
||||||
function contentType(file) {
|
|
||||||
const ext = path.extname(file).toLowerCase();
|
|
||||||
return MIME[ext] ?? 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walk(dir) {
|
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
const files = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await walk(fullPath)));
|
|
||||||
} else {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverRoutes() {
|
|
||||||
const files = await walk(dist);
|
|
||||||
const routes = new Set();
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.endsWith('.html')) continue;
|
|
||||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
|
||||||
if (rel === '404.html') continue;
|
|
||||||
if (rel === INDEX_FILE) {
|
|
||||||
routes.add('/');
|
|
||||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
|
||||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
|
||||||
} else {
|
|
||||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...routes].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveFile(url) {
|
|
||||||
const parsed = new URL(url, 'http://localhost');
|
|
||||||
const safePath = path
|
|
||||||
.normalize(decodeURIComponent(parsed.pathname))
|
|
||||||
.replace(/^\/+/, '')
|
|
||||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
|
||||||
const candidate = path.join(dist, safePath);
|
|
||||||
const candidates = [
|
|
||||||
candidate,
|
|
||||||
path.join(candidate, 'index.html'),
|
|
||||||
path.join(dist, `${safePath}.html`),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of candidates) {
|
|
||||||
try {
|
|
||||||
const fileStat = await stat(file);
|
|
||||||
if (fileStat.isFile()) return file;
|
|
||||||
} catch {
|
|
||||||
// Try the next candidate.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.join(dist, '404.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(dist);
|
|
||||||
} catch {
|
|
||||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some CI/dev containers mount /tmp as a very small tmpfs. Chromium uses the
|
|
||||||
// process temp directory for profiles and internal files; putting it under the
|
|
||||||
// already-ignored .astro/ directory keeps the overflow check reproducible even
|
|
||||||
// when the system temp mount is full.
|
|
||||||
await rm(browserTmp, { recursive: true, force: true });
|
|
||||||
await mkdir(browserTmp, { recursive: true });
|
|
||||||
process.env.TMPDIR = browserTmp;
|
|
||||||
process.env.TMP = browserTmp;
|
|
||||||
process.env.TEMP = browserTmp;
|
|
||||||
|
|
||||||
const routes = await discoverRoutes();
|
const routes = await discoverRoutes();
|
||||||
|
const { server, port } = await startDistServer();
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
const file = await resolveFile(req.url ?? '/');
|
|
||||||
const body = await readFile(file);
|
|
||||||
res.writeHead(200, { 'content-type': contentType(file) });
|
|
||||||
res.end(body);
|
|
||||||
} catch (error) {
|
|
||||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
||||||
res.end(String(error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
||||||
const { port } = server.address();
|
|
||||||
const failures = [];
|
const failures = [];
|
||||||
|
|
||||||
function launchBrowser() {
|
|
||||||
return chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TMPDIR: browserTmp,
|
|
||||||
TMP: browserTmp,
|
|
||||||
TEMP: browserTmp,
|
|
||||||
},
|
|
||||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withTimeout(promise, timeoutMs, label) {
|
|
||||||
let timeout;
|
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
promise,
|
|
||||||
new Promise((_, reject) => {
|
|
||||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeClosePage(page) {
|
|
||||||
await withTimeout(
|
|
||||||
page.close(),
|
|
||||||
CLOSE_TIMEOUT_MS,
|
|
||||||
'Timed out while closing Playwright page'
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeCloseContext(context) {
|
|
||||||
await withTimeout(
|
|
||||||
context.close(),
|
|
||||||
CLOSE_TIMEOUT_MS,
|
|
||||||
'Timed out while closing Playwright context'
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeCloseBrowser(browser) {
|
|
||||||
const childProcess = browser.process?.();
|
|
||||||
try {
|
|
||||||
await withTimeout(
|
|
||||||
browser.close(),
|
|
||||||
CLOSE_TIMEOUT_MS,
|
|
||||||
'Timed out while closing Chromium'
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
childProcess?.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openBrowser() {
|
|
||||||
return withTimeout(
|
|
||||||
launchBrowser(),
|
|
||||||
LAUNCH_TIMEOUT_MS,
|
|
||||||
'Timed out while launching Chromium'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newMeasurementContext(browser, width) {
|
async function newMeasurementContext(browser, width) {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
viewport: { width, height: 900 },
|
viewport: { width, height: 900 },
|
||||||
|
|
@ -227,13 +59,6 @@ async function measureViewport(page) {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRetryNavigation(error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
|
||||||
message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function measureRoute(context, route) {
|
async function measureRoute(context, route) {
|
||||||
let page;
|
let page;
|
||||||
try {
|
try {
|
||||||
|
|
@ -263,7 +88,7 @@ try {
|
||||||
let browser;
|
let browser;
|
||||||
let context;
|
let context;
|
||||||
try {
|
try {
|
||||||
browser = await openBrowser();
|
browser = await openBrowser(browserTmp);
|
||||||
context = await openMeasurementContext(browser, width);
|
context = await openMeasurementContext(browser, width);
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
let result;
|
let result;
|
||||||
|
|
@ -278,7 +103,7 @@ try {
|
||||||
}
|
}
|
||||||
await safeCloseContext(context);
|
await safeCloseContext(context);
|
||||||
await safeCloseBrowser(browser);
|
await safeCloseBrowser(browser);
|
||||||
browser = await openBrowser();
|
browser = await openBrowser(browserTmp);
|
||||||
context = await openMeasurementContext(browser, width);
|
context = await openMeasurementContext(browser, width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +121,7 @@ try {
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
await cleanupBrowserTmp(browserTmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,115 +1,31 @@
|
||||||
import { createServer } from 'node:http';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { chromium } from 'playwright';
|
import {
|
||||||
|
CONTEXT_TIMEOUT_MS,
|
||||||
|
PAGE_TIMEOUT_MS,
|
||||||
|
cleanupBrowserTmp,
|
||||||
|
openBrowser,
|
||||||
|
safeCloseBrowser,
|
||||||
|
safeCloseContext,
|
||||||
|
safeClosePage,
|
||||||
|
setupBrowserTmp,
|
||||||
|
shouldRetryNavigation,
|
||||||
|
withTimeout,
|
||||||
|
} from './lib/browser.mjs';
|
||||||
|
import { discoverRoutes, requireDist, startDistServer } from './lib/dist.mjs';
|
||||||
|
|
||||||
const dist = path.resolve('dist');
|
|
||||||
const previewCss = path.resolve('src/styles/global.css');
|
const previewCss = path.resolve('src/styles/global.css');
|
||||||
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
|
const browserTmp = path.resolve('.astro', 'playwright-preview-cropping-tmp');
|
||||||
const INDEX_FILE = 'index.html';
|
|
||||||
const PREVIEW_SELECTOR = '[data-uncropped-preview]';
|
const PREVIEW_SELECTOR = '[data-uncropped-preview]';
|
||||||
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
// Common device widths: iPhone SE / Galaxy S / iPhone 14 / iPad portrait /
|
||||||
// iPad landscape / common laptop / full HD desktop.
|
// iPad landscape / common laptop / full HD desktop.
|
||||||
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
const VIEWPORT_WIDTHS = [320, 390, 430, 768, 1024, 1440, 1920];
|
||||||
const MAX_NAV_RETRIES = 4;
|
const MAX_NAV_RETRIES = 4;
|
||||||
const CLOSE_TIMEOUT_MS = 3000;
|
|
||||||
const LAUNCH_TIMEOUT_MS = 10000;
|
|
||||||
const CONTEXT_TIMEOUT_MS = 8000;
|
|
||||||
const PAGE_TIMEOUT_MS = 15000;
|
|
||||||
const MEASURE_TIMEOUT_MS = 30000;
|
const MEASURE_TIMEOUT_MS = 30000;
|
||||||
const CLIP_TOLERANCE_PX = 0.75;
|
const CLIP_TOLERANCE_PX = 0.75;
|
||||||
const RATIO_TOLERANCE = 0.01;
|
const RATIO_TOLERANCE = 0.01;
|
||||||
|
|
||||||
const MIME = {
|
await requireDist();
|
||||||
'.html': 'text/html; charset=utf-8',
|
|
||||||
'.css': 'text/css; charset=utf-8',
|
|
||||||
'.js': 'text/javascript; charset=utf-8',
|
|
||||||
'.svg': 'image/svg+xml',
|
|
||||||
'.png': 'image/png',
|
|
||||||
'.jpg': 'image/jpeg',
|
|
||||||
'.jpeg': 'image/jpeg',
|
|
||||||
'.webp': 'image/webp',
|
|
||||||
'.avif': 'image/avif',
|
|
||||||
'.ico': 'image/x-icon',
|
|
||||||
'.woff': 'font/woff',
|
|
||||||
'.woff2': 'font/woff2',
|
|
||||||
'.mp4': 'video/mp4',
|
|
||||||
'.webm': 'video/webm',
|
|
||||||
'.vtt': 'text/vtt; charset=utf-8',
|
|
||||||
'.pdf': 'application/pdf',
|
|
||||||
};
|
|
||||||
|
|
||||||
function contentType(file) {
|
|
||||||
const ext = path.extname(file).toLowerCase();
|
|
||||||
return MIME[ext] ?? 'application/octet-stream';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function walk(dir) {
|
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await walk(fullPath)));
|
|
||||||
} else {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverRoutes() {
|
|
||||||
const files = await walk(dist);
|
|
||||||
const routes = new Set();
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.endsWith('.html')) continue;
|
|
||||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
|
||||||
if (rel === '404.html') continue;
|
|
||||||
if (rel === INDEX_FILE) {
|
|
||||||
routes.add('/');
|
|
||||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
|
||||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
|
||||||
} else {
|
|
||||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...routes].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveFile(url) {
|
|
||||||
const parsed = new URL(url, 'http://localhost');
|
|
||||||
const safePath = path
|
|
||||||
.normalize(decodeURIComponent(parsed.pathname))
|
|
||||||
.replace(/^\/+/, '')
|
|
||||||
.replace(/^(\.\.(\/|\\|$))+/, '');
|
|
||||||
const candidate = path.join(dist, safePath);
|
|
||||||
const candidates = [
|
|
||||||
candidate,
|
|
||||||
path.join(candidate, 'index.html'),
|
|
||||||
path.join(dist, `${safePath}.html`),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const file of candidates) {
|
|
||||||
try {
|
|
||||||
const fileStat = await stat(file);
|
|
||||||
if (fileStat.isFile()) return file;
|
|
||||||
} catch {
|
|
||||||
// Try the next candidate.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.join(dist, '404.html');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await stat(dist);
|
|
||||||
} catch {
|
|
||||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
function lineAndColumn(text, index) {
|
function lineAndColumn(text, index) {
|
||||||
const before = text.slice(0, index);
|
const before = text.slice(0, index);
|
||||||
|
|
@ -197,95 +113,11 @@ async function checkPreviewCroppingStyles() {
|
||||||
return styleFailures;
|
return styleFailures;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep Chromium temp files inside the repo so the check is reproducible in CI
|
await setupBrowserTmp(browserTmp);
|
||||||
// containers with very small /tmp mounts.
|
|
||||||
await rm(browserTmp, { recursive: true, force: true });
|
|
||||||
await mkdir(browserTmp, { recursive: true });
|
|
||||||
process.env.TMPDIR = browserTmp;
|
|
||||||
process.env.TMP = browserTmp;
|
|
||||||
process.env.TEMP = browserTmp;
|
|
||||||
|
|
||||||
const routes = await discoverRoutes();
|
const routes = await discoverRoutes();
|
||||||
const failures = await checkPreviewCroppingStyles();
|
const failures = await checkPreviewCroppingStyles();
|
||||||
|
const { server, port } = await startDistServer();
|
||||||
const server = createServer(async (req, res) => {
|
|
||||||
try {
|
|
||||||
const file = await resolveFile(req.url ?? '/');
|
|
||||||
const body = await readFile(file);
|
|
||||||
res.writeHead(200, { 'content-type': contentType(file) });
|
|
||||||
res.end(body);
|
|
||||||
} catch (error) {
|
|
||||||
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
|
||||||
res.end(String(error));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
||||||
const { port } = server.address();
|
|
||||||
|
|
||||||
function launchBrowser() {
|
|
||||||
return chromium.launch({
|
|
||||||
headless: true,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TMPDIR: browserTmp,
|
|
||||||
TMP: browserTmp,
|
|
||||||
TEMP: browserTmp,
|
|
||||||
},
|
|
||||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withTimeout(promise, timeoutMs, label) {
|
|
||||||
let timeout;
|
|
||||||
try {
|
|
||||||
return await Promise.race([
|
|
||||||
promise,
|
|
||||||
new Promise((_, reject) => {
|
|
||||||
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeClosePage(page) {
|
|
||||||
await withTimeout(
|
|
||||||
page.close(),
|
|
||||||
CLOSE_TIMEOUT_MS,
|
|
||||||
'Timed out while closing Playwright page'
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeCloseContext(context) {
|
|
||||||
await withTimeout(
|
|
||||||
context.close(),
|
|
||||||
CLOSE_TIMEOUT_MS,
|
|
||||||
'Timed out while closing Playwright context'
|
|
||||||
).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function safeCloseBrowser(browser) {
|
|
||||||
const childProcess = browser.process?.();
|
|
||||||
try {
|
|
||||||
await withTimeout(
|
|
||||||
browser.close(),
|
|
||||||
CLOSE_TIMEOUT_MS,
|
|
||||||
'Timed out while closing Chromium'
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
childProcess?.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openBrowser() {
|
|
||||||
return withTimeout(
|
|
||||||
launchBrowser(),
|
|
||||||
LAUNCH_TIMEOUT_MS,
|
|
||||||
'Timed out while launching Chromium'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newMeasurementContext(browser, width) {
|
async function newMeasurementContext(browser, width) {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
|
|
@ -451,13 +283,6 @@ async function inspectPreviews(page, route, width, phase, index = null) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRetryNavigation(error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
|
||||||
message
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function measureRoute(context, route, width) {
|
async function measureRoute(context, route, width) {
|
||||||
let page;
|
let page;
|
||||||
|
|
||||||
|
|
@ -492,7 +317,7 @@ try {
|
||||||
let context;
|
let context;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
browser = await openBrowser();
|
browser = await openBrowser(browserTmp);
|
||||||
context = await openMeasurementContext(browser, width);
|
context = await openMeasurementContext(browser, width);
|
||||||
|
|
||||||
for (const route of routes) {
|
for (const route of routes) {
|
||||||
|
|
@ -509,7 +334,7 @@ try {
|
||||||
|
|
||||||
await safeCloseContext(context);
|
await safeCloseContext(context);
|
||||||
await safeCloseBrowser(browser);
|
await safeCloseBrowser(browser);
|
||||||
browser = await openBrowser();
|
browser = await openBrowser(browserTmp);
|
||||||
context = await openMeasurementContext(browser, width);
|
context = await openMeasurementContext(browser, width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +348,7 @@ try {
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
server.close();
|
server.close();
|
||||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
await cleanupBrowserTmp(browserTmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,16 @@
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
import { once } from 'node:events';
|
import { once } from 'node:events';
|
||||||
import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import { createServer as createNetServer } from 'node:net';
|
import { createServer as createNetServer } from 'node:net';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { chromium } from 'playwright';
|
import {
|
||||||
|
cleanupBrowserTmp,
|
||||||
|
openBrowser,
|
||||||
|
safeCloseBrowser,
|
||||||
|
setupBrowserTmp,
|
||||||
|
} from './lib/browser.mjs';
|
||||||
|
import { discoverRoutes, requireDist } from './lib/dist.mjs';
|
||||||
|
|
||||||
const dist = path.resolve('dist');
|
|
||||||
const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
|
const browserTmp = path.resolve('.astro', 'playwright-astro-audit-tmp');
|
||||||
const outputJson = path.resolve(
|
const outputJson = path.resolve(
|
||||||
process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
|
process.env.ASTRO_AUDIT_OUTPUT_JSON ?? '.astro/astro-audit-results.json'
|
||||||
|
|
@ -19,7 +24,6 @@ const astroBin = path.resolve(
|
||||||
process.platform === 'win32' ? 'astro.cmd' : 'astro'
|
process.platform === 'win32' ? 'astro.cmd' : 'astro'
|
||||||
);
|
);
|
||||||
const HOST = '127.0.0.1';
|
const HOST = '127.0.0.1';
|
||||||
const INDEX_FILE = 'index.html';
|
|
||||||
const SERVER_START_TIMEOUT_MS = 60000;
|
const SERVER_START_TIMEOUT_MS = 60000;
|
||||||
const CLOSE_TIMEOUT_MS = 3000;
|
const CLOSE_TIMEOUT_MS = 3000;
|
||||||
const NAV_TIMEOUT_MS = 20000;
|
const NAV_TIMEOUT_MS = 20000;
|
||||||
|
|
@ -51,21 +55,7 @@ function parseViewports(raw = process.env.ASTRO_AUDIT_VIEWPORTS ?? DEFAULT_VIEWP
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function walk(dir) {
|
async function discoverAuditRoutes() {
|
||||||
const entries = await readdir(dir, { withFileTypes: true });
|
|
||||||
const files = [];
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(dir, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await walk(fullPath)));
|
|
||||||
} else {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverRoutes() {
|
|
||||||
if (process.env.ASTRO_AUDIT_ROUTES) {
|
if (process.env.ASTRO_AUDIT_ROUTES) {
|
||||||
return process.env.ASTRO_AUDIT_ROUTES.split(',')
|
return process.env.ASTRO_AUDIT_ROUTES.split(',')
|
||||||
.map((route) => route.trim())
|
.map((route) => route.trim())
|
||||||
|
|
@ -73,29 +63,8 @@ async function discoverRoutes() {
|
||||||
.map((route) => (route.startsWith('/') ? route : `/${route}`));
|
.map((route) => (route.startsWith('/') ? route : `/${route}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await requireDist();
|
||||||
await stat(dist);
|
return discoverRoutes();
|
||||||
} catch {
|
|
||||||
throw new Error('dist/ does not exist. Run npm run build first.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = await walk(dist);
|
|
||||||
const routes = new Set();
|
|
||||||
for (const file of files) {
|
|
||||||
if (!file.endsWith('.html')) continue;
|
|
||||||
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
|
||||||
if (rel === '404.html') continue;
|
|
||||||
|
|
||||||
if (rel === INDEX_FILE) {
|
|
||||||
routes.add('/');
|
|
||||||
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
|
||||||
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
|
||||||
} else {
|
|
||||||
routes.add('/' + rel.replace(/\.html$/, '/'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...routes].sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFreePort() {
|
async function getFreePort() {
|
||||||
|
|
@ -169,20 +138,6 @@ async function stopProcess(child) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function safeCloseBrowser(browser) {
|
|
||||||
const childProcess = browser?.process?.();
|
|
||||||
try {
|
|
||||||
await Promise.race([
|
|
||||||
browser.close(),
|
|
||||||
sleep(CLOSE_TIMEOUT_MS).then(() => {
|
|
||||||
throw new Error('Timed out while closing Chromium');
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
} catch {
|
|
||||||
childProcess?.kill('SIGKILL');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function viewportLabel(viewport) {
|
function viewportLabel(viewport) {
|
||||||
return `${viewport.width}x${viewport.height}`;
|
return `${viewport.width}x${viewport.height}`;
|
||||||
}
|
}
|
||||||
|
|
@ -385,19 +340,15 @@ function renderMarkdown(report) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewports = parseViewports();
|
const viewports = parseViewports();
|
||||||
const routes = await discoverRoutes();
|
const routes = await discoverAuditRoutes();
|
||||||
|
|
||||||
if (routes.length === 0) {
|
if (routes.length === 0) {
|
||||||
throw new Error('No HTML routes found to audit.');
|
throw new Error('No HTML routes found to audit.');
|
||||||
}
|
}
|
||||||
|
|
||||||
await rm(browserTmp, { recursive: true, force: true });
|
await setupBrowserTmp(browserTmp);
|
||||||
await mkdir(browserTmp, { recursive: true });
|
|
||||||
await mkdir(path.dirname(outputJson), { recursive: true });
|
await mkdir(path.dirname(outputJson), { recursive: true });
|
||||||
await mkdir(path.dirname(outputMarkdown), { recursive: true });
|
await mkdir(path.dirname(outputMarkdown), { recursive: true });
|
||||||
process.env.TMPDIR = browserTmp;
|
|
||||||
process.env.TMP = browserTmp;
|
|
||||||
process.env.TEMP = browserTmp;
|
|
||||||
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const baseUrl = `http://${HOST}:${port}/`;
|
const baseUrl = `http://${HOST}:${port}/`;
|
||||||
|
|
@ -409,16 +360,7 @@ try {
|
||||||
devServer = startAstroDev(port);
|
devServer = startAstroDev(port);
|
||||||
await waitForDevServer(baseUrl, devServer);
|
await waitForDevServer(baseUrl, devServer);
|
||||||
|
|
||||||
browser = await chromium.launch({
|
browser = await openBrowser(browserTmp);
|
||||||
headless: true,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
|
||||||
TMPDIR: browserTmp,
|
|
||||||
TMP: browserTmp,
|
|
||||||
TEMP: browserTmp,
|
|
||||||
},
|
|
||||||
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const viewport of viewports) {
|
for (const viewport of viewports) {
|
||||||
const context = await browser.newContext({
|
const context = await browser.newContext({
|
||||||
|
|
@ -480,5 +422,5 @@ try {
|
||||||
} finally {
|
} finally {
|
||||||
if (browser) await safeCloseBrowser(browser);
|
if (browser) await safeCloseBrowser(browser);
|
||||||
if (devServer) await stopProcess(devServer);
|
if (devServer) await stopProcess(devServer);
|
||||||
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
await cleanupBrowserTmp(browserTmp);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
89
scripts/lib/browser.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { mkdir, rm } from 'node:fs/promises';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
export const CLOSE_TIMEOUT_MS = 3000;
|
||||||
|
export const LAUNCH_TIMEOUT_MS = 10000;
|
||||||
|
export const CONTEXT_TIMEOUT_MS = 8000;
|
||||||
|
export const PAGE_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
|
// Chromium puts profiles and internal files in the process temp directory.
|
||||||
|
// Some CI/dev containers mount /tmp as a very small tmpfs, so the checks point
|
||||||
|
// it at an already-ignored directory under .astro/ instead.
|
||||||
|
export async function setupBrowserTmp(browserTmp) {
|
||||||
|
await rm(browserTmp, { recursive: true, force: true });
|
||||||
|
await mkdir(browserTmp, { recursive: true });
|
||||||
|
process.env.TMPDIR = browserTmp;
|
||||||
|
process.env.TMP = browserTmp;
|
||||||
|
process.env.TEMP = browserTmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupBrowserTmp(browserTmp) {
|
||||||
|
await rm(browserTmp, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTimeout(promise, timeoutMs, label) {
|
||||||
|
let timeout;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timeout = setTimeout(() => reject(new Error(label)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeClosePage(page) {
|
||||||
|
await withTimeout(
|
||||||
|
page.close(),
|
||||||
|
CLOSE_TIMEOUT_MS,
|
||||||
|
'Timed out while closing Playwright page'
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeCloseContext(context) {
|
||||||
|
await withTimeout(
|
||||||
|
context.close(),
|
||||||
|
CLOSE_TIMEOUT_MS,
|
||||||
|
'Timed out while closing Playwright context'
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function safeCloseBrowser(browser) {
|
||||||
|
const childProcess = browser.process?.();
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
browser.close(),
|
||||||
|
CLOSE_TIMEOUT_MS,
|
||||||
|
'Timed out while closing Chromium'
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
childProcess?.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBrowser(browserTmp) {
|
||||||
|
return withTimeout(
|
||||||
|
chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TMPDIR: browserTmp,
|
||||||
|
TMP: browserTmp,
|
||||||
|
TEMP: browserTmp,
|
||||||
|
},
|
||||||
|
args: ['--disable-dev-shm-usage', '--disable-gpu', '--no-sandbox'],
|
||||||
|
}),
|
||||||
|
LAUNCH_TIMEOUT_MS,
|
||||||
|
'Timed out while launching Chromium'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRetryNavigation(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
return /ERR_INSUFFICIENT_RESOURCES|Execution context was destroyed|Target.*closed|has been closed|Timed out while|navigation/i.test(
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
107
scripts/lib/dist.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { readFile, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { walk } from './walk.mjs';
|
||||||
|
|
||||||
|
export const dist = path.resolve('dist');
|
||||||
|
|
||||||
|
const INDEX_FILE = 'index.html';
|
||||||
|
|
||||||
|
export async function requireDist() {
|
||||||
|
try {
|
||||||
|
await stat(dist);
|
||||||
|
} catch {
|
||||||
|
throw new Error('dist/ does not exist. Run npm run build first.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derives the site's route list from the HTML files in dist/.
|
||||||
|
export async function discoverRoutes() {
|
||||||
|
const files = await walk(dist);
|
||||||
|
const routes = new Set();
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.endsWith('.html')) continue;
|
||||||
|
const rel = path.relative(dist, file).replaceAll(path.sep, '/');
|
||||||
|
if (rel === '404.html') continue;
|
||||||
|
if (rel === INDEX_FILE) {
|
||||||
|
routes.add('/');
|
||||||
|
} else if (rel.endsWith(`/${INDEX_FILE}`)) {
|
||||||
|
routes.add('/' + rel.slice(0, -INDEX_FILE.length));
|
||||||
|
} else {
|
||||||
|
routes.add('/' + rel.replace(/\.html$/, '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...routes].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps a request URL (or pathname) to a file in dist/, mirroring static-host
|
||||||
|
// semantics: the path itself, its directory index.html, or the extensionless
|
||||||
|
// .html variant. Returns null when nothing matches.
|
||||||
|
export async function resolveFile(url) {
|
||||||
|
const parsed = new URL(url, 'http://localhost');
|
||||||
|
const safePath = path
|
||||||
|
.normalize(decodeURIComponent(parsed.pathname))
|
||||||
|
.replace(/^\/+/, '')
|
||||||
|
.replace(/^(\.\.(\/|\\|$))+/, '');
|
||||||
|
const candidate = path.join(dist, safePath);
|
||||||
|
const candidates = [
|
||||||
|
candidate,
|
||||||
|
path.join(candidate, 'index.html'),
|
||||||
|
path.join(dist, `${safePath}.html`),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const file of candidates) {
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(file);
|
||||||
|
if (fileStat.isFile()) return file;
|
||||||
|
} catch {
|
||||||
|
// Try the next candidate.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.avif': 'image/avif',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function contentType(file) {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
return MIME[ext] ?? 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serves dist/ on an ephemeral localhost port, falling back to 404.html.
|
||||||
|
// Returns the server (callers own closing it) and the assigned port.
|
||||||
|
export async function startDistServer() {
|
||||||
|
const server = createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = (await resolveFile(req.url ?? '/')) ?? path.join(dist, '404.html');
|
||||||
|
const body = await readFile(file);
|
||||||
|
res.writeHead(200, { 'content-type': contentType(file) });
|
||||||
|
res.end(body);
|
||||||
|
} catch (error) {
|
||||||
|
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end(String(error));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
return { server, port: server.address().port };
|
||||||
|
}
|
||||||
23
scripts/lib/walk.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { readdir, stat } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
// Recursively lists every file under entryPath. A path that is itself a file
|
||||||
|
// resolves to a single-element list, so callers can mix files and directories.
|
||||||
|
export async function walk(entryPath) {
|
||||||
|
const entryStat = await stat(entryPath);
|
||||||
|
if (entryStat.isFile()) return [entryPath];
|
||||||
|
|
||||||
|
const entries = await readdir(entryPath, { withFileTypes: true });
|
||||||
|
const files = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(entryPath, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await walk(fullPath)));
|
||||||
|
} else {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,7 @@ import TagList from './TagList.astro';
|
||||||
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
|
import { ARTICLE_THUMBNAIL, articlePath, formatDate, formatDateShort } from '../lib/site';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
posts: CollectionEntry<'posts'>[];
|
posts: CollectionEntry<'work'>[];
|
||||||
showYear?: boolean;
|
showYear?: boolean;
|
||||||
tagLimit?: number;
|
tagLimit?: number;
|
||||||
timeline?: boolean;
|
timeline?: boolean;
|
||||||
|
|
@ -39,7 +39,7 @@ const {
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<p>{post.data.description}</p>
|
<p>{post.data.description}</p>
|
||||||
<TagList tags={post.data.tags} limit={tagLimit} />
|
<TagList tags={post.data.article?.tags ?? []} limit={tagLimit} />
|
||||||
</article>
|
</article>
|
||||||
<time datetime={post.data.date.toISOString()}>
|
<time datetime={post.data.date.toISOString()}>
|
||||||
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
{showYear ? formatDate(post.data.date) : formatDateShort(post.data.date)}
|
||||||
|
|
|
||||||
|
|
@ -2,34 +2,22 @@
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import ProjectLinks from './ProjectLinks.astro';
|
import ProjectLinks from './ProjectLinks.astro';
|
||||||
|
|
||||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
type Link = CollectionEntry<'work'>['data']['links'][number];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role?: string;
|
|
||||||
projectPeriod?: string;
|
projectPeriod?: string;
|
||||||
stack?: string[];
|
stack?: string[];
|
||||||
scale?: string;
|
scale?: string;
|
||||||
outcome?: string;
|
|
||||||
links?: Link[];
|
links?: Link[];
|
||||||
headingId: string;
|
headingId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { projectPeriod, stack = [], scale, links = [], headingId } = Astro.props;
|
||||||
role,
|
|
||||||
projectPeriod,
|
|
||||||
stack = [],
|
|
||||||
scale,
|
|
||||||
outcome,
|
|
||||||
links = [],
|
|
||||||
headingId,
|
|
||||||
} = Astro.props;
|
|
||||||
|
|
||||||
const rows: Array<[string, string]> = [];
|
const rows: Array<[string, string]> = [];
|
||||||
if (role) rows.push(['Role', role]);
|
|
||||||
if (projectPeriod) rows.push(['Period', projectPeriod]);
|
if (projectPeriod) rows.push(['Period', projectPeriod]);
|
||||||
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
|
if (stack.length > 0) rows.push(['Stack', stack.join(', ')]);
|
||||||
if (scale) rows.push(['Scale', scale]);
|
if (scale) rows.push(['Scale', scale]);
|
||||||
if (outcome) rows.push(['Outcome', outcome]);
|
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const year = new Date().getFullYear();
|
||||||
<address class="footer-contact">
|
<address class="footer-contact">
|
||||||
<a href={`mailto:${site.email}`}>Email</a>
|
<a href={`mailto:${site.email}`}>Email</a>
|
||||||
<a href={site.cv} rel="noopener">CV</a>
|
<a href={site.cv} rel="noopener">CV</a>
|
||||||
<a href={site.github} rel="noopener me">GitHub</a>
|
<a href={site.git} rel="noopener me">Git</a>
|
||||||
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
<a href={site.linkedin} rel="noopener me">LinkedIn</a>
|
||||||
</address>
|
</address>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
---
|
---
|
||||||
import { navItems, site } from '../lib/site';
|
import { THEME_BG, navItems, normalizeTrailingSlash, site } from '../lib/site';
|
||||||
|
|
||||||
const currentPath = Astro.url.pathname;
|
const current = normalizeTrailingSlash(Astro.url.pathname);
|
||||||
const current =
|
|
||||||
currentPath === '/' || currentPath.endsWith('/') || /\.[^/]+$/.test(currentPath)
|
|
||||||
? currentPath
|
|
||||||
: `${currentPath}/`;
|
|
||||||
|
|
||||||
// Exact match for the current page; section match (descendant URLs) for
|
// Exact match for the current page; section match (descendant URLs) for
|
||||||
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
// ancestor links. `aria-current="page"` is reserved for the exact page,
|
||||||
|
|
@ -92,17 +88,16 @@ const headerNavItems = navItems.filter((item) => item.href !== '/' && !item.foot
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<script is:inline data-theme-script>
|
<script is:inline data-theme-script define:vars={{ THEME_BG }}>
|
||||||
// Co-located with the button so the initial aria state is set as soon as the
|
// Co-located with the button so the initial aria state is set as soon as the
|
||||||
// button parses, avoiding a flash of the wrong icon. The theme itself is
|
// button parses, avoiding a flash of the wrong icon. The theme itself is
|
||||||
// already on <html> from theme-init.js in <head>.
|
// already on <html> from theme-init.js in <head>. THEME_BG is injected from
|
||||||
|
// src/lib/site.ts via define:vars.
|
||||||
(function () {
|
(function () {
|
||||||
var root = document.documentElement;
|
var root = document.documentElement;
|
||||||
var switcher = document.getElementById('theme-switcher');
|
var switcher = document.getElementById('theme-switcher');
|
||||||
if (!switcher) return;
|
if (!switcher) return;
|
||||||
|
|
||||||
// Keep in sync with --color-bg in global.css and theme-init.js.
|
|
||||||
var THEME_BG = { light: '#fbfaf7', dark: '#201f1d' };
|
|
||||||
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
var themeColorMetas = document.querySelectorAll('meta[name="theme-color"]');
|
||||||
|
|
||||||
function sync(theme) {
|
function sync(theme) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
|
||||||
import PostMediaFigure from './PostMediaFigure.astro';
|
import PostMediaFigure from './PostMediaFigure.astro';
|
||||||
|
import type { WorkMedia } from '../lib/site';
|
||||||
|
|
||||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
type MediaItem = WorkMedia;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
items: MediaItem[];
|
items: MediaItem[];
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
|
||||||
import { Picture } from 'astro:assets';
|
import { Picture } from 'astro:assets';
|
||||||
|
import type { WorkMedia } from '../lib/site';
|
||||||
|
|
||||||
type MediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
type MediaItem = WorkMedia;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: MediaItem;
|
item: MediaItem;
|
||||||
|
|
@ -18,7 +18,7 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
|
||||||
{
|
{
|
||||||
item.type === 'video' ? (
|
item.type === 'video' ? (
|
||||||
// Decorative videos stay inert and hidden from assistive tech. Meaningful
|
// Decorative videos stay inert and hidden from assistive tech. Meaningful
|
||||||
// videos expose controls, captions, and an accessible name.
|
// videos expose controls and an accessible name.
|
||||||
item.decorative ? (
|
item.decorative ? (
|
||||||
<video
|
<video
|
||||||
muted
|
muted
|
||||||
|
|
@ -40,26 +40,17 @@ const videoHeight = item.type === 'video' ? (item.poster?.height ?? 720) : undef
|
||||||
poster={item.poster?.src}
|
poster={item.poster?.src}
|
||||||
width={videoWidth}
|
width={videoWidth}
|
||||||
height={videoHeight}
|
height={videoHeight}
|
||||||
aria-label={item.alt}
|
aria-label={item.caption}
|
||||||
>
|
>
|
||||||
{item.webm && <source src={item.webm} type="video/webm" />}
|
{item.webm && <source src={item.webm} type="video/webm" />}
|
||||||
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
{item.mp4 && <source src={item.mp4} type="video/mp4" />}
|
||||||
{item.captions && (
|
|
||||||
<track
|
|
||||||
kind="captions"
|
|
||||||
src={item.captions}
|
|
||||||
srclang="en"
|
|
||||||
label={item.captionsLabel}
|
|
||||||
default
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</video>
|
</video>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
item.src && (
|
item.src && (
|
||||||
<Picture
|
<Picture
|
||||||
src={item.src}
|
src={item.src}
|
||||||
alt={item.decorative ? '' : (item.alt ?? '')}
|
alt={item.decorative ? '' : (item.caption ?? '')}
|
||||||
formats={['avif', 'webp']}
|
formats={['avif', 'webp']}
|
||||||
widths={[480, 960, 1280, 1920]}
|
widths={[480, 960, 1280, 1920]}
|
||||||
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
sizes="(max-width: 700px) calc(100vw - 2 * clamp(20px, 4vw, 32px)), (max-width: 1100px) min(calc(100vw - 4rem), 56rem), 56rem"
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import VideoThumbnail from './VideoThumbnail.astro';
|
||||||
import { absoluteUrl, getHeaderVideo } from '../lib/site';
|
import { absoluteUrl, getHeaderVideo } from '../lib/site';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
post: CollectionEntry<'posts'>;
|
post: CollectionEntry<'work'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post } = Astro.props;
|
const { post } = Astro.props;
|
||||||
|
|
@ -13,7 +13,7 @@ const headerVideo = getHeaderVideo(post);
|
||||||
const demoLink = post.data.links.find(
|
const demoLink = post.data.links.find(
|
||||||
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
|
(link) => !link.download && link.label.trim().toLowerCase() === 'demo'
|
||||||
);
|
);
|
||||||
const iframeUrl = post.data.iframeThumbnail ? demoLink?.url : undefined;
|
const iframeUrl = post.data.article?.iframeThumbnail ? demoLink?.url : undefined;
|
||||||
const iframeSrc = iframeUrl?.startsWith('/') ? absoluteUrl(iframeUrl) : iframeUrl;
|
const iframeSrc = iframeUrl?.startsWith('/') ? absoluteUrl(iframeUrl) : iframeUrl;
|
||||||
const iframeTitle = demoLink
|
const iframeTitle = demoLink
|
||||||
? `${demoLink.label}: ${post.data.title}`
|
? `${demoLink.label}: ${post.data.title}`
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,14 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
|
import { isExternal } from '../lib/site';
|
||||||
|
|
||||||
type Link = CollectionEntry<'projects'>['data']['links'][number];
|
type Link = CollectionEntry<'work'>['data']['links'][number];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
links: Link[];
|
links: Link[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const { links } = Astro.props;
|
const { links } = Astro.props;
|
||||||
|
|
||||||
function isExternal(url: string) {
|
|
||||||
return /^https?:\/\//.test(url);
|
|
||||||
}
|
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
---
|
---
|
||||||
import type { CollectionEntry } from 'astro:content';
|
import type { CollectionEntry } from 'astro:content';
|
||||||
import { getEntry } from 'astro:content';
|
|
||||||
import EntryThumbnail from './EntryThumbnail.astro';
|
import EntryThumbnail from './EntryThumbnail.astro';
|
||||||
import VideoThumbnail from './VideoThumbnail.astro';
|
import VideoThumbnail from './VideoThumbnail.astro';
|
||||||
import type { HeaderVideo } from '../lib/site';
|
import {
|
||||||
import { PROJECT_THUMBNAIL, articlePath, entrySlug, getHeaderVideo } from '../lib/site';
|
PROJECT_THUMBNAIL,
|
||||||
|
articlePath,
|
||||||
|
entrySlug,
|
||||||
|
getHeaderVideo,
|
||||||
|
isExternal,
|
||||||
|
projectCard,
|
||||||
|
} from '../lib/site';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projects: CollectionEntry<'projects'>[];
|
projects: CollectionEntry<'work'>[];
|
||||||
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
|
// Opt-in: eagerly load thumbnails that are reliably above the fold. Lists
|
||||||
// below substantial content should leave this at zero.
|
// below substantial content should leave this at zero.
|
||||||
eagerFirstThumbnail?: boolean;
|
eagerFirstThumbnail?: boolean;
|
||||||
|
|
@ -20,27 +25,15 @@ const {
|
||||||
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
|
eagerThumbnailCount = eagerFirstThumbnail ? 1 : 0,
|
||||||
} = Astro.props;
|
} = Astro.props;
|
||||||
|
|
||||||
function isExternal(url: string) {
|
// Project and article are the same entry now. A card links to its article when
|
||||||
return /^https?:\/\//.test(url);
|
// the entry has a published (non-draft) `article` facet; an entry without one
|
||||||
}
|
// is a project card with no article page. When that article has a header video,
|
||||||
|
// the card thumbnail becomes a click-to-play poster (the card body still opens
|
||||||
// The `essay` field is a `reference('posts')`, so when present it's always a
|
// the project site).
|
||||||
// `{ collection, id }` shape that `getEntry` resolves to a CollectionEntry.
|
function publishedArticleHref(project: CollectionEntry<'work'>) {
|
||||||
// Drafts are skipped because their article page is not built. A project may
|
const article = project.data.article;
|
||||||
// have no essay (no article) just as an article may have no project; the
|
if (!article || article.draft) return undefined;
|
||||||
// relationship is optional in both directions.
|
return articlePath(project);
|
||||||
const essayHrefs = new Map<string, string>();
|
|
||||||
// When the linked article has a header video, the card thumbnail becomes a
|
|
||||||
// click-to-play poster (the card body still opens the project site).
|
|
||||||
const essayVideos = new Map<string, HeaderVideo>();
|
|
||||||
for (const project of projects) {
|
|
||||||
const essay = project.data.essay;
|
|
||||||
if (!essay) continue;
|
|
||||||
const resolved = await getEntry(essay);
|
|
||||||
if (!resolved || resolved.data.draft) continue;
|
|
||||||
essayHrefs.set(project.id, articlePath(resolved));
|
|
||||||
const headerVideo = getHeaderVideo(resolved);
|
|
||||||
if (headerVideo) essayVideos.set(project.id, headerVideo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The whole card opens the project's website: the first link that isn't a
|
// The whole card opens the project's website: the first link that isn't a
|
||||||
|
|
@ -48,7 +41,7 @@ for (const project of projects) {
|
||||||
// The Open button is that link, and its overlay makes the entire card
|
// The Open button is that link, and its overlay makes the entire card
|
||||||
// clickable. Projects without such a link have no Open button and are not
|
// clickable. Projects without such a link have no Open button and are not
|
||||||
// clickable; their article, if any, is reachable through the Article link.
|
// clickable; their article, if any, is reachable through the Article link.
|
||||||
function websiteUrl(project: CollectionEntry<'projects'>) {
|
function websiteUrl(project: CollectionEntry<'work'>) {
|
||||||
return project.data.links.find((link) => !link.download)?.url;
|
return project.data.links.find((link) => !link.download)?.url;
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
@ -58,8 +51,9 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||||
projects.map((project, index) => {
|
projects.map((project, index) => {
|
||||||
const anchor = entrySlug(project);
|
const anchor = entrySlug(project);
|
||||||
const titleId = `${anchor}-title`;
|
const titleId = `${anchor}-title`;
|
||||||
const essayHref = essayHrefs.get(project.id);
|
const card = projectCard(project);
|
||||||
const headerVideo = essayVideos.get(project.id);
|
const essayHref = publishedArticleHref(project);
|
||||||
|
const headerVideo = essayHref ? getHeaderVideo(project) : undefined;
|
||||||
const website = websiteUrl(project);
|
const website = websiteUrl(project);
|
||||||
const websiteExternal = website ? isExternal(website) : false;
|
const websiteExternal = website ? isExternal(website) : false;
|
||||||
const eager = index < eagerThumbnailCount;
|
const eager = index < eagerThumbnailCount;
|
||||||
|
|
@ -70,8 +64,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||||
<VideoThumbnail
|
<VideoThumbnail
|
||||||
class="entry-thumbnail project-thumbnail"
|
class="entry-thumbnail project-thumbnail"
|
||||||
variant="card"
|
variant="card"
|
||||||
poster={project.data.thumbnail.src}
|
poster={card.thumbnail.src}
|
||||||
alt={project.data.thumbnail.alt}
|
alt={card.thumbnail.alt}
|
||||||
video={headerVideo}
|
video={headerVideo}
|
||||||
widths={PROJECT_THUMBNAIL.widths}
|
widths={PROJECT_THUMBNAIL.widths}
|
||||||
sizes={PROJECT_THUMBNAIL.sizes}
|
sizes={PROJECT_THUMBNAIL.sizes}
|
||||||
|
|
@ -80,8 +74,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<EntryThumbnail
|
<EntryThumbnail
|
||||||
src={project.data.thumbnail.src}
|
src={card.thumbnail.src}
|
||||||
alt={project.data.thumbnail.alt}
|
alt={card.thumbnail.alt}
|
||||||
class="project-thumbnail"
|
class="project-thumbnail"
|
||||||
widths={PROJECT_THUMBNAIL.widths}
|
widths={PROJECT_THUMBNAIL.widths}
|
||||||
sizes={PROJECT_THUMBNAIL.sizes}
|
sizes={PROJECT_THUMBNAIL.sizes}
|
||||||
|
|
@ -91,8 +85,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||||
)}
|
)}
|
||||||
<article class="project-card__summary">
|
<article class="project-card__summary">
|
||||||
<div class="project-card__head">
|
<div class="project-card__head">
|
||||||
<h3 id={titleId}>{project.data.title}</h3>
|
<h3 id={titleId}>{card.title}</h3>
|
||||||
<p class="project-description">{project.data.description}</p>
|
<p class="project-description">{card.description}</p>
|
||||||
</div>
|
</div>
|
||||||
{(essayHref || website) && (
|
{(essayHref || website) && (
|
||||||
<div class="project-card__actions">
|
<div class="project-card__actions">
|
||||||
|
|
@ -100,7 +94,7 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||||
<a
|
<a
|
||||||
class="project-article-link"
|
class="project-article-link"
|
||||||
href={essayHref}
|
href={essayHref}
|
||||||
aria-label={`Read the article about ${project.data.title}`}
|
aria-label={`Read the article about ${card.title}`}
|
||||||
>
|
>
|
||||||
Article
|
Article
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
|
|
@ -114,8 +108,8 @@ function websiteUrl(project: CollectionEntry<'projects'>) {
|
||||||
target={websiteExternal ? '_blank' : undefined}
|
target={websiteExternal ? '_blank' : undefined}
|
||||||
aria-label={
|
aria-label={
|
||||||
websiteExternal
|
websiteExternal
|
||||||
? `Open the ${project.data.title} site in a new tab`
|
? `Open the ${card.title} site in a new tab`
|
||||||
: `Open ${project.data.title}`
|
: `Open ${card.title}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Open
|
Open
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ const playScript = `
|
||||||
class="video-thumbnail__play"
|
class="video-thumbnail__play"
|
||||||
type="button"
|
type="button"
|
||||||
data-video-play
|
data-video-play
|
||||||
aria-label={`Play video: ${video.alt ?? alt}`}
|
aria-label={`Play video: ${video.caption ?? alt}`}
|
||||||
>
|
>
|
||||||
<span class="video-thumbnail__play-icon" aria-hidden="true">
|
<span class="video-thumbnail__play-icon" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24" focusable="false">
|
<svg viewBox="0 0 24 24" focusable="false">
|
||||||
|
|
@ -106,22 +106,11 @@ const playScript = `
|
||||||
preload="none"
|
preload="none"
|
||||||
playsinline
|
playsinline
|
||||||
poster={video.poster?.src}
|
poster={video.poster?.src}
|
||||||
aria-label={video.alt}
|
aria-label={video.caption}
|
||||||
hidden
|
hidden
|
||||||
>
|
>
|
||||||
{video.webm && <source src={video.webm} type="video/webm" />}
|
{video.webm && <source src={video.webm} type="video/webm" />}
|
||||||
{video.mp4 && <source src={video.mp4} type="video/mp4" />}
|
{video.mp4 && <source src={video.mp4} type="video/mp4" />}
|
||||||
{
|
|
||||||
video.captions && (
|
|
||||||
<track
|
|
||||||
kind="captions"
|
|
||||||
src={video.captions}
|
|
||||||
srclang="en"
|
|
||||||
label={video.captionsLabel}
|
|
||||||
default
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
<noscript>
|
<noscript>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { defineCollection, reference } from 'astro:content';
|
import { defineCollection } from 'astro:content';
|
||||||
import type { SchemaContext } from 'astro:content';
|
import type { SchemaContext } from 'astro:content';
|
||||||
import { glob } from 'astro/loaders';
|
import { glob } from 'astro/loaders';
|
||||||
import { z } from 'astro/zod';
|
import { z } from 'astro/zod';
|
||||||
|
|
@ -41,6 +41,17 @@ function isIframeUrl(url: string) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const TAGS = [
|
||||||
|
'ai',
|
||||||
|
'systems',
|
||||||
|
'graphics',
|
||||||
|
'simulation',
|
||||||
|
'embedded',
|
||||||
|
'web',
|
||||||
|
'tools',
|
||||||
|
'games',
|
||||||
|
] as const;
|
||||||
|
|
||||||
const linkSchema = z.object({
|
const linkSchema = z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
url: linkUrl,
|
url: linkUrl,
|
||||||
|
|
@ -53,13 +64,23 @@ const thumbnailSchema = ({ image }: SchemaContext) =>
|
||||||
alt: z.string().min(1, 'Thumbnail alt text must not be empty.'),
|
alt: z.string().min(1, 'Thumbnail alt text must not be empty.'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Thumbnail override used by the project card to swap just the image when the
|
||||||
|
// card wants a different crop from the article. The alt text is never
|
||||||
|
// overridden, so it stays a single source of truth on the top-level thumbnail.
|
||||||
|
const thumbnailOverrideSchema = ({ image }: SchemaContext) =>
|
||||||
|
z.object({
|
||||||
|
src: image(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The caption is the single source of truth for a media item's text: it shows
|
||||||
|
// as the visible <figcaption> and doubles as the image alt / accessible name,
|
||||||
|
// so meaningful media carries one string instead of a caption/alt pair.
|
||||||
const mediaSchema = ({ image }: SchemaContext) =>
|
const mediaSchema = ({ image }: SchemaContext) =>
|
||||||
z
|
z
|
||||||
.discriminatedUnion('type', [
|
.discriminatedUnion('type', [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(['image', 'diagram']),
|
type: z.enum(['image', 'diagram']),
|
||||||
src: image(),
|
src: image(),
|
||||||
alt: z.string().optional(),
|
|
||||||
decorative: z.boolean().optional(),
|
decorative: z.boolean().optional(),
|
||||||
caption: z.string().optional(),
|
caption: z.string().optional(),
|
||||||
transcript: z.string().optional(),
|
transcript: z.string().optional(),
|
||||||
|
|
@ -70,9 +91,6 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
||||||
poster: image().optional(),
|
poster: image().optional(),
|
||||||
mp4: mediaUrl.optional(),
|
mp4: mediaUrl.optional(),
|
||||||
webm: mediaUrl.optional(),
|
webm: mediaUrl.optional(),
|
||||||
captions: mediaUrl.optional(),
|
|
||||||
captionsLabel: z.string().default('English captions'),
|
|
||||||
alt: z.string().optional(),
|
|
||||||
decorative: z.boolean().optional(),
|
decorative: z.boolean().optional(),
|
||||||
caption: z.string().optional(),
|
caption: z.string().optional(),
|
||||||
transcript: z.string().optional(),
|
transcript: z.string().optional(),
|
||||||
|
|
@ -81,89 +99,72 @@ const mediaSchema = ({ image }: SchemaContext) =>
|
||||||
message: 'Video media needs at least one mp4 or webm source.',
|
message: 'Video media needs at least one mp4 or webm source.',
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
.refine((item) => item.decorative || (Boolean(item.alt) && Boolean(item.caption)), {
|
.refine((item) => item.decorative || Boolean(item.caption), {
|
||||||
message: 'Meaningful media needs both alt text and a caption.',
|
message: 'Meaningful media needs a caption.',
|
||||||
})
|
});
|
||||||
.refine(
|
|
||||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.captions),
|
|
||||||
{
|
|
||||||
message: 'Meaningful video needs captions.',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(item) => item.type !== 'video' || item.decorative || Boolean(item.transcript),
|
|
||||||
{
|
|
||||||
message: 'Meaningful video needs a transcript.',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const posts = defineCollection({
|
// A single collection where each entry can carry an `article` facet (a written
|
||||||
loader: glob({ pattern: '**/*.md', base: './src/content/posts' }),
|
// page under /articles/<slug>), a `project` facet (a card in the /projects/
|
||||||
|
// index), or both. Fields shared by both facets live at the top level and are
|
||||||
|
// written once; the few where the project card deliberately differs from the
|
||||||
|
// article are overridden inside `project`. The markdown body is the article's
|
||||||
|
// content.
|
||||||
|
const articleFacet = ({ image }: SchemaContext) =>
|
||||||
|
z.object({
|
||||||
|
updated: z.coerce.date().optional(),
|
||||||
|
draft: z.boolean().default(false),
|
||||||
|
iframeThumbnail: z.boolean().default(false),
|
||||||
|
featuredOrder: z.number().optional(),
|
||||||
|
tags: z.array(z.enum(TAGS)).default([]),
|
||||||
|
stack: z.array(z.string()).optional(),
|
||||||
|
scale: z.string().min(1).optional(),
|
||||||
|
media: z.array(mediaSchema({ image })).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectFacet = ({ image }: SchemaContext) =>
|
||||||
|
z.object({
|
||||||
|
selected: z.boolean().default(false),
|
||||||
|
// Card chips. When omitted, the card falls back to the article's `stack`.
|
||||||
|
technologies: z.array(z.string()).optional(),
|
||||||
|
// Card overrides. When omitted, the card uses the top-level value.
|
||||||
|
title: z.string().optional(),
|
||||||
|
thumbnail: thumbnailOverrideSchema({ image }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const work = defineCollection({
|
||||||
|
loader: glob({ pattern: '**/*.md', base: './src/content/work' }),
|
||||||
schema: ({ image }) =>
|
schema: ({ image }) =>
|
||||||
z
|
z
|
||||||
.object({
|
.object({
|
||||||
|
// Shared identity (single source of truth).
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string().max(160),
|
description: z.string().max(160),
|
||||||
date: z.coerce.date(),
|
|
||||||
updated: z.coerce.date().optional(),
|
|
||||||
draft: z.boolean().default(false),
|
|
||||||
thumbnail: thumbnailSchema({ image }),
|
thumbnail: thumbnailSchema({ image }),
|
||||||
iframeThumbnail: z.boolean().default(false),
|
period: z.string().min(1).optional(),
|
||||||
tags: z.array(
|
date: z.coerce.date(),
|
||||||
z.enum([
|
|
||||||
'ai',
|
|
||||||
'systems',
|
|
||||||
'graphics',
|
|
||||||
'simulation',
|
|
||||||
'embedded',
|
|
||||||
'web',
|
|
||||||
'tools',
|
|
||||||
'games',
|
|
||||||
])
|
|
||||||
),
|
|
||||||
featuredOrder: z.number().optional(),
|
|
||||||
projectPeriod: z.string().optional(),
|
|
||||||
role: z.string().optional(),
|
|
||||||
stack: z.array(z.string()).optional(),
|
|
||||||
scale: z.string().optional(),
|
|
||||||
outcome: z.string().optional(),
|
|
||||||
audience: z
|
|
||||||
.enum(['general', 'technical', 'recruiter-relevant'])
|
|
||||||
.default('technical'),
|
|
||||||
links: z.array(linkSchema).default([]),
|
links: z.array(linkSchema).default([]),
|
||||||
media: z.array(mediaSchema({ image })).default([]),
|
|
||||||
|
article: articleFacet({ image }).optional(),
|
||||||
|
project: projectFacet({ image }).optional(),
|
||||||
|
})
|
||||||
|
.refine((entry) => Boolean(entry.article) || Boolean(entry.project), {
|
||||||
|
message: 'An entry needs at least an `article` or a `project` facet.',
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(post) =>
|
(entry) =>
|
||||||
!post.iframeThumbnail ||
|
!entry.article?.iframeThumbnail ||
|
||||||
post.links.some(
|
entry.links.some(
|
||||||
(link) =>
|
(link) =>
|
||||||
!link.download &&
|
!link.download &&
|
||||||
link.label.trim().toLowerCase() === 'demo' &&
|
link.label.trim().toLowerCase() === 'demo' &&
|
||||||
isIframeUrl(link.url)
|
isIframeUrl(link.url)
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
path: ['iframeThumbnail'],
|
path: ['article', 'iframeThumbnail'],
|
||||||
message:
|
message:
|
||||||
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
|
'iframeThumbnail requires a non-download Demo link with an https or root-relative URL.',
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const projects = defineCollection({
|
export const collections = { work };
|
||||||
loader: glob({ pattern: '**/*.md', base: './src/content/projects' }),
|
|
||||||
schema: ({ image }) =>
|
|
||||||
z.object({
|
|
||||||
title: z.string(),
|
|
||||||
description: z.string().max(160),
|
|
||||||
thumbnail: thumbnailSchema({ image }),
|
|
||||||
period: z.string(),
|
|
||||||
sortDate: z.coerce.date(),
|
|
||||||
technologies: z.array(z.string()).default([]),
|
|
||||||
selected: z.boolean().default(false),
|
|
||||||
essay: reference('posts').optional(),
|
|
||||||
links: z.array(linkSchema).default([]),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = { posts, projects };
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
|
@ -1,44 +0,0 @@
|
||||||
---
|
|
||||||
title: A 50 FPS Game Engine on an 8-Bit Microcontroller
|
|
||||||
description: 'A handheld game built from the PCB up: ATtiny85V, OLED, IR receiver, EEPROM, 8 MHz 8-bit ALU. 50 FPS floor.'
|
|
||||||
date: 2026-05-06
|
|
||||||
projectPeriod: 'Spring 2020'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/ad-astra.jpg
|
|
||||||
alt: The Ad Astra game running on a small OLED display.
|
|
||||||
tags: ['embedded', 'games', 'systems']
|
|
||||||
role: Hardware and firmware author
|
|
||||||
stack: ['C', 'ATtiny85V', 'SPI OLED', 'IR receiver', 'EEPROM', 'KiCad']
|
|
||||||
scale: 8 MHz, 8-bit ALU, ~31 mW at full brightness, ~1.5 mA standby, 15–20 ms frame budget
|
|
||||||
outcome: A handheld built from schematic to firmware, with a 50 FPS game on it
|
|
||||||
audience: technical
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/ad_astra
|
|
||||||
media:
|
|
||||||
- type: video
|
|
||||||
poster: ./_assets/ad-astra.jpg
|
|
||||||
webm: /media/video/ad_astra.webm
|
|
||||||
mp4: /media/video/ad_astra.mp4
|
|
||||||
captions: /media/video/ad_astra.vtt
|
|
||||||
alt: Video demonstration of the embedded game running on a small OLED display.
|
|
||||||
caption: The whole thing, from board and firmware to sprites and game loop, runs on a single ATtiny85V at 8 MHz.
|
|
||||||
transcript: No spoken dialogue. The handheld board runs its OLED game; the player moves through the small display while the IR input controls gameplay.
|
|
||||||
---
|
|
||||||
|
|
||||||
I'd done microcontroller work on dev boards before and it always felt like I was renting the hardware. As soon as I had a real board with my own soldering on it, bugs stopped feeling like software inconveniences and started feeling like consequences of choices I'd made in KiCad. That shift was most of the value of doing it this way. Four years on from [my first hardware project](/articles/lights-synchronized-to-music/), the lesson was that owning the whole stack down to the copper changes how you debug.
|
|
||||||
|
|
||||||
This one is a handheld game built from the PCB up around an ATtiny85V: 8-bit ALU at 8 MHz, no FPU, no SIMD, 8 KB of flash. Anything I built had to fit inside that, or I'd be staring at a brick.
|
|
||||||
|
|
||||||
## The bits worth showing
|
|
||||||
|
|
||||||
- **SIMD-on-an-8-bit-ALU display driver.** The OLED is 128×64 monochrome, 1024 bytes per frame. The driver packs four pixels into a byte and processes them with bit-parallel tricks. That's how the frame budget stayed under 20 ms with room for game logic.
|
|
||||||
- **Prototype-based inheritance, in C.** Entities share behaviour by pointing at a struct of function pointers. No vtable, no class, no allocator. Cheap dispatch and the whole object model fits on one screen.
|
|
||||||
- **Atomic EEPROM commits.** Sprite data and save state both live in EEPROM. The commit path writes a new region, then swaps a tiny header pointer. Pull the battery mid-write and the previous version is intact.
|
|
||||||
- **PNG-to-C sprite pipeline.** A Python script turns PNG artwork into static C arrays the firmware can include directly. Asset workflow without ever leaving the source tree.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- **A host-side emulator.** Debugging firmware directly on hardware was character-building and slow. A small SDL-based simulator linking the same C code would have shortened the iteration loop from "reflash and hope" to "rebuild and run."
|
|
||||||
- **Power numbers I'd actually trust.** I have peak and standby draw. I don't have a curve over a real gameplay session, so I honestly can't say how long the battery lasts under load. I can only say it outlasted my patience.
|
|
||||||
- **A development log for the driver.** The display driver and the EEPROM commit protocol are the parts I'd still defend. They deserved diagrams and measurements at the time, not the half page of comments I left them with.
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
title: Avoid
|
|
||||||
description: My first browser game. Tiny, archived for honesty.
|
|
||||||
date: 2026-04-29
|
|
||||||
projectPeriod: 'January 2018'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/avoid.jpg
|
|
||||||
alt: Screenshot of the Avoid web game.
|
|
||||||
tags: ['games', 'web']
|
|
||||||
role: Game author
|
|
||||||
stack: ['JavaScript', 'Canvas']
|
|
||||||
outcome: My first browser game; kept for the timeline
|
|
||||||
audience: general
|
|
||||||
---
|
|
||||||
|
|
||||||
Keeping it here because pretending the older work didn't happen would be dishonest. The first browser game I wrote, January 2018. It isn't good, but it was the moment a `<canvas>` element stopped being mysterious.
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
---
|
|
||||||
title: Backing Up Running Databases Without Stopping Them
|
|
||||||
description: A Bash container around BorgBackup. BTRFS snapshots give atomic consistency, numeric env vars give multi-target 3-2-1, the loop is sleep not cron.
|
|
||||||
date: 2026-05-29
|
|
||||||
projectPeriod: '2024-2026'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/backup.png
|
|
||||||
alt: Placeholder thumbnail for the backup container post.
|
|
||||||
tags: ['systems', 'tools']
|
|
||||||
role: Container and script author
|
|
||||||
stack: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
|
|
||||||
scale: One container, multiple targets per host, two years of restored incidents
|
|
||||||
outcome: A self-hosted backup that has survived every actual incident I've thrown at it
|
|
||||||
audience: technical
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/backup-container
|
|
||||||
- label: Container image
|
|
||||||
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
|
|
||||||
---
|
|
||||||
|
|
||||||
Once you self-host a few services with live databases, the backup question stops being theoretical. A Postgres or SQLite file half-written when `tar` reads it goes into the archive in a state nothing on Earth will replay; you just don't find out until the restore. Two years in, with multiple incidents I had to actually recover from (including the photos behind the [e-ink frame](/articles/frame-eink-photo-display/)), I trust this stack precisely because the correctness argument is short: BTRFS gives me an atomic snapshot, and everything above it can be a shell script. One Alpine container, ~75 lines of Bash, pushes that snapshot to one or more [Borg](https://borgbackup.readthedocs.io/) repositories on a fixed interval. Multi-target is numeric env vars (`BORG_REPO_0`, `BORG_REPO_1`, ...). No config format, no DSL; the env file is the configuration.
|
|
||||||
|
|
||||||
## The problem the snapshot solves
|
|
||||||
|
|
||||||
I self-host several databases that are mid-write at every moment of the day. `tar | borg create` against the live volume is a race: a Postgres or SQLite file that's half-written when borg reads it goes into the archive in a state nothing on Earth can replay. The "right" answer is to coordinate a quiesce with every database: a fan-out of `pg_dump`, SQLite `.backup`, Redis `BGSAVE`, and so on, all with retry, timeouts, and per-app credentials.
|
|
||||||
|
|
||||||
The cheaper answer, if you've put everything on one BTRFS volume, is `btrfs subvolume snapshot`. It returns instantly with a copy-on-write fork of the entire filesystem. Every file is now atomically consistent at exactly the same instant. Run borg against the snapshot, not against the live volume.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
btrfs subvolume snapshot /btrfs-root /snapshot
|
|
||||||
cd "/snapshot/btrfs-root${BACKUP_RELATIVE_PATH:-}"
|
|
||||||
borg create ... ::"{hostname}-{now:%Y-%m-%dT%H:%M:%S}" .
|
|
||||||
```
|
|
||||||
|
|
||||||
The snapshot lives only for the duration of the borg run. A `trap cleanup EXIT` deletes the subvolume whether the backup succeeded, failed, or was killed. The next run snapshots fresh.
|
|
||||||
|
|
||||||
This shifts the entire correctness argument from "did I quiesce every database in time" to "does BTRFS give me a consistent snapshot." It does. That's why everything below it can be a shell script.
|
|
||||||
|
|
||||||
## Multi-target as numeric env vars
|
|
||||||
|
|
||||||
The 3-2-1 backup rule wants three copies, two media, one offsite. My answer is a remote (rsync.net) and a local HDD, both fed from the same snapshot. The wire format for "multiple targets" is just numbered env vars:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
BORG_PASSPHRASE_0=...
|
|
||||||
BORG_REMOTE_PATH_0=borg1
|
|
||||||
BORG_REPO_0=username@username.rsync.net:~/backup
|
|
||||||
|
|
||||||
BORG_PASSPHRASE_1=...
|
|
||||||
BORG_REPO_1=/local-backup
|
|
||||||
```
|
|
||||||
|
|
||||||
`backup-wrapper.sh` loops `index=0` upward, exports `BORG_PASSPHRASE` / `BORG_REPO` / `BORG_REMOTE_PATH` from the indexed copies, runs `backup.sh`, unsets them, increments. Stops the first time the next index has no passphrase.
|
|
||||||
|
|
||||||
There's also a no-index fallback (`BORG_REPO=...` with no number) for the single-target case. Same script, no extra config plane.
|
|
||||||
|
|
||||||
I keep coming back to this pattern for small-system orchestration. The env file _is_ the data structure. There's no YAML parsing, no JSON schema, no config-validation layer between you and the variable that actually matters.
|
|
||||||
|
|
||||||
## The scheduler is a sleep, not cron
|
|
||||||
|
|
||||||
```bash
|
|
||||||
while true; do
|
|
||||||
/src/backup-wrapper.sh 2>&1 | log_message
|
|
||||||
sleep "$SLEEP_TIME"
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
A comment in the file says it out loud: "Using a simple sleep loop to schedule backups instead of cron to avoid concurrency issues." Cron with a one-hour cadence and a backup that occasionally takes 70 minutes will eventually overlap itself. The sleep-loop can't: the next run starts when the previous one is done, plus the interval. One process, one snapshot, one borg invocation. Concurrency bugs you can't have are concurrency bugs you don't have.
|
|
||||||
|
|
||||||
## Healthcheck is a file mtime
|
|
||||||
|
|
||||||
`borg create` succeeded? Write `date > /health/backup_completion_time.log`. The Docker healthcheck shells out every 10 seconds and compares that mtime against `MAX_BACKUP_AGE_SECONDS` (default 86400). Older than that, container is unhealthy and whatever's watching containers (in my case a notification hook) finds out.
|
|
||||||
|
|
||||||
Two subtleties worth naming:
|
|
||||||
|
|
||||||
- **First-boot grace period.** If `backup_completion_time.log` doesn't exist yet (fresh container, first backup still running), fall back to `container_start_time.log` so the container isn't reported unhealthy during the first scheduled run.
|
|
||||||
- **Partial success is not success.** In multi-target mode, the completion log is only written if _every_ target succeeded. One repo failing means the healthcheck stays red even if the other two are fine. Stale-but-quiet was the failure mode I wanted to make impossible.
|
|
||||||
|
|
||||||
## Smaller calls
|
|
||||||
|
|
||||||
- **`borg break-lock` at the start of every run.** If the previous container was killed mid-backup, the repo is locked and the next `borg create` will hang. Just break it. There's only ever one writer because of the sleep loop.
|
|
||||||
- **`set -e` after `borg init`, not before.** The init line is the only one allowed to fail (first run on a fresh repo). Everything after halts on error.
|
|
||||||
- **`BORG_RSH='ssh -oBatchMode=yes'`.** Fail fast if SSH would have prompted, instead of hanging forever inside a detached container.
|
|
||||||
- **`ServerAliveInterval 30` in `ssh_config`.** Long borg transfers across home-ISP NAT get killed if nothing flows for a few minutes. Keepalives keep the tunnel open.
|
|
||||||
- **`--files-cache=ctime,size,inode`.** The default `mtime,size,inode` re-hashes files when their mtime changes; on BTRFS, ctime is the more honest signal of "this content actually changed."
|
|
||||||
- **`compression=zstd,12`.** The sweet spot for backup data on my hardware: substantially better than zlib, not so slow it dominates the run.
|
|
||||||
- **`borg compact --threshold=5 --cleanup-commits`.** Reclaims space from pruned archives whenever the segment-file fragmentation crosses 5%.
|
|
||||||
- **`IGNORE_GIT_UNTRACKED=true`.** Optional. Walks every `.git` dir under the snapshot, runs `git ls-files --others --exclude-standard`, and feeds the result into `--exclude-from`. Skips `target/`, `node_modules/`, build caches; anything the repo already knows isn't worth keeping.
|
|
||||||
- **`SYS_ADMIN` capability on the container.** Needed for `btrfs subvolume snapshot` and `delete` from inside the namespace. The narrower capability set didn't have a way through.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- **A test rig that restores into an empty volume on a schedule.** "Backups exist" is not the property I care about. "Backups restore" is. I have anecdotal evidence after every incident; I don't have a green checkmark before one.
|
|
||||||
- **A failure notifier separate from the healthcheck.** Docker healthcheck-unhealthy is one signal; I'd also want an explicit push (ntfy, email, Telegram) on first failure of a run, so I don't have to be watching the container state.
|
|
||||||
- **Parallel targets when network and disk don't compete.** The current loop is strictly sequential: rsync.net then local HDD. They share neither bandwidth nor spindles; they could run in parallel and halve the wall-clock. Sequential made the wrapper trivial; the trade was knowable and I made it.
|
|
||||||
|
|
||||||
Two years in, the part I'd defend hardest is the snapshot. Everything above it is a wrapper that could be rewritten in an afternoon. The snapshot is what makes the wrapper allowed to be one.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
title: A Unity City Where Bad PLC Code Made Cars Crash
|
|
||||||
description: A REST-controlled traffic-light sim for a cybersecurity event. Bad PLC code showed up as car crashes, the most honest feedback loop I've shipped.
|
|
||||||
date: 2026-05-01
|
|
||||||
projectPeriod: 'July-August 2018'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/city-simulation.jpg
|
|
||||||
alt: Screenshot of a Unity traffic simulation.
|
|
||||||
tags: ['simulation', 'systems']
|
|
||||||
role: Simulation author
|
|
||||||
stack: ['Unity', 'C#', 'REST API', 'Blender']
|
|
||||||
outcome: Visible consequences for an otherwise abstract PLC challenge
|
|
||||||
audience: technical
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
Most security challenges punish wrong answers with a red "incorrect." This one punished them with car wrecks, and people learned faster. A PLC cybersecurity event in the summer of 2018 needed something visceral; I built a small Unity city where the traffic lights were driven by a REST API and contestants wrote the control logic.
|
|
||||||
|
|
||||||
All decisions ran on the server and got broadcast to clients. The harder problem wasn't the simulation; it was making the broadcast fault-tolerant on conference Wi-Fi without flooding it. I built it solo, including the models and animations in Blender. Not a flex, just context for why everything's a little janky.
|
|
||||||
|
|
||||||
There was also a HUD overlay for tweets. It felt clever at the time and dated horribly. Skip that part.
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
---
|
|
||||||
title: One Game Library, Imported by Both the Client and the Server
|
|
||||||
description: A mobile multiplayer browser game where client and server linked the same TypeScript module. One source of truth, one fewer class of bug.
|
|
||||||
date: 2026-05-07
|
|
||||||
projectPeriod: 'Autumn-Winter 2020'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/decla-red.jpg
|
|
||||||
alt: The decla.red browser game interface showing a space scene.
|
|
||||||
tags: ['games', 'web', 'systems']
|
|
||||||
role: Game and backend systems author
|
|
||||||
stack: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL', 'SDF-2D']
|
|
||||||
scale: Multiple game servers, each talking to 16–32 clients, browser and mobile
|
|
||||||
outcome: A multiplayer browser game that proved SDF-2D survived a real game loop
|
|
||||||
audience: technical
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/decla.red
|
|
||||||
- label: BSc thesis
|
|
||||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
|
||||||
download: true
|
|
||||||
media:
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/decla-red.jpg
|
|
||||||
alt: The decla.red browser game interface showing a space scene with team controls and planets.
|
|
||||||
caption: A real game loop is a worse audience than a tech demo. That's the point.
|
|
||||||
---
|
|
||||||
|
|
||||||
My thesis was a renderer; proving it in a real multiplayer loop was the point. A real game loop is a worse audience than a tech demo. That's the point. So through autumn 2020 I built decla.red on top of [SDF-2D](/articles/sdf-2d-ray-tracing/): a conquest-style space shooter, two teams, small planets, ray-traced 2D rendering, browser and mobile. The architecture decision worth remembering came out of needing the server and the client to stop lying to each other: one TypeScript module containing the game rules, linked by both sides of the wire.
|
|
||||||
|
|
||||||
## The split that usually goes wrong
|
|
||||||
|
|
||||||
Real-time multiplayer has an awkward two-machine problem. The server has to be authoritative or the game is cheatable; the client has to feel immediate or the game is unplayable. If you write the rules twice, once on each side, they will drift. Eventually a player's screen will say one thing and the server will think another.
|
|
||||||
|
|
||||||
I wanted the server's "compute the next state" function and the client's "predict the next state locally" function to be literally the same function. So I put the rules in a shared TypeScript library, published nothing, and had both `package.json` files link to it.
|
|
||||||
|
|
||||||
The win wasn't elegance, it was the bugs that didn't happen. Client prediction stopped being an approximation of the server; it _was_ the server, run optimistically and reconciled when the authoritative update came back.
|
|
||||||
|
|
||||||
## Other choices worth a sentence
|
|
||||||
|
|
||||||
- **k-d trees for spatial queries.** Once the world held more than a few dozen objects, naive collision and proximity checks dominated the server tick. A k-d tree dropped them out of the profile.
|
|
||||||
- **Message-passing object model.** Lifted from Smalltalk's `doesNotUnderstand:` idea. Entities respond to messages they care about and ignore the rest. Easier to extend than the inheritance tree I tried first, and less brittle.
|
|
||||||
- **Firebase only for server discovery.** Not for game state, just for "which servers are currently in the pool." Tiny consistent store, didn't need to write one.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- **Observability for desync.** Multiplayer systems live or die by visibility into divergence. I had logs; I needed dashboards showing the rate, the shape, and the triggering interaction for every prediction miss. Without those, debugging was guessing.
|
|
||||||
- **Don't tangle rendering and networking in the same tree.** Both were interesting, both put different kinds of pressure on the architecture, and the directories grew into each other. Separate top-level folders from day one next time.
|
|
||||||
- **Skip multi-server until the math demands it.** I wired up multi-server early because it sounded right. With 16–32 clients per server I was nowhere near needing it; the complexity wasn't free.
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
---
|
|
||||||
title: A Physics Practice App for the Hungarian Érettségi
|
|
||||||
description: A static jQuery site I built in high school to drill past exam questions. 659 questions, a decade of past papers, still online and still used.
|
|
||||||
date: 2026-05-28
|
|
||||||
projectPeriod: '2017-2018'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/fizika.jpg
|
|
||||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons over a light textured background.
|
|
||||||
tags: ['web', 'tools']
|
|
||||||
role: Question database, frontend, backend
|
|
||||||
stack: ['jQuery', 'vanilla HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
|
||||||
outcome: A free practice app real students still find when they search for past érettségi physics papers
|
|
||||||
audience: general
|
|
||||||
links:
|
|
||||||
- label: Live
|
|
||||||
url: https://fizika.schmelczer.dev
|
|
||||||
- label: Source
|
|
||||||
url: https://home.schmelczer.dev/git/andras/fizika
|
|
||||||
---
|
|
||||||
|
|
||||||
I needed it. In my last year of high school I was about to sit the _emelt szintű_ (advanced-level) physics érettségi, and the practice material I could find online was either paywalled or scattered across PDFs that wouldn't tell you whether your answer was right. So one evening I started typing past exam questions into a JSON file. A few weeks later I had something resembling a study tool, and a few weeks after that I had 659 questions covering more than a decade of past papers.
|
|
||||||
|
|
||||||
The site is intentionally small. A static frontend on jQuery, four CSS files, a JSON blob of questions, a folder of scanned diagrams from the original papers. You pick a topic (_Mechanika, Hőtan, Elektromosság, Atomfizika_) or hunt down a specific year's exam, get a randomised quiz, answer, and the page colours each row green or red. Past results sit in `localStorage`, because the audience was high schoolers; account-less was the privacy answer.
|
|
||||||
|
|
||||||
It outgrew Firebase eventually. I moved the data to a small Express backend so I could keep editing questions without a paid plan, with a JSON file and an image folder as the storage layer. The admin routes have no auth; instead, the service stays off the public internet and I edit through an SSH-forwarded localhost. Fine for a one-person CMS, terrible advice for anything with multiple editors.
|
|
||||||
|
|
||||||
What I'd change if I were starting it now:
|
|
||||||
|
|
||||||
- **Astro instead of jQuery plus a Node server.** The whole thing could be one static site that re-renders on push. No backend, no CSP fiddling, no Docker.
|
|
||||||
- **Markdown source, not a hand-edited JSON file.** Editing questions in JSON is fine until you forget a comma at 1am and the site stops loading.
|
|
||||||
- **A real licence note on the question text.** The papers are public exam material, but it's worth saying so somewhere on the page.
|
|
||||||
|
|
||||||
It's been online in some form for eight years. Every spring I get a few emails from students asking whether I'll add the latest year's paper. I usually do, eventually. The thing I made for myself in 2017 is still doing its job for someone else's last year of high school, and that's the only metric on it I actually care about.
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
---
|
|
||||||
title: A WebGPU Drawing Garden Where Agents Rewrite Your Strokes
|
|
||||||
description: A single-file WebGPU drawing toy. You stroke a colour, agents follow it, and a 3×3 matrix per vibe gives each preset its personality.
|
|
||||||
date: 2026-05-22
|
|
||||||
projectPeriod: '2026'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/fleeting-garden.jpg
|
|
||||||
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
|
|
||||||
iframeThumbnail: true
|
|
||||||
tags: ['graphics', 'simulation', 'web']
|
|
||||||
role: Graphics and shader author
|
|
||||||
stack: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
|
||||||
scale: One HTML file, ~10 WGSL shaders, 6 vibe presets, 60 FPS target on consumer hardware
|
|
||||||
outcome: A browser drawing toy where user strokes seed an agent simulation that overwrites them
|
|
||||||
audience: technical
|
|
||||||
links:
|
|
||||||
- label: Demo
|
|
||||||
url: /fleeting/
|
|
||||||
- label: Source
|
|
||||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
|
||||||
media:
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/fleeting-garden.jpg
|
|
||||||
alt: Close-up of intertwining cyan, violet, and yellow agent trails radiating into a kaleidoscopic central knot.
|
|
||||||
caption: A snapshot from one session. What you see is the trail texture; the agents that drew it are already gone.
|
|
||||||
---
|
|
||||||
|
|
||||||
Nine numbers in `{-1, 0, 1}` arranged in a 3×3 matrix decide an entire vibe's personality. That constraint is what kept me up: proving simplicity can be expressive, that you don't need a behaviour function per preset. A WebGPU drawing toy where you stroke a colour, agents spawn along it, and the garden slowly overwrites the patch you laid down. One static HTML file, six compute stages, none of them skippable.
|
|
||||||
|
|
||||||
## Why physarum needed a knob
|
|
||||||
|
|
||||||
Physarum-style agent sims are everywhere and most of them stop being interesting after thirty seconds, because they converge to the same family of branching shapes no matter what you feed them. Seeding the initial condition isn't enough; the input has to keep being a force inside the loop, otherwise you're just watching the attractor settle.
|
|
||||||
|
|
||||||
My second self-imposed constraint was that one engine had to produce six visibly different presets without forking. The first prototype had a `switch (preset)` with one behaviour function per vibe and it was already painful at vibe two. I needed the personality to live in data, not code.
|
|
||||||
|
|
||||||
## The reaction matrix
|
|
||||||
|
|
||||||
Each vibe is a 3×3 table of colour-to-colour affinities. When an agent of colour `i` looks at the trail in front of it, it weights the three channels of that sample by row `i` of the matrix, then uses the sign to pick left, right, or straight. That's it. The whole behaviour rule.
|
|
||||||
|
|
||||||
Three examples of what nine numbers can do:
|
|
||||||
|
|
||||||
- **Aurora Mycelium:** cyclic, each colour chases the next. Agents wind into ribbons.
|
|
||||||
- **Velvet Observatory:** every off-diagonal entry negative. Colours repel into separate islands.
|
|
||||||
- **Paper Lantern Fog:** matrix filled with ones. Colours collapse into one cooperative blob.
|
|
||||||
|
|
||||||
Adding a tenth number to the matrix would tax every existing vibe. Tuning the nine I have is a text edit. Six presets in, I haven't extended it.
|
|
||||||
|
|
||||||
## The compute work, broken into small jobs
|
|
||||||
|
|
||||||
Six stages, ten WGSL files, each one short enough that I can hold it in my head when something breaks:
|
|
||||||
|
|
||||||
1. **Agent step:** sample the trail at a sensor offset, pick a turn, move, deposit colour. ~300 lines, the longest one.
|
|
||||||
2. **Diffusion:** blur and decay so old marks soften. The boring one, and the one you can't skip: without it, strokes stay forever and the garden collapses into noise.
|
|
||||||
3. **Brush:** write user strokes into both the trail texture and a separate "source" texture the agents can read.
|
|
||||||
4. **Eraser:** two variants: one clears a region of the trail, the other kills agents in a radius.
|
|
||||||
5. **Agent generation:** spawn along strokes, resize the buffer when the cap changes, compact after erasure so dead slots don't waste GPU time.
|
|
||||||
6. **Render:** read the trail, apply palette and grain.
|
|
||||||
|
|
||||||
The bind-group setup overhead from running more pipelines was lost in the noise next to the simulation cost. The win was that when the eraser shader started killing the wrong agents, I opened one file and reasoned about it without touching anything else.
|
|
||||||
|
|
||||||
## Smaller calls
|
|
||||||
|
|
||||||
- **Adaptive cap, circular buffer.** If FPS drops, the cap shrinks; if there's headroom, it grows. When the cap is hit, new agents overwrite older ones. The decay you see, a stroke vanishing thirty seconds after you drew it, isn't an explicit eraser, it's the buffer wrapping around.
|
|
||||||
- **URL is the share format.** The chosen vibe is in the query string. The "send your friend this preset" link is just a URL with `?vibe=tidepool-lantern` on it. The parser is tolerant about accents and casing because people retype these.
|
|
||||||
- **One HTML file.** All CSS and JS inline. The piano samples sit beside it. Self-contained enough to email or drop on a USB stick.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- The intro animation (agents fly in to spell the title, then transition to steady state) couples three shaders through a single `progress: 0 → 1` value. It's the bit I'd least want to refactor today. Next time I'd model the intro as its own dispatch with its own buffer and hand off cleanly.
|
|
||||||
- Mobile works, but the toolbar fights the canvas for screen and the agent cap has to shrink hard to keep frame time down. A proper fix means rethinking the toolbar and exposing the cap-vs-resolution tradeoff to the user.
|
|
||||||
- The simulation has invariants that proptest would falsify in minutes: agent count under the cap, every stroke produces a positive-coloured deposit on the next frame, and the eraser doesn't leak agents past its radius. Snapshot tests aren't the right tool here.
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
title: Predicting EUR/USD With Hanning Windows
|
|
||||||
description: A weekend frequency-domain experiment that did a passable job on EUR/USD. I would not have trusted it with my money, and I didn't.
|
|
||||||
date: 2026-05-03
|
|
||||||
projectPeriod: 'Autumn 2019'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/forex.jpg
|
|
||||||
alt: Chart comparing predicted and actual EUR/USD exchange rates.
|
|
||||||
tags: ['systems', 'tools']
|
|
||||||
role: Experiment author
|
|
||||||
stack: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
|
||||||
outcome: A prediction server, an MQL4 trading client, and a clearer view of how far my edge wasn't
|
|
||||||
audience: technical
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
In the autumn of 2019 I was an undergrad with a few weekends free and the quiet conviction that I could find a small edge on EUR/USD. The screenshots were flattering: the prediction (blue) hugged the actual rate (green) in a way that looked like skill. A linear regression in the frequency domain, dressed up. I did not trade real money with it, and that restraint is the only thing about the project that aged well.
|
|
||||||
|
|
||||||
The pipeline:
|
|
||||||
|
|
||||||
- Smooth the input series.
|
|
||||||
- Differentiate.
|
|
||||||
- Short-time Fourier transform with overlapped, Hanning-windowed frames.
|
|
||||||
- Extrapolate the frequency-domain coefficients.
|
|
||||||
- Invert everything back to a predicted price series.
|
|
||||||
|
|
||||||
A Python server (NumPy, SciPy, Flask) ran the model. An MQL4 client on a broker terminal called the server and would have placed trades if I'd dared.
|
|
||||||
|
|
||||||
What I actually learned: even a naive model can show a sometimes-profitable backtest, and that's the trap. The real game is built by people with co-located servers, microsecond ticks, and millions in infrastructure. This project taught me how far my edge wasn't.
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
---
|
|
||||||
title: A JavaFX Editor for the Cooling Simulator
|
|
||||||
description: Companion editor for the cooling-system sim. Drag-and-drop graph layout, JSON export, upload-to-backend. Small tool, mattered more than I expected.
|
|
||||||
date: 2026-04-25
|
|
||||||
projectPeriod: 'October-November 2018'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/process-simulator-input.jpg
|
|
||||||
alt: JavaFX graph editor for the cooling system simulator.
|
|
||||||
tags: ['simulation', 'tools']
|
|
||||||
role: Editor author
|
|
||||||
stack: ['JavaFX', 'JSON', 'REST API']
|
|
||||||
outcome: A drag-and-drop graph editor that let non-developers feed the simulator
|
|
||||||
audience: technical
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
Non-technical event organisers needed to rewire a cooling plant in real time without me hovering. That was the brief, and it ruled out every interface I'd have enjoyed writing. The [cooling system sim](/articles/nuclear-cooling-simulation/) was only as useful as the tool that fed it, so in late 2018 I built a JavaFX desktop editor: lay out the plant as a graph, edit each element's parameters in a side panel, export JSON, or upload straight to the backend.
|
|
||||||
|
|
||||||
Small tool, and the whole event hinged on it. If I built it again I'd skip JavaFX and put the editor in the browser next to the monitoring clients. One install fewer for everyone, and one fewer reason for someone to call me over.
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
---
|
|
||||||
title: A Python Framework Where Doing the Right Thing Is the Default
|
|
||||||
description: My MSc thesis. 33 catalogued ML deployment habits, a decorator-shaped Python API, and a survey of working engineers on which actually got adopted.
|
|
||||||
date: 2026-05-09
|
|
||||||
projectPeriod: '2022'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/great-ai.png
|
|
||||||
alt: Example Python code using the GreatAI API.
|
|
||||||
tags: ['ai', 'systems', 'tools']
|
|
||||||
featuredOrder: 1
|
|
||||||
role: Researcher and framework author
|
|
||||||
stack: ['Python', 'decorators', 'FastAPI', 'survey design']
|
|
||||||
scale: 33 deployment habits surveyed, 6 proposed additions, framework evaluated by working data scientists and engineers
|
|
||||||
outcome: A pip-installable framework, an MSc thesis, and one strong opinion about API surface area
|
|
||||||
audience: recruiter-relevant
|
|
||||||
links:
|
|
||||||
- label: PyPI
|
|
||||||
url: https://pypi.org/project/great-ai/
|
|
||||||
- label: Project site
|
|
||||||
url: https://great-ai.scoutinscience.com
|
|
||||||
- label: MSc thesis
|
|
||||||
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
|
||||||
download: true
|
|
||||||
media:
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/great-ai.png
|
|
||||||
alt: Example Python code using GreatAI decorators and prediction helpers.
|
|
||||||
caption: A working GreatAI service is about ten lines on top of a plain prediction function.
|
|
||||||
---
|
|
||||||
|
|
||||||
By the end of 2021 I had stopped believing the people skipping ML deployment best practices were the problem. They knew the list. They agreed with the list. They had a deadline, and every item on the list cost five lines of glue. My MSc thesis turned that into the actual research question: not "what should engineers do" but "what API shape makes doing the right thing cheaper than not." The framework that fell out, `great-ai`, is a decorator on a plain Python function. The thesis behind it is the part worth reading.
|
|
||||||
|
|
||||||
## The thing nobody wants to admit
|
|
||||||
|
|
||||||
The literature has a long list of habits you should adopt when shipping an ML service: track inputs, version models, expose health, log decisions, keep predictions reproducible. Everyone agrees with the list. Almost nobody implements all of it.
|
|
||||||
|
|
||||||
I spent the bulk of the thesis catalogueing 33 such habits, proposing 6 more, and surveying engineers on which actually got applied in their day jobs. The data was pretty clear about the failure mode: it wasn't ignorance, it wasn't laziness, it wasn't budget. It was that the cost of doing the right thing, five lines of glue per habit multiplied across a stack, was higher than the visible cost of skipping it. So skipping it became the default.
|
|
||||||
|
|
||||||
So the real research question wasn't "what should engineers do." It was "what API shape makes doing the right thing cheaper than not."
|
|
||||||
|
|
||||||
## The framework's bet
|
|
||||||
|
|
||||||
- **A decorator on a plain function.** `@GreatAI.create` turns a regular Python function into a deployed service with metadata, request tracing, and a versioned interface. No inheritance, no project layout, no enforced directory structure. The mental cost is one import.
|
|
||||||
- **Implicit behaviour only for cross-cutting concerns.** Logging, versioning, metadata are implicit. Anything touching business logic stays explicit. The rule: if it would surprise me when I'm debugging, it shouldn't be implicit.
|
|
||||||
- **Own the contract, leave the storage alone.** Where you persist logs, models, or metrics is your choice; GreatAI defines the shape and provides defaults. The model registry stays somebody else's library.
|
|
||||||
|
|
||||||
The survey backed up the central premise: ease of use and functionality both matter for adoption, and they're independent axes. A framework that ticks every box and is awkward will lose to a smaller one that doesn't.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- I'd narrow further. Anything GreatAI did that overlapped with MLflow, BentoML, or modern observability stacks would go. The durable bit was always the decorator and the catalogue behind it.
|
|
||||||
- I'd publish the survey instrument separately. The 33-habit catalogue and the adoption-vs-impact methodology outlive the framework. People still ask about that part.
|
|
||||||
- I'd stop calling them "best practices." I used that phrase in the thesis and it aged into corporate-speak. The honest name is "things that hurt later if you skip them."
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
---
|
|
||||||
title: Syncing State with an Immutable Trie
|
|
||||||
description: 'A visual goal tracker whose lasting idea was the sync model: an immutable trie so structural diffs are trivial and only deltas cross the wire.'
|
|
||||||
date: 2026-05-05
|
|
||||||
projectPeriod: 'August-September 2019'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/towers.jpg
|
|
||||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
|
||||||
tags: ['systems', 'web', 'tools']
|
|
||||||
featuredOrder: 4
|
|
||||||
role: Full-stack author
|
|
||||||
stack: ['Python', 'Angular', 'TypeScript', 'Custom sync protocol']
|
|
||||||
scale: Multi-device goal and task state shared between clients and a server
|
|
||||||
outcome: A working sync protocol where structural sharing made the delta tiny
|
|
||||||
audience: recruiter-relevant
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/life-towers/
|
|
||||||
media:
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/towers.jpg
|
|
||||||
alt: Screenshot of a life tracking web interface represented with tower-like visual structures.
|
|
||||||
caption: The interface was a 2019 weekend experiment. The trie underneath aged better.
|
|
||||||
---
|
|
||||||
|
|
||||||
In August 2019 I wanted a goal tracker I'd actually open, on whichever device was nearest, without watching it disagree with itself. Nothing off the shelf fit, so I built one over a couple of weekends. The tower metaphor was the part friends saw; the part that aged well was the sync model that fell out of needing the same state in three places at once.
|
|
||||||
|
|
||||||
## The problem in one paragraph
|
|
||||||
|
|
||||||
Pick any non-trivial mutable object graph, sync it across devices, and you end up either sending the whole thing on every change (wasteful) or writing ad-hoc diff logic per shape (brittle). I wanted a representation where the _shape_ of the data made the diff fall out for free.
|
|
||||||
|
|
||||||
## The trie, concretely
|
|
||||||
|
|
||||||
A goal in Life Towers is a path of strings. `Health / Running / 5k`. Tasks under a goal hang off the leaf. A user's whole state is a tree, and a trie is exactly the data structure that makes that tree's _identity_ manipulable.
|
|
||||||
|
|
||||||
Two properties did the heavy lifting:
|
|
||||||
|
|
||||||
- **Structural sharing.** When you tick off a task under `Health / Running / 5k`, the new root reuses every untouched subtree by reference. The `Career` branch and the `Reading` branch are the same objects they were before. Comparing the old and new roots is mostly pointer equality; only the path that actually changed gets walked.
|
|
||||||
- **Immutability.** Updates produce new structure instead of mutating. "Where I was" and "where I am" become two pointers, not two snapshots. The diff between them is whatever's not shared, and that walk is O(changes), not O(state).
|
|
||||||
|
|
||||||
The sync loop falls out:
|
|
||||||
|
|
||||||
1. Client holds the last root the server acknowledged plus its own current root.
|
|
||||||
2. To send: walk only the unshared paths, emit one op per changed leaf. In practice that's a handful of bytes for a typical edit, no matter how large the rest of the tree is.
|
|
||||||
3. Server applies, returns its new root.
|
|
||||||
4. Client rebases any in-flight edits by replaying them on top.
|
|
||||||
|
|
||||||
There's no conflict resolution layer because the operations commute on the structure. Two clients adding tasks under different branches produce non-overlapping deltas that compose trivially. The hard cases (two clients editing the same leaf) are tiny and obvious, because they're the _only_ place the deltas touch the same path.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- **Property tests around the rebase.** The reconcile path is exactly where a generator finds bugs that hand-written tests never think to write. I had hand-written cases; I'd start with `proptest` now.
|
|
||||||
- **A standalone spec for the wire format.** The part worth lifting out was the protocol, not the goal tracker. A short spec would let me (or anyone) reimplement it in a different stack without re-deriving everything from the Python source.
|
|
||||||
- **Strip the visual experiment.** The tower visualisation was fun but it bound the storage to a UI metaphor. The sync model should be a library; the towers should be a separate toy.
|
|
||||||
|
|
||||||
## If you take one idea from this
|
|
||||||
|
|
||||||
Most sync problems are diff problems pretending to be transport problems. Pick the data structure that makes the diff free, and the protocol almost writes itself. The corollary: if you're writing a lot of "if this changed, send that" code, you're using the wrong structure.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
---
|
|
||||||
title: 'My First Real Project: LEDs Driven by an FFT'
|
|
||||||
description: A Raspberry Pi music player that drove RGB strips through MOSFETs. The first thing I started and actually finished.
|
|
||||||
date: 2026-04-26
|
|
||||||
projectPeriod: 'Spring 2016'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/leds.jpg
|
|
||||||
alt: RGB LED strips lit by a music synchronisation project.
|
|
||||||
tags: ['systems', 'tools']
|
|
||||||
role: Hardware and software author
|
|
||||||
stack: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
|
|
||||||
outcome: The first non-trivial project I started and finished
|
|
||||||
audience: technical
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
Spring 2016. I had a Raspberry Pi, a couple of 12V RGB LED strips someone had given me, a handful of MOSFETs from an electronics kit, and zero idea what I was doing. I wired one of the MOSFETs backwards and it got hot enough to leave a small mark on the breadboard. I learned to read a datasheet, slowly, by needing one. This was the first thing I started and actually finished.
|
|
||||||
|
|
||||||
The plan was something like: play music, look at it, make the lights match. I got bands wrong first. Mapping raw audio amplitude to brightness made the lights pulse with anything (clipping, voice, fan noise), a strobing mess that hurt to look at. Reading about Fourier transforms long enough to type `numpy.fft.fft(audio_chunk)` into a REPL was the moment the project started actually behaving like the thing I'd imagined. Bass-heavy frequency bins went to red; mids to green; highs to blue. Smoothing the output over a few frames stopped the seizure-inducing flicker.
|
|
||||||
|
|
||||||
The frontend was a vanilla web page on the same Pi: pick a track, tweak the band thresholds, see what changed. No framework. Just a `<select>`, a few sliders, and an `XMLHttpRequest`. It worked.
|
|
||||||
|
|
||||||
It's not impressive in 2026. The thing I actually keep from it isn't the FFT or the MOSFETs; it's the discovery that I'd rather have a finished janky thing than an elegant unfinished one. Most of the projects on this site are downstream of that discovery; [the ATtiny85 handheld](/articles/ad-astra-attiny85-game-engine/) four years later is the same instinct with the soldering iron held steadier. I'd still recommend the same path to anyone learning: pick something physical, plug things together until they work, accept that the first version will be ugly.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
title: 'My Notes: A Markdown App for Android'
|
|
||||||
description: A small Android note app built on Markwon. The idea wasn't new; the point was learning a platform that wasn't the web.
|
|
||||||
date: 2026-05-02
|
|
||||||
projectPeriod: 'November 2019'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/my-notes.png
|
|
||||||
alt: Screenshots of the My Notes Android app.
|
|
||||||
tags: ['tools']
|
|
||||||
role: Android app author
|
|
||||||
stack: ['Android', 'Markdown', 'Markwon']
|
|
||||||
outcome: A working notes app and my first time outside the web stack
|
|
||||||
audience: technical
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/my-notes
|
|
||||||
---
|
|
||||||
|
|
||||||
In November 2019 I wrote my own notes app for Android, used it daily for a while, and then it lost a long battle with Obsidian. The loss was the lesson: I learned what I actually wanted from a notes app by watching mine fail to be it. Years later that same itch is why I wrote [reconcile-text](/articles/reconcile-text-3-way-merge/); by then I was editing the same notes in Vim, VS Code, and Obsidian, and nothing existed to merge three independently-edited copies back into one.
|
|
||||||
|
|
||||||
The app itself was small: Markdown notes, hashtag filtering, Markwon for rendering. Every developer writes their own notes app eventually and the bar for shipping one isn't high. What I actually wanted was a few weeks outside the web stack, somewhere with different conventions about lifecycle, storage, and resource constraints. Android delivered that. I'd still recommend "write a small thing on a new platform" as a way to recalibrate what you take for granted.
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
---
|
|
||||||
title: 'Two Graphs Are Simpler Than One: A Cooling System Simulator'
|
|
||||||
description: Live cooling-system sim for a PLC cybersecurity event. Splitting flow and heat into two graph passes kept it cheap and the behaviour believable.
|
|
||||||
date: 2026-05-04
|
|
||||||
projectPeriod: 'October-November 2018'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/process-simulator.jpg
|
|
||||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
|
||||||
tags: ['simulation', 'systems', 'tools']
|
|
||||||
featuredOrder: 5
|
|
||||||
role: Simulation and UI author
|
|
||||||
stack: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
|
||||||
scale: One remote sim server, many monitoring clients, separate JavaFX graph editor
|
|
||||||
outcome: A believable PLC simulation usable by non-specialists during a live cybersecurity challenge
|
|
||||||
audience: recruiter-relevant
|
|
||||||
links: []
|
|
||||||
media:
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/process-simulator.jpg
|
|
||||||
alt: Screenshot of the cooling system simulator with pipes, pumps, coolers, and temperature values.
|
|
||||||
caption: Flow ran first as a graph traversal, then heat solved as a matrix equation.
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/process-simulator-input.jpg
|
|
||||||
alt: Screenshot of the JavaFX graph editor used to define simulator input.
|
|
||||||
caption: The JavaFX editor produced JSON that the simulator ate as input.
|
|
||||||
---
|
|
||||||
|
|
||||||
Trying to solve flow and heat as a coupled system would have been a real CFD problem and I had two weeks. A cybersecurity event in late 2018 needed a cooling-system simulator that contestants could poke at through PLCs over a weekend, and the deadline shaped every decision after it: cheap to compute, plausible to a non-specialist, runs all weekend on one server. The useful design move was modelling flow and heat as **two separate graph passes**, not one combined PDE.
|
|
||||||
|
|
||||||
## What the event needed
|
|
||||||
|
|
||||||
The challenge was about PLCs. Contestants would change setpoints, valves, or pump speeds, and we needed them to see whether their action made the plant stable, wasted coolant, or melted something. That meant:
|
|
||||||
|
|
||||||
- Multiple monitoring clients had to update from one simulation server in near real time.
|
|
||||||
- The system had to be configurable enough that the event organisers could ship me a new plant on Friday night and have it running Saturday morning.
|
|
||||||
- It had to be obvious. A simulator nobody understands isn't a teaching tool, it's noise.
|
|
||||||
|
|
||||||
## The split that made it cheap
|
|
||||||
|
|
||||||
Instead of the coupled solver:
|
|
||||||
|
|
||||||
1. **Flow first, as graph traversal.** Walk the pipe graph from the pumps, accumulate pressure, distribute water to nodes.
|
|
||||||
2. **Heat second, as a linear system.** Build the adjacency matrix from the flow result, add boundary conditions (heaters, exchangers, base temperatures), solve for node temperatures with NumPy.
|
|
||||||
3. Repeat both passes per tick.
|
|
||||||
|
|
||||||
This is wrong as physics. It's right as a model. Flow doesn't react to instantaneous heat in any way contestants could perceive, and the cost of solving them separately was a tiny fraction of solving them together. The clean phase boundary also meant when "the heat is weird," I knew exactly which pass to look at.
|
|
||||||
|
|
||||||
## Why the editor mattered
|
|
||||||
|
|
||||||
The simulator's most-used UI was the _input_ editor, a separate JavaFX tool where you laid out the plant, set parameters per element, and exported JSON the sim ate. I wrote up the editor's [own story here](/articles/graph-editor-javafx-simulation-input/), because in hindsight it deserved to be its own project.
|
|
||||||
|
|
||||||
The lesson: a simulation is only as useful as its input pipeline. If editing the plant requires editing source, organisers won't use it.
|
|
||||||
|
|
||||||
## What I'd change
|
|
||||||
|
|
||||||
- **State what the model claims.** A convincing sim needs an honest README about what it does and doesn't model. Mine didn't. Anyone who took the numbers seriously could have walked away believing more than the model deserved.
|
|
||||||
- **Recorded scenarios as regression tests.** Sim projects drift in ways that look plausible on screen. Storing "this input over 60 seconds produces these outputs" would have caught me when I broke the temperature solver on Saturday morning at the event.
|
|
||||||
- **Skip JavaFX.** Cross-platform packaging was painful and the desktop dependency made the editor harder to hand off than it should have been. A web-based editor in the same browser the monitors used would have meant one fewer install for the organisers.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
title: A Colour Grader Where Distance Was the Whole Idea
|
|
||||||
description: Pick a colour, transform every nearby colour as a function of distance. A proof-of-concept grader I built to try one interaction idea.
|
|
||||||
date: 2026-04-30
|
|
||||||
projectPeriod: 'June 2018'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/photo-colour-grader.jpg
|
|
||||||
alt: Colour grading interface with tonal controls and an edited preview.
|
|
||||||
tags: ['graphics', 'web', 'tools']
|
|
||||||
role: Interface and image processing author
|
|
||||||
stack: ['JavaScript', 'Canvas', 'Image processing']
|
|
||||||
outcome: A working proof-of-concept grader and an interaction model I'd still defend
|
|
||||||
audience: technical
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
In June 2018 I got tired of every grader I tried making me think in masks. I wanted to point at "this orange" in a photo from one of my [walks](/articles/photo-site-generator/), nudge it, and have the neighbouring reds and yellows come along by however much made sense. Distance in colour space, not a brush. So I built the proof.
|
|
||||||
|
|
||||||
The UI was a colour wheel where you'd click to drop a marker, drag to move it, click anywhere to add another. Each marker had its own settings; transformations fell off smoothly with distance from the picked colour. No masks, ever.
|
|
||||||
|
|
||||||
I never built it into a real tool. The idea still feels right: distance in colour space is the natural unit for prose-style editing of an image. If I returned to it, I'd reach for WebGL instead of canvas. The interaction only earns its keep if the preview is live on a real photo, and canvas couldn't get there.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
---
|
|
||||||
title: A Photo Site That Generated Itself From a Folder
|
|
||||||
description: A Webpack script that turns a folder of photos into a static site with responsive image variants. Mostly here as an excuse to talk about walks.
|
|
||||||
date: 2026-04-27
|
|
||||||
projectPeriod: 'Summer 2016'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/photos.jpg
|
|
||||||
alt: Screenshot of a generated photography site.
|
|
||||||
tags: ['web', 'tools']
|
|
||||||
role: Site generator author
|
|
||||||
stack: ['Webpack', 'Image processing', 'Static site generation']
|
|
||||||
outcome: A photography site that updated itself when I dropped new images into a folder
|
|
||||||
audience: general
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
||||||
I take walks with a camera. Most of what I shoot isn't good, but the act of walking slowly with a frame to think about is the most reliable way I know to come back with an idea for whatever I'm working on. In the summer of 2016 I wanted somewhere to put the few frames that survived, and I wasn't going to maintain a CMS for it.
|
|
||||||
|
|
||||||
So a Webpack script: point it at a directory of full-size photos, get a static site with responsive variants per image. Drop in a new photo, run the build, deploy. The pipeline mattered less than making the habit visible. The same habit later produced a [colour grader](/articles/photo-colour-grader/) for the same shots.
|
|
||||||
|
|
||||||
If I rebuilt it today I'd use Astro, which is what this site runs on.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
---
|
|
||||||
title: A 3D Voxel Game in C, Built While Learning Pointers
|
|
||||||
description: My Basics of Programming project. 3D platformer in C with SDL 1.2, destructible terrain, time-slowdown powerups, and a great many segmentation faults.
|
|
||||||
date: 2026-04-28
|
|
||||||
projectPeriod: 'Autumn 2017'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/platform-game.jpg
|
|
||||||
alt: Screenshot from a 3D platform game written in C.
|
|
||||||
tags: ['games', 'systems']
|
|
||||||
role: Game author
|
|
||||||
stack: ['C', 'SDL 1.2', 'Voxel terrain']
|
|
||||||
outcome: A playable course project, and the moment programming clicked
|
|
||||||
audience: technical
|
|
||||||
---
|
|
||||||
|
|
||||||
Autumn 2017, Basics of Programming, a deadline that forced me to learn C the hard way. I'd write almost none of it the same way today, and I'd defend every choice in it anyway. A 3D voxel platformer in pure C with SDL 1.2. No engine, no scripting layer.
|
|
||||||
|
|
||||||
Maps were randomly generated and destructible voxel by voxel, so the player could dig their way out of trouble or wall off flying enemies that merged into larger ones as they got closer. Powerups let you shoot, or slow down time at the cost of points.
|
|
||||||
|
|
||||||
What I actually learned was pointers, painfully, through an adequate number of segfaults. The course was meant to teach the basics of programming; for me it was the moment programming stopped feeling like a list of facts and started feeling like a thing I could build with. The next time I reached for C it was on hardware that punished waste; see [Ad Astra](/articles/ad-astra-attiny85-game-engine/).
|
|
||||||
|
|
||||||
First-project privilege.
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
---
|
|
||||||
title: A 2D Ray Tracer for the Browser, Tuned for the Phone in Your Pocket
|
|
||||||
description: 'My BSc thesis library. The mobile GPU shaped the architecture: tile-based passes, deferred shading, shaders generated per scene and device.'
|
|
||||||
date: 2026-05-08
|
|
||||||
projectPeriod: 'Autumn-Winter 2020'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/sdf2d.jpg
|
|
||||||
alt: SDF-2D browser demo with soft lighting effects.
|
|
||||||
tags: ['graphics', 'web', 'systems']
|
|
||||||
featuredOrder: 3
|
|
||||||
role: Library author
|
|
||||||
stack:
|
|
||||||
['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields', 'Dynamic shader generation']
|
|
||||||
scale: Browser library, mobile-targeted, real-time on consumer GPUs, both WebGL1 and WebGL2 paths
|
|
||||||
outcome: An NPM package and BSc thesis; the renderer behind the decla.red multiplayer game
|
|
||||||
audience: recruiter-relevant
|
|
||||||
links:
|
|
||||||
- label: NPM package
|
|
||||||
url: https://www.npmjs.com/package/sdf-2d
|
|
||||||
- label: Video
|
|
||||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
|
||||||
- label: BSc thesis
|
|
||||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
|
||||||
download: true
|
|
||||||
media:
|
|
||||||
- type: image
|
|
||||||
src: ./_assets/sdf2d.jpg
|
|
||||||
alt: Browser demo page showing SDF-2D scenes rendered with soft lighting effects.
|
|
||||||
caption: SDF-2D shipped as a TypeScript library, not a one-shot demo. That distinction shaped most of the design.
|
|
||||||
---
|
|
||||||
|
|
||||||
Winter 2020, BSc thesis deadline closing in, and the thing had to run acceptably on my advisor's laptop the day he graded it. That single shipping pressure exposed every lazy assumption in the architecture and picked the design: tile-based passes, deferred shading, shaders generated per scene and per device. A 2D ray tracer in the browser via signed distance fields: soft shadows, smooth reflections, no triangle mesh. The other half of the thesis was [decla.red](/articles/declared-shared-simulation-code/), the multiplayer game that proved the renderer survived a real game loop.
|
|
||||||
|
|
||||||
## What "mobile GPU" actually meant
|
|
||||||
|
|
||||||
A 2D SDF ray tracer is conceptually simple: for each pixel, march along a ray, sample the distance field, accumulate light. The implementation that works on a desktop NVIDIA card spends so much per pixel that a mobile GPU melts. So the design problem was never "can SDFs do soft shadows" (yes, easily), it was "what work can I avoid per pixel without giving up the look."
|
|
||||||
|
|
||||||
Three constraints did most of the design work:
|
|
||||||
|
|
||||||
- **WebGL1 and WebGL2 both supported.** No "modern browser only" cheat. That ruled out anything that needed compute shaders or storage buffers.
|
|
||||||
- **No per-scene hand-tuned shader.** This is a library; users plug in their own scene descriptions. The renderer has to compile something appropriate at runtime.
|
|
||||||
- **Acceptable on a phone.** Not "good when the user owns the right hardware." It had to be acceptable on the laptop my advisor used to grade the thesis.
|
|
||||||
|
|
||||||
## How it actually runs
|
|
||||||
|
|
||||||
- **Tile-based rendering.** Group pixels and reason about them together. Most regions of a frame share the same nearby geometry, so you can early-out enormous swathes of pixel work if you know the tile's bounds. This was the single biggest perf win.
|
|
||||||
- **Deferred shading.** Separate "find the surface" from "shade the surface." Shadow casting and reflections need the same geometry queries; doing them once per pixel and reusing the result was worth the extra texture bandwidth.
|
|
||||||
- **Generated shaders per scene and device.** If a scene has no reflective surfaces, the generated shader doesn't carry the reflection path. If the device only supports WebGL1, the shader doesn't reach for WebGL2 features. Static feature flags do this badly; runtime generation does it well.
|
|
||||||
- **TypeScript scene descriptions, no DSL.** I prototyped a small DSL for SDF authoring and threw it away. Pride's expensive. Users describe scenes in plain TypeScript and the library compiles them down. A DSL would have meant one more language to teach and one more compiler to debug.
|
|
||||||
|
|
||||||
## Held up, didn't hold up
|
|
||||||
|
|
||||||
- **Held up:** the mobile constraint forced structural perf work instead of cosmetic perf work. When something only runs on a desktop GPU you mistake headroom for good architecture, and the rude awakening comes from a user.
|
|
||||||
- **Held up:** keeping the library boundary clean. A demo can hide a messy implementation; a published package can't.
|
|
||||||
- **Didn't:** I had no instrumentation around shader variants. Today I'd ship a small `?debug=1` overlay that prints exactly which shader got compiled for that session and why.
|
|
||||||
- **Didn't:** the docs are words about ray marching. The ideas are visual; the explanation should have been too. Diagrams next time.
|
|
||||||
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 377 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
|
@ -1,47 +0,0 @@
|
||||||
<svg width="200" height="200" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#357ABD;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Background circle -->
|
|
||||||
<circle cx="100" cy="100" r="90" fill="url(#grad1)" opacity="0.15"/>
|
|
||||||
|
|
||||||
<!-- Main vault icon -->
|
|
||||||
<g transform="translate(100, 100)">
|
|
||||||
<!-- Vault body -->
|
|
||||||
<rect x="-45" y="-50" width="90" height="80" rx="8" fill="none" stroke="url(#grad1)" stroke-width="6"/>
|
|
||||||
|
|
||||||
<!-- Vault door circle -->
|
|
||||||
<circle cx="0" cy="-10" r="22" fill="none" stroke="url(#grad1)" stroke-width="5"/>
|
|
||||||
<circle cx="0" cy="-10" r="14" fill="none" stroke="url(#grad1)" stroke-width="3"/>
|
|
||||||
<circle cx="0" cy="-10" r="6" fill="url(#grad1)"/>
|
|
||||||
|
|
||||||
<!-- Vault handle -->
|
|
||||||
<line x1="0" y1="-4" x2="18" y2="-4" stroke="url(#grad1)" stroke-width="3" stroke-linecap="round"/>
|
|
||||||
<circle cx="18" cy="-4" r="4" fill="url(#grad1)"/>
|
|
||||||
|
|
||||||
<!-- Link chain -->
|
|
||||||
<g opacity="0.9">
|
|
||||||
<!-- Left link -->
|
|
||||||
<ellipse cx="-30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
|
||||||
<!-- Right link -->
|
|
||||||
<ellipse cx="30" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
|
||||||
<!-- Center link connecting them -->
|
|
||||||
<ellipse cx="0" cy="40" rx="12" ry="8" fill="none" stroke="url(#grad1)" stroke-width="4"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Sync arrows (subtle) -->
|
|
||||||
<g opacity="0.5">
|
|
||||||
<!-- Clockwise arrow top-right -->
|
|
||||||
<path d="M 35 -35 Q 50 -35 50 -20 L 50 -15" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
|
||||||
<polygon points="50,-15 47,-22 53,-22" fill="url(#grad1)"/>
|
|
||||||
|
|
||||||
<!-- Counter-clockwise arrow bottom-left -->
|
|
||||||
<path d="M -35 25 Q -50 25 -50 10 L -50 5" fill="none" stroke="url(#grad1)" stroke-width="2.5" stroke-linecap="round"/>
|
|
||||||
<polygon points="-50,5 -47,12 -53,12" fill="url(#grad1)"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 2 KiB |
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
title: Ad Astra
|
|
||||||
description: 'A handheld game built from a custom PCB up: ATtiny85V, OLED, IR, EEPROM. 8-bit ALU at 8 MHz, 50 FPS floor.'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/ad-astra.jpg
|
|
||||||
alt: The Ad Astra handheld game running on its OLED display.
|
|
||||||
period: 'Spring 2020'
|
|
||||||
sortDate: 2020-04-01
|
|
||||||
technologies: ['C', 'ATtiny85V', 'OLED', 'EEPROM', 'PCB design']
|
|
||||||
selected: true
|
|
||||||
essay: ad-astra-attiny85-game-engine
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/ad_astra
|
|
||||||
---
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
title: Avoid
|
|
||||||
description: My first browser game, kept around so the timeline is honest.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/avoid.jpg
|
|
||||||
alt: Screenshot of the Avoid canvas game.
|
|
||||||
period: 'January 2018'
|
|
||||||
sortDate: 2018-01-01
|
|
||||||
technologies: ['JavaScript', 'Canvas']
|
|
||||||
selected: false
|
|
||||||
essay: avoid-early-web-game
|
|
||||||
---
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
title: Backup Container
|
|
||||||
description: A Bash container around BorgBackup. BTRFS snapshot for atomic consistency, numeric env vars for multi-target 3-2-1, sleep-loop instead of cron.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/backup.png
|
|
||||||
alt: Placeholder thumbnail for the backup container project.
|
|
||||||
period: '2024-2026'
|
|
||||||
sortDate: 2024-06-01
|
|
||||||
technologies: ['Bash', 'BorgBackup', 'BTRFS', 'Alpine', 'Docker', 'SSH', 'zstd']
|
|
||||||
selected: false
|
|
||||||
essay: backup-container-btrfs-borg
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/backup-container
|
|
||||||
- label: Container image
|
|
||||||
url: https://github.com/schmelczer/backup-container/pkgs/container/backup-container
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: City Simulation
|
|
||||||
description: A Unity city where REST-controlled traffic lights made bad PLC code visible as car crashes.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/city-simulation.jpg
|
|
||||||
alt: Screenshot of a Unity city traffic simulation.
|
|
||||||
period: 'July-August 2018'
|
|
||||||
sortDate: 2018-08-01
|
|
||||||
technologies: ['Unity', 'C#', 'REST API', 'Blender']
|
|
||||||
selected: false
|
|
||||||
essay: city-simulation-unity-traffic
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Photo Colour Grader
|
|
||||||
description: Pick a colour, edit every nearby colour as a function of distance. A grader built around one interaction idea.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/photo-colour-grader.jpg
|
|
||||||
alt: Screenshot of a colour grading interface applied to a photograph.
|
|
||||||
period: 'June 2018'
|
|
||||||
sortDate: 2018-06-01
|
|
||||||
technologies: ['JavaScript', 'Canvas', 'Image processing']
|
|
||||||
selected: false
|
|
||||||
essay: photo-colour-grader
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
---
|
|
||||||
title: decla.red
|
|
||||||
description: Browser multiplayer where the client and server linked the same TypeScript rules module. Concurrency bugs you can't have are bugs you don't have.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/declared.jpg
|
|
||||||
alt: The decla.red browser game interface showing a space scene.
|
|
||||||
period: 'Autumn-Winter 2020'
|
|
||||||
sortDate: 2020-11-01
|
|
||||||
technologies: ['TypeScript', 'Node.js', 'WebSockets', 'Firebase', 'WebGL']
|
|
||||||
selected: true
|
|
||||||
essay: declared-shared-simulation-code
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/decla.red
|
|
||||||
- label: BSc thesis
|
|
||||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
|
||||||
download: true
|
|
||||||
---
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
title: Fizika
|
|
||||||
description: 'I needed it for my own physics érettségi: 659 past-paper questions, jQuery, localStorage, no accounts. Eight years on, students still find it.'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/fizika.jpg
|
|
||||||
alt: Screenshot of the Fizika practice app showing topic-selection buttons.
|
|
||||||
period: '2017-2018'
|
|
||||||
sortDate: 2018-05-01
|
|
||||||
technologies: ['jQuery', 'HTML/CSS', 'Node/Express', 'JSON', 'localStorage']
|
|
||||||
selected: false
|
|
||||||
essay: fizika-erettsegi-practice-app
|
|
||||||
links:
|
|
||||||
- label: Live
|
|
||||||
url: https://fizika.schmelczer.dev
|
|
||||||
- label: Source
|
|
||||||
url: https://home.schmelczer.dev/git/andras/fizika
|
|
||||||
---
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
---
|
|
||||||
title: Fleeting Garden
|
|
||||||
description: A single-file WebGPU drawing toy. Your strokes seed a swarm; nine numbers per vibe give each preset its personality.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/fleeting-garden.jpg
|
|
||||||
alt: A kaleidoscopic Fleeting Garden snapshot of cyan, violet, and yellow agent trails radiating from a central knot.
|
|
||||||
period: '2026'
|
|
||||||
sortDate: 2026-05-01
|
|
||||||
technologies: ['TypeScript', 'WebGPU', 'WGSL', 'Compute shaders', 'Vite', 'Tweakpane']
|
|
||||||
selected: true
|
|
||||||
essay: fleeting-garden-webgpu-drawing
|
|
||||||
links:
|
|
||||||
- label: Demo
|
|
||||||
url: /fleeting/
|
|
||||||
- label: Source
|
|
||||||
url: https://home.schmelczer.dev/git/andras/webgpu
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Foreign Exchange Prediction Experiment
|
|
||||||
description: A Hanning-windowed STFT experiment on EUR/USD. Passable backtest, sober conclusions, no real money risked.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/forex.jpg
|
|
||||||
alt: Chart from a foreign exchange prediction experiment.
|
|
||||||
period: 'Autumn 2019'
|
|
||||||
sortDate: 2019-10-01
|
|
||||||
technologies: ['Python', 'NumPy', 'SciPy', 'Flask', 'MQL4']
|
|
||||||
selected: false
|
|
||||||
essay: foreign-exchange-prediction-experiment
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
---
|
|
||||||
title: Frame
|
|
||||||
description: A LAN-only e-ink photo frame. Pulls from self-hosted Immich, gated on Home Assistant presence, Atkinson-dithered to 6 colours, no cloud.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/frame.jpg
|
|
||||||
alt: The e-ink frame on the wall showing a dithered landscape scene with the capture age and EXIF location painted into the bottom corners.
|
|
||||||
period: '2026'
|
|
||||||
sortDate: 2026-05-01
|
|
||||||
technologies:
|
|
||||||
[
|
|
||||||
'Python',
|
|
||||||
'Raspberry Pi Zero 2W',
|
|
||||||
'Waveshare PhotoPainter',
|
|
||||||
'Immich',
|
|
||||||
'Home Assistant',
|
|
||||||
'numba',
|
|
||||||
'Atkinson dither',
|
|
||||||
]
|
|
||||||
selected: true
|
|
||||||
essay: frame-eink-photo-display
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://home.schmelczer.dev/git/andras/frame
|
|
||||||
---
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
title: GreatAI
|
|
||||||
description: One decorator on a Python function turned it into a deployed ML service. MSc thesis with a survey to back the API choices.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/great-ai.png
|
|
||||||
alt: Example Python code using the GreatAI API.
|
|
||||||
period: '2022'
|
|
||||||
sortDate: 2022-01-01
|
|
||||||
technologies: ['Python', 'ML deployment', 'API design']
|
|
||||||
selected: true
|
|
||||||
essay: greatai-ai-deployment-api
|
|
||||||
links:
|
|
||||||
- label: PyPI
|
|
||||||
url: https://pypi.org/project/great-ai/
|
|
||||||
- label: Project site
|
|
||||||
url: https://great-ai.scoutinscience.com
|
|
||||||
- label: MSc thesis
|
|
||||||
url: /media/downloads/great-ai-andras-schmelczer.pdf
|
|
||||||
download: true
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Lights Synchronized to Music
|
|
||||||
description: Raspberry Pi music player, NumPy FFT, MOSFETs, RGB strips. The first thing I built that I actually finished.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/leds.jpg
|
|
||||||
alt: RGB LED strips glowing from a music synchronization project.
|
|
||||||
period: 'Spring 2016'
|
|
||||||
sortDate: 2016-04-01
|
|
||||||
technologies: ['Python', 'NumPy', 'FFT', 'Raspberry Pi', 'MOSFETs', 'vanilla web']
|
|
||||||
selected: false
|
|
||||||
essay: lights-synchronized-to-music
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
title: My Notes
|
|
||||||
description: A small Android Markdown note app. The point was a few weeks outside the web stack.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/my-notes.png
|
|
||||||
alt: Screenshot of the My Notes Android markdown app.
|
|
||||||
period: 'November 2019'
|
|
||||||
sortDate: 2019-11-01
|
|
||||||
technologies: ['Android', 'Markdown', 'Markwon']
|
|
||||||
selected: false
|
|
||||||
essay: my-notes-android-markdown-app
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/my-notes
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Graph Editor
|
|
||||||
description: A drag-and-drop JavaFX editor that let event organisers reconfigure the cooling sim without me sitting next to them.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/process-simulator-input.jpg
|
|
||||||
alt: JavaFX editor interface for the cooling system simulator input graph.
|
|
||||||
period: 'October-November 2018'
|
|
||||||
sortDate: 2018-10-15
|
|
||||||
technologies: ['JavaFX', 'JSON', 'REST API']
|
|
||||||
selected: false
|
|
||||||
essay: graph-editor-javafx-simulation-input
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Cooling System Simulation
|
|
||||||
description: 'A live cooling-plant simulator for a PLC cybersecurity event. Flow as graph traversal and heat as a matrix solve: two passes instead of one PDE.'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/nuclear-simulation.jpg
|
|
||||||
alt: Cooling system simulator interface with pipes, pumps, and temperature values.
|
|
||||||
period: 'October-November 2018'
|
|
||||||
sortDate: 2018-11-01
|
|
||||||
technologies: ['Python', 'Flask', 'NumPy', 'HTML canvas', 'JavaFX']
|
|
||||||
selected: true
|
|
||||||
essay: nuclear-cooling-simulation
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
---
|
|
||||||
title: Perfect Postcode
|
|
||||||
description: A UK property-intelligence map. ~25M historical transactions, ~150 features per row, all u16-quantised in RAM, served from a single Rust binary.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/perfect-postcode.jpg
|
|
||||||
alt: The Perfect Postcode dashboard with active filters on property type, price, transit time, and crime, showing a Manchester map with matching properties as a heatmap.
|
|
||||||
period: '2026'
|
|
||||||
sortDate: 2026-05-01
|
|
||||||
technologies:
|
|
||||||
[
|
|
||||||
'Rust',
|
|
||||||
'Axum',
|
|
||||||
'Polars',
|
|
||||||
'h3o',
|
|
||||||
'rayon',
|
|
||||||
'PocketBase',
|
|
||||||
'PMTiles',
|
|
||||||
'MapLibre',
|
|
||||||
'deck.gl',
|
|
||||||
'Conveyal R5',
|
|
||||||
'Gemini',
|
|
||||||
]
|
|
||||||
selected: true
|
|
||||||
essay: perfect-postcode-rust-property-server
|
|
||||||
links:
|
|
||||||
- label: Site
|
|
||||||
url: https://perfect-postcode.co.uk
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Photo Site Generator
|
|
||||||
description: Point a Webpack script at a folder of photos, get a static site with responsive image variants. An excuse to walk with a camera.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/photos.jpg
|
|
||||||
alt: Screenshot of a generated photography site.
|
|
||||||
period: 'Summer 2016'
|
|
||||||
sortDate: 2016-07-01
|
|
||||||
technologies: ['Webpack', 'Image processing', 'Static site generation']
|
|
||||||
selected: false
|
|
||||||
essay: photo-site-generator
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
---
|
|
||||||
title: Platform Game
|
|
||||||
description: My Basics of Programming project. 3D voxel game in C and SDL 1.2. Pointers, learned painfully.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/platform-game.jpg
|
|
||||||
alt: Screenshot from an early 3D platform game.
|
|
||||||
period: 'Autumn 2017'
|
|
||||||
sortDate: 2017-10-01
|
|
||||||
technologies: ['C', 'SDL 1.2', 'Voxel terrain']
|
|
||||||
selected: false
|
|
||||||
essay: platform-game-c-sdl
|
|
||||||
links: []
|
|
||||||
---
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
---
|
|
||||||
title: reconcile-text
|
|
||||||
description: One Rust core, three packages. Merges Markdown notes from three editors I don't control, with no operation history. Never emits markers.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/reconcile.png
|
|
||||||
alt: The reconcile-text logo and tagline "Conflict-free 3-way text merging".
|
|
||||||
period: '2025'
|
|
||||||
sortDate: 2025-05-01
|
|
||||||
technologies: ['Rust', 'WebAssembly', 'Python', 'pyo3', 'wasm-bindgen', 'Myers diff']
|
|
||||||
selected: true
|
|
||||||
essay: reconcile-text-3-way-merge
|
|
||||||
links:
|
|
||||||
- label: Demo
|
|
||||||
url: /reconcile/
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/reconcile
|
|
||||||
- label: crates.io
|
|
||||||
url: https://crates.io/crates/reconcile-text
|
|
||||||
- label: npm
|
|
||||||
url: https://www.npmjs.com/package/reconcile-text
|
|
||||||
- label: PyPI
|
|
||||||
url: https://pypi.org/project/reconcile-text/
|
|
||||||
---
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
---
|
|
||||||
title: SDF-2D
|
|
||||||
description: A browser 2D ray-tracer tuned for the phone in your pocket. Tile-based passes, deferred shading, shaders generated per scene and device.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/sdf2d.jpg
|
|
||||||
alt: SDF-2D browser demo with soft lighting effects.
|
|
||||||
period: 'Autumn-Winter 2020'
|
|
||||||
sortDate: 2020-12-01
|
|
||||||
technologies: ['TypeScript', 'WebGL', 'WebGL2', 'Signed distance fields']
|
|
||||||
selected: true
|
|
||||||
essay: sdf-2d-ray-tracing
|
|
||||||
links:
|
|
||||||
- label: NPM package
|
|
||||||
url: https://www.npmjs.com/package/sdf-2d
|
|
||||||
- label: Video
|
|
||||||
url: https://www.youtube.com/watch?v=K3cEtnZUNR0
|
|
||||||
- label: BSc thesis
|
|
||||||
url: /media/downloads/sdf2d-andras-schmelczer.pdf
|
|
||||||
download: true
|
|
||||||
---
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
---
|
|
||||||
title: Life Towers
|
|
||||||
description: A multi-device goal tracker. The trie underneath made the sync diff free; the towers were just the UI.
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/towers.jpg
|
|
||||||
alt: Life Towers goal tracking interface with tower-like visual structures.
|
|
||||||
period: 'August-September 2019'
|
|
||||||
sortDate: 2019-09-01
|
|
||||||
technologies: ['Python', 'Angular', 'TypeScript', 'Immutable trie']
|
|
||||||
selected: true
|
|
||||||
essay: life-towers-immutable-tries
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/life-towers/
|
|
||||||
---
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
---
|
|
||||||
title: VaultLink
|
|
||||||
description: 'I refuse to give up the editor: Obsidian, Vim, VS Code, sed. Self-hosted sync that survives all four, built on reconcile-text underneath.'
|
|
||||||
thumbnail:
|
|
||||||
src: ./_assets/vault-link.svg
|
|
||||||
alt: 'The VaultLink logo: a chain-link mark in a soft gradient.'
|
|
||||||
period: '2025-2026'
|
|
||||||
sortDate: 2025-12-01
|
|
||||||
technologies:
|
|
||||||
[
|
|
||||||
'Rust',
|
|
||||||
'axum',
|
|
||||||
'sqlx',
|
|
||||||
'SQLite',
|
|
||||||
'WebSockets',
|
|
||||||
'TypeScript',
|
|
||||||
'Obsidian plugin',
|
|
||||||
'ts-rs',
|
|
||||||
'wasm-bindgen',
|
|
||||||
'reconcile-text',
|
|
||||||
]
|
|
||||||
selected: true
|
|
||||||
essay: vault-link-obsidian-sync
|
|
||||||
links:
|
|
||||||
- label: Source
|
|
||||||
url: https://github.com/schmelczer/vault-link
|
|
||||||
- label: Docs
|
|
||||||
url: https://vault-link.schmelczer.dev
|
|
||||||
---
|
|
||||||
BIN
src/content/work/_assets/ad-astra-breadboard.jpg
Normal file
|
After Width: | Height: | Size: 389 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |