Add private hosted web alpha

This commit is contained in:
2026-07-12 12:10:39 -05:00
parent dad6ee4ff4
commit 6559295a64
12 changed files with 521 additions and 117 deletions
+1
View File
@@ -9,6 +9,7 @@ lerna-debug.log*
node_modules
dist
dist-hosted
dist-ssr
src-tauri/target
src-tauri/gen
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.1.23 - 2026-07-12
- Added the hosted web build path for the private online alpha.
- Added a dependency-free Node web server that serves the production app, exposes LibreBible package files, and persists the hosted workspace through SQLite.
- Added hosted workspace synchronization for sessions, notes, settings, and timelines while keeping local preview storage unchanged.
- Added the first repeatable deployment script for pushing the built app, server, and LibreBible package snapshot to Christ Unscripted.
## 0.1.22 - 2026-07-12
- Reordered the roadmap around a web-first path to get Libre Study online privately on Christ Unscripted.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "libre-study",
"version": "0.1.22",
"version": "0.1.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "libre-study",
"version": "0.1.22",
"version": "0.1.23",
"dependencies": {
"@tauri-apps/api": "^2.11.1",
"react": "^19.2.7",
+3 -1
View File
@@ -1,15 +1,17 @@
{
"name": "libre-study",
"private": true,
"version": "0.1.22",
"version": "0.1.23",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"dev:web": "vite --host 127.0.0.1",
"dev:tauri": "tauri dev",
"build": "tsc -b && vite build",
"build:web:hosted": "tsc -b && vite build --base=/study/ --outDir dist-hosted",
"lint": "oxlint",
"preview": "vite preview",
"serve:web": "node server/libre-study-server.mjs",
"tauri": "tauri"
},
"dependencies": {
+54
View File
@@ -0,0 +1,54 @@
param(
[string]$HostName = "christunscripted",
[string]$RemoteRoot = "/sites/libre-study",
[string]$RemoteBibleDataRoot = "/sites/libre-bible-data/packages/json",
[int]$Port = 9174
)
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$dataRepo = Resolve-Path (Join-Path $repoRoot "..\libre-bible-data")
$repoRootWsl = (wsl wslpath -a "$repoRoot").Trim()
$dataRepoWsl = (wsl wslpath -a "$dataRepo").Trim()
$commit = (git -C $repoRoot rev-parse HEAD).Trim()
$deployedAt = (Get-Date).ToUniversalTime().ToString("o")
Push-Location $repoRoot
try {
$env:VITE_REMOTE_WORKSPACE = "true"
$env:VITE_API_BASE_URL = "/study/api"
$env:VITE_LIBREBIBLE_BASE_URL = "/study/librebible"
npm.cmd run build:web:hosted
}
finally {
Remove-Item Env:VITE_REMOTE_WORKSPACE -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:VITE_LIBREBIBLE_BASE_URL -ErrorAction SilentlyContinue
Pop-Location
}
$deployInfo = @{
commit = $commit
deployedAt = $deployedAt
source = "libre-study"
} | ConvertTo-Json -Compress
$escapedDeployInfo = $deployInfo.Replace("'", "'\''")
wsl ssh $HostName "mkdir -p '$RemoteRoot/app' '$RemoteRoot/server' '$RemoteRoot/state' '$RemoteBibleDataRoot' && printf '%s' '$escapedDeployInfo' > '$RemoteRoot/deploy-info.json'"
wsl rsync -az --delete "$repoRootWsl/dist-hosted/" "${HostName}:$RemoteRoot/app/"
wsl rsync -az --delete "$repoRootWsl/server/" "${HostName}:$RemoteRoot/server/"
wsl rsync -az --delete "$dataRepoWsl/packages/json/" "${HostName}:$RemoteBibleDataRoot/"
$pm2Command = @"
cd '$RemoteRoot' &&
if pm2 describe libre-study-web >/dev/null 2>&1; then
PORT=$Port PUBLIC_DIR='$RemoteRoot/app' LIBREBIBLE_DATA_DIR='$RemoteBibleDataRoot' STATE_DIR='$RemoteRoot/state' DB_PATH='$RemoteRoot/state/libre-study-web.sqlite3' DEPLOY_INFO_PATH='$RemoteRoot/deploy-info.json' pm2 restart libre-study-web --update-env;
else
PORT=$Port PUBLIC_DIR='$RemoteRoot/app' LIBREBIBLE_DATA_DIR='$RemoteBibleDataRoot' STATE_DIR='$RemoteRoot/state' DB_PATH='$RemoteRoot/state/libre-study-web.sqlite3' DEPLOY_INFO_PATH='$RemoteRoot/deploy-info.json' pm2 start '$RemoteRoot/server/libre-study-server.mjs' --name libre-study-web --update-env;
fi &&
pm2 save
"@
wsl ssh $HostName $pm2Command
Write-Output "Deployed Libre Study web commit $commit to $HostName:$RemoteRoot on port $Port."
+204
View File
@@ -0,0 +1,204 @@
import { createHash } from 'node:crypto'
import { createReadStream, existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
import { extname, join, normalize, resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import http from 'node:http'
const port = Number(process.env.PORT ?? 9174)
const publicDir = resolve(process.env.PUBLIC_DIR ?? 'dist')
const dataDir = resolve(process.env.LIBREBIBLE_DATA_DIR ?? '../libre-bible-data/packages/json')
const stateDir = resolve(process.env.STATE_DIR ?? 'web-state')
const dbPath = resolve(process.env.DB_PATH ?? join(stateDir, 'libre-study-web.sqlite3'))
const deployInfoPath = process.env.DEPLOY_INFO_PATH ? resolve(process.env.DEPLOY_INFO_PATH) : ''
const sqliteBin = process.env.SQLITE_BIN ?? 'sqlite3'
const maxBodyBytes = Number(process.env.MAX_BODY_BYTES ?? 4 * 1024 * 1024)
const mimeTypes = new Map([
['.css', 'text/css; charset=utf-8'],
['.html', 'text/html; charset=utf-8'],
['.ico', 'image/x-icon'],
['.js', 'application/javascript; charset=utf-8'],
['.json', 'application/json; charset=utf-8'],
['.jsonl', 'application/x-ndjson; charset=utf-8'],
['.map', 'application/json; charset=utf-8'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.txt', 'text/plain; charset=utf-8'],
['.woff2', 'font/woff2'],
])
mkdirSync(stateDir, { recursive: true })
sqlite(`
CREATE TABLE IF NOT EXISTS workspace_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
function sqlite(sql) {
const output = execFileSync(sqliteBin, ['-json', dbPath, sql], {
encoding: 'utf8',
maxBuffer: 1024 * 1024 * 20,
})
return output.trim() ? JSON.parse(output) : []
}
function quoteSql(value) {
return `'${String(value).replaceAll("'", "''")}'`
}
function sendJson(response, status, value) {
const body = JSON.stringify(value)
response.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
'Cache-Control': 'no-store',
})
response.end(body)
}
function sendText(response, status, value) {
response.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(value),
'Cache-Control': 'no-store',
})
response.end(value)
}
function readBody(request) {
return new Promise((resolveBody, reject) => {
let size = 0
const chunks = []
request.on('data', (chunk) => {
size += chunk.length
if (size > maxBodyBytes) {
reject(new Error('Request body is too large.'))
request.destroy()
return
}
chunks.push(chunk)
})
request.on('end', () => resolveBody(Buffer.concat(chunks).toString('utf8')))
request.on('error', reject)
})
}
function safeFile(root, requestPath) {
const decoded = decodeURIComponent(requestPath)
const candidate = normalize(join(root, decoded))
const rootWithSeparator = root.endsWith('\\') || root.endsWith('/') ? root : `${root}/`
if (candidate !== root && !candidate.startsWith(rootWithSeparator)) return null
return candidate
}
function serveFile(response, filePath, cache = 'no-store') {
if (!existsSync(filePath) || !statSync(filePath).isFile()) return false
const stat = statSync(filePath)
const contentType = mimeTypes.get(extname(filePath).toLowerCase()) ?? 'application/octet-stream'
response.writeHead(200, {
'Content-Type': contentType,
'Content-Length': stat.size,
'Cache-Control': cache,
})
createReadStream(filePath).pipe(response)
return true
}
function serveStatic(response, requestPath) {
const pathWithoutSlash = requestPath.replace(/^\/+/, '')
const staticPath = safeFile(publicDir, pathWithoutSlash || 'index.html')
if (staticPath && serveFile(response, staticPath, requestPath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-store')) {
return true
}
return serveFile(response, join(publicDir, 'index.html'), 'no-store')
}
function serveLibreBible(response, requestPath) {
const relative = requestPath.replace(/^\/librebible\/?/, '')
const filePath = safeFile(dataDir, relative)
if (!filePath || !serveFile(response, filePath, 'public, max-age=300')) {
sendText(response, 404, 'Not found')
}
}
function deployInfo() {
if (!deployInfoPath || !existsSync(deployInfoPath)) return { commit: 'unknown' }
try {
return JSON.parse(readFileSync(deployInfoPath, 'utf8'))
} catch {
return { commit: readFileSync(deployInfoPath, 'utf8').trim() || 'unknown' }
}
}
async function handleApi(request, response, url) {
if (url.pathname === '/api/health') {
sendJson(response, 200, { ok: true, deploy: deployInfo() })
return
}
if (url.pathname === '/api/workspace' && request.method === 'GET') {
const rows = sqlite('SELECT state_json FROM workspace_state WHERE id = 1;')
if (!rows.length) {
response.writeHead(204, { 'Cache-Control': 'no-store' })
response.end()
return
}
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
})
response.end(rows[0].state_json)
return
}
if (url.pathname === '/api/workspace' && request.method === 'PUT') {
const body = await readBody(request)
let parsed
try {
parsed = JSON.parse(body)
} catch {
sendJson(response, 400, { error: 'Workspace payload must be valid JSON.' })
return
}
sqlite(
`INSERT INTO workspace_state (id, state_json, updated_at)
VALUES (1, json(${quoteSql(JSON.stringify(parsed))}), CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET state_json = excluded.state_json, updated_at = CURRENT_TIMESTAMP;`,
)
sendJson(response, 200, {
ok: true,
checksum: createHash('sha256').update(JSON.stringify(parsed)).digest('hex'),
})
return
}
sendJson(response, 404, { error: 'Unknown API route.' })
}
const server = http.createServer((request, response) => {
const url = new URL(request.url ?? '/', 'http://localhost')
Promise.resolve()
.then(() => {
if (url.pathname.startsWith('/api/')) return handleApi(request, response, url)
if (url.pathname.startsWith('/librebible/')) return serveLibreBible(response, url.pathname)
return serveStatic(response, url.pathname)
})
.catch((error) => {
sendJson(response, 500, { error: String(error.message ?? error) })
})
})
server.listen(port, '127.0.0.1', () => {
console.log(`Libre Study web server listening on http://127.0.0.1:${port}`)
console.log(`Serving app from ${publicDir}`)
console.log(`Serving LibreBible data from ${dataDir}`)
console.log(`Persisting workspace to ${dbPath}`)
})
+1 -1
View File
@@ -77,7 +77,7 @@ checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]]
name = "app"
version = "0.1.22"
version = "0.1.23"
dependencies = [
"log",
"rusqlite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "0.1.22"
version = "0.1.23"
description = "A Tauri App"
authors = ["you"]
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Libre Study",
"version": "0.1.22",
"version": "0.1.23",
"identifier": "org.librestudy.desktop",
"build": {
"frontendDist": "../dist",
+197 -72
View File
@@ -19,6 +19,8 @@ const activeSessionStorageKey = 'libre-study.active-session.v1'
const studySettingsStorageKey = 'libre-study.study-settings.v1'
const timelineLimit = 200
const defaultTranslationId = 'kjv-eng-kjv2006'
const remoteWorkspaceEnabled = import.meta.env.VITE_REMOTE_WORKSPACE === 'true'
const remoteWorkspaceApiBase = (import.meta.env.VITE_API_BASE_URL ?? '/api').replace(/\/$/, '')
type ViewMode = 'page' | 'study' | 'notes' | 'compare'
type StrongDisplayMode = 'off' | 'highlight' | 'numbers'
type PageMotion = 'idle' | 'turn-forward' | 'turn-back'
@@ -53,6 +55,31 @@ type StudySession = {
timeline: TimelineEntry[]
}
type StudySettings = {
lemmaFontSize: number
linkedFontSize: number
originalCollapsed: boolean
linkedCollapsed: boolean
definitionCollapsed: boolean
studyNotesCollapsed: boolean
commentaryCollapsed: boolean
dictionaryCollapsed: boolean
showOriginalLine: boolean
showLinkedLine: boolean
showDefinitionPanel: boolean
showCommentaryPanel: boolean
showDictionaryPanel: boolean
splitPercent: number
notesPinned: boolean
themeMode: ThemeMode
}
type WorkspaceSnapshot = {
sessions: StudySession[]
activeSessionId: string
studySettings: StudySettings
}
function reference(verse: Verse) {
return `${verse.book} ${verse.chapter}:${verse.verse}`
}
@@ -102,6 +129,40 @@ function formatTimelineTime(value: string) {
return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
async function loadRemoteWorkspaceSnapshot(): Promise<Partial<WorkspaceSnapshot> | null> {
if (!remoteWorkspaceEnabled) return null
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
credentials: 'include',
})
if (response.status === 204 || response.status === 404) return null
if (response.status === 401) {
throw new Error('Login is required for the hosted workspace.')
}
if (!response.ok) {
throw new Error(`Unable to load hosted workspace: ${response.status} ${response.statusText}`)
}
return response.json() as Promise<Partial<WorkspaceSnapshot>>
}
async function saveRemoteWorkspaceSnapshot(snapshot: WorkspaceSnapshot) {
if (!remoteWorkspaceEnabled) return
const response = await fetch(`${remoteWorkspaceApiBase}/workspace`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(snapshot),
})
if (!response.ok) {
throw new Error(`Unable to save hosted workspace: ${response.status} ${response.statusText}`)
}
}
function abbreviateBook(book: string) {
const abbreviations: Record<string, string> = {
Genesis: 'Gen',
@@ -278,8 +339,31 @@ function App() {
const restoredSessionId = useRef('')
const pageMotionTimeout = useRef<number | null>(null)
const notesDragOpenTimeout = useRef<number | null>(null)
const remoteWorkspaceSaveTimeout = useRef<number | null>(null)
const readerRef = useRef<HTMLElement | null>(null)
const hydratedNoteChapters = useRef(new Set<string>())
const workspaceHydrated = useRef(false)
function applyStudySettings(settings?: Partial<StudySettings>) {
if (!settings) return
setLemmaFontSize(settings.lemmaFontSize ?? 17)
setLinkedFontSize(settings.linkedFontSize ?? 15)
setOriginalCollapsed(settings.originalCollapsed ?? false)
setLinkedCollapsed(settings.linkedCollapsed ?? false)
setDefinitionCollapsed(settings.definitionCollapsed ?? false)
setStudyNotesCollapsed(settings.studyNotesCollapsed ?? false)
setCommentaryCollapsed(settings.commentaryCollapsed ?? false)
setDictionaryCollapsed(settings.dictionaryCollapsed ?? false)
setShowOriginalLine(settings.showOriginalLine ?? true)
setShowLinkedLine(settings.showLinkedLine ?? true)
setShowDefinitionPanel(settings.showDefinitionPanel ?? true)
setShowCommentaryPanel(settings.showCommentaryPanel ?? true)
setShowDictionaryPanel(settings.showDictionaryPanel ?? true)
setSplitPercent(settings.splitPercent ?? 50)
setNotesPinned(settings.notesPinned ?? false)
setThemeMode(settings.themeMode ?? 'light')
}
async function loadLibrary(translationId = activeSession?.baseTranslation ?? defaultTranslationId) {
setError(null)
@@ -408,48 +492,71 @@ function App() {
setTagInput('')
}
const studySettingsSnapshot = useMemo<StudySettings>(
() => ({
lemmaFontSize,
linkedFontSize,
originalCollapsed,
linkedCollapsed,
definitionCollapsed,
studyNotesCollapsed,
commentaryCollapsed,
dictionaryCollapsed,
showOriginalLine,
showLinkedLine,
showDefinitionPanel,
showCommentaryPanel,
showDictionaryPanel,
splitPercent,
notesPinned,
themeMode,
}),
[
commentaryCollapsed,
definitionCollapsed,
dictionaryCollapsed,
lemmaFontSize,
linkedCollapsed,
linkedFontSize,
notesPinned,
originalCollapsed,
showCommentaryPanel,
showDefinitionPanel,
showDictionaryPanel,
showLinkedLine,
showOriginalLine,
splitPercent,
studyNotesCollapsed,
themeMode,
],
)
useEffect(() => {
const savedStudySettings = localStorage.getItem(studySettingsStorageKey)
const savedSessions = localStorage.getItem(sessionStorageKey)
const savedActiveSessionId = localStorage.getItem(activeSessionStorageKey)
let cancelled = false
async function restoreWorkspace() {
let remoteSnapshot: Partial<WorkspaceSnapshot> | null = null
if (remoteWorkspaceEnabled) {
try {
remoteSnapshot = await loadRemoteWorkspaceSnapshot()
} catch (reason) {
setError(String(reason))
}
}
const savedStudySettings = remoteSnapshot?.studySettings
? JSON.stringify(remoteSnapshot.studySettings)
: localStorage.getItem(studySettingsStorageKey)
const savedSessions = remoteSnapshot?.sessions
? JSON.stringify(remoteSnapshot.sessions)
: localStorage.getItem(sessionStorageKey)
const savedActiveSessionId =
remoteSnapshot?.activeSessionId ?? localStorage.getItem(activeSessionStorageKey)
let nextSessions: StudySession[] = []
if (savedStudySettings) {
try {
const parsed = JSON.parse(savedStudySettings) as Partial<{
lemmaFontSize: number
linkedFontSize: number
originalCollapsed: boolean
linkedCollapsed: boolean
definitionCollapsed: boolean
studyNotesCollapsed: boolean
commentaryCollapsed: boolean
dictionaryCollapsed: boolean
showOriginalLine: boolean
showLinkedLine: boolean
showDefinitionPanel: boolean
showCommentaryPanel: boolean
showDictionaryPanel: boolean
splitPercent: number
notesPinned: boolean
themeMode: ThemeMode
}>
setLemmaFontSize(parsed.lemmaFontSize ?? 17)
setLinkedFontSize(parsed.linkedFontSize ?? 15)
setOriginalCollapsed(parsed.originalCollapsed ?? false)
setLinkedCollapsed(parsed.linkedCollapsed ?? false)
setDefinitionCollapsed(parsed.definitionCollapsed ?? false)
setStudyNotesCollapsed(parsed.studyNotesCollapsed ?? false)
setCommentaryCollapsed(parsed.commentaryCollapsed ?? false)
setDictionaryCollapsed(parsed.dictionaryCollapsed ?? false)
setShowOriginalLine(parsed.showOriginalLine ?? true)
setShowLinkedLine(parsed.showLinkedLine ?? true)
setShowDefinitionPanel(parsed.showDefinitionPanel ?? true)
setShowCommentaryPanel(parsed.showCommentaryPanel ?? true)
setShowDictionaryPanel(parsed.showDictionaryPanel ?? true)
setSplitPercent(parsed.splitPercent ?? 50)
setNotesPinned(parsed.notesPinned ?? false)
setThemeMode(parsed.themeMode ?? 'light')
applyStudySettings(JSON.parse(savedStudySettings) as Partial<StudySettings>)
} catch {
localStorage.removeItem(studySettingsStorageKey)
}
@@ -485,13 +592,33 @@ function App() {
const selectedSession =
nextSessions.find((session) => session.id === selectedSessionId) ?? nextSessions[0]
if (cancelled) return
setSessions(nextSessions)
setActiveSessionId(selectedSessionId)
getTranslations().then(setTranslations).catch((reason) => setError(String(reason)))
loadLibrary(selectedSession.baseTranslation)
.catch((reason) => setError(String(reason)))
.finally(() => setLoading(false))
.finally(() => {
if (!cancelled) {
setLoading(false)
window.setTimeout(() => {
workspaceHydrated.current = true
}, 0)
}
})
}
restoreWorkspace().catch((reason) => {
if (!cancelled) {
setError(String(reason))
setLoading(false)
}
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
@@ -509,42 +636,40 @@ function App() {
useEffect(() => {
localStorage.setItem(
studySettingsStorageKey,
JSON.stringify({
lemmaFontSize,
linkedFontSize,
originalCollapsed,
linkedCollapsed,
definitionCollapsed,
studyNotesCollapsed,
commentaryCollapsed,
dictionaryCollapsed,
showOriginalLine,
showLinkedLine,
showDefinitionPanel,
showCommentaryPanel,
showDictionaryPanel,
splitPercent,
notesPinned,
themeMode,
}),
JSON.stringify(studySettingsSnapshot),
)
}, [studySettingsSnapshot])
useEffect(() => {
if (!remoteWorkspaceEnabled || !workspaceHydrated.current || !sessions.length || !activeSessionId) {
return
}
if (remoteWorkspaceSaveTimeout.current) {
window.clearTimeout(remoteWorkspaceSaveTimeout.current)
}
remoteWorkspaceSaveTimeout.current = window.setTimeout(() => {
saveRemoteWorkspaceSnapshot({
sessions,
activeSessionId,
studySettings: studySettingsSnapshot,
}).catch((reason) => {
console.warn('Unable to save hosted workspace.', reason)
})
remoteWorkspaceSaveTimeout.current = null
}, 650)
return () => {
if (remoteWorkspaceSaveTimeout.current) {
window.clearTimeout(remoteWorkspaceSaveTimeout.current)
remoteWorkspaceSaveTimeout.current = null
}
}
}, [
commentaryCollapsed,
definitionCollapsed,
dictionaryCollapsed,
lemmaFontSize,
linkedCollapsed,
linkedFontSize,
notesPinned,
originalCollapsed,
showCommentaryPanel,
showDefinitionPanel,
showDictionaryPanel,
showLinkedLine,
showOriginalLine,
splitPercent,
studyNotesCollapsed,
themeMode,
activeSessionId,
sessions,
studySettingsSnapshot,
])
useEffect(() => {
+1 -1
View File
@@ -16,7 +16,7 @@ type TauriWindow = Window &
}
const browserTagKey = 'libre-study-browser-tags'
const libreBibleDevRoot = '/@fs/W:/libre-bible-data/packages/json'
const libreBibleDevRoot = import.meta.env.VITE_LIBREBIBLE_BASE_URL ?? '/@fs/W:/libre-bible-data/packages/json'
const defaultTranslationId = 'kjv-eng-kjv2006'
type ImportedVerse = {
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_LIBREBIBLE_BASE_URL?: string
readonly VITE_REMOTE_WORKSPACE?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}