Text mode typed only with the device's own keyboard, so switch and gaze users could not type in it (D8). The on-screen keyboard's keys are cells: switch scanning scans them with the board's scan engine (the shell's current_grid; a press goes to keyboard::press_at), a look's dwell lands on a key by where it is drawn, a touch presses it, and the keys draw the highlight and the dwell's progress themselves. What is added to those paths reaches the keys too. Row 0 holds the word suggestions; the sentence helper and first letters take its place, as they took the suggestions' before, and scanning covers the rows below while they do. Space, Delete, Delete word, Clear (Undo), Speak, Shift (capitals at a sentence start by themselves), numbers and punctuation, ✨, 🔤 and the person's phrases are keys. The national layout, alphabetical, most used letters first and colour groups are chosen in Settings → Keyboard, and kept. On by default for switches, gaze, head tracking and joysticks; the device keyboard for touch, a tap away. What is typed lives in a TextBox in the app state, so the box and the keys change it alike (first letters, Undo, suggestions). The alphabet by ear reads letter groups, most written first, and takes a yes from a switch, a look at Yes, or a tap; it paces itself by its spoken cues and the person's scan time. Chinese: pinyin converts to the words it spells exactly, from a pinyin index of the pack's words (data/pinyin, built by tools/lang/build_pinyin.py with pypinyin), fetched only when Chinese is typed. SPEC §12 (proposed): keyboards and alphabet access. Co-Authored-By: Claude Opus 5.5 <[email protected]>
269 lines
15 KiB
Shell
Executable file
269 lines
15 KiB
Shell
Executable file
#!/usr/bin/env bash
|
|
# ============================================================================
|
|
# prerender-static.sh — build aac.transaction.science as a self-contained
|
|
# static site.
|
|
#
|
|
# AAC ships as a static CSR-hydrate SPA, but it's a Leptos SSR app: the
|
|
# index.html the browser loads is the SSR server's *rendered* output (it carries
|
|
# the hydration island markers and the pre-rendered board). A plain
|
|
# `cargo leptos build` never emits that index.html, and it doesn't copy the
|
|
# PWA assets that live in `static/`. This script closes both gaps and makes the
|
|
# result safe to cache forever:
|
|
#
|
|
# build -> target/site/{pkg/, icons/, symbols/} (cargo-leptos)
|
|
# render -> target/site/index.html (capture SSR `/`)
|
|
# hash -> pkg/aac-web.<sha256:12>.{js,wasm,css} (and rewrite references)
|
|
# vendor -> target/site/vendor/mediapipe-<ver>-<rev>/ (eye gaze, same origin)
|
|
# worker -> target/site/sw.js (static/sw.js + precache list)
|
|
#
|
|
# Output: a self-contained `target/site/` ready to rsync to prod. Deploy it with
|
|
# no_cache_bust — the names are already content-hashed, and the service worker
|
|
# lists them. Idempotent.
|
|
#
|
|
# Hosting under a sub-path, or under another name, is a build-time choice; see
|
|
# AAC_BASE_PATH, AAC_SW_URL, AAC_APP_TITLE and AAC_HIDE_ENERGY in
|
|
# crates/aac-web/src/app.rs. This script builds the root-mounted default.
|
|
# ============================================================================
|
|
set -euo pipefail
|
|
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
PORT="${AAC_PRERENDER_PORT:-39207}"
|
|
SITE="target/site"
|
|
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/aac-rs"
|
|
|
|
# wasm-bindgen's CLI must match the crate version exactly (Cargo.toml pins it).
|
|
# Use a private copy of the right version rather than the machine's global one.
|
|
WBG_VER="$(awk '/^name = "wasm-bindgen"$/{getline; gsub(/version = |"/, ""); print; exit}' Cargo.lock)"
|
|
if ! wasm-bindgen --version 2>/dev/null | grep -qx "wasm-bindgen $WBG_VER"; then
|
|
WBG_ROOT="$CACHE/wasm-bindgen-$WBG_VER"
|
|
if [ ! -x "$WBG_ROOT/bin/wasm-bindgen" ]; then
|
|
echo "▸ installing wasm-bindgen-cli $WBG_VER into $WBG_ROOT"
|
|
cargo install --quiet --locked --root "$WBG_ROOT" wasm-bindgen-cli --version "$WBG_VER"
|
|
fi
|
|
export PATH="$WBG_ROOT/bin:$PATH"
|
|
fi
|
|
|
|
echo "▸ cargo leptos build --release"
|
|
rm -rf "$SITE/pkg"
|
|
cargo leptos build --release
|
|
find "$SITE/pkg" -name '*.d.ts' -delete
|
|
# Symbol sprites and their licences (cargo-leptos copies public/; a clean copy
|
|
# drops sprites of earlier builds, whose names carry another hash).
|
|
rm -rf "$SITE/symbols"
|
|
cp -R crates/aac-web/public/symbols "$SITE/symbols"
|
|
# Language packs for word prediction (data/lang: content-hashed, with their
|
|
# licence) under models/lang/, where the app's model store downloads a
|
|
# language's pack once, verifies it and keeps it (aac-web lang_packs.rs).
|
|
rm -rf "$SITE/models/lang"
|
|
mkdir -p "$SITE/models/lang"
|
|
cp data/lang/*.bin data/lang/index.json data/lang/LICENSE.md "$SITE/models/lang/"
|
|
# The pinyin index for typing Chinese on the on-screen keyboard (data/pinyin,
|
|
# content-hashed, with its licence): fetched only when Chinese is typed.
|
|
rm -rf "$SITE/models/pinyin"
|
|
mkdir -p "$SITE/models/pinyin"
|
|
cp data/pinyin/*.txt data/pinyin/index.json data/pinyin/LICENSE.md "$SITE/models/pinyin/"
|
|
|
|
echo "▸ starting SSR server on 127.0.0.1:$PORT to prerender /"
|
|
LOG="$(mktemp)"
|
|
LEPTOS_OUTPUT_NAME=aac-web \
|
|
LEPTOS_SITE_ROOT="$SITE" \
|
|
LEPTOS_SITE_PKG_DIR=pkg \
|
|
LEPTOS_SITE_ADDR="127.0.0.1:$PORT" \
|
|
LEPTOS_ENV=PROD \
|
|
target/release/aac-web > "$LOG" 2>&1 &
|
|
SRV=$!
|
|
trap 'kill "$SRV" 2>/dev/null || true' EXIT
|
|
|
|
# Wait for the listener (up to ~30s).
|
|
ready=false
|
|
for _ in $(seq 1 120); do
|
|
if curl -sf "http://127.0.0.1:$PORT/" -o /dev/null 2>/dev/null; then ready=true; break; fi
|
|
sleep 0.25
|
|
done
|
|
if ! $ready; then
|
|
echo "✗ SSR server did not come up; log:" >&2; tail -5 "$LOG" >&2; exit 1
|
|
fi
|
|
|
|
echo "▸ capturing rendered / -> $SITE/index.html"
|
|
curl -sf "http://127.0.0.1:$PORT/" -o "$SITE/index.html"
|
|
|
|
kill "$SRV" 2>/dev/null || true
|
|
trap - EXIT
|
|
|
|
# Sanity-check the capture is the hydrating SSR shell, not an error page.
|
|
test -s "$SITE/index.html"
|
|
grep -q 'mod.hydrate' "$SITE/index.html" || { echo "✗ captured index.html is not the hydrating shell" >&2; exit 1; }
|
|
grep -q '<title>AAC' "$SITE/index.html" || { echo "✗ captured index.html missing expected title" >&2; exit 1; }
|
|
|
|
echo "▸ content-hashing pkg/"
|
|
hash12() { shasum -a 256 "$1" | cut -c1-12; }
|
|
# Portable in-place sed (BSD and GNU disagree about -i): sed_i <args…> <file>
|
|
sed_i() { local f="${!#}"; sed "${@:1:$#-1}" "$f" > "$f.tmp" && mv "$f.tmp" "$f"; }
|
|
P="$SITE/pkg"
|
|
[ -f "$P/aac-web_bg.wasm" ] || mv "$P/aac-web.wasm" "$P/aac-web_bg.wasm"
|
|
W="$(hash12 "$P/aac-web_bg.wasm")"; mv "$P/aac-web_bg.wasm" "$P/aac-web.$W.wasm"
|
|
C="$(hash12 "$P/aac-web.css")"; mv "$P/aac-web.css" "$P/aac-web.$C.css"
|
|
# The glue may name its wasm; point it at the hashed file before hashing the glue.
|
|
sed_i -e "s#aac-web_bg\\.wasm#aac-web.$W.wasm#g" -e "s#aac-web\\.wasm#aac-web.$W.wasm#g" "$P/aac-web.js"
|
|
# wasm-bindgen names the snippet directory after the crate, not its contents,
|
|
# so a snippet keeps its URL from build to build, and Cloudflare gives every .js
|
|
# a 4-hour browser cache whatever Caddy sends. A returning visitor would then
|
|
# pair a new aac-web.js with an old snippet, and the app would not start (seen
|
|
# on 2026-09-30). Name each snippet directory by its contents too, before
|
|
# aac-web.js (whose imports name it) is hashed.
|
|
for d in "$P"/snippets/*/; do
|
|
d="${d%/}"; name="$(basename "$d")"
|
|
S="$(cd "$d" && find . -type f | LC_ALL=C sort | while IFS= read -r f; do printf '%s %s\n' "$f" "$(shasum -a 256 "$f" | cut -c1-64)"; done | shasum -a 256 | cut -c1-12)"
|
|
mv "$d" "$P/snippets/$name-$S"
|
|
# Only where the script loads it (`from "…"`, `import("…")`): the keys of
|
|
# the WebAssembly import object keep the old path, which the .wasm names.
|
|
sed_i -e "s#\(from *[\"']\)\./snippets/$name/#\1./snippets/$name-$S/#g" \
|
|
-e "s#\(import( *[\"']\)\./snippets/$name/#\1./snippets/$name-$S/#g" "$P/aac-web.js"
|
|
grep -q "snippets/$name-$S/" "$P/aac-web.js" || { echo "✗ aac-web.js does not import snippets/$name-$S" >&2; exit 1; }
|
|
if grep -qE "(from *[\"']|import\( *[\"'])\./snippets/$name/" "$P/aac-web.js"; then
|
|
echo "✗ aac-web.js still loads snippets/$name by its old name" >&2; exit 1
|
|
fi
|
|
done
|
|
J="$(hash12 "$P/aac-web.js")"; mv "$P/aac-web.js" "$P/aac-web.$J.js"
|
|
sed_i \
|
|
-e "s#/pkg/aac-web_bg\\.wasm#/pkg/aac-web.$W.wasm#g" \
|
|
-e "s#/pkg/aac-web\\.wasm#/pkg/aac-web.$W.wasm#g" \
|
|
-e "s#/pkg/aac-web\\.js#/pkg/aac-web.$J.js#g" \
|
|
-e "s#/pkg/aac-web\\.css#/pkg/aac-web.$C.css#g" \
|
|
"$SITE/index.html"
|
|
# Leptos's hydration loader builds its URLs from arguments:
|
|
# (root, pkg_path, output_name, wasm_output_name)
|
|
LOADER='("", "pkg", "aac-web", "aac-web")'
|
|
grep -qF "$LOADER" "$SITE/index.html" || { echo "✗ hydration loader call not found — has Leptos changed its loader?" >&2; exit 1; }
|
|
sed_i "s#(\"\", \"pkg\", \"aac-web\", \"aac-web\")#(\"\", \"pkg\", \"aac-web.$J\", \"aac-web.$W\")#" "$SITE/index.html"
|
|
for f in "aac-web.$W.wasm" "aac-web.$J.js" "aac-web.$C.css" "\"aac-web.$J\", \"aac-web.$W\""; do
|
|
grep -q "$f" "$SITE/index.html" || { echo "✗ index.html does not reference $f" >&2; exit 1; }
|
|
done
|
|
|
|
# Eye gaze loads MediaPipe from vendor/mediapipe-<version>-<revision>/ on this
|
|
# origin (crates/aac-web/src/bindings/mediapipe_init.js), so turning on the
|
|
# camera makes no third-party request and the app stays cross-origin isolated.
|
|
rm -rf "$SITE/vendor"
|
|
bash third_party/mediapipe/vendor.sh "$SITE" "$CACHE"
|
|
MP_DIR="$(cd "$SITE/vendor" && echo mediapipe-*)"
|
|
[ -d "$SITE/vendor/$MP_DIR" ] || { echo "✗ vendor.sh left no vendor/mediapipe-* directory" >&2; exit 1; }
|
|
|
|
# On-device voices, downloaded only when the person asks (neural_voice.rs):
|
|
# the Moonshine WASM runtime, vendored and pinned like MediaPipe, and the
|
|
# voice packs, built outside git by tools/voices/build_voices.py, under
|
|
# voices/ (cloned, not copied, on APFS), once tools/voices/check_packs.py
|
|
# has matched them to the committed catalogue. A build without them must say
|
|
# so (AAC_VOICES=none): a deploy would otherwise delete the voices it serves.
|
|
rm -rf "$SITE/voices"
|
|
if [ "${AAC_VOICES:-}" = "none" ]; then
|
|
echo "▸ AAC_VOICES=none: no better voices in this build; the app offers the device's voices only"
|
|
else
|
|
bash third_party/moonshine-wasm/vendor.sh "$SITE" "$CACHE"
|
|
VOICES="${AAC_VOICES_DIR:-$(cd ../../.. && pwd)/aac-voice-packs}"
|
|
python3 tools/voices/check_packs.py "$VOICES" \
|
|
|| { echo "✗ build the packs (tools/voices/build_voices.py), or set AAC_VOICES=none to build without better voices" >&2; exit 1; }
|
|
cp -cR "$VOICES" "$SITE/voices" 2>/dev/null || cp -R "$VOICES" "$SITE/voices"
|
|
# index.json and LICENSES.md keep their names from one catalogue to the next,
|
|
# and the builder stamps every file with one fixed time: give these two this
|
|
# build's own, or rsync's size-and-time check would keep prod's old pointer,
|
|
# to a catalogue its --delete has just removed.
|
|
touch "$SITE/voices/index.json" "$SITE/voices/LICENSES.md"
|
|
fi
|
|
# The sentence helper, downloaded only when the person asks (ai_helper.rs):
|
|
# Google's LiteRT-LM web runtime, vendored and pinned like Moonshine, and the
|
|
# model's parts, built outside git by tools/ai/build_ai.py, under ai/ (cloned,
|
|
# not copied, on APFS), once tools/ai/check_packs.py has matched them to the
|
|
# committed catalogue. A build without them must say so (AAC_AI=none).
|
|
rm -rf "$SITE/ai"
|
|
if [ "${AAC_AI:-}" = "none" ]; then
|
|
echo "▸ AAC_AI=none: no sentence helper in this build"
|
|
else
|
|
bash third_party/litert-lm/vendor.sh "$SITE" "$CACHE"
|
|
AI="${AAC_AI_DIR:-$(cd ../../.. && pwd)/aac-ai-packs}"
|
|
python3 tools/ai/check_packs.py "$AI" \
|
|
|| { echo "✗ build the model parts (tools/ai/build_ai.py), or set AAC_AI=none to build without the sentence helper" >&2; exit 1; }
|
|
cp -cR "$AI" "$SITE/ai" 2>/dev/null || cp -R "$AI" "$SITE/ai"
|
|
touch "$SITE/ai/index.json" "$SITE/ai/LICENSES.md"
|
|
fi
|
|
# The listener, downloaded only when the person asks (listen.rs):
|
|
# onnxruntime-web, vendored and pinned like LiteRT-LM, and the model's files,
|
|
# built outside git by tools/asr/build_asr.py, under asr/ (cloned, not
|
|
# copied, on APFS), once tools/asr/check_packs.py has matched them to the
|
|
# committed catalogue. A build without them must say so (AAC_ASR=none).
|
|
rm -rf "$SITE/asr"
|
|
if [ "${AAC_ASR:-}" = "none" ]; then
|
|
echo "▸ AAC_ASR=none: no listener in this build"
|
|
else
|
|
bash third_party/onnxruntime-web/vendor.sh "$SITE" "$CACHE"
|
|
ASR="${AAC_ASR_DIR:-$(cd ../../.. && pwd)/aac-asr-packs}"
|
|
python3 tools/asr/check_packs.py "$ASR" \
|
|
|| { echo "✗ build the listener's files (tools/asr/build_asr.py), or set AAC_ASR=none to build without the listener" >&2; exit 1; }
|
|
cp -cR "$ASR" "$SITE/asr" 2>/dev/null || cp -R "$ASR" "$SITE/asr"
|
|
touch "$SITE/asr/index.json" "$SITE/asr/LICENSES.md"
|
|
fi
|
|
# First letters, downloaded only when the person asks (abbrev.rs): it runs
|
|
# on the listener's onnxruntime-web (vendored above, or here when the
|
|
# listener is not in this build), with its own worker and Hugging Face's
|
|
# tokenizer library, vendored and pinned like it, and the model's files,
|
|
# built outside git by tools/ae/build_ae.py, under ae/ (cloned, not copied,
|
|
# on APFS), once tools/ae/check_packs.py has matched them to the committed
|
|
# catalogue. A build without them must say so (AAC_AE=none).
|
|
rm -rf "$SITE/ae"
|
|
if [ "${AAC_AE:-}" = "none" ]; then
|
|
echo "▸ AAC_AE=none: no first letters in this build"
|
|
else
|
|
[ "${AAC_ASR:-}" = "none" ] && bash third_party/onnxruntime-web/vendor.sh "$SITE" "$CACHE"
|
|
bash third_party/hf-tokenizers/vendor.sh "$SITE" "$CACHE"
|
|
AE="${AAC_AE_DIR:-$(cd ../../.. && pwd)/aac-ae-packs}"
|
|
python3 tools/ae/check_packs.py "$AE" \
|
|
|| { echo "✗ build first letters' files (tools/ae/build_ae.py), or set AAC_AE=none to build without first letters" >&2; exit 1; }
|
|
cp -cR "$AE" "$SITE/ae" 2>/dev/null || cp -R "$AE" "$SITE/ae"
|
|
touch "$SITE/ae/index.json" "$SITE/ae/LICENSES.md"
|
|
fi
|
|
# The loaders of the sentence helper, the listener and first letters must
|
|
# never reach another site.
|
|
AI_SNIP="$(grep -rl 'ensure_engine' "$SITE/pkg/snippets" || true)"
|
|
[ -n "$AI_SNIP" ] || { echo "✗ the sentence helper's snippet is not in pkg/snippets" >&2; exit 1; }
|
|
if grep -nE 'https?://' $AI_SNIP; then echo "✗ the sentence helper's snippet names another site" >&2; exit 1; fi
|
|
LISTEN_SNIP="$(grep -rl 'listen_once' "$SITE/pkg/snippets" || true)"
|
|
[ -n "$LISTEN_SNIP" ] || { echo "✗ the listener's snippet is not in pkg/snippets" >&2; exit 1; }
|
|
if grep -nE 'https?://' $LISTEN_SNIP; then echo "✗ the listener's snippet names another site" >&2; exit 1; fi
|
|
AE_SNIP="$(grep -rl '__aacLettersEngine' "$SITE/pkg/snippets" || true)"
|
|
[ -n "$AE_SNIP" ] || { echo "✗ first letters' snippet is not in pkg/snippets" >&2; exit 1; }
|
|
if grep -nE 'https?://' $AE_SNIP; then echo "✗ first letters' snippet names another site" >&2; exit 1; fi
|
|
SNIP="$(grep -rl 'initialize_face_landmarker' "$SITE/pkg/snippets" || true)"
|
|
[ -n "$SNIP" ] || { echo "✗ eye-tracking snippet not found in pkg/snippets" >&2; exit 1; }
|
|
if grep -nE 'https?://' $SNIP; then echo "✗ eye-tracking snippet loads from a third party" >&2; exit 1; fi
|
|
|
|
echo "▸ bundling PWA assets and the service worker"
|
|
cp crates/aac-web/static/manifest.json "$SITE/manifest.json"
|
|
# Precache: the shell, the manifest, every file in pkg/ (including wasm-bindgen
|
|
# snippets such as the eye-tracking loader), the icons, and every symbol sprite
|
|
# the prerendered board draws (the default set and the sets it falls back to),
|
|
# so symbols show offline from the first visit, and their LICENSES.md. Other sprites are cached by the
|
|
# worker the first time they are drawn.
|
|
PRECACHE=("/" "/manifest.json")
|
|
while IFS= read -r f; do PRECACHE+=("/${f#"$SITE"/}"); done < <(find "$SITE/pkg" "$SITE/icons" -type f | LC_ALL=C sort)
|
|
DRAWN="$(grep -oE '/symbols/[a-z]+\.[0-9a-f]{12}\.svg' "$SITE/index.html" | LC_ALL=C sort -u || true)"
|
|
[ -n "$DRAWN" ] || { echo "✗ the prerendered board draws no symbols" >&2; exit 1; }
|
|
for u in $DRAWN; do
|
|
[ -f "$SITE$u" ] || { echo "✗ index.html draws $u, which the build does not ship" >&2; exit 1; }
|
|
PRECACHE+=("$u")
|
|
done
|
|
# Settings links the symbols' licences; the worker serves it offline too.
|
|
[ -f "$SITE/symbols/LICENSES.md" ] || { echo "✗ the build does not ship symbols/LICENSES.md" >&2; exit 1; }
|
|
PRECACHE+=("/symbols/LICENSES.md")
|
|
SYMBOL_FILES="[$(cd "$SITE/symbols" && printf '"%s",' *.svg | sed 's/,$//')]"
|
|
VERSION="$(for u in "${PRECACHE[@]}"; do
|
|
if [ "$u" = "/" ]; then f="$SITE/index.html"; else f="$SITE$u"; fi
|
|
printf '%s %s\n' "$u" "$(shasum -a 256 "$f" | cut -c1-64)"
|
|
done | shasum -a 256 | cut -c1-12)"
|
|
LIST="[$(printf '"%s",' "${PRECACHE[@]}" | sed 's/,$//')]"
|
|
# The worker keeps only the current vendor directory in its vendor cache.
|
|
sed -e "s#__VERSION__#$VERSION#" -e "s#__PRECACHE_URLS__#$LIST#" -e "s#__VENDOR_DIRS__#[\"$MP_DIR/\"]#" \
|
|
-e "s#__SYMBOL_FILES__#$SYMBOL_FILES#" \
|
|
crates/aac-web/static/sw.js > "$SITE/sw.js"
|
|
grep -q "__" "$SITE/sw.js" && grep -nE "__[A-Z_]+__" "$SITE/sw.js" && { echo "✗ sw.js has an unfilled placeholder" >&2; exit 1; }
|
|
|
|
echo "✓ self-contained dist ready at $SITE/ (service worker $VERSION, ${#PRECACHE[@]} files precached)"
|
|
ls -1 "$SITE" "$SITE/pkg"
|