Vibecoded CI/CD
53
.gitea/workflows/deploy-lean.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Build and Deploy Lean
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
# This pipeline owns the lean editor image: heavy (it builds lean4web
|
||||
# with Mathlib), so it runs only when lean/** actually changes
|
||||
paths:
|
||||
- "lean/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install SSH client
|
||||
run: |
|
||||
apt-get update -y && apt-get install -y openssh-client
|
||||
|
||||
- name: Setup SSH inside container
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
|
||||
# Add the container host to known_hosts
|
||||
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Log in to registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
# Multi-stage: compilers/dev deps stay in the builder, the pushed image
|
||||
# is the slim runtime (toolchain + client + Mathlib cache). Still a
|
||||
# heavy build and a multi-GB push — expect it to take a while.
|
||||
# Pin upstream with: docker build --build-arg LEAN4WEB_REF=v0.2.2 ...
|
||||
- name: Build lean editor server
|
||||
run: |
|
||||
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest ./lean
|
||||
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.aranroig.com -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
cd /var/www/app/
|
||||
docker compose pull lean
|
||||
docker compose up -d lean
|
||||
EOF
|
||||
@@ -3,6 +3,10 @@ name: Build and Deploy Nuxt
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
# The Lean editor carries Mathlib and moves on its own schedule — its
|
||||
# image is built and pushed by deploy-lean.yml, only when lean/** changes
|
||||
paths-ignore:
|
||||
- "lean/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -39,16 +43,13 @@ jobs:
|
||||
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-backend:latest ./backend
|
||||
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-backend:latest
|
||||
|
||||
- name: Build lean backend
|
||||
run: |
|
||||
docker build -t git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest ./lean
|
||||
docker push git.aranroig.com/${{ secrets.REGISTRY_USER }}/mathew-lean:latest
|
||||
|
||||
- name: Copy files
|
||||
run: |
|
||||
scp docker-compose.yml deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/
|
||||
scp nginx.conf deploy@${{ secrets.DEPLOY_HOST }}:/var/www/app/nginx.conf
|
||||
|
||||
# `compose pull` also pulls the lean image from the registry —
|
||||
# deploy-lean.yml is what keeps that tag current
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh deploy@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||
@@ -57,4 +58,3 @@ jobs:
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
EOF
|
||||
|
||||
|
||||
36
AGENTS.md
@@ -63,10 +63,17 @@ its theme (live, same page), and the Goals/Messages pane.
|
||||
|
||||
`lean/Dockerfile` still builds lean4web (upstream's real Lean + Mathlib per session;
|
||||
`--build-arg LEAN4WEB_REF=<tag|branch>` pins it) and runs its websocket server on port 8080;
|
||||
the compose `lean` service runs it. Upstream asks for a dedicated machine, and the image is
|
||||
heavy (Mathlib). It runs `NO_BWRAP=true` — bubblewrap cannot nest in a container, so Docker
|
||||
itself is the sandbox; whoever reaches the port can run Lean. Build/run, TLS, security and
|
||||
projects: `lean/README.md`.
|
||||
the compose `lean` service runs it (image `git.aranroig.com/syndria98/mathew-lean:latest` —
|
||||
CI builds and pushes it, and deploys lean, only when `lean/**` changes: `deploy-lean.yml`;
|
||||
the regular deploy just pulls the tag). Upstream asks for a dedicated machine, so the build
|
||||
is **multi-stage**: dev tooling stays in the builder, and the pushed image is a slim runtime
|
||||
of node + elan toolchain + built client + Mathlib's compiled cache (~11GB — the cache is
|
||||
the bulk; upstream's second demo project `Stable` is dropped, its extra ~3GB toolchain was
|
||||
unused). `--build-arg BAKE_MATHLIB=false` ships Mathlib *out* of the image (~4GB) into a
|
||||
`.lake` volume, filled once at first start by `lean/entrypoint.sh` — see lean/README.md.
|
||||
It runs `NO_BWRAP=true` — bubblewrap cannot nest in a container, so Docker itself is the
|
||||
sandbox; whoever reaches the port can run Lean. Build/run, TLS, security and projects:
|
||||
`lean/README.md`.
|
||||
|
||||
- `frontend/app/utils/lean/lsp.ts` — a minimal LSP JSON-RPC client over a websocket
|
||||
(request/response pairing, notification dispatch, and auto-ack of the server→client
|
||||
@@ -206,6 +213,24 @@ carry `<code>`/`<span>` markup are drawn with `v-html` through `th()`
|
||||
(`useAppLocale`), which escapes interpolated values. Adding a language = add a catalog
|
||||
copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
|
||||
|
||||
- **The same bundle also ships as a desktop app** (Tauri 2, `src-tauri/`):
|
||||
`npm run desktop:dev` / `desktop:build` / `desktop:generate` / `desktop:icons`
|
||||
in `frontend/` (needs Rust and the WebKitGTK stack; the shell, the scripts and
|
||||
the build prerequisites are `src-tauri/README.md`). `nuxt.config.ts` changes
|
||||
shape on one flag — `MATHEW_DESKTOP=1`, implied too by the `TAURI_ENV_*` the
|
||||
Tauri CLI passes to its hooks: `ssr: false`, no `/api` proxy route, the
|
||||
pre-paint script from `public/preboot.js` instead of inline — and writes the
|
||||
files to `dist-desktop/public` (never over the web `.output/`), which is what
|
||||
`frontendDist` reads. Nothing in `app/` is desktop code: `utils/desktop.ts`
|
||||
asks at run time whether it is in a window (`__TAURI_INTERNALS__`) and only
|
||||
then does the window's thing — outward links leave through the `opener`
|
||||
plugin (`plugins/desktop.ts`), and the session token and interface language
|
||||
mirror into localStorage (`keepTokenRemembered`, since a webview is not owed
|
||||
a cookie jar for its own `tauri://localhost`). A desktop build bakes the API
|
||||
base to `https://mathew.aranroig.com` (`NUXT_PUBLIC_API_BASE` overrides, e.g.
|
||||
`http://localhost:5000` to test against this machine) and leaves `leanUrl`
|
||||
empty — a window on a random laptop has no `localhost:8080` to find.
|
||||
|
||||
- `app/pages/` — `/` (search), `/wiki/[lang]/[slug]` (one language version of an
|
||||
article — the address every version has, English included — drawn as a page of
|
||||
the open book), `/wiki/[slug]` (an address naming no language: the middleware
|
||||
@@ -425,5 +450,6 @@ copy + an `APP_LANGUAGES` entry + the code in the backend's `SUPPORTED_LOCALES`.
|
||||
A tool page like this is wired in step: `ToolPage`/`TOOL_PAGE_PATHS`
|
||||
(`utils/routes.ts`), the page file, `TOOL_LABEL_KEYS`, the `ToolPane` branch and spine
|
||||
glyph, the sidebar link, and the `PAGE_TOOLS` entry.
|
||||
- Frontend dev server may fall back to port 3001 if 3000 is taken.
|
||||
- Frontend dev server may fall back to port 3001 if 3000 is taken — the desktop
|
||||
shell's `devUrl` is fixed to 3000, so free the port before `npm run desktop:dev`.
|
||||
- `frontend/types/markdown-it-texmath.d.ts` provides types for the untyped texmath package.
|
||||
|
||||
@@ -23,12 +23,22 @@ services:
|
||||
|
||||
# The Lean editor (lean4web) — its own server, since it runs a real Lean
|
||||
# process per session and asks for a dedicated machine (see lean/).
|
||||
# CI builds and pushes this image only when lean/** changes.
|
||||
lean:
|
||||
build: ./lean
|
||||
image: mathew-lean:latest
|
||||
build:
|
||||
context: ./lean
|
||||
# To ship a ~4GB image that keeps Mathlib's cache in the volume below
|
||||
# instead of inside itself (filled once at first start — see the
|
||||
# "Keeping Mathlib out of the image" section of lean/README.md):
|
||||
# args:
|
||||
# BAKE_MATHLIB: "false"
|
||||
image: git.aranroig.com/syndria98/mathew-lean:latest
|
||||
ports:
|
||||
- "8080:8080"
|
||||
restart: always
|
||||
# Only with BAKE_MATHLIB: "false" above:
|
||||
# volumes:
|
||||
# - lean-mathlib:/opt/lean4web/Projects/MathlibDemo/.lake
|
||||
|
||||
backend:
|
||||
image: git.aranroig.com/syndria98/mathew-backend:latest
|
||||
@@ -44,3 +54,8 @@ services:
|
||||
# anyone could then forge sessions against the public site.
|
||||
JWT_SECRET: ${JWT_SECRET:-}
|
||||
restart: always
|
||||
|
||||
# Only with the lean volume mode (uncomment together with the lean build arg
|
||||
# and the lean service's volumes entry):
|
||||
# volumes:
|
||||
# lean-mathlib:
|
||||
|
||||
3
frontend/.gitignore
vendored
@@ -6,6 +6,9 @@
|
||||
.cache
|
||||
dist
|
||||
|
||||
# The desktop bundle (nuxt generate in its desktop shape, read by src-tauri/)
|
||||
dist-desktop
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
A session is a bearer token the API handed us, kept in a cookie rather than
|
||||
localStorage so the server can see it too: the sidebar then knows who is
|
||||
signed in on the very first draw, before any script runs. The token itself is
|
||||
never decoded here — the API is asked who it belongs to (see restoreSession). */
|
||||
never decoded here — the API is asked who it belongs to (see restoreSession).
|
||||
|
||||
In the desktop build the token is kept twice — see keepTokenRemembered, at
|
||||
the foot of this file. */
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
@@ -34,6 +37,11 @@ export function useAuth() {
|
||||
maxAge: WEEK_IN_SECONDS,
|
||||
sameSite: 'lax',
|
||||
})
|
||||
/* A window is not owed a cookie jar: its own address is one a webview is not
|
||||
everywhere obliged to store cookies against, and a session that could not
|
||||
outlive the window would ask the person to sign in every time they opened
|
||||
the app. A no-op anywhere else. */
|
||||
keepTokenRemembered(token, TOKEN_COOKIE)
|
||||
const user = useState<AuthUser | null>('auth:user', () => null)
|
||||
|
||||
const loggedIn = computed(() => user.value !== null)
|
||||
|
||||
65
frontend/app/plugins/desktop.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/* The window's own habits.
|
||||
|
||||
These are the two things the wiki does differently once it is a desktop app
|
||||
rather than a page of the web — see utils/desktop.ts for the asking, and
|
||||
src-tauri/ for the shell. The web build takes the first line and returns:
|
||||
nothing here changes how a browser sees the wiki.
|
||||
|
||||
Runs after the auth plugin (plugins come in name order), which matters: by
|
||||
now a signed-in person's account has already had its say about the interface
|
||||
language, and an account outranks this device. */
|
||||
|
||||
import { isDesktop, leadsOutward, openExternal, storeGet, storeSet } from '~/utils/desktop'
|
||||
|
||||
/** Where a window keeps the interface language it was last shown. Also the
|
||||
* name of the cookie the web build writes it to — the same choice, remembered
|
||||
* the way this kind of host can be sure of remembering it. */
|
||||
const LOCALE_KEY = 'mathew-locale'
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
if (!import.meta.client || !isDesktop()) return
|
||||
|
||||
/* Links. The wiki's own addresses are relative and the router turns them as
|
||||
it always does; an absolute one is about something outside this window —
|
||||
and a window has one page, no tab strip, no back button a reader would
|
||||
trust after the wiki had been replaced by some other site's document. So
|
||||
those go out to the system's browser instead, and the window keeps
|
||||
showing the wiki it was asked to show.
|
||||
|
||||
Captured on the way in, so it also catches the links drawn from article
|
||||
Markdown — the rendered text belongs to the article, which has no reason
|
||||
to know it is not in a browser. */
|
||||
document.addEventListener(
|
||||
'click',
|
||||
(event) => {
|
||||
if (event.defaultPrevented || event.button !== 0) return
|
||||
// a modified click is the reader asking for the browser's own gesture
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
const target = event.target as Element | null
|
||||
const href = target?.closest?.('a')?.getAttribute('href')
|
||||
if (!href || !leadsOutward(href)) return
|
||||
event.preventDefault()
|
||||
void openExternal(href)
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
||||
/* The interface language, kept where a window can be certain of keeping it.
|
||||
On the web the choice rides on the `mathew-locale` cookie, which the server
|
||||
reads so the first draw already speaks it. A window draws nothing on a
|
||||
server, and its own address — `tauri://localhost` — is one a webview is
|
||||
not everywhere obliged to keep cookies for, so the choice is mirrored into
|
||||
localStorage: a lost cookie costs a language switch at the next start, and
|
||||
that is all.
|
||||
|
||||
An account's own choice outranks the mirror, as it outranks the cookie —
|
||||
hence the guard, the auth plugin having run first. Whatever the UI ends up
|
||||
speaking is then written back, so the mirror always says what this window
|
||||
last showed. */
|
||||
const { locale, choose } = useAppLocale()
|
||||
const { user } = useAuth()
|
||||
const remembered = storeGet(LOCALE_KEY)
|
||||
if (remembered && !user.value?.locale) void choose(remembered)
|
||||
|
||||
watch(locale, (code) => storeSet(LOCALE_KEY, code))
|
||||
})
|
||||
116
frontend/app/utils/desktop.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
/* Running as a desktop app.
|
||||
|
||||
One bundle answers both a browser and an app window (Tauri — see
|
||||
src-tauri/), so nothing in `app/` is desktop code: it is the wiki asking at
|
||||
run time whether it happens to be sitting in a window. The answer comes from
|
||||
the shell, which marks the page it loads and marks no other — so the same
|
||||
dist-desktop/ files opened in an ordinary browser behave as a web page, and
|
||||
a web page that somehow met Tauri would only gain the shell's habits.
|
||||
|
||||
What a window does differently, then, is small and all about being a window
|
||||
with one page and no browser chrome: a link leads out to the system's own
|
||||
browser rather than replacing the wiki in the only view there is, and the
|
||||
few bytes the app remembers of a person live where a window can be certain
|
||||
of keeping them. */
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import { watch } from 'vue'
|
||||
|
||||
/** The handle every Tauri webview injects into its page — present before the
|
||||
* npm API is imported, and without it. Nothing else sets this name. */
|
||||
export function isDesktop(): boolean {
|
||||
if (!import.meta.client) return false
|
||||
return (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ != null
|
||||
}
|
||||
|
||||
/* A window is expected to have localStorage. A private browser may throw on
|
||||
being asked for it at all, and a setting that cannot be remembered is worth
|
||||
far less than a page that still draws — so the three doors below are the
|
||||
only place any of it is touched, and none of them lets a throw through. */
|
||||
|
||||
export function storeGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function storeSet(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value)
|
||||
} catch {
|
||||
/* nothing remembered; nothing else depends on it */
|
||||
}
|
||||
}
|
||||
|
||||
export function storeClear(key: string): void {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
} catch {
|
||||
/* as good as cleared, for as long as this page lives */
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an address leads outside this app — to a document on some other
|
||||
* site, or to a mail client, or to a phone dialler. The app's own pages are
|
||||
* the relative `/wiki/…` the router already knows; an absolute address that
|
||||
* happens to name the wiki's public site counts as outward too, and opens
|
||||
* there in the browser, which is the honest reading of a link written out in
|
||||
* full. */
|
||||
export function leadsOutward(href: string): boolean {
|
||||
if (!import.meta.client || !href) return false
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(href, window.location.href)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol === 'mailto:' || url.protocol === 'tel:') return true
|
||||
return (url.protocol === 'http:' || url.protocol === 'https:') && url.origin !== window.location.origin
|
||||
}
|
||||
|
||||
/** Hand a document to whatever machine-level application owns it: the browser
|
||||
* for a page, the mail client for `mailto:`. In a browser a link already does
|
||||
* this, so the fallback is the browser's own new tab; in a window there is no
|
||||
* other tab, and following the link in place would leave the wiki's page
|
||||
* gone from the only view there is. */
|
||||
export async function openExternal(url: string): Promise<void> {
|
||||
if (!import.meta.client || !url) return
|
||||
if (isDesktop()) {
|
||||
try {
|
||||
const { openUrl } = await import('@tauri-apps/plugin-opener')
|
||||
await openUrl(url)
|
||||
return
|
||||
} catch {
|
||||
/* no shell to hand it to, or this window is not allowed this one — the
|
||||
least worse thing left is the browser's way out */
|
||||
}
|
||||
}
|
||||
window.open(url, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
/** Keep a session token where a window cannot lose it.
|
||||
|
||||
On the web a cookie is the whole of it: the server reads it, and the browser
|
||||
keeps it as long as the API said the token lasts. A window's own address
|
||||
(`tauri://localhost`) is one a webview is not everywhere obliged to keep
|
||||
cookies for, and a session dying with the window would mean signing in again
|
||||
every time the app was opened — for a token the API hands out for a week. So
|
||||
here it is mirrored into localStorage and taken back from there whenever the
|
||||
cookie comes up empty, whichever of the two this webview chose to keep.
|
||||
|
||||
The cookie still leads: it is the one already at hand, the one a web build
|
||||
would have read, and the one the sign-out line clears. A browser asked this
|
||||
question of the same files ignores all of it — there is nothing to mirror
|
||||
where cookies work — so the wiki's behavior there is untouched. */
|
||||
export function keepTokenRemembered(token: Ref<string | null>, key: string): void {
|
||||
if (!import.meta.client || !isDesktop()) return
|
||||
|
||||
if (!token.value) {
|
||||
const kept = storeGet(key)
|
||||
if (kept) token.value = kept
|
||||
}
|
||||
// sync, so signing out clears the mirror in the same breath, not on a tick
|
||||
watch(token, (value) => (value ? storeSet(key, value) : storeClear(key)), { flush: 'sync' })
|
||||
}
|
||||
36
frontend/desktop/build.mjs
Normal file
@@ -0,0 +1,36 @@
|
||||
/* The bundle the desktop app loads.
|
||||
|
||||
nuxt.config.ts reads a MATHEW_DESKTOP flag and, in that shape, writes the
|
||||
wiki as plain files — no server, no `/api` proxy, since a window has neither
|
||||
— into dist-desktop/public, which is the `frontendDist` src-tauri/tauri.conf.json
|
||||
names. `tauri build` gets here through `npm run desktop:generate`, which is
|
||||
that config's `beforeBuildCommand`.
|
||||
|
||||
The reason this file exists rather than a script line: `MATHEW_DESKTOP=1 nuxt
|
||||
generate` is a bashism, and desktop builds are something Windows is asked for
|
||||
too. Setting the variable here works from any shell.
|
||||
|
||||
The wiki the app ships pointed at is overridable like any other build
|
||||
setting — set it in the environment before `npm run desktop:build`:
|
||||
NUXT_PUBLIC_API_BASE=http://localhost:5000 a wiki on this machine
|
||||
NUXT_PUBLIC_LEAN_URL=https://lean.example a Lean server the app can reach
|
||||
(Lean's default is empty, which leaves /lean saying there is no server here
|
||||
rather than reaching for a localhost that is not on the machine). */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
|
||||
|
||||
const run = spawnSync(npm, ['exec', '--', 'nuxt', 'generate'], {
|
||||
cwd: fileURLToPath(new URL('..', import.meta.url)),
|
||||
env: { ...process.env, MATHEW_DESKTOP: '1' },
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (run.error) {
|
||||
console.error(`desktop: could not run \`nuxt generate\` — ${run.error.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
process.exit(run.status ?? 1)
|
||||
BIN
frontend/desktop/icon-source.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
88
frontend/desktop/make-icon.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draw desktop/icon-source.png — the 1024² master the icon set is cut from.
|
||||
|
||||
The wiki's own mark is the ∑ in the sidebar (AppHeader.vue's .logo-mark) over
|
||||
the dark reading surface, so the app icon is that: the sigma, in the accent the
|
||||
wiki wears by default, on the dark navy of its dark theme.
|
||||
|
||||
python3 desktop/make-icon.py # writes desktop/icon-source.png
|
||||
npm run desktop:icons # cuts src-tauri/icons/ from it
|
||||
|
||||
Requires Pillow (pip install pillow). Nothing else in the project uses it —
|
||||
this is build tooling, not app code.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
SIZE = 1024
|
||||
PAD = 64 # the margin macOS wants round an app icon; harmless elsewhere
|
||||
RADIUS = 200
|
||||
TOP = (16, 21, 44) # the navy a touch above --bg, so the square reads as an object
|
||||
BOTTOM = (6, 8, 14) # ... down to just under it
|
||||
ACCENT = (124, 140, 255) # --accent, dark theme (#7c8cff)
|
||||
MARK = "\u2211" # ∑ — the wiki's mark
|
||||
|
||||
FONTS = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
|
||||
]
|
||||
|
||||
OUT = Path(__file__).resolve().parent / "icon-source.png"
|
||||
|
||||
|
||||
def load_font(size: int) -> ImageFont.FreeTypeFont:
|
||||
"""The first system font that actually carries a sigma."""
|
||||
for path in FONTS:
|
||||
try:
|
||||
font = ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
continue
|
||||
if font.getbbox(MARK)[2] > 0:
|
||||
return font
|
||||
raise SystemExit(f"no font with a {MARK} found — look for one of: {', '.join(FONTS)}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# vertical gradient, then clipped to a rounded square
|
||||
gradient = Image.new("RGB", (1, SIZE))
|
||||
for y in range(SIZE):
|
||||
t = y / (SIZE - 1)
|
||||
gradient.putpixel((0, y), tuple(round(a + (b - a) * t) for a, b in zip(TOP, BOTTOM)))
|
||||
gradient = gradient.resize((SIZE, SIZE))
|
||||
|
||||
mask = Image.new("L", (SIZE, SIZE), 0)
|
||||
ImageDraw.Draw(mask).rounded_rectangle(
|
||||
[PAD, PAD, SIZE - PAD, SIZE - PAD], radius=RADIUS, fill=255
|
||||
)
|
||||
|
||||
icon = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
icon.paste(gradient, (0, 0), mask)
|
||||
|
||||
draw = ImageDraw.Draw(icon)
|
||||
# a hairline of accent light along the edge, so the dark square does not
|
||||
# vanish into a dark taskbar
|
||||
draw.rounded_rectangle(
|
||||
[PAD, PAD, SIZE - PAD, SIZE - PAD], radius=RADIUS, outline=(124, 140, 255, 56), width=3
|
||||
)
|
||||
|
||||
font = load_font(600)
|
||||
left, top, right, bottom = draw.textbbox((0, 0), MARK, font=font)
|
||||
w, h = right - left, bottom - top
|
||||
# centred on the box the glyph is drawn in, not on its typographic cell:
|
||||
# an ascent-based centre would sit the sigma high
|
||||
draw.text(
|
||||
((SIZE - w) / 2 - left, (SIZE - h) / 2 - top),
|
||||
MARK,
|
||||
font=font,
|
||||
fill=ACCENT + (255,),
|
||||
)
|
||||
|
||||
icon.save(OUT)
|
||||
print(f"wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,24 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
|
||||
/* The desktop build — the same wiki in an app window (Tauri, see src-tauri/).
|
||||
A window has no Nuxt server and no nginx behind it, so the wiki is written
|
||||
out as plain files for the system webview to read off disk and every address
|
||||
it calls has to be absolute. `desktop/build.mjs` sets the flag; the Tauri CLI
|
||||
carries TAURI_ENV_* into its own build hooks, so `tauri build` reaches this
|
||||
branch without anyone remembering to export anything. The web build takes
|
||||
none of it: same config file, different shape of output. */
|
||||
const desktop = process.env.MATHEW_DESKTOP === '1' || !!process.env.TAURI_ENV_PLATFORM
|
||||
|
||||
/* Where a desktop build takes its wiki from. Baked in at build — the app has
|
||||
no origin of its own to route `/api` through — and overridable for anyone
|
||||
building against another wiki: NUXT_PUBLIC_API_BASE (a staging wiki, this
|
||||
machine), and NUXT_PUBLIC_LEAN_URL for a Lean server the app can actually
|
||||
reach. Lean's default stays empty on purpose: a window on a random laptop
|
||||
has no localhost:8080 to find, and /lean says so plainly rather than
|
||||
reaching for a server that is not there. */
|
||||
const apiDefault = desktop ? 'https://mathew.aranroig.com' : 'http://localhost:5000'
|
||||
const leanDefault = desktop ? '' : 'http://localhost:8080'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
@@ -37,26 +57,43 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
/* A page of the web is drawn on the server; a window on the machine cannot
|
||||
be — the webview draws these same pages itself, from the bundle. The book,
|
||||
the middleware and the popups all run client-side either way, so nothing
|
||||
but where HTML first comes from changes. */
|
||||
ssr: !desktop,
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
// Where the UI calls the API. The Docker image sets this to "" so the
|
||||
// browser calls /api on its own origin and nginx routes it to the
|
||||
// backend; dev keeps the absolute localhost address.
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? 'http://localhost:5000',
|
||||
// backend; dev keeps the absolute localhost address, and the desktop
|
||||
// build the public wiki it ships pointed at.
|
||||
apiBase: process.env.NUXT_PUBLIC_API_BASE ?? apiDefault,
|
||||
// Where the Lean editor server (lean4web — see lean/) lives: /lean
|
||||
// frames this address. It is the browser's own way in, not the
|
||||
// server's — the iframe talks to its own origin from there on.
|
||||
leanUrl: process.env.NUXT_PUBLIC_LEAN_URL ?? 'http://localhost:8080',
|
||||
leanUrl: process.env.NUXT_PUBLIC_LEAN_URL ?? leanDefault,
|
||||
},
|
||||
},
|
||||
|
||||
/* The desktop bundle: files under dist-desktop/ and nothing to run. Written
|
||||
there rather than into .output/ so a desktop build never overwrites the
|
||||
server bundle a web build left, and `frontendDist` in tauri.conf.json has
|
||||
a directory of its own to read. */
|
||||
...(desktop
|
||||
? { nitro: { preset: 'static', output: { dir: 'dist-desktop' } } }
|
||||
: {}),
|
||||
|
||||
// The Nuxt server's own API calls (SSR) are relative once apiBase is "",
|
||||
// and land on this Nitro route, which forwards them to the backend. The
|
||||
// Docker image bakes NUXT_API_PROXY_TARGET=https://mathew.aranroig.com in
|
||||
// at build; nginx routes that address on to the backend.
|
||||
routeRules: {
|
||||
'/api/**': { proxy: `${process.env.NUXT_API_PROXY_TARGET || 'http://localhost:5000'}/api/**` },
|
||||
},
|
||||
// at build; nginx routes that address on to the backend. A desktop build has
|
||||
// no Nuxt server to answer this, and no reason for one: the app calls the
|
||||
// API by its own absolute address.
|
||||
routeRules: desktop
|
||||
? {}
|
||||
: { '/api/**': { proxy: `${process.env.NUXT_API_PROXY_TARGET || 'http://localhost:5000'}/api/**` } },
|
||||
|
||||
app: {
|
||||
head: {
|
||||
@@ -68,7 +105,18 @@ export default defineNuxtConfig({
|
||||
{ name: 'theme-color', media: '(prefers-color-scheme: light)', content: '#f7f8fb' },
|
||||
{ name: 'theme-color', media: '(prefers-color-scheme: dark)', content: '#0a0c12' },
|
||||
],
|
||||
script: [
|
||||
script: desktop
|
||||
? [
|
||||
/* The same pre-paint, from a file: a window is a host one may
|
||||
sensibly hold to a `script-src 'self'` Content Security Policy,
|
||||
and code written into the HTML would not survive one.
|
||||
public/preboot.js carries this exact code — keep the pair in
|
||||
step. (src-tauri/ sets no policy yet; keeping this one in a
|
||||
file is what makes turning it on a config line, not a page
|
||||
rewrite.) */
|
||||
{ tagPosition: 'head', src: '/preboot.js' },
|
||||
]
|
||||
: [
|
||||
{
|
||||
// Apply saved theme & accent before first paint to avoid a flash
|
||||
tagPosition: 'head',
|
||||
|
||||
253
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/i18n": "^10.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"isomorphic-dompurify": "^4.4.0",
|
||||
"katex": "^0.18.9",
|
||||
@@ -19,6 +20,7 @@
|
||||
"vue-router": "^5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.12.1",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"sass": "^1.105.0"
|
||||
@@ -4756,6 +4758,257 @@
|
||||
"integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==",
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@tauri-apps/api": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.1.tgz",
|
||||
"integrity": "sha512-DeyFHa3wynpyoqTDikDEDGTJIq4LQ5USfolQGRmGIWT6JMADyxZBTDa5cAdT3tDg73rUXufPaCwN7aXBos4OnQ==",
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.1.tgz",
|
||||
"integrity": "sha512-kEDEiGzG+yAc5FeLxtXpES/VN+F2C8H0r4gDVtfRLKxT9np9101d9REG/Kgo2lr0hKi0IpDsODiHnU3+naOmLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.12.1",
|
||||
"@tauri-apps/cli-darwin-x64": "2.12.1",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.12.1",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.12.1",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.12.1",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.12.1",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.12.1",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.12.1",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.12.1",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.12.1",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.1.tgz",
|
||||
"integrity": "sha512-8bMnbpJ2jKO/ufrOPL65EMHC3ptw7fzeraoQkwibyfGLqIl+UCWsC0152kmNbW6r9NvVQduXqAZF5mH1QgTWhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.1.tgz",
|
||||
"integrity": "sha512-G+2qte8GNqNlEZ9ftd21rbu/APVkl3r/nzNDI7LVxEoqJWVNe9GbEgQUMH1r4PHT20Z2UxR3KjCkltIb0e/Wjg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.1.tgz",
|
||||
"integrity": "sha512-5b3n4DaTW+A1Zntnh4Yn44VyLrlPEpB8kMOBRfWkzNnzolBk+AYhKIfKjCLmoC8Mob3ETodxAEOMkE2YPKEWzQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.1.tgz",
|
||||
"integrity": "sha512-APArHhDu+8pXSCkCQmvaz1nD3d8WTmlCoSS/oG475TJ6AATBvyQOAmf5qELgsQ4s2+8MOsop0Zd699RKELF1Cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.1.tgz",
|
||||
"integrity": "sha512-JaCxcLoV0oGtllVkLr4UAHWG/wTfkEunnUqnsEsGYOp0VRxnUwHg63wFBD0jU2ul5eFfSLIDS4RF2gfzsSo5Jg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.1.tgz",
|
||||
"integrity": "sha512-K5+VXM7+SGVDwZleA2+lMgvbTVuA5e1tkiVEawu1Yxj26ZyEfO8h8xTC1rBdRX2xgJDSwCk6vPUBCS6i+Z5slQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.1.tgz",
|
||||
"integrity": "sha512-Z1QGPr49HJZMktu+Spu6Q1b/f9ANadXvqZZZt2645ua1x1ev6prOgp85eiGv2jRzohFEMLo6aJyaLjLUX5KIvA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.1.tgz",
|
||||
"integrity": "sha512-nHuEhRPOMSJ/8RRhtMRxB9B9lVr7xxpe4DapBz8x1GIau3vdzjHHJ1hAfmw+PyayPLZ6XoaVIHPgI17rX6ii8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.1.tgz",
|
||||
"integrity": "sha512-I0oPk2uGh86R8dAs2X5OxSP+/GqSc8jh1Px3l8EqrocSNivsRHDTh2k3PVOqdv163dlV6PIVvmhZAbH/lPfZrA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.1.tgz",
|
||||
"integrity": "sha512-baNZRBfepAJ5qNFRMwzgBk94xQmkcmTZrNDjZjnzrq2vAaTC3NAhallb/zo3hyoCGQlMS13sdovktrg46NZz6Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.1.tgz",
|
||||
"integrity": "sha512-dIFaKslrzcbesb+bBh+E1R9km241pHyj32w6D9FPtvQeYpRUFbiCLEa/2S+S0UnIytfxepp4P+X8IerGqnXeYg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-opener": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.7.0.tgz",
|
||||
"integrity": "sha512-mBorYfVKh9Lt7s4ZFtKbMUTxBw+Y/sVmSlKCtkkoBgMTC/DIbt/kaPm67L/2djAUqDI1wbixy5OHHVmipjuGMw==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare",
|
||||
"desktop:generate": "node desktop/build.mjs",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:icons": "tauri icon desktop/icon-source.png"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/i18n": "^10.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2.7.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"isomorphic-dompurify": "^4.4.0",
|
||||
"katex": "^0.18.9",
|
||||
@@ -22,6 +27,7 @@
|
||||
"vue-router": "^5.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.12.1",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"sass": "^1.105.0"
|
||||
|
||||
17
frontend/public/preboot.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/* Apply saved theme & accent before first paint to avoid a flash.
|
||||
*
|
||||
* This is the desktop build's copy of the inline snippet nuxt.config.ts puts
|
||||
* in <head> for the web (keep the two in step). Out here as a file because a
|
||||
* window may be held to a Content Security Policy — see src-tauri/README.md —
|
||||
* and `script-src 'self'` would have no room for a script written inline in
|
||||
* the HTML.
|
||||
* A classic <script src> in <head> still runs before the page paints, so the
|
||||
* surfaces arrive already the right way round. */
|
||||
try {
|
||||
var d = document.documentElement
|
||||
var s = localStorage.getItem('mathew-theme')
|
||||
var t = s || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
|
||||
d.setAttribute('data-theme', t)
|
||||
var a = localStorage.getItem('mathew-accent')
|
||||
if (a && a !== 'violet') d.setAttribute('data-accent', a)
|
||||
} catch (e) {}
|
||||
6
frontend/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
# Rust's build output — hundreds of MB, never belongs in the wiki.
|
||||
target/
|
||||
|
||||
# Written by tauri-build on every build: the JSON schemas for the permissions
|
||||
# listed under capabilities/. Generated, so regenerable.
|
||||
gen/schemas
|
||||
4912
frontend/src-tauri/Cargo.lock
generated
Normal file
27
frontend/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[package]
|
||||
name = "mathew"
|
||||
version = "0.1.0"
|
||||
description = "Mathew — a wiki for mathematics, in a window of its own"
|
||||
authors = ["Aran Roig"]
|
||||
edition = "2021"
|
||||
|
||||
# The app is a library with a thin main: Tauri's own entry points want it that
|
||||
# way, so the same `run()` also serves a mobile build should one ever be asked
|
||||
# for (the window itself is desktop-shaped — see src/lib.rs).
|
||||
[lib]
|
||||
name = "mathew_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
# A link inside the window hands its document to the system's browser rather
|
||||
# than replacing the wiki in the only view there is. The wiki asks for this
|
||||
# itself (app/utils/desktop.ts); this is the door it goes out by.
|
||||
tauri-plugin-opener = "2"
|
||||
|
||||
# Only a desktop app can be launched twice onto one window.
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-single-instance = "2"
|
||||
78
frontend/src-tauri/README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# src-tauri/ — the app window
|
||||
|
||||
Mathew as a **desktop app** (Tauri 2, Linux / Windows / macOS): the wiki in a
|
||||
window of its own, no browser around it. The shell is deliberately thin — the
|
||||
app *is* the same Nuxt bundle the website serves, written out as plain files
|
||||
and embedded in the binary. Nothing in `frontend/app/` is desktop code; the
|
||||
bundle asks at run time whether it happens to be sitting in a window
|
||||
(`app/utils/desktop.ts`) and only then takes the window's habits: a link leads
|
||||
out to the system's browser instead of replacing the wiki in the only view
|
||||
there is, and the session token and interface language are mirrored into
|
||||
`localStorage` because a webview is not everywhere obliged to keep cookies for
|
||||
its own `tauri://localhost` address.
|
||||
|
||||
The wiki needs a wiki to talk to: the window holds no server and no database.
|
||||
It calls the **public Mathew** over HTTPS by default — the API base is baked in
|
||||
at build, see the table below.
|
||||
|
||||
## Build & run
|
||||
|
||||
From `frontend/` — these need [Rust](https://rustup.rs) (stable) and the
|
||||
[Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your OS;
|
||||
on Linux that is the WebKitGTK stack:
|
||||
`libwebkit2gtk-4.1-dev libgtk-3-dev libssl-dev libxdo-dev librsvg2-dev build-essential`.
|
||||
|
||||
| Script | What it does |
|
||||
| --- | --- |
|
||||
| `npm run desktop:dev` | `tauri dev` — opens the **Nuxt dev server** (`localhost:3000`, started for you) in the window, hot-reload included; `isDesktop()` is already true inside it |
|
||||
| `npm run desktop:build` | `tauri build` — runs `desktop:generate` (below), compiles the shell, embeds the files, and leaves installers under `src-tauri/target/release/bundle/` (deb/rpm/AppImage on Linux, NSIS/MSI on Windows, .app/.dmg on macOS) |
|
||||
| `npm run desktop:generate` | the bundle the window loads: `MATHEW_DESKTOP=1 nuxt generate` → `dist-desktop/public`, the `frontendDist` this config names (a desktop build never overwrites the web `.output/`) |
|
||||
| `npm run desktop:icons` | re-cuts `src-tauri/icons/` from `desktop/icon-source.png` (redraw that one with `python3 desktop/make-icon.py`) |
|
||||
|
||||
The desktop shape lives in `nuxt.config.ts` under one flag
|
||||
(`MATHEW_DESKTOP=1`, also implied by the `TAURI_ENV_*` the Tauri CLI passes to
|
||||
its build hooks): `ssr: false`, no `/api` proxy route, and the pre-paint theme
|
||||
script served from `public/preboot.js` instead of inline. The web build takes
|
||||
none of it. Deep routes (`/wiki/en/bayes-theorem`, …) are not files in the
|
||||
bundle — Tauri's own asset serving answers unknown paths with `index.html`, so
|
||||
the client router takes over exactly as it does on a static host.
|
||||
|
||||
**Dev-server port**: `devUrl` is fixed to `3000`, and Nuxt falls back to `3001`
|
||||
when `3000` is taken (see the main gotchas) — free the port before
|
||||
`desktop:dev`, or the window loads whatever else answers there.
|
||||
|
||||
## Where the app reaches
|
||||
|
||||
| Build variable | Default (desktop) | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NUXT_PUBLIC_API_BASE` | `https://mathew.aranroig.com` | the wiki the app reads and writes; point it at `http://localhost:5000` to test against this machine |
|
||||
| `NUXT_PUBLIC_LEAN_URL` | empty | the Lean server `/lean` talks to; empty leaves that page saying there is no server here, which is the honest answer on a random laptop. The API's default CORS (`*`) carries the window's `tauri://localhost` origin fine — it sends a bearer header, not cookies |
|
||||
|
||||
Set them in the environment before `npm run desktop:build`.
|
||||
|
||||
## What the shell owns
|
||||
|
||||
- **one window per launch** — `tauri-plugin-single-instance`: pressing the
|
||||
icon again brings the open window forward instead of stacking a second wiki
|
||||
behind it (`src/lib.rs`).
|
||||
- **the way out** — `tauri-plugin-opener` is the door `app/utils/desktop.ts`
|
||||
hands outward links to the system's browser; `capabilities/default.json` is
|
||||
what grants it. That and `core:default` are the whole permission list:
|
||||
reading and writing the wiki is the webview's own business.
|
||||
- **no CSP** (`csp: null` in `tauri.conf.json`): the window loads no foreign
|
||||
script and talks to one API over HTTPS, and `/lean`'s Monaco wants
|
||||
`eval`-like tricks a strict `script-src` would have to excuse. The one
|
||||
script the page writes itself (the pre-paint theme, `public/preboot.js`)
|
||||
already lives in a **file**, so turning a policy on later is a config line —
|
||||
keep it that way if one is ever set.
|
||||
|
||||
Nothing here is signed: bundles come out unsigned, and updating is
|
||||
reinstalling. An updater, signing, and a per-machine "which wiki?" setting are
|
||||
the obvious next steps if this ever ships for real.
|
||||
|
||||
## Versioning
|
||||
|
||||
The app's version lives in `tauri.conf.json` (`version`); it names the
|
||||
installers and the platform bundle metadata. Commit `Cargo.lock` once a build
|
||||
has produced one — for an application binary, a locked dependency set is what
|
||||
makes a rebuild reproducible.
|
||||
3
frontend/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
7
frontend/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "What the Mathew window may ask of the machine. Reading and writing the wiki is the webview's own business — plain HTTP out to the API and, if one is configured, a websocket to the Lean server — and needs no permission here. The one thing it cannot do by itself is hand a link to the system's browser, which is what `opener:default` lets app/utils/desktop.ts ask for.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "opener:default"]
|
||||
}
|
||||
BIN
frontend/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
frontend/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
frontend/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
frontend/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
frontend/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
frontend/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
frontend/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
frontend/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
frontend/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
BIN
frontend/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
BIN
frontend/src-tauri/icons/icon.icns
Normal file
BIN
frontend/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 951 B |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
frontend/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
38
frontend/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
/* Mathew as a window.
|
||||
|
||||
The shell is thin on purpose. Everything the app *is* — the open book, the
|
||||
editor, the five interface languages — is the Nuxt bundle it loads, written
|
||||
out by `npm run desktop:generate` and read from disk by the system webview
|
||||
(see src-tauri/README.md). The wiki reaches its own API by itself: no Rust
|
||||
command stands between the two, so an article is fetched the same way in the
|
||||
window as in a browser.
|
||||
|
||||
Two things are the shell's own:
|
||||
|
||||
- `opener`, the door a link goes out by. The wiki asks for it by name
|
||||
(app/utils/desktop.ts); `capabilities/default.json` is what answers.
|
||||
- one window per launch. Pressing the icon again brings the window that is
|
||||
already open to the front instead of stacking a second copy of the wiki
|
||||
behind it. */
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::Manager;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let mut builder = tauri::Builder::default().plugin(tauri_plugin_opener::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
builder
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Mathew");
|
||||
}
|
||||
7
frontend/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
// An app window has no terminal to answer from: keep one from opening behind
|
||||
// it on Windows, where a release build would otherwise start a console.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
mathew_desktop_lib::run()
|
||||
}
|
||||
47
frontend/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Mathew",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.aranroig.mathew",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:3000",
|
||||
"beforeBuildCommand": "npm run desktop:generate",
|
||||
"frontendDist": "../dist-desktop/public"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Mathew",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 940,
|
||||
"minHeight": 600,
|
||||
"center": true,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"publisher": "Aran Roig",
|
||||
"homepage": "https://mathew.aranroig.com",
|
||||
"category": "Education",
|
||||
"shortDescription": "A collaborative wiki of mathematics",
|
||||
"longDescription": "Mathew is a wiki of mathematical concepts: Markdown with LaTeX, an open shelf of articles in several languages, a Markdown editor, a calculator, a graph plotter and a Lean 4 editor. This is the same wiki in an app window of its own; it reads and writes a Mathew wiki over HTTP, so it needs one to reach.",
|
||||
"copyright": "© Aran Roig",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,19 @@
|
||||
# docker build -t mathew-lean ./lean
|
||||
# docker run -p 8080:8080 mathew-lean # → http://localhost:8080
|
||||
#
|
||||
# The build is heavy by nature (Mathlib is fetched and its precompiled
|
||||
# cache unpacked); expect a long build and an image of several GB.
|
||||
# The build is heavy by nature (Mathlib is fetched and its precompiled cache
|
||||
# unpacked), but multi-stage keeps the junk out of the pushed image: the
|
||||
# compilers, npm dev dependencies and caches stay in the builder, and the
|
||||
# final image is a slim runtime with exactly what a session needs — the node
|
||||
# server, the elan toolchain, the built client, and the project's Lean cache.
|
||||
#
|
||||
# To ship an image without Mathlib itself (~4GB instead of ~11GB), pass
|
||||
# `--build-arg BAKE_MATHLIB=false` and hold the cache in a volume instead:
|
||||
# the entrypoint fills a fresh volume once at first start. See
|
||||
# "Keeping Mathlib out of the image" in lean/README.md.
|
||||
|
||||
FROM node:24-bookworm
|
||||
# ---------- build ----------
|
||||
FROM node:24-bookworm AS build
|
||||
|
||||
# lake fetches dependencies over git/curl, drives clang to link, and elan
|
||||
# (Lean's toolchain manager) installs the toolchains each project pins
|
||||
@@ -28,19 +37,35 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN curl -sSfL https://elan.lean-lang.org/elan-init.sh | bash -s -- -y --default-toolchain none
|
||||
ENV PATH="/root/.elan/bin:${PATH}"
|
||||
|
||||
# cypress (a root dev dependency) pulls an ~800MB browser bundle on install —
|
||||
# nothing in this build runs browser tests, so skip it
|
||||
ENV CYPRESS_INSTALL_BINARY=0
|
||||
|
||||
# The lean4web source. Pass a tag or branch to pin a build:
|
||||
# docker build --build-arg LEAN4WEB_REF=v0.2.2 ./lean
|
||||
ARG LEAN4WEB_REF=main
|
||||
RUN git clone --depth 1 --branch "${LEAN4WEB_REF}" \
|
||||
https://github.com/leanprover-community/lean4web.git /opt/lean4web
|
||||
|
||||
# Ship only the MathlibDemo project: the wiki opens the default project, and
|
||||
# upstream's second demo ("Stable") pins its own toolchain — elan would carry
|
||||
# a second ~3GB one that no session ever uses.
|
||||
RUN rm -rf /opt/lean4web/Projects/Stable
|
||||
|
||||
WORKDIR /opt/lean4web
|
||||
RUN npm install
|
||||
|
||||
# Build the Lean projects under Projects/ (MathlibDemo pulls Mathlib and
|
||||
# unpacks its precompiled cache — the slow part of the image) and then the
|
||||
# editor client, which the server serves alongside its websocket
|
||||
RUN npm run build:projects
|
||||
# Build the Lean projects under Projects/. For MathlibDemo this fetches
|
||||
# Mathlib and unpacks its precompiled cache — the slow part of the image.
|
||||
# With BAKE_MATHLIB=false that step is skipped and only the pinned toolchain
|
||||
# is installed: the project ships unbuilt, and the entrypoint fills it on a
|
||||
# first start (the intended pairing is a volume over the project's .lake).
|
||||
ARG BAKE_MATHLIB=true
|
||||
RUN if [ "${BAKE_MATHLIB}" = "true" ]; then \
|
||||
npm run build:projects; \
|
||||
else \
|
||||
(cd Projects/MathlibDemo && lake --version > /dev/null); \
|
||||
fi
|
||||
|
||||
# Mathew's theme layer: the wiki's font, stylesheet, and theme handshake
|
||||
# join the client's public/ so they ship in the built bundle, and a sed
|
||||
@@ -56,9 +81,48 @@ RUN sed -i \
|
||||
|
||||
RUN npm run build:client
|
||||
|
||||
# ---------- runtime ----------
|
||||
# Only what serving sessions needs. The lean/lake binaries carry their own
|
||||
# shared libraries — nothing else from the builder has to come along.
|
||||
FROM node:24-bookworm-slim
|
||||
|
||||
# bash for scripts, curl for the healthcheck and Mathlib's cache fetch,
|
||||
# git for lake's dependency operations
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash curl git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# elan and the toolchain(s) the projects resolved at build time
|
||||
COPY --from=build /root/.elan /root/.elan
|
||||
ENV PATH="/root/.elan/bin:${PATH}"
|
||||
|
||||
WORKDIR /opt/lean4web
|
||||
|
||||
# The server, the built (wiki-skinned) client, and the manifests. Note the
|
||||
# whole prod dependency tree: the server imports vscode-ws-jsonrpc out of the
|
||||
# client workspace (npm hoists it), so a server-only `npm ci -w` would miss it.
|
||||
COPY --from=build /opt/lean4web/package.json /opt/lean4web/package-lock.json ./
|
||||
COPY --from=build /opt/lean4web/client/package.json ./client/
|
||||
COPY --from=build /opt/lean4web/server ./server
|
||||
COPY --from=build /opt/lean4web/client/dist ./client/dist
|
||||
# The Lean project: toolchain pin, lakefile, and — unless BAKE_MATHLIB=false
|
||||
# — Mathlib and its unpacked cache, the bulk of the image.
|
||||
COPY --from=build /opt/lean4web/Projects ./Projects
|
||||
|
||||
RUN npm ci --omit=dev \
|
||||
&& rm -rf /root/.npm
|
||||
|
||||
# Normally a no-op: the image already carries the built Mathlib. With
|
||||
# BAKE_MATHLIB=false and a volume over the project's .lake, this fills a
|
||||
# fresh volume once before the server starts (see lean/README.md).
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Bubblewrap cannot sandbox inside a container (no nested user namespaces by
|
||||
# default) — Docker itself is the isolation here, so the server runs Lean
|
||||
# directly. PORT 8080 is lean4web's production default: editor + websocket.
|
||||
# `npm run prod` would only add nodemon (a dev dependency) around this same
|
||||
# `node index.mjs`, so the final image runs the server directly.
|
||||
ENV NODE_ENV=production \
|
||||
NO_BWRAP=true \
|
||||
PORT=8080
|
||||
@@ -67,4 +131,5 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \
|
||||
CMD curl -sf http://localhost:8080/health || exit 1
|
||||
|
||||
CMD ["npm", "run", "prod"]
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["node", "server/index.mjs"]
|
||||
|
||||
@@ -25,6 +25,14 @@ docker build -t mathew-lean ./lean
|
||||
docker run --rm -p 8080:8080 mathew-lean
|
||||
```
|
||||
|
||||
The build is **multi-stage**: compilers, dev dependencies and build caches stay
|
||||
in the builder, and the pushed image is a slim runtime carrying only what
|
||||
sessions need — the node server, the elan toolchain, the built client, and
|
||||
Mathlib's precompiled cache (the bulk of it, ~7GB of olean files; total image
|
||||
~11GB). The upstream "Stable" demo project is dropped: it pinned a second
|
||||
~3GB toolchain, and the wiki only ever opens the default project
|
||||
(`MathlibDemo`).
|
||||
|
||||
`GET http://localhost:8080/health` answers when the server is up — the image
|
||||
carries a `HEALTHCHECK` for it. The frontend finds the server at
|
||||
`NUXT_PUBLIC_LEAN_URL` (default `http://localhost:8080`, dev included), so out
|
||||
@@ -38,6 +46,29 @@ docker build --build-arg LEAN4WEB_REF=v0.2.2 -t mathew-lean ./lean
|
||||
|
||||
(any branch or tag; the clone is shallow, so commit SHAs are not offered.)
|
||||
|
||||
## Keeping Mathlib out of the image
|
||||
|
||||
Mathlib's compiled cache is what makes this image (and pushing it) heavy. If
|
||||
the registry or the CI runner chokes on it, build without Mathlib and let a
|
||||
volume hold it instead:
|
||||
|
||||
```bash
|
||||
docker build --build-arg BAKE_MATHLIB=false -t mathew-lean ./lean # ~4GB
|
||||
docker run --rm -p 8080:8080 -v lean-mathlib:/opt/lean4web/Projects/MathlibDemo/.lake mathew-lean
|
||||
```
|
||||
|
||||
The `BAKE_MATHLIB=false` image still ships the pinned elan toolchain, but not
|
||||
the project's `.lake`: the entrypoint notices a fresh volume and fills it once
|
||||
at first start — `lake update` fetches the sources pinned in
|
||||
`lake-manifest.json`, Mathlib's post-update hook downloads the matching
|
||||
precompiled cache, `lake build` writes the project's own module. Expect ~10
|
||||
minutes and a few hundred MB of download; restarts after that are instant,
|
||||
and **image rebuilds never touch Mathlib again** (the volume stays). The
|
||||
trade: the cache must survive on the host, first start needs network, and it
|
||||
redoes once if you recreate with a clean volume — hence the default staying
|
||||
`BAKE_MATHLIB=true`. The commented blocks in `docker-compose.yml` wire this
|
||||
up for the production stack.
|
||||
|
||||
## The protocol the wiki's client speaks
|
||||
|
||||
The websocket endpoint is `/websocket/<project>` (project names come from
|
||||
@@ -117,21 +148,27 @@ The image clones lean4web at build time, so a rebuild is an update:
|
||||
docker compose build --pull lean && docker compose up -d lean
|
||||
```
|
||||
|
||||
Rebuilding also re-runs `Projects/*/leanweb-build.sh` — the demo projects are
|
||||
rebuilt and Mathlib's cache re-fetched, so treat this like the heavy build it
|
||||
is (several GB of image; run it on the machine that hosts the editor).
|
||||
Rebuilding also re-runs the project build — Mathlib's pin moves with upstream
|
||||
and its cache re-downloads, so treat this like the heavy build it is (run it
|
||||
on the machine that hosts the editor; pin `LEAN4WEB_REF` for a build that
|
||||
stands still).
|
||||
|
||||
CI follows the same split: `.gitea/workflows/deploy-lean.yml` rebuilds, pushes
|
||||
and redeploys the lean image **only when `lean/**` changes**; the regular
|
||||
deploy pipeline ignores `lean/**` entirely and just pulls whatever
|
||||
`mathew-lean:latest` the registry holds. (In volume mode the pushed image is
|
||||
~4GB and a lean rebuild doesn't fetch Mathlib at all.)
|
||||
|
||||
## Adding Lean projects
|
||||
|
||||
Anything under `Projects/` of the *image source* becomes a selectable project
|
||||
(`#project=<name>`). To ship one with the image, build a custom image that
|
||||
copies it in before the project build step:
|
||||
Anything under `Projects/` of the *running image* becomes a selectable project
|
||||
(`#project=<name>`). To ship one with the image, add it in the Dockerfile's
|
||||
builder stage, before the project build step:
|
||||
|
||||
```dockerfile
|
||||
FROM mathew-lean AS base # or edit lean/Dockerfile directly
|
||||
# in lean/Dockerfile, build stage, before the BAKE_MATHLIB step
|
||||
COPY MyProject /opt/lean4web/Projects/MyProject
|
||||
WORKDIR /opt/lean4web/Projects/MyProject
|
||||
RUN lake build
|
||||
RUN cd Projects/MyProject && lake build
|
||||
```
|
||||
|
||||
See upstream's [Adding Projects](https://github.com/leanprover-community/lean4web/blob/main/doc/Projects.md)
|
||||
|
||||
27
lean/entrypoint.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Mathew — lean server entrypoint.
|
||||
#
|
||||
# In the usual image this does nothing: the built Mathlib already sits under
|
||||
# the project and the server starts straight away.
|
||||
#
|
||||
# With the image built as `--build-arg BAKE_MATHLIB=false` (see README's
|
||||
# "Keeping Mathlib out of the image"), the project's .lake is meant to be a
|
||||
# volume: a fresh one gets filled once, at first start, so Mathlib's ~7GB of
|
||||
# compiled cache never travels through the registry.
|
||||
set -eu
|
||||
|
||||
PROJECT=/opt/lean4web/Projects/MathlibDemo
|
||||
|
||||
if [ ! -f "$PROJECT/.lake/build/lib/lean/MathlibDemo.olean" ]; then
|
||||
echo "[mathew-lean] $PROJECT/.lake holds no built Mathlib — fetching it once (expect ~10 minutes)"
|
||||
cd "$PROJECT"
|
||||
# lake update pulls the sources pinned in lake-manifest.json, and Mathlib's
|
||||
# post-update hook fetches the matching precompiled cache; lake build then
|
||||
# only writes the project's own module. The image's pinned toolchain
|
||||
# (elan) stays as it is — no lean-toolchain re-curl, no surprise downloads.
|
||||
lake update -R
|
||||
lake build
|
||||
echo "[mathew-lean] Mathlib ready"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||