Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c249d6a6e9 | |||
| 2d8f23509a | |||
| 0f640c74b2 | |||
| 54ccb3d712 | |||
| 35fa7cec05 | |||
| 2385166213 | |||
| fddb141d19 | |||
| 8272d6de88 | |||
| 4fc86ebd3d | |||
| 12cabd1d1b | |||
| 676663ffcc | |||
| 7d5deebfce | |||
| 7a50f42e02 | |||
| 38edd5269c | |||
| 2eccfe7f75 | |||
| 7670982f60 | |||
| f50d6cb053 | |||
| e0a4938a78 | |||
| 2e657e51d6 | |||
| 4c13d139f6 | |||
| 405c1d27c9 | |||
| c9727a26e4 | |||
| 8530c5ebe0 | |||
| 83b6b323c3 | |||
| 8b9fe4e760 | |||
| 654cd7db9f | |||
| 51620caf8b | |||
| 96fc1a90a1 | |||
| f1d827202f | |||
| 8691907210 | |||
| e3fb781042 | |||
| f2abd42969 | |||
| 7e1c2ce53a | |||
| 67b27c2684 | |||
| 4d9adb2723 | |||
| c38e2b8b49 | |||
| 899879472e | |||
| 28a578ec85 | |||
| 4766071e69 | |||
| 539cb877b4 | |||
| 4d3ac2b9ea | |||
| 2cb81bd246 | |||
| eae5a0ebc7 | |||
| cb0d9ac9fa | |||
| 59055aa67e | |||
| 18f9220e73 | |||
| 9b01fab383 | |||
| 7c2b903e4d | |||
| 2d4ca6f13a | |||
| d67bc77b0e | |||
| 2a9c4cfe81 | |||
| 3e10d1c4ee | |||
| b3541ba6f3 | |||
| aa3fb56458 | |||
| e87bbfe5b0 | |||
| 56d66b109e | |||
| b6c1a2d5ce | |||
| 5f761c1772 | |||
| 4800a51957 | |||
| 7c86657548 | |||
| 3af4e2f46a | |||
| 8f2f6d1684 | |||
| 3bb293f7a7 | |||
| 2ae860a1a8 | |||
| 422ac30e6c | |||
| a89b8bd282 | |||
| a241e2c201 | |||
| 4460c1d9d6 | |||
| 0407e17c68 | |||
| 24cd4bf13d | |||
| 090018c191 | |||
| 48fd24a6b2 | |||
| 141cf95f9e | |||
| 1e5ce6c068 | |||
| ce0572e01e | |||
| 824b873c69 | |||
| b2b2ed13aa | |||
| 09f5329a07 | |||
| 0323fd4813 | |||
| af54b5fd49 | |||
| 5b5a54f7b1 | |||
| a7c2403c4a | |||
| d52821e7cc | |||
| d3a9259b42 | |||
| a2db1f04be | |||
| 070d0c1352 | |||
| 016da98027 | |||
| 1aae2fb3fc | |||
| 12290c5d93 | |||
| 85ecaf1ff6 | |||
| 3b8879b9ca | |||
| f341d6e64d | |||
| ecbf7d4bdc | |||
| ba5d159f28 | |||
| a851f53005 | |||
| e4db4fe458 | |||
| 4424286dfd | |||
| 0771eeab43 | |||
| 3fe806fc0e | |||
| cc0db0813f |
@@ -0,0 +1,383 @@
|
||||
name: Build and Deploy Verso (prod)
|
||||
|
||||
# Production deploy. Triggered only by pushes to the `prod` branch — keep `main`
|
||||
# for day-to-day work and fast-forward `prod` when a build is stable.
|
||||
#
|
||||
# Differences from the test deploy (deploy-verso.yml):
|
||||
# - Runs in the `verso` namespace (test runs in `test`).
|
||||
# - Mongo / Redis / app data live on PersistentVolumeClaims and are applied
|
||||
# idempotently: this workflow NEVER deletes them, so data survives deploys.
|
||||
# - The replica set is initialised only once.
|
||||
# - Builds/pushes a distinct image tag (verso:stable) so prod and test never
|
||||
# clobber each other's image.
|
||||
# - SMTP comes from the `verso-smtp` Secret (create it with kubectl); email is
|
||||
# optional so the app still boots before the secret exists.
|
||||
# - Public self-registration stays off (CE default): friends-only, admin
|
||||
# creates accounts / sends invites.
|
||||
#
|
||||
# Out of band (do once): create the PVCs (server-ce/k8s/verso-prod-pvcs.yaml,
|
||||
# with your storageClass), the `verso-smtp` Secret, and a verso.alocoq.fr
|
||||
# Ingress (see server-ce/k8s/verso-prod-ingress.example.yaml) + DNS.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- prod
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SITE_URL: https://verso.alocoq.fr
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: native
|
||||
timeout-minutes: 240
|
||||
|
||||
steps:
|
||||
- name: Build and push Verso prod image with BuildKit
|
||||
run: |
|
||||
kubectl -n ci delete job verso-buildkit-prod --ignore-not-found=true --wait=true
|
||||
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: verso-buildkit-prod
|
||||
namespace: ci
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
initContainers:
|
||||
- name: prepare
|
||||
image: alpine/git:latest
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eux
|
||||
REG=registry.git.svc.cluster.local:5000
|
||||
git clone --depth 1 --branch prod https://git.alocoq.fr/alois/verso.git /workspace/repo
|
||||
|
||||
# Build the base image only when Dockerfile-base changes
|
||||
# (content-hash tag); otherwise reuse the cached base.
|
||||
BTAG=$(sha256sum /workspace/repo/server-ce/Dockerfile-base | cut -c1-16)
|
||||
printf '%s' "$BTAG" > /workspace/base_tag
|
||||
if wget -qO- "http://$REG/v2/verso-base/tags/list" 2>/dev/null | grep -q "\"base-$BTAG\""; then
|
||||
echo "Base image base-$BTAG already present - skipping base build"
|
||||
else
|
||||
touch /workspace/build-base
|
||||
echo "Base image base-$BTAG not found - base will be built"
|
||||
fi
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
|
||||
containers:
|
||||
- name: buildkit
|
||||
image: moby/buildkit:latest
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eux
|
||||
|
||||
REG=registry.git.svc.cluster.local:5000
|
||||
|
||||
mkdir -p /etc/buildkit
|
||||
printf '[registry."%s"]\n http = true\n insecure = true\n' "$REG" > /etc/buildkit/buildkitd.toml
|
||||
|
||||
BTAG=$(cat /workspace/base_tag)
|
||||
BASE_REF="$REG/verso-base:base-$BTAG"
|
||||
|
||||
if [ -f /workspace/build-base ]; then
|
||||
buildctl-daemonless.sh build \
|
||||
--frontend=dockerfile.v0 \
|
||||
--local context=/workspace/repo \
|
||||
--local dockerfile=/workspace/repo/server-ce \
|
||||
--opt filename=Dockerfile-base \
|
||||
--import-cache type=registry,ref=$REG/verso-cache:base \
|
||||
--export-cache type=registry,ref=$REG/verso-cache:base,mode=max \
|
||||
--output type=image,name=$BASE_REF,push=true,registry.insecure=true
|
||||
else
|
||||
echo "Reusing existing base image $BASE_REF"
|
||||
fi
|
||||
|
||||
# App image → verso:stable (prod tag).
|
||||
buildctl-daemonless.sh build \
|
||||
--frontend=dockerfile.v0 \
|
||||
--local context=/workspace/repo \
|
||||
--local dockerfile=/workspace/repo/server-ce \
|
||||
--opt filename=Dockerfile \
|
||||
--opt build-arg:OVERLEAF_BASE_TAG=$BASE_REF \
|
||||
--import-cache type=registry,ref=$REG/verso-cache:app \
|
||||
--export-cache type=registry,ref=$REG/verso-cache:app,mode=max \
|
||||
--output type=image,name=$REG/verso:stable,push=true,registry.insecure=true
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir: {}
|
||||
EOF
|
||||
|
||||
- name: Wait for build
|
||||
run: |
|
||||
kubectl -n ci wait --for=condition=complete job/verso-buildkit-prod --timeout=14400s
|
||||
|
||||
- name: Show build logs
|
||||
if: always()
|
||||
run: |
|
||||
kubectl -n ci logs job/verso-buildkit-prod -c prepare || true
|
||||
kubectl -n ci logs job/verso-buildkit-prod -c buildkit || true
|
||||
|
||||
- name: Ensure data services (Mongo + Redis, never deleted)
|
||||
run: |
|
||||
# Mongo/Redis. Applied idempotently — this step must never delete
|
||||
# these, so project data survives every deploy. The namespace and the
|
||||
# PVCs (server-ce/k8s/verso-prod-pvcs.yaml) are provisioned out of
|
||||
# band, so the runner only needs namespaced rights in `verso` (like
|
||||
# `test`). This step assumes the namespace and the
|
||||
# mongo-data / redis-data / verso-data PVCs already exist.
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mongo
|
||||
namespace: verso
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mongo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mongo
|
||||
spec:
|
||||
containers:
|
||||
- name: mongo
|
||||
image: mongo:8
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||
ports:
|
||||
- containerPort: 27017
|
||||
volumeMounts:
|
||||
- name: mongo-data
|
||||
mountPath: /data/db
|
||||
volumes:
|
||||
- name: mongo-data
|
||||
persistentVolumeClaim:
|
||||
claimName: mongo-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mongo
|
||||
namespace: verso
|
||||
spec:
|
||||
selector:
|
||||
app: mongo
|
||||
ports:
|
||||
- name: mongo
|
||||
port: 27017
|
||||
targetPort: 27017
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: verso
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7
|
||||
# AOF persistence so a restart doesn't drop in-flight edits
|
||||
# before they're flushed to Mongo.
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: redis-data
|
||||
persistentVolumeClaim:
|
||||
claimName: redis-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: verso
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
EOF
|
||||
|
||||
kubectl -n verso rollout status deployment/mongo --timeout=300s
|
||||
kubectl -n verso rollout status deployment/redis --timeout=300s
|
||||
|
||||
- name: Initialise Mongo replica set (only if not already initialised)
|
||||
run: |
|
||||
kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval '
|
||||
try {
|
||||
rs.status()
|
||||
print("replica set already initialised")
|
||||
} catch (e) {
|
||||
if (e.codeName === "NotYetInitialized" || /no replset config/i.test(e.message)) {
|
||||
rs.initiate({ _id: "rs0", members: [{ _id: 0, host: "mongo:27017" }] })
|
||||
print("replica set initiated")
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
'
|
||||
kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval '
|
||||
while (rs.status().myState !== 1) { sleep(1000) }
|
||||
print("Mongo replica set is PRIMARY")
|
||||
'
|
||||
|
||||
- name: Ensure Verso deployment + service
|
||||
run: |
|
||||
# Stamp the instance name with this build number, e.g. "Verso V0.12 Alpha".
|
||||
NAV_TITLE="Verso V0.${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}} Alpha"
|
||||
cat <<'EOF' | sed "s|__NAV_TITLE__|${NAV_TITLE}|g" | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: verso
|
||||
spec:
|
||||
replicas: 1
|
||||
# RWO data volume → can't run two pods at once; recreate on update.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: verso
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: verso
|
||||
spec:
|
||||
securityContext:
|
||||
# App runs as www-data (uid/gid 33); make the data volume
|
||||
# group-writable by it.
|
||||
fsGroup: 33
|
||||
initContainers:
|
||||
- name: init-data-perms
|
||||
image: busybox:latest
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eux
|
||||
mkdir -p /data/template_files /data/user_files \
|
||||
/data/compiles /data/cache /data/output /data/published
|
||||
chown -R 33:33 /data
|
||||
volumeMounts:
|
||||
- name: verso-data
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: verso
|
||||
image: registry.alocoq.fr/verso:stable
|
||||
# :stable is a fixed tag, so force a pull on every rollout to
|
||||
# pick up the freshly built image.
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: OVERLEAF_MONGO_URL
|
||||
value: mongodb://mongo:27017/sharelatex?replicaSet=rs0
|
||||
- name: OVERLEAF_REDIS_HOST
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis
|
||||
- name: OVERLEAF_APP_NAME
|
||||
value: Verso
|
||||
- name: OVERLEAF_NAV_TITLE
|
||||
value: "__NAV_TITLE__"
|
||||
- name: OVERLEAF_SITE_URL
|
||||
value: https://verso.alocoq.fr
|
||||
- name: OVERLEAF_SITE_LANGUAGE
|
||||
value: fr
|
||||
# Allow anonymous visitors so public published-presentation
|
||||
# links and read-only share links work without login.
|
||||
- name: OVERLEAF_ALLOW_PUBLIC_ACCESS
|
||||
value: "true"
|
||||
# NB: anonymous read-AND-write sharing is intentionally NOT
|
||||
# enabled (compiles are unsandboxed → only trusted accounts
|
||||
# may trigger them). Public self-registration is also off
|
||||
# (CE default): admin creates accounts / sends invites.
|
||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||
value: "true"
|
||||
# (SMTP email vars are loaded below via envFrom.)
|
||||
# SMTP for password-reset / invite emails. All
|
||||
# OVERLEAF_EMAIL_* vars come from the optional 'verso-smtp'
|
||||
# Secret (its keys must be named exactly like those env
|
||||
# vars). Optional, so the app boots before the secret exists.
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: verso-smtp
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: verso-data
|
||||
mountPath: /var/lib/overleaf/data
|
||||
volumes:
|
||||
- name: verso-data
|
||||
persistentVolumeClaim:
|
||||
claimName: verso-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: verso
|
||||
spec:
|
||||
selector:
|
||||
app: verso
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
EOF
|
||||
|
||||
- name: Deploy Verso image
|
||||
run: |
|
||||
kubectl -n verso set image deployment/verso \
|
||||
verso=registry.alocoq.fr/verso:stable
|
||||
kubectl -n verso rollout restart deployment/verso
|
||||
kubectl -n verso rollout status deployment/verso --timeout=600s
|
||||
|
||||
- name: Create initial admin (only if no users exist)
|
||||
run: |
|
||||
COUNT=$(kubectl -n verso exec deploy/mongo -- mongosh sharelatex --quiet --eval 'db.users.countDocuments()' | tr -d '[:space:]')
|
||||
if [ "$COUNT" = "0" ]; then
|
||||
echo "No users yet — creating the initial admin account"
|
||||
kubectl -n verso exec deploy/verso -- bash -lc '
|
||||
cd /overleaf/services/web
|
||||
node modules/server-ce-scripts/scripts/create-user \
|
||||
--admin \
|
||||
--email=alois.coquillard@gmail.com
|
||||
'
|
||||
else
|
||||
echo "Users already exist ($COUNT) — skipping admin creation"
|
||||
fi
|
||||
@@ -0,0 +1,339 @@
|
||||
name: Build and Deploy Verso
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SITE_URL: https://test.alocoq.fr
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: native
|
||||
timeout-minutes: 240
|
||||
|
||||
steps:
|
||||
- name: Build and push Verso images with BuildKit
|
||||
run: |
|
||||
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
|
||||
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: verso-buildkit
|
||||
namespace: ci
|
||||
spec:
|
||||
backoffLimit: 0
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
initContainers:
|
||||
- name: prepare
|
||||
image: alpine/git:latest
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eux
|
||||
REG=registry.git.svc.cluster.local:5000
|
||||
git clone --depth 1 https://git.alocoq.fr/alois/verso.git /workspace/repo
|
||||
|
||||
# (#1) Build the base image only when it actually changes.
|
||||
# The base layers' only repo input is Dockerfile-base, so
|
||||
# we key on a content hash of that file: the base is tagged
|
||||
# verso-base:base-<hash> and the app builds FROM that exact
|
||||
# tag. If a base with this hash is already in the registry,
|
||||
# the heavy base build (apt, TeX Live, Quarto) is skipped.
|
||||
BTAG=$(sha256sum /workspace/repo/server-ce/Dockerfile-base | cut -c1-16)
|
||||
printf '%s' "$BTAG" > /workspace/base_tag
|
||||
if wget -qO- "http://$REG/v2/verso-base/tags/list" 2>/dev/null | grep -q "\"base-$BTAG\""; then
|
||||
echo "Base image base-$BTAG already present - skipping base build"
|
||||
else
|
||||
touch /workspace/build-base
|
||||
echo "Base image base-$BTAG not found - base will be built"
|
||||
fi
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
|
||||
containers:
|
||||
- name: buildkit
|
||||
image: moby/buildkit:latest
|
||||
securityContext:
|
||||
privileged: true
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eux
|
||||
|
||||
# Push to the in-cluster registry (plain HTTP) to bypass
|
||||
# the Traefik ingress, whose read timeout was killing the
|
||||
# multi-GB TeX Live layer upload mid-stream. Mark the
|
||||
# registry http+insecure so both push and the base pull
|
||||
# for the app build treat it as plain HTTP. Written inside
|
||||
# the container so no extra k8s resources are needed.
|
||||
REG=registry.git.svc.cluster.local:5000
|
||||
|
||||
mkdir -p /etc/buildkit
|
||||
printf '[registry."%s"]\n http = true\n insecure = true\n' "$REG" > /etc/buildkit/buildkitd.toml
|
||||
|
||||
BTAG=$(cat /workspace/base_tag)
|
||||
BASE_REF="$REG/verso-base:base-$BTAG"
|
||||
|
||||
# (#1) Base build, only when prepare flagged it changed.
|
||||
# (#2) Import/export a registry layer cache so that, when
|
||||
# the base does change, unchanged layers (e.g. apt) are
|
||||
# still reused instead of rebuilt from scratch.
|
||||
if [ -f /workspace/build-base ]; then
|
||||
buildctl-daemonless.sh build \
|
||||
--frontend=dockerfile.v0 \
|
||||
--local context=/workspace/repo \
|
||||
--local dockerfile=/workspace/repo/server-ce \
|
||||
--opt filename=Dockerfile-base \
|
||||
--import-cache type=registry,ref=$REG/verso-cache:base \
|
||||
--export-cache type=registry,ref=$REG/verso-cache:base,mode=max \
|
||||
--output type=image,name=$BASE_REF,push=true,registry.insecure=true
|
||||
else
|
||||
echo "Reusing existing base image $BASE_REF"
|
||||
fi
|
||||
|
||||
# App image, built FROM the content-pinned base tag.
|
||||
# (#2) The registry cache lets yarn install be skipped when
|
||||
# package.json is unchanged; the web build only re-runs
|
||||
# when the frontend source actually changes.
|
||||
buildctl-daemonless.sh build \
|
||||
--frontend=dockerfile.v0 \
|
||||
--local context=/workspace/repo \
|
||||
--local dockerfile=/workspace/repo/server-ce \
|
||||
--opt filename=Dockerfile \
|
||||
--opt build-arg:OVERLEAF_BASE_TAG=$BASE_REF \
|
||||
--import-cache type=registry,ref=$REG/verso-cache:app \
|
||||
--export-cache type=registry,ref=$REG/verso-cache:app,mode=max \
|
||||
--output type=image,name=$REG/verso:latest,push=true,registry.insecure=true
|
||||
volumeMounts:
|
||||
- name: workspace
|
||||
mountPath: /workspace
|
||||
|
||||
volumes:
|
||||
- name: workspace
|
||||
emptyDir: {}
|
||||
EOF
|
||||
|
||||
- name: Wait for build
|
||||
run: |
|
||||
kubectl -n ci wait --for=condition=complete job/verso-buildkit --timeout=14400s
|
||||
|
||||
- name: Show build logs
|
||||
if: always()
|
||||
run: |
|
||||
kubectl -n ci logs job/verso-buildkit -c prepare || true
|
||||
kubectl -n ci logs job/verso-buildkit -c buildkit || true
|
||||
|
||||
- name: Recreate test dependencies
|
||||
run: |
|
||||
kubectl -n test delete deployment mongo redis --ignore-not-found=true --wait=true
|
||||
kubectl -n test delete service mongo redis --ignore-not-found=true --wait=true
|
||||
|
||||
cat <<'EOF' | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mongo
|
||||
namespace: test
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mongo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mongo
|
||||
spec:
|
||||
containers:
|
||||
- name: mongo
|
||||
image: mongo:8
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||
ports:
|
||||
- containerPort: 27017
|
||||
volumeMounts:
|
||||
- name: mongo-data
|
||||
mountPath: /data/db
|
||||
volumes:
|
||||
- name: mongo-data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mongo
|
||||
namespace: test
|
||||
spec:
|
||||
selector:
|
||||
app: mongo
|
||||
ports:
|
||||
- name: mongo
|
||||
port: 27017
|
||||
targetPort: 27017
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: test
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: redis-data
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: test
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
EOF
|
||||
|
||||
kubectl -n test rollout status deployment/mongo --timeout=180s
|
||||
kubectl -n test rollout status deployment/redis --timeout=180s
|
||||
|
||||
sleep 5
|
||||
|
||||
kubectl -n test exec deploy/mongo -- mongosh --eval '
|
||||
rs.initiate({
|
||||
_id: "rs0",
|
||||
members: [{ _id: 0, host: "mongo:27017" }]
|
||||
})
|
||||
'
|
||||
|
||||
kubectl -n test exec deploy/mongo -- mongosh --eval '
|
||||
while (rs.status().myState !== 1) {
|
||||
sleep(1000)
|
||||
}
|
||||
print("Mongo replica set is PRIMARY")
|
||||
'
|
||||
|
||||
- name: Ensure Verso deployment exists
|
||||
run: |
|
||||
# Stamp the instance name with this build number, e.g. "Verso V0.83 Alpha".
|
||||
NAV_TITLE="Verso V0.${GITHUB_RUN_NUMBER:-${GITEA_RUN_NUMBER:-0}} Alpha"
|
||||
cat <<'EOF' | sed "s|__NAV_TITLE__|${NAV_TITLE}|g" | kubectl apply -f -
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: test
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: verso
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: verso
|
||||
spec:
|
||||
containers:
|
||||
- name: verso
|
||||
# Pull via the public address: the cluster nodes' containerd
|
||||
# is configured for registry.alocoq.fr, not the in-cluster
|
||||
# service name. Both front the same registry storage, so the
|
||||
# image pushed via the in-cluster address resolves here too.
|
||||
image: registry.alocoq.fr/verso:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: OVERLEAF_MONGO_URL
|
||||
value: mongodb://mongo:27017/sharelatex?replicaSet=rs0
|
||||
- name: OVERLEAF_REDIS_HOST
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis
|
||||
- name: OVERLEAF_APP_NAME
|
||||
value: Verso
|
||||
- name: OVERLEAF_NAV_TITLE
|
||||
value: "__NAV_TITLE__"
|
||||
- name: OVERLEAF_SITE_URL
|
||||
value: https://test.alocoq.fr
|
||||
# Default UI language for the instance.
|
||||
- name: OVERLEAF_SITE_LANGUAGE
|
||||
value: fr
|
||||
# Allow anonymous visitors to reach the site so link
|
||||
# sharing and public presentation links work without a
|
||||
# login. Per-project and per-route access checks still
|
||||
# apply; private presentation links still require login.
|
||||
- name: OVERLEAF_ALLOW_PUBLIC_ACCESS
|
||||
value: "true"
|
||||
# Also let anonymous visitors use read-AND-write share
|
||||
# links (edit without an account). Read-only links only
|
||||
# need OVERLEAF_ALLOW_PUBLIC_ACCESS above.
|
||||
- name: OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING
|
||||
value: "true"
|
||||
# Let Quarto Python cells use a project's requirements.txt:
|
||||
# the compiler installs it into a cached venv. Gated to the
|
||||
# project owner + invited collaborators (never anonymous /
|
||||
# link-sharing users).
|
||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||
value: "true"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: test
|
||||
spec:
|
||||
selector:
|
||||
app: verso
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
EOF
|
||||
|
||||
- name: Deploy Verso image
|
||||
run: |
|
||||
kubectl -n test set image deployment/verso \
|
||||
verso=registry.alocoq.fr/verso:latest
|
||||
|
||||
kubectl -n test rollout restart deployment/verso
|
||||
kubectl -n test rollout status deployment/verso --timeout=300s
|
||||
|
||||
- name: Create admin user
|
||||
run: |
|
||||
sleep 20
|
||||
kubectl -n test exec deploy/verso -- bash -lc '
|
||||
cd /overleaf/services/web
|
||||
node modules/server-ce-scripts/scripts/create-user \
|
||||
--admin \
|
||||
--email=test@example.com || true
|
||||
'
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
kubectl -n ci delete job verso-buildkit --ignore-not-found=true --wait=true
|
||||
@@ -1,80 +1,233 @@
|
||||
<h1 align="center">
|
||||
<br>
|
||||
<a href="https://www.overleaf.com"><img src="doc/logo.png" alt="Overleaf" width="300"></a>
|
||||
</h1>
|
||||
|
||||
<h4 align="center">An open-source online real-time collaborative LaTeX editor.</h4>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/overleaf/overleaf/wiki">Wiki</a> •
|
||||
<a href="https://www.overleaf.com/for/enterprises">Server Pro</a> •
|
||||
<a href="#contributing">Contributing</a> •
|
||||
<a href="https://mailchi.mp/overleaf.com/community-edition-and-server-pro">Mailing List</a> •
|
||||
<a href="#authors">Authors</a> •
|
||||
<a href="#license">License</a>
|
||||
<img src="services/web/public/img/ol-brand/verso-logo.svg" alt="Verso" width="440">
|
||||
</p>
|
||||
|
||||
<img src="doc/screenshot.png" alt="A screenshot of a project being edited in Overleaf Community Edition">
|
||||
<p align="center">
|
||||
Figure 1: A screenshot of a project being edited in Overleaf Community Edition.
|
||||
</p>
|
||||
**A collaborative, real-time editor for Quarto, LaTeX and Typst — documents and presentations.**
|
||||
|
||||
## Community Edition
|
||||
Verso is a fork of [Overleaf](https://github.com/overleaf/overleaf) that adds
|
||||
first-class [Quarto](https://quarto.org) and [Typst](https://typst.app) support
|
||||
alongside Overleaf's LaTeX toolchain. It keeps Overleaf's real-time
|
||||
collaboration infrastructure and runs **three compilers side by side**, chosen
|
||||
automatically from the root file's extension:
|
||||
|
||||
[Overleaf](https://www.overleaf.com) is an open-source online real-time collaborative LaTeX editor. We run a hosted version at [www.overleaf.com](https://www.overleaf.com), but you can also run your own local version, and contribute to the development of Overleaf.
|
||||
| Root file | Compiler | Typical output |
|
||||
|-----------|----------|----------------|
|
||||
| `.qmd` | Quarto | PDF (via Typst or LaTeX), or an HTML/RevealJS deck |
|
||||
| `.tex` | `latexmk` / TeX Live | PDF |
|
||||
| `.typ` | Typst | PDF |
|
||||
|
||||
> [!CAUTION]
|
||||
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
|
||||
All three coexist on one server; no per-project configuration is required to
|
||||
pick the engine.
|
||||
|
||||
For more information on Sandbox Compiles check out our [documentation](https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles).
|
||||
---
|
||||
|
||||
## Enterprise
|
||||
## Features
|
||||
|
||||
If you want help installing and maintaining Overleaf in your lab or workplace, we offer an officially supported version called [Overleaf Server Pro](https://www.overleaf.com/for/enterprises). It also includes more features for security (SSO with LDAP or SAML), administration and collaboration (e.g. tracked changes). [Find out more!](https://www.overleaf.com/for/enterprises)
|
||||
- **Real-time collaboration** — multiple people editing the same file at once,
|
||||
powered by Overleaf's operational-transformation engine, with live cursors
|
||||
and full project history.
|
||||
- **Three compilers, auto-dispatched** — Quarto, LaTeX and Typst projects live
|
||||
side by side; the runner is selected from the root file's extension.
|
||||
- **Language-aware editor for all three**:
|
||||
- *LaTeX* — syntax highlighting, command/environment/reference autocomplete,
|
||||
linting (inherited from Overleaf).
|
||||
- *Quarto (`.qmd`)* — Markdown highlighting plus Quarto-aware completions:
|
||||
code chunks (```` ```{python} ````, `{r}`, `{julia}`, `{ojs}`…), callouts
|
||||
and fenced divs (`::: {.callout-note}`, columns, tabsets) and
|
||||
cross-references (`@fig-`, `@tbl-`, `@sec-`, `@eq-`).
|
||||
- *Typst (`.typ`)* — syntax highlighting and completions for the common
|
||||
functions and markup (`#import`, `#let`, `#set`, `#show`, `#figure`,
|
||||
`#table`, `#cite`, …).
|
||||
- **Document outline** — section headings are extracted into the sidebar
|
||||
outline panel for LaTeX, Quarto (`#`, `##`, …) and Typst (`=`, `==`, …).
|
||||
- **Format at a glance** — the project dashboard shows a per-project format
|
||||
badge (Quarto / Typst / LaTeX), and the compiler dropdown greys out engines
|
||||
that don't apply to the current root file.
|
||||
- **Publish & share compiled output** — publish the compiled result as a
|
||||
standalone page at `/p/:token`, with three independent access tiers (project
|
||||
members / any logged-in user / public). Works for both HTML/RevealJS decks
|
||||
(served live) and PDFs (embedded inline). HTML decks also get a one-click
|
||||
**Present** button in the toolbar.
|
||||
- **Quarto Python cells** — optional per-project virtual environment built from
|
||||
the project's `requirements.txt`, so Python code chunks run during render
|
||||
(gated to the project owner and invited collaborators).
|
||||
- **Auto-compile** — the preview refreshes automatically shortly after you stop
|
||||
typing.
|
||||
|
||||
## Keeping up to date
|
||||
## Output formats
|
||||
|
||||
Sign up to the [mailing list](https://mailchi.mp/overleaf.com/community-edition-and-server-pro) to get updates on Overleaf releases and development.
|
||||
In the YAML frontmatter of a `.qmd` file:
|
||||
|
||||
## Installation
|
||||
```yaml
|
||||
format: typst # → PDF preview, rendered via Typst (no LaTeX required)
|
||||
format: pdf # → PDF preview, rendered via LaTeX
|
||||
format: revealjs # → interactive HTML slideshow preview
|
||||
format: html # → a static HTML page
|
||||
```
|
||||
|
||||
We have detailed installation instructions in the [Overleaf Toolkit](https://github.com/overleaf/toolkit/).
|
||||
Typst ships inside Quarto, so `format: typst` needs no separate installation.
|
||||
|
||||
## Upgrading
|
||||
> **Note on display math**: keep `$$ … $$` blocks on a single line. Multi-line
|
||||
> display-math blocks can trigger YAML parse errors in some Quarto versions.
|
||||
|
||||
If you are upgrading from a previous version of Overleaf, please see the [Release Notes section on the Wiki](https://github.com/overleaf/overleaf/wiki#release-notes) for all of the versions between your current version and the version you are upgrading to.
|
||||
## Quick start
|
||||
|
||||
## Overleaf Docker Image
|
||||
### With Docker
|
||||
|
||||
This repo contains two dockerfiles, [`Dockerfile-base`](server-ce/Dockerfile-base), which builds the
|
||||
`sharelatex/sharelatex-base` image, and [`Dockerfile`](server-ce/Dockerfile) which builds the
|
||||
`sharelatex/sharelatex` (or "community") image.
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 80:80 \
|
||||
-v ~/verso_data:/var/lib/overleaf \
|
||||
--name verso \
|
||||
registry.alocoq.fr/verso:latest
|
||||
```
|
||||
|
||||
The Base image generally contains the basic dependencies like `wget`, plus `texlive`.
|
||||
We split this out because it's a pretty heavy set of
|
||||
dependencies, and it's nice to not have to rebuild all of that every time.
|
||||
Open `http://localhost` in your browser, then visit `/launchpad` on first run to
|
||||
create the admin account.
|
||||
|
||||
The `sharelatex/sharelatex` image extends the base image and adds the actual Overleaf code
|
||||
and services.
|
||||
### Build from source
|
||||
|
||||
Use `make build-base` and `make build-community` from `server-ce/` to build these images.
|
||||
```bash
|
||||
# Build the base image (system deps + Quarto + TeX Live)
|
||||
cd server-ce
|
||||
make build-base
|
||||
|
||||
We use the [Phusion base-image](https://github.com/phusion/baseimage-docker)
|
||||
(which is extended by our `base` image) to provide us with a VM-like container
|
||||
in which to run the Overleaf services. Baseimage uses the `runit` service
|
||||
manager to manage services, and we add our init-scripts from the `server-ce/runit`
|
||||
folder.
|
||||
# Build the application image
|
||||
make build-community
|
||||
```
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `server-ce/Dockerfile-base` | Base OS image — system deps, Quarto (with Typst) and a TeX Live (`latexmk`) toolchain |
|
||||
| `server-ce/Dockerfile` | Application image — Node services and the compiled frontend |
|
||||
|
||||
## Architecture
|
||||
|
||||
Verso is a microservices monorepo (Yarn workspaces). All services run inside a
|
||||
single container managed by `runit`, with `nginx` as the front router.
|
||||
|
||||
```
|
||||
browser ──→ nginx:80
|
||||
├── / ──────────────────→ web:4000 (main app, React UI)
|
||||
├── /socket.io ──────────→ real-time:3026 (WebSocket, OT engine)
|
||||
├── /p/:token ───────────→ web (published output)
|
||||
└── /project/*/output/* → clsi-nginx:8080 (compiled output files)
|
||||
|
||||
web → document-updater → Redis pub/sub → real-time → browser
|
||||
web → CLSI (quarto render / latexmk / typst) → output files → nginx → browser
|
||||
```
|
||||
|
||||
| Service | Role |
|
||||
|---------|------|
|
||||
| `web` | HTTP API, React frontend, auth, project & sharing management |
|
||||
| `real-time` | WebSocket layer, live cursors and edit sync |
|
||||
| `document-updater` | Operational transformation, Redis pub/sub |
|
||||
| `clsi` | Compiler — runs `quarto render` (`.qmd`), `latexmk` (`.tex`) or `typst` (`.typ`) and serves output |
|
||||
| `docstore` | Document text storage (MongoDB) |
|
||||
| `filestore` | Binary file storage (S3 or local) |
|
||||
| `project-history` | Change history and version tracking |
|
||||
|
||||
## Writing documents
|
||||
|
||||
### Quarto (`main.qmd`)
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: My Presentation
|
||||
author: Your Name
|
||||
date: today
|
||||
format: revealjs
|
||||
---
|
||||
|
||||
## Slide one
|
||||
|
||||
Write **Markdown** here.
|
||||
|
||||
## Mathematics
|
||||
|
||||
$$\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}$$
|
||||
```
|
||||
|
||||
Switch `format: revealjs` to `format: typst` (or `pdf`) for a PDF preview.
|
||||
|
||||
### LaTeX (`main.tex`)
|
||||
|
||||
LaTeX works exactly as in Overleaf: a project whose root file is a `.tex` file
|
||||
compiles with `latexmk`/TeX Live, no setting required. The **Example LaTeX
|
||||
project** in the *New project* menu is a ready-made starting point.
|
||||
|
||||
> The bundled TeX Live is a minimal install. Documents that need extra packages
|
||||
> may not build out of the box — see `server-ce/Dockerfile-base` for how to
|
||||
> switch to a fuller TeX Live scheme.
|
||||
|
||||
### Typst (`main.typ`)
|
||||
|
||||
A project whose root file is a `.typ` file compiles directly to PDF with
|
||||
[Typst](https://typst.app) — fast, modern markup with a real scripting
|
||||
language. Verso drives the Typst bundled with Quarto, so no extra install is
|
||||
needed. Use the **Blank Typst project** entry in the *New project* menu to get
|
||||
started.
|
||||
|
||||
## Publishing compiled output
|
||||
|
||||
From **Share → Publish**, Verso compiles the project and snapshots the result to
|
||||
a standalone page at `/p/:token`:
|
||||
|
||||
- **HTML / RevealJS** decks are served as a live page (the **Present** toolbar
|
||||
button is a one-click shortcut to this).
|
||||
- **PDF** output is embedded inline; the raw file stays reachable at
|
||||
`/p/:token/output.pdf`.
|
||||
|
||||
Three stable links are issued, one per access tier — project members, any
|
||||
logged-in user, or anyone — and each can be copied or independently reset.
|
||||
|
||||
## Environment variables
|
||||
|
||||
Verso inherits all of Overleaf's environment variables (prefixed `OVERLEAF_`).
|
||||
The most commonly needed:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `OVERLEAF_APP_NAME` | `Verso` | Name shown in the UI |
|
||||
| `OVERLEAF_NAV_TITLE` | — | Instance name/version shown in the top bar |
|
||||
| `OVERLEAF_MONGO_URL` | `mongodb://mongo/sharelatex` | MongoDB connection string |
|
||||
| `OVERLEAF_REDIS_HOST` | `localhost` | Redis host |
|
||||
| `OVERLEAF_SITE_URL` | — | Public URL (used in emails and published links) |
|
||||
| `OVERLEAF_SITE_LANGUAGE` | `en` | Default UI language (e.g. `fr`) |
|
||||
| `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV` | `false` | Allow Quarto Python cells to use a project `requirements.txt` |
|
||||
| `OVERLEAF_ADMIN_EMAIL` | — | Email for the first admin account |
|
||||
|
||||
See the [Overleaf Server documentation](https://github.com/overleaf/overleaf/wiki)
|
||||
for the full list.
|
||||
|
||||
## Relation to Overleaf
|
||||
|
||||
Verso is a fork of [Overleaf Community Edition](https://github.com/overleaf/overleaf).
|
||||
The main additions on top of upstream are:
|
||||
|
||||
- Quarto and Typst compilers running alongside LaTeX, dispatched by the root
|
||||
file's extension.
|
||||
- Editor language support (highlighting, autocomplete, outline) for Quarto and
|
||||
Typst.
|
||||
- A per-project format badge on the dashboard and a root-file-aware compiler
|
||||
selector.
|
||||
- Publishing/sharing of compiled output (HTML decks and PDFs) via `/p/:token`
|
||||
with tiered access links, and a toolbar **Present** shortcut.
|
||||
- Optional per-project Python virtual environments for Quarto code execution.
|
||||
- Verso branding (name, logo, palette, loading animation).
|
||||
|
||||
All other infrastructure — real-time collaboration, history, auth, file
|
||||
storage, project management — is unchanged from Overleaf.
|
||||
|
||||
## Contributing
|
||||
|
||||
Please see the [CONTRIBUTING](CONTRIBUTING.md) file for information on contributing to the development of Overleaf.
|
||||
|
||||
## Authors
|
||||
|
||||
[The Overleaf Team](https://www.overleaf.com/about)
|
||||
Contributions are welcome — open an issue or pull request on the
|
||||
[Verso repository](https://git.alocoq.fr/alois/verso). The upstream Overleaf
|
||||
contribution guidelines are in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
|
||||
GNU Affero General Public License v3 — see [LICENSE](LICENSE).
|
||||
|
||||
Copyright (c) Overleaf, 2014-2025.
|
||||
Copyright © Overleaf, 2014–2026 (original code).
|
||||
Verso modifications © Aloïs Coquillard, 2026.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Design: per-project Python dependencies (cached virtualenv)
|
||||
|
||||
Status: **Phase 1 implemented** (gated behind `OVERLEAF_ENABLE_PROJECT_PYTHON_VENV`,
|
||||
on in the deployment). Network egress policy and venv eviction (Phases 2–3)
|
||||
remain. Captures the plan for letting Quarto `{python}` cells use libraries
|
||||
beyond the curated base set.
|
||||
|
||||
## What ships in Phase 1
|
||||
|
||||
- A project root `requirements.vrf` is installed into a venv cached by its
|
||||
sha256, created with `python3 -m venv --system-site-packages`; `QuartoRunner`
|
||||
points Quarto at it via `QUARTO_PYTHON`. A per-hash `flock` serialises
|
||||
concurrent builds; pip output is merged into `output.log`; on failure the
|
||||
render falls back to the base interpreter (and the missing-package message
|
||||
surfaces). Venvs live under `PYTHON_VENVS_DIR`
|
||||
(default `/var/lib/overleaf/data/python-venvs`).
|
||||
- Gated by `userCanInstallPython` (`PythonVenvGate.mjs`) to the project owner +
|
||||
invited collaborators (any role) — never anonymous / link-sharing users —
|
||||
threaded to CLSI as `allowPythonInstall` on the editor compile, presentation
|
||||
export, and publish paths.
|
||||
|
||||
### Known Phase-1 limitations
|
||||
|
||||
- The first build of a heavy `requirements.vrf` runs within the compile
|
||||
timeout; a very large install can be killed and retried next compile (the
|
||||
venv is only marked complete on success).
|
||||
- No egress restriction yet (Phase 2) — installs reach PyPI directly.
|
||||
- No eviction yet (Phase 3) — venvs accumulate under `PYTHON_VENVS_DIR`.
|
||||
|
||||
## Background
|
||||
|
||||
Quarto executes `` ```{python} `` cells through a Jupyter kernel. The base image
|
||||
([`server-ce/Dockerfile-base`](../server-ce/Dockerfile-base)) bundles a curated
|
||||
scientific stack (numpy, pandas, scipy, matplotlib, seaborn, scikit-learn,
|
||||
sympy, plotly, tabulate). Anything outside that set currently fails the render
|
||||
with `ModuleNotFoundError`.
|
||||
|
||||
As a first step that already shipped, the Quarto log parser
|
||||
([`quarto-log-parser.ts`](../services/web/frontend/js/ide/log-parser/quarto-log-parser.ts))
|
||||
turns a missing-package traceback into an actionable message. This document is
|
||||
the *next* step: letting a project declare and install its own dependencies.
|
||||
|
||||
**Key constraint:** the instance runs with anonymous read+write enabled
|
||||
(`OVERLEAF_ALLOW_ANONYMOUS_READ_AND_WRITE_SHARING=true`), so compiles can be
|
||||
triggered by untrusted users. Installing arbitrary packages is therefore a
|
||||
security decision, not just a convenience.
|
||||
|
||||
## Mechanism
|
||||
|
||||
1. **Declaration.** A standard `requirements.vrf` at the project root opts the
|
||||
project in (familiar, Quarto-agnostic, supports version pinning).
|
||||
2. **Keying.** CLSI hashes `sha256(requirements.vrf + python version)`. The hash
|
||||
names a venv directory on a **persistent volume**, e.g.
|
||||
`…/data/python-venvs/<hash>/`. Identical dependency sets share one venv across
|
||||
projects and compiles.
|
||||
3. **Build-if-missing.** `python3 -m venv --system-site-packages <dir>` (so the
|
||||
bundled stack stays visible and only the *extra* deps are installed — smaller
|
||||
and faster), then `<dir>/bin/pip install -r requirements.vrf`. Guard with a
|
||||
per-hash `flock` so concurrent compiles don't build the same venv twice.
|
||||
4. **Point Quarto at it.** Set `QUARTO_PYTHON=<dir>/bin/python3` in the render
|
||||
environment (threaded web → CLSI exactly like `exportMode`). With
|
||||
`--system-site-packages`, `ipykernel` from the base is importable, so the
|
||||
kernel runs in that interpreter with base + project packages.
|
||||
|
||||
## Guard rails
|
||||
|
||||
- **Auth gating.** Only run the install path for **logged-in owner/collaborator**
|
||||
compiles. Anonymous-link compiles use the plain base interpreter and never
|
||||
trigger installs. Web decides and passes a boolean to CLSI; default-deny.
|
||||
- **Network egress.** The compile environment must reach PyPI to install.
|
||||
Restrict egress to PyPI / an internal mirror only (k8s NetworkPolicy + pip
|
||||
`--index-url`), not arbitrary hosts.
|
||||
- **Resource caps.** Install timeout, venv size cap, max package count; surface
|
||||
overruns as a clear log error.
|
||||
- **Trust boundary.** Even gated, a trusted user installing packages is
|
||||
arbitrary code execution in the sandbox. Containment stays the CLSI container
|
||||
+ resource limits + egress policy. This is owner-trust-level by design.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- **Eviction.** `touch` the venv on use; an LRU cleanup job prunes the oldest
|
||||
venvs when the volume exceeds a size budget.
|
||||
- **Failure UX.** pip errors flow into the log panel (reusing the friendly-error
|
||||
pattern) showing pip's output.
|
||||
|
||||
## Rollout
|
||||
|
||||
- **Phase 1.** Detection + `flock` venv build + `QUARTO_PYTHON`, behind a
|
||||
settings flag (default **off**), gated to logged-in owner, dev volume.
|
||||
- **Phase 2.** Egress NetworkPolicy + index pinning + eviction job.
|
||||
- **Phase 3.** Nicer pip-error surfacing + a small project-settings UI
|
||||
affordance.
|
||||
|
||||
## Open decisions
|
||||
|
||||
- `requirements.vrf` vs a frontmatter field vs both?
|
||||
- Shared global venv volume vs per-user namespacing (sharing is cheaper;
|
||||
per-user is stricter isolation)?
|
||||
- Allow native/compiled wheels (broader support) vs wheels-only/no-build
|
||||
(tighter security)?
|
||||
@@ -3,7 +3,8 @@
|
||||
# Overleaf Community Edition (overleaf/overleaf)
|
||||
# ---------------------------------------------
|
||||
|
||||
ARG OVERLEAF_BASE_TAG=sharelatex/sharelatex-base:latest
|
||||
#ARG OVERLEAF_BASE_TAG=sharelatex/sharelatex-base:latest
|
||||
ARG OVERLEAF_BASE_TAG=sharelatex/sharelatex-base:5
|
||||
FROM $OVERLEAF_BASE_TAG
|
||||
|
||||
WORKDIR /overleaf
|
||||
@@ -18,26 +19,55 @@ COPY server-ce/genScript.js server-ce/services.js /overleaf/
|
||||
# Corepack setup, shared between all the images.
|
||||
ENV PATH="/overleaf/node_modules/.bin:$PATH"
|
||||
ENV COREPACK_HOME=/opt/corepack
|
||||
RUN corepack enable && corepack install -g yarn@4.14.1
|
||||
#RUN corepack enable && corepack install -g yarn@4.14.1
|
||||
RUN corepack enable && corepack prepare yarn@4.14.1 --activate
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
# Install yarn dependencies
|
||||
# -------------------------
|
||||
# The git-sourced @replit/codemirror-* deps are prepared with Yarn Classic,
|
||||
# whose cache lives in /usr/local/share/.cache/yarn. We mount that as a *tmpfs*
|
||||
# (fresh every build) rather than a persistent BuildKit cache: when it was
|
||||
# persistent, BuildKit would garbage-collect/evict part of it between builds,
|
||||
# leaving a half-populated cache that Yarn Classic then tripped over (missing
|
||||
# .yarn-tarball.tgz / EEXIST). A clean cache per build is reliable; the cost is
|
||||
# re-fetching that small set of git deps. The valuable Berry cache
|
||||
# (server-ce-yarn-cache) stays persistent. YARN_NETWORK_CONCURRENCY=1 is kept
|
||||
# as cheap insurance against concurrent writes to the fresh cache.
|
||||
#
|
||||
# Preparing those git deps also makes Yarn Classic fetch esbuild's ~10
|
||||
# per-platform binaries, whose downloads occasionally arrive truncated ("the
|
||||
# file appears to be corrupt" / missing .yarn-tarball.tgz). Since the tmpfs is
|
||||
# fresh each build there is nothing to fall back to, so we wrap the step in a
|
||||
# small retry loop that wipes the classic cache and re-fetches before failing.
|
||||
RUN --mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
||||
--mount=type=cache,target=/usr/local/share/.cache/yarn,id=server-ce-yarn-fallback-cache \
|
||||
--mount=type=tmpfs,target=/tmp node genScript install | bash
|
||||
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
for i in 1 2 3; do \
|
||||
node genScript install | YARN_NETWORK_CONCURRENCY=1 bash && exit 0; \
|
||||
echo "==== install attempt $i failed; wiping Yarn Classic cache and retrying ===="; \
|
||||
rm -rf /usr/local/share/.cache/yarn/* 2>/dev/null || true; \
|
||||
done; \
|
||||
exit 1
|
||||
|
||||
# Add the actual source files
|
||||
# ---------------------------
|
||||
COPY --parents libraries/ services/ tools/migrations/ /overleaf/
|
||||
RUN --mount=type=cache,target=/root/.cache \
|
||||
--mount=type=cache,target=/root/.yarn/berry/cache,id=server-ce-yarn-cache \
|
||||
--mount=type=cache,target=/usr/local/share/.cache/yarn,id=server-ce-yarn-fallback-cache \
|
||||
--mount=type=tmpfs,target=/usr/local/share/.cache/yarn \
|
||||
--mount=type=cache,target=/overleaf/services/web/node_modules/.cache,id=server-ce-webpack-cache \
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
node genScript compile | bash
|
||||
|
||||
for i in 1 2 3; do \
|
||||
node genScript compile | YARN_NETWORK_CONCURRENCY=1 bash && exit 0; \
|
||||
echo "==== compile attempt $i failed; wiping Yarn Classic cache and retrying ===="; \
|
||||
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
|
||||
rm -rf /usr/local/share/.cache/yarn/* 2>/dev/null || true; \
|
||||
done; \
|
||||
echo "==== PACK LOGS (all attempts failed) ===="; \
|
||||
find /tmp -name pack.log -exec cat {} \; 2>/dev/null || true; \
|
||||
exit 1
|
||||
# Copy runit service startup scripts to its location
|
||||
# --------------------------------------------------
|
||||
ADD server-ce/runit /etc/service
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
FROM phusion/baseimage:noble-1.0.3
|
||||
|
||||
# Makes sure LuaTex cache is writable
|
||||
# Makes sure LuaTeX cache is writable
|
||||
# -----------------------------------
|
||||
ENV TEXMFVAR=/var/lib/overleaf/tmp/texmf-var
|
||||
|
||||
@@ -39,44 +39,118 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
/etc/nginx/nginx.conf \
|
||||
/etc/nginx/sites-enabled/default
|
||||
|
||||
# Install TexLive
|
||||
# ---------------
|
||||
# CTAN mirrors occasionally fail, in that case install TexLive using a
|
||||
# different server, for example https://ctan.crest.fr
|
||||
# Install Quarto (bundles Typst for PDF rendering — no LaTeX needed)
|
||||
# ------------------------------------------------------------------
|
||||
ARG QUARTO_VERSION=1.6.39
|
||||
RUN curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" -o /tmp/quarto.deb \
|
||||
&& dpkg -i /tmp/quarto.deb \
|
||||
&& rm /tmp/quarto.deb \
|
||||
&& mkdir -p /var/www/.cache/quarto /var/www/.local/share \
|
||||
&& chown -R www-data:www-data /var/www/.cache /var/www/.local
|
||||
|
||||
# Pre-install popular Quarto extensions
|
||||
# -----------------------------------------------------------------------
|
||||
# Extensions land in /opt/quarto-extensions/_extensions/<author>/<name>/.
|
||||
# QuartoRunner copies them into each project's compile dir (no-clobber,
|
||||
# so user-uploaded extensions in their project always take precedence).
|
||||
# To add more: append another line: && quarto add --no-prompt <author>/<repo>
|
||||
# -----------------------------------------------------------------------
|
||||
RUN mkdir -p /opt/quarto-extensions \
|
||||
&& cd /opt/quarto-extensions \
|
||||
\
|
||||
# Typst document formats
|
||||
&& quarto add --no-prompt igorlima/charged-ieee \
|
||||
\
|
||||
# RevealJS presentation plugins (official Quarto extensions)
|
||||
&& quarto add --no-prompt quarto-ext/fontawesome \
|
||||
&& quarto add --no-prompt quarto-ext/attribution \
|
||||
&& quarto add --no-prompt quarto-ext/pointer \
|
||||
&& quarto add --no-prompt quarto-ext/drop \
|
||||
\
|
||||
&& chown -R www-data:www-data /opt/quarto-extensions
|
||||
|
||||
# Install Jupyter so Quarto can execute Python code cells in documents/decks
|
||||
# -----------------------------------------------------------------------
|
||||
# Quarto runs ```{python}``` cells through a Jupyter kernel. It uses the system
|
||||
# python3 it detected (/usr/bin/python3), so Jupyter must be installed there.
|
||||
# We install only the headless execution stack Quarto needs (jupyter-client +
|
||||
# nbclient/nbformat + the ipykernel kernel + pyyaml, which Quarto's own
|
||||
# /opt/quarto/share/jupyter wrapper imports), not the notebook/lab servers, and
|
||||
# register a system-wide "python3" kernelspec under /usr/local/share/jupyter so
|
||||
# it is discoverable regardless of HOME/XDG. Noble's Python is externally
|
||||
# managed (PEP 668), hence --break-system-packages in this controlled image.
|
||||
# The runtime user (www-data) writes Jupyter's runtime/connection files under
|
||||
# its HOME (/var/www/.local), which is made writable in the Quarto step above.
|
||||
# python3-venv is needed so a project's requirements.txt can be installed into
|
||||
# a per-project venv (see QuartoRunner / PythonVenvGate).
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y python3-pip python3-venv \
|
||||
&& pip3 install --no-cache-dir --break-system-packages \
|
||||
jupyter-core jupyter-client nbclient nbformat ipykernel pyyaml \
|
||||
&& python3 -m ipykernel install --prefix /usr/local --name python3 --display-name "Python 3" \
|
||||
# Bundle the common scientific-Python stack so most decks "just work" without
|
||||
# any per-project install. matplotlib renders headless (Agg) automatically;
|
||||
# opencv-python-headless is the GUI-less OpenCV build (provides cv2) suited to
|
||||
# a server. To add more later, append to this list (the cheapest way to cover
|
||||
# a library many projects need).
|
||||
&& pip3 install --no-cache-dir --break-system-packages \
|
||||
numpy pandas scipy matplotlib seaborn scikit-learn sympy plotly tabulate \
|
||||
opencv-python-headless tqdm \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.cache
|
||||
|
||||
# Install decktape + headless Chromium (for exporting RevealJS decks to PDF)
|
||||
# -----------------------------------------------------------------------
|
||||
# decktape drives a headless Chromium (via Puppeteer) to print the rendered
|
||||
# reveal.js slides to a faithful, one-slide-per-page PDF. Chromium is the
|
||||
# open-source engine (BSD); decktape is MIT, Puppeteer Apache-2.0 — all
|
||||
# permissive and AGPL-compatible. They are invoked as a separate process
|
||||
# (QuartoRunner runs `decktape ...`), never linked into the app.
|
||||
#
|
||||
# # docker build \
|
||||
# --build-arg TEXLIVE_MIRROR=https://ctan.crest.fr/tex-archive/systems/texlive/tlnet \
|
||||
# -f Dockerfile-base -t sharelatex/sharelatex-base .
|
||||
# Puppeteer downloads its Chromium into PUPPETEER_CACHE_DIR during the global
|
||||
# install; we put it in a world-readable /opt path so the www-data runtime user
|
||||
# can launch it. Playwright is used only as a robust, distro-aware installer for
|
||||
# Chromium's system libraries (handles Ubuntu Noble's t64 package renames).
|
||||
ENV PUPPETEER_CACHE_DIR=/opt/puppeteer
|
||||
RUN npm install -g decktape \
|
||||
&& npx --yes playwright@latest install-deps chromium \
|
||||
&& chmod -R a+rX /opt/puppeteer \
|
||||
&& rm -rf /root/.npm /root/.cache
|
||||
|
||||
# Install TeX Live (for compiling .tex projects with latexmk)
|
||||
# -----------------------------------------------------------------------
|
||||
# Verso compiles .qmd with Quarto and .tex with latexmk; both engines live
|
||||
# side by side.
|
||||
#
|
||||
# MINIMAL install (current): the upstream-Overleaf approach — scheme-basic
|
||||
# (~300 MB) plus a few essential packages via tlmgr. Fast to build and small.
|
||||
# Many documents that need extra packages (tikz, beamer, siunitx, extra
|
||||
# fonts, ...) will NOT compile out of the box; users can be told to keep
|
||||
# those projects in Quarto/Typst for now.
|
||||
#
|
||||
# TO GO FULL LATER (when the project is mature): change
|
||||
# selected_scheme scheme-basic -> scheme-full
|
||||
# and optionally drop the explicit `tlmgr install` line. That single change
|
||||
# restores a complete LaTeX toolchain at the cost of size/build time.
|
||||
# Alternatively add individual packages to the `tlmgr install` list below.
|
||||
# -----------------------------------------------------------------------
|
||||
ARG TEXLIVE_MIRROR=https://mirror.ox.ac.uk/sites/ctan.org/systems/texlive/tlnet
|
||||
ENV PATH="${PATH}:/usr/local/texlive/bin/x86_64-linux"
|
||||
|
||||
RUN mkdir /install-tl-unx \
|
||||
&& wget --quiet https://tug.org/texlive/files/texlive.asc \
|
||||
&& gpg --import texlive.asc \
|
||||
&& rm texlive.asc \
|
||||
&& wget --quiet ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz \
|
||||
&& wget --quiet ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz.sha512 \
|
||||
&& wget --quiet ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz.sha512.asc \
|
||||
&& gpg --verify install-tl-unx.tar.gz.sha512.asc \
|
||||
&& sha512sum -c install-tl-unx.tar.gz.sha512 \
|
||||
&& tar -xz -C /install-tl-unx --strip-components=1 -f install-tl-unx.tar.gz \
|
||||
&& rm install-tl-unx.tar.gz* \
|
||||
&& echo "tlpdbopt_autobackup 0" >> /install-tl-unx/texlive.profile \
|
||||
&& curl -sSL ${TEXLIVE_MIRROR}/install-tl-unx.tar.gz \
|
||||
| tar -xzC /install-tl-unx --strip-components=1 \
|
||||
&& echo "tlpdbopt_autobackup 0" >> /install-tl-unx/texlive.profile \
|
||||
&& echo "tlpdbopt_install_docfiles 0" >> /install-tl-unx/texlive.profile \
|
||||
&& echo "tlpdbopt_install_srcfiles 0" >> /install-tl-unx/texlive.profile \
|
||||
&& echo "selected_scheme scheme-basic" >> /install-tl-unx/texlive.profile \
|
||||
\
|
||||
&& echo "TEXDIR /usr/local/texlive" >> /install-tl-unx/texlive.profile \
|
||||
&& /install-tl-unx/install-tl \
|
||||
-profile /install-tl-unx/texlive.profile \
|
||||
-repository ${TEXLIVE_MIRROR} \
|
||||
\
|
||||
&& $(find /usr/local/texlive -name tlmgr) path add \
|
||||
&& tlmgr install --repository ${TEXLIVE_MIRROR} \
|
||||
&& /usr/local/texlive/bin/x86_64-linux/tlmgr install \
|
||||
--repository ${TEXLIVE_MIRROR} \
|
||||
latexmk \
|
||||
texcount \
|
||||
synctex \
|
||||
etoolbox \
|
||||
xetex \
|
||||
&& tlmgr path add \
|
||||
&& rm -rf /install-tl-unx
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
FROM phusion/baseimage:noble-1.0.2
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gnupg \
|
||||
nginx \
|
||||
logrotate \
|
||||
cron \
|
||||
redis-tools \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js 22
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Needed by Overleaf scripts
|
||||
RUN npm install -g corepack
|
||||
|
||||
# Runit/log dirs expected by Overleaf
|
||||
RUN mkdir -p /etc/service /var/log/overleaf /overleaf
|
||||
|
||||
WORKDIR /overleaf
|
||||
@@ -173,6 +173,12 @@ const settings = {
|
||||
clsiCacheDir: Path.join(DATA_DIR, 'cache'),
|
||||
// Where to write the output files to disk after running LaTeX
|
||||
outputDir: Path.join(DATA_DIR, 'output'),
|
||||
// Where to store published-presentation snapshots served at /p/:token.
|
||||
// Lives on the data volume so it is writable by the app user (and, with a
|
||||
// persistent volume, survives restarts).
|
||||
publishedPresentationsFolder:
|
||||
process.env.PUBLISHED_PRESENTATIONS_PATH ||
|
||||
Path.join(DATA_DIR, 'published'),
|
||||
},
|
||||
|
||||
// Server Config
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# App tier for the prod (verso namespace) instance: the Verso Deployment and
|
||||
# Service. Matches what the deploy workflow applies, except OVERLEAF_NAV_TITLE
|
||||
# is a static "Verso Alpha" here — the workflow overwrites it with the build
|
||||
# number ("Verso V0.<n> Alpha") on each deploy.
|
||||
#
|
||||
# The image registry.alocoq.fr/verso:stable is produced by the prod workflow
|
||||
# (push to the `prod` branch). If you apply this file before the first prod
|
||||
# build, the pod will sit in ImagePullBackOff until that image exists — that's
|
||||
# expected.
|
||||
#
|
||||
# kubectl apply -f server-ce/k8s/verso-prod-app.yaml
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: verso
|
||||
spec:
|
||||
replicas: 1
|
||||
# RWO data volume → can't run two pods at once; recreate on update.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: verso
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: verso
|
||||
spec:
|
||||
securityContext:
|
||||
# App runs as www-data (uid/gid 33); make the data volume
|
||||
# group-writable by it.
|
||||
fsGroup: 33
|
||||
initContainers:
|
||||
- name: init-data-perms
|
||||
image: busybox:latest
|
||||
command: ["sh", "-c"]
|
||||
args:
|
||||
- |
|
||||
set -eux
|
||||
mkdir -p /data/template_files /data/user_files \
|
||||
/data/compiles /data/cache /data/output /data/published
|
||||
chown -R 33:33 /data
|
||||
volumeMounts:
|
||||
- name: verso-data
|
||||
mountPath: /data
|
||||
containers:
|
||||
- name: verso
|
||||
image: registry.alocoq.fr/verso:stable
|
||||
# :stable is a fixed tag, so force a pull on every rollout to pick up
|
||||
# the freshly built image.
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 80
|
||||
env:
|
||||
- name: OVERLEAF_MONGO_URL
|
||||
value: mongodb://mongo:27017/sharelatex?replicaSet=rs0
|
||||
- name: OVERLEAF_REDIS_HOST
|
||||
value: redis
|
||||
- name: REDIS_HOST
|
||||
value: redis
|
||||
- name: OVERLEAF_APP_NAME
|
||||
value: Verso
|
||||
- name: OVERLEAF_NAV_TITLE
|
||||
value: "Verso Alpha"
|
||||
- name: OVERLEAF_SITE_URL
|
||||
value: https://verso.alocoq.fr
|
||||
- name: OVERLEAF_SITE_LANGUAGE
|
||||
value: fr
|
||||
# Allow anonymous visitors so public published-presentation links
|
||||
# and read-only share links work without login.
|
||||
- name: OVERLEAF_ALLOW_PUBLIC_ACCESS
|
||||
value: "true"
|
||||
# NB: anonymous read-AND-write sharing is intentionally NOT enabled
|
||||
# (compiles are unsandboxed → only trusted accounts may trigger
|
||||
# them). Public self-registration is also off (CE default).
|
||||
- name: OVERLEAF_ENABLE_PROJECT_PYTHON_VENV
|
||||
value: "true"
|
||||
# SMTP for password-reset / invite emails. All OVERLEAF_EMAIL_* vars
|
||||
# are loaded from the optional 'verso-smtp' Secret — its keys must be
|
||||
# named exactly like these env vars (see the kubectl create secret
|
||||
# command in the docs). Optional, so the app still boots before the
|
||||
# secret exists; email just stays off.
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: verso-smtp
|
||||
optional: true
|
||||
volumeMounts:
|
||||
- name: verso-data
|
||||
mountPath: /var/lib/overleaf/data
|
||||
volumes:
|
||||
- name: verso-data
|
||||
persistentVolumeClaim:
|
||||
claimName: verso-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: verso
|
||||
spec:
|
||||
selector:
|
||||
app: verso
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 80
|
||||
@@ -0,0 +1,102 @@
|
||||
# Data tier for the prod (verso namespace) instance: Mongo + Redis Deployments
|
||||
# and Services. Identical to what the deploy workflow applies — provided as a
|
||||
# standalone file so you can bootstrap and validate the namespace before the
|
||||
# first prod build (and before granting the runner access).
|
||||
#
|
||||
# Order:
|
||||
# 1. kubectl apply -f server-ce/k8s/verso-prod-pvcs.yaml (with storageClass)
|
||||
# 2. kubectl apply -f server-ce/k8s/verso-prod-data.yaml (this file)
|
||||
# 3. wait for mongo to be Ready, then initialise the replica set ONCE:
|
||||
# kubectl -n verso exec deploy/mongo -- mongosh --quiet --eval \
|
||||
# 'rs.initiate({_id:"rs0",members:[{_id:0,host:"mongo:27017"}]})'
|
||||
# (the workflow also does this idempotently, so it's optional here)
|
||||
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mongo
|
||||
namespace: verso
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: mongo
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: mongo
|
||||
spec:
|
||||
containers:
|
||||
- name: mongo
|
||||
image: mongo:8
|
||||
command: ["mongod", "--replSet", "rs0", "--bind_ip_all"]
|
||||
ports:
|
||||
- containerPort: 27017
|
||||
volumeMounts:
|
||||
- name: mongo-data
|
||||
mountPath: /data/db
|
||||
volumes:
|
||||
- name: mongo-data
|
||||
persistentVolumeClaim:
|
||||
claimName: mongo-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: mongo
|
||||
namespace: verso
|
||||
spec:
|
||||
selector:
|
||||
app: mongo
|
||||
ports:
|
||||
- name: mongo
|
||||
port: 27017
|
||||
targetPort: 27017
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: verso
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: redis
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: redis
|
||||
spec:
|
||||
containers:
|
||||
- name: redis
|
||||
image: redis:7
|
||||
# AOF persistence so a restart doesn't drop in-flight edits before
|
||||
# they're flushed to Mongo.
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
volumeMounts:
|
||||
- name: redis-data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: redis-data
|
||||
persistentVolumeClaim:
|
||||
claimName: redis-data
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: redis
|
||||
namespace: verso
|
||||
spec:
|
||||
selector:
|
||||
app: redis
|
||||
ports:
|
||||
- name: redis
|
||||
port: 6379
|
||||
targetPort: 6379
|
||||
@@ -0,0 +1,44 @@
|
||||
# Example Ingress for the prod (verso namespace) instance at verso.alocoq.fr.
|
||||
#
|
||||
# This is NOT applied by the deploy workflow on purpose: the test ingress is
|
||||
# managed by hand, and TLS/annotations depend on your cluster's ingress
|
||||
# controller (Traefik) and cert setup. Copy this, adapt it to match how
|
||||
# test.alocoq.fr is wired, then `kubectl apply -f` it once.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - DNS: verso.alocoq.fr → your ingress/load-balancer IP.
|
||||
# - A TLS cert for verso.alocoq.fr (cert-manager, or a manually created
|
||||
# Secret referenced under tls.secretName).
|
||||
#
|
||||
# Adjust:
|
||||
# - ingressClassName (e.g. "traefik") to match your controller.
|
||||
# - annotations (cert-manager issuer, Traefik entrypoints/router, etc.) to
|
||||
# match the test ingress.
|
||||
# - the TLS block (cert-manager will create the secret; otherwise create it).
|
||||
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: verso
|
||||
namespace: verso
|
||||
annotations:
|
||||
# --- adapt these to match your test.alocoq.fr ingress ---
|
||||
# cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
# traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- verso.alocoq.fr
|
||||
secretName: verso-tls
|
||||
rules:
|
||||
- host: verso.alocoq.fr
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: verso
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,49 @@
|
||||
# PersistentVolumeClaims for the prod (verso namespace) instance.
|
||||
#
|
||||
# Provisioned out of band (not by the deploy workflow) so the storageClass is
|
||||
# under your control. Create them ONCE, before the first prod deploy:
|
||||
#
|
||||
# kubectl apply -f server-ce/k8s/verso-prod-pvcs.yaml
|
||||
#
|
||||
# Use a Ceph RBD (block) storageClass for all three — every volume here is
|
||||
# single-writer ReadWriteOnce (Mongo, Redis, and the single app pod). Set
|
||||
# storageClassName below to your RBD class (run `kubectl get storageclass` to
|
||||
# find its name). Sizes are starting points; RBD supports online expansion.
|
||||
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: mongo-data
|
||||
namespace: verso
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
# storageClassName: ceph-rbd # <- set to your RBD (block) storageClass
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: redis-data
|
||||
namespace: verso
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
# storageClassName: ceph-rbd # <- set to your RBD (block) storageClass
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: verso-data
|
||||
namespace: verso
|
||||
# verso-data is mounted at /var/lib/overleaf/data: user files, compiles, output
|
||||
# cache, and published-presentation snapshots.
|
||||
spec:
|
||||
accessModes: [ReadWriteOnce]
|
||||
# storageClassName: ceph-rbd # <- set to your RBD (block) storageClass
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
@@ -20,13 +20,28 @@ server {
|
||||
access_log off;
|
||||
# Ignore symlinks possibly created by users
|
||||
disable_symlinks on;
|
||||
# enable compression for tex auxiliary files, but not for pdf files
|
||||
# enable compression for text-based output: tex auxiliary files, logs, and
|
||||
# HTML/CSS/JS (RevealJS presentations). Already-compressed formats (pdf,
|
||||
# png/jpeg/webp, woff/woff2) are deliberately omitted to avoid wasting CPU.
|
||||
gzip on;
|
||||
gzip_types text/plain;
|
||||
gzip_types text/plain text/html text/css application/javascript application/json image/svg+xml;
|
||||
gzip_proxied any;
|
||||
# only compress responses worth compressing
|
||||
gzip_min_length 1024;
|
||||
types {
|
||||
text/plain log blg aux stdout stderr;
|
||||
application/pdf pdf;
|
||||
text/html html htm;
|
||||
text/css css;
|
||||
application/javascript js;
|
||||
application/json json;
|
||||
image/svg+xml svg svgz;
|
||||
image/png png;
|
||||
image/jpeg jpeg jpg;
|
||||
image/gif gif;
|
||||
image/webp webp;
|
||||
font/woff woff;
|
||||
font/woff2 woff2;
|
||||
application/pdf pdf;
|
||||
text/plain log blg aux stdout stderr txt;
|
||||
}
|
||||
# handle output files for specific users
|
||||
location ~ ^/project/([0-9a-f]+)/user/([0-9a-f]+)/build/([0-9a-f-]+)/output/(.+)$ {
|
||||
|
||||
@@ -46,7 +46,7 @@ http {
|
||||
gzip_disable "msie6";
|
||||
gzip_proxied any; # allow upstream server to compress.
|
||||
|
||||
client_max_body_size 50m;
|
||||
client_max_body_size 500m;
|
||||
|
||||
# gzip_vary on;
|
||||
# gzip_proxied any;
|
||||
|
||||
@@ -47,11 +47,14 @@ COPY libraries/settings/ /overleaf/libraries/settings/
|
||||
COPY libraries/stream-utils/ /overleaf/libraries/stream-utils/
|
||||
COPY services/clsi/ /overleaf/services/clsi/
|
||||
|
||||
FROM app AS with-texlive
|
||||
FROM app AS with-quarto
|
||||
|
||||
ARG QUARTO_VERSION=1.6.39
|
||||
RUN apt-get update \
|
||||
&& apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends fontconfig inkscape python3-pygments qpdf \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" -o /tmp/quarto.deb \
|
||||
&& dpkg -i /tmp/quarto.deb \
|
||||
&& rm /tmp/quarto.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p cache compiles output \
|
||||
@@ -60,6 +63,15 @@ RUN mkdir -p cache compiles output \
|
||||
CMD ["node", "--expose-gc", "app.js"]
|
||||
|
||||
FROM app
|
||||
|
||||
ARG QUARTO_VERSION=1.6.39
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& curl -fsSL "https://github.com/quarto-dev/quarto-cli/releases/download/v${QUARTO_VERSION}/quarto-${QUARTO_VERSION}-linux-amd64.deb" -o /tmp/quarto.deb \
|
||||
&& dpkg -i /tmp/quarto.deb \
|
||||
&& rm /tmp/quarto.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN mkdir -p cache compiles output \
|
||||
&& chown node:node cache compiles output
|
||||
|
||||
|
||||
@@ -90,7 +90,9 @@ function compile(req, res, next) {
|
||||
} else {
|
||||
if (
|
||||
outputFiles.some(
|
||||
file => file.path === 'output.pdf' && file.size > 0
|
||||
file =>
|
||||
(file.path === 'output.pdf' && file.size > 0) ||
|
||||
file.path === 'output.html'
|
||||
)
|
||||
) {
|
||||
status = 'success'
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import Path from 'node:path'
|
||||
import { callbackify } from 'node:util'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import OError from '@overleaf/o-error'
|
||||
import ResourceWriter from './ResourceWriter.js'
|
||||
import QuartoRunner from './QuartoRunner.js'
|
||||
import LatexRunner from './LatexRunner.js'
|
||||
import TypstRunner from './TypstRunner.js'
|
||||
import OutputFileFinder from './OutputFileFinder.js'
|
||||
import OutputCacheManager from './OutputCacheManager.js'
|
||||
import ClsiMetrics from './Metrics.js'
|
||||
@@ -18,16 +19,12 @@ import CommandRunner from './CommandRunner.js'
|
||||
import ContentCacheMetrics from './ContentCacheMetrics.js'
|
||||
import SynctexOutputParser from './SynctexOutputParser.js'
|
||||
import CLSICacheHandler from './CLSICacheHandler.js'
|
||||
import StatsManager from './StatsManager.js'
|
||||
import SafeReader from './SafeReader.js'
|
||||
import LatexMetrics from './LatexMetrics.js'
|
||||
import { callbackifyMultiResult } from '@overleaf/promise-utils'
|
||||
import * as HistoryResourceWriter from './HistoryResourceWriter.js'
|
||||
|
||||
const { downloadLatestCompileCache, downloadOutputDotSynctexFromCompileCache } =
|
||||
CLSICacheHandler
|
||||
const { emitPdfStats } = ContentCacheMetrics
|
||||
const { enableLatexMkMetrics, addLatexFdbMetrics } = LatexMetrics
|
||||
const { shouldSkipMetrics } = ClsiMetrics
|
||||
|
||||
const KNOWN_LATEXMK_RULES = new Set([
|
||||
@@ -44,6 +41,43 @@ const KNOWN_LATEXMK_RULES = new Set([
|
||||
|
||||
const LATEX_PASSES_RULES = new Set(['latex', 'lualatex', 'xelatex', 'pdflatex'])
|
||||
|
||||
// Quarto handles .qmd/.md/.Rmd sources; everything else (.tex, .ltx, .Rtex,
|
||||
// .Rnw) is compiled with latexmk via LatexRunner. Dispatch is by the root
|
||||
// file's extension, so LaTeX and Quarto projects can coexist on one server.
|
||||
function _isQuartoFile(rootResourcePath) {
|
||||
return /\.(qmd|md|rmd)$/i.test(rootResourcePath || '')
|
||||
}
|
||||
|
||||
// A bare Typst source (.typ) compiles straight to PDF with the Typst that
|
||||
// ships inside Quarto (see TypstRunner), separate from the Quarto pipeline.
|
||||
function _isTypstFile(rootResourcePath) {
|
||||
return /\.typ$/i.test(rootResourcePath || '')
|
||||
}
|
||||
|
||||
// Return a runner with a uniform { run, isRunning, kill } interface so the
|
||||
// rest of CompileManager doesn't need to know which engine is in use.
|
||||
function _getRunner(rootResourcePath) {
|
||||
if (_isTypstFile(rootResourcePath)) {
|
||||
return {
|
||||
run: (name, opts) => TypstRunner.promises.runTypst(name, opts),
|
||||
isRunning: name => TypstRunner.isRunning(name),
|
||||
kill: name => TypstRunner.promises.killTypst(name),
|
||||
}
|
||||
}
|
||||
if (_isQuartoFile(rootResourcePath)) {
|
||||
return {
|
||||
run: (name, opts) => QuartoRunner.promises.runQuarto(name, opts),
|
||||
isRunning: name => QuartoRunner.isRunning(name),
|
||||
kill: name => QuartoRunner.promises.killQuarto(name),
|
||||
}
|
||||
}
|
||||
return {
|
||||
run: (name, opts) => LatexRunner.promises.runLatex(name, opts),
|
||||
isRunning: name => LatexRunner.isRunning(name),
|
||||
kill: name => LatexRunner.promises.killLatex(name),
|
||||
}
|
||||
}
|
||||
|
||||
function getCompileName(projectId, userId) {
|
||||
if (userId != null) {
|
||||
return `${projectId}-${userId}`
|
||||
@@ -124,7 +158,11 @@ async function doCompile(request, stats, timings) {
|
||||
)
|
||||
|
||||
// apply a series of file modifications/creations for draft mode and tikz
|
||||
if (request.draft) {
|
||||
// Draft mode injects LaTeX preamble commands — skip for Quarto files
|
||||
const isLatexFile = /\.(tex|ltx|Rtex)$/i.test(
|
||||
request.rootResourcePath || ''
|
||||
)
|
||||
if (request.draft && isLatexFile) {
|
||||
await DraftModeManager.promises.injectDraftMode(
|
||||
Path.join(compileDir, request.rootResourcePath)
|
||||
)
|
||||
@@ -196,18 +234,10 @@ async function doCompile(request, stats, timings) {
|
||||
|
||||
const compileName = getCompileName(request.project_id, request.user_id)
|
||||
|
||||
// Record latexmk -time stats for a subset of users
|
||||
const recordPerformanceMetrics = StatsManager.sampleRequest(
|
||||
request,
|
||||
Settings.performanceLogSamplingPercentage
|
||||
)
|
||||
|
||||
// Define a `latexmk` property on the stats object
|
||||
// to collect latexmk -time stats.
|
||||
enableLatexMkMetrics(stats)
|
||||
const runner = _getRunner(request.rootResourcePath)
|
||||
|
||||
try {
|
||||
await LatexRunner.promises.runLatex(compileName, {
|
||||
await runner.run(compileName, {
|
||||
directory: compileDir,
|
||||
mainFile: request.rootResourcePath,
|
||||
compiler: request.compiler,
|
||||
@@ -217,6 +247,8 @@ async function doCompile(request, stats, timings) {
|
||||
environment: env,
|
||||
compileGroup: request.compileGroup,
|
||||
stopOnFirstError: request.stopOnFirstError,
|
||||
exportMode: request.exportMode,
|
||||
allowPythonInstall: request.allowPythonInstall,
|
||||
stats,
|
||||
timings,
|
||||
})
|
||||
@@ -294,50 +326,13 @@ async function doCompile(request, stats, timings) {
|
||||
})
|
||||
timings.compileE2E = Date.now() - e2eCompileStart
|
||||
|
||||
const status = stats['latexmk-errors'] ? 'error' : 'success'
|
||||
const status = 'success'
|
||||
_emitMetrics(request, status, stats, timings)
|
||||
|
||||
if (stats['pdf-size'] && !shouldSkipMetrics(request)) {
|
||||
emitPdfStats(stats, timings, request)
|
||||
}
|
||||
|
||||
// Record compile performance for a subset of users
|
||||
if (recordPerformanceMetrics) {
|
||||
// Add fdb metrics if available
|
||||
try {
|
||||
const fdbFileContent = await _readFdbFile(compileDir)
|
||||
if (fdbFileContent) {
|
||||
addLatexFdbMetrics(fdbFileContent, stats)
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore errors reading fdb file
|
||||
logger.warn(
|
||||
{ err, projectId, userId },
|
||||
'error reading fdb file for performance metrics'
|
||||
)
|
||||
}
|
||||
|
||||
const loadavg = typeof os.loadavg === 'function' ? os.loadavg() : undefined
|
||||
|
||||
logger.info(
|
||||
{
|
||||
userId: request.user_id,
|
||||
projectId: request.project_id,
|
||||
timeTaken: timings.compile,
|
||||
clsiRequest: request,
|
||||
stats,
|
||||
timings,
|
||||
// explicitly include latexmk stats to bypass the non-enumerable property
|
||||
latexmk: stats.latexmk,
|
||||
loadavg1m: loadavg?.[0],
|
||||
loadavg5m: loadavg?.[1],
|
||||
loadavg15m: loadavg?.[2],
|
||||
samplingPercentage: Settings.performanceLogSamplingPercentage,
|
||||
},
|
||||
'sampled performance log'
|
||||
)
|
||||
}
|
||||
|
||||
return { outputFiles, buildId, baseHistoryVersion }
|
||||
}
|
||||
|
||||
@@ -366,33 +361,27 @@ async function _saveOutputFiles({
|
||||
return { outputFiles, allEntries, buildId }
|
||||
}
|
||||
|
||||
// Set a maximum size for reading output.fdb_latexmk files
|
||||
// This limit is chosen to prevent excessive memory usage and ensure performance,
|
||||
// as fdb files are typically much smaller and only metrics are extracted from them.
|
||||
const MAX_FDB_FILE_SIZE = 1024 * 1024 // 1 MB
|
||||
|
||||
async function _readFdbFile(compileDir) {
|
||||
const fdbFile = Path.join(compileDir, 'output.fdb_latexmk')
|
||||
const { result } = await SafeReader.promises.readFile(
|
||||
fdbFile,
|
||||
MAX_FDB_FILE_SIZE,
|
||||
'utf8'
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
async function stopCompile(projectId, userId) {
|
||||
const compileName = getCompileName(projectId, userId)
|
||||
// stopCompile has no root path, so check both runners — only one can be
|
||||
// active for a given compileName at a time.
|
||||
const isRunning =
|
||||
QuartoRunner.isRunning(compileName) ||
|
||||
LatexRunner.isRunning(compileName) ||
|
||||
TypstRunner.isRunning(compileName)
|
||||
const lock = LockManager.getExistingLock(getCompileDir(projectId, userId))
|
||||
let lockReleased
|
||||
if (lock) {
|
||||
lockReleased = lock.waitForRelease()
|
||||
} else {
|
||||
if (!LatexRunner.isRunning(compileName)) return
|
||||
if (!isRunning) return
|
||||
logger.warn({ projectId, userId }, 'found running compile without lock')
|
||||
lockReleased = Promise.resolve()
|
||||
}
|
||||
await QuartoRunner.promises.killQuarto(compileName)
|
||||
await LatexRunner.promises.killLatex(compileName)
|
||||
await TypstRunner.promises.killTypst(compileName)
|
||||
await lockReleased
|
||||
}
|
||||
|
||||
|
||||
@@ -197,13 +197,13 @@ function _buildLatexCommand(mainFile, opts = {}) {
|
||||
command.push(...opts.flags)
|
||||
}
|
||||
|
||||
// TeX Engine selection
|
||||
const compilerFlag = COMPILER_FLAGS[opts.compiler]
|
||||
if (compilerFlag) {
|
||||
command.push(compilerFlag)
|
||||
} else {
|
||||
throw new Error(`unknown compiler: ${opts.compiler}`)
|
||||
}
|
||||
// TeX Engine selection. A .tex project may carry a non-LaTeX compiler value
|
||||
// (e.g. 'quarto', the fork-wide default for Project.compiler) because the
|
||||
// runner is chosen by file extension, not by this setting. In that case fall
|
||||
// back to pdfLaTeX rather than throwing — throwing here surfaces as an opaque
|
||||
// HTTP 500 with no compile log.
|
||||
const compilerFlag = COMPILER_FLAGS[opts.compiler] || COMPILER_FLAGS.pdflatex
|
||||
command.push(compilerFlag)
|
||||
|
||||
// We want to run latexmk on the tex file which we will automatically
|
||||
// generate from the Rtex/Rmd/md file.
|
||||
|
||||
@@ -89,9 +89,10 @@ export default CommandRunner = {
|
||||
err.terminated = true
|
||||
return callback(err)
|
||||
} else if (code === 1) {
|
||||
// exit status from chktex
|
||||
// exit status from chktex (and any compiler that exits 1 on failure)
|
||||
err = new Error('exited')
|
||||
err.code = code
|
||||
err.stdout = stdout // preserve captured output for callers
|
||||
return callback(err)
|
||||
} else {
|
||||
return callback(null, { stdout, exitCode: code })
|
||||
|
||||
@@ -20,16 +20,29 @@ async function walkFolder(compileDir, d, files, allEntries) {
|
||||
}
|
||||
}
|
||||
|
||||
// Media that an HTML/RevealJS deck references at runtime (img/video/audio).
|
||||
// These are usually project *input* files, which would normally be excluded
|
||||
// from the output set — but for HTML output the browser fetches them from the
|
||||
// output path, so they must be served. (For PDF output they are embedded, so
|
||||
// the exclusion still applies.)
|
||||
const MEDIA_REGEX =
|
||||
/\.(png|jpe?g|gif|svg|webp|avif|bmp|ico|mp4|webm|ogg|ogv|mov|m4v|mp3|wav|m4a|woff2?|ttf|otf)$/i
|
||||
|
||||
async function findOutputFiles(resources, directory) {
|
||||
const files = []
|
||||
const allEntries = []
|
||||
await walkFolder(directory, '', files, allEntries)
|
||||
|
||||
const incomingResources = new Set(resources.map(resource => resource.path))
|
||||
// For HTML output (Quarto/RevealJS), referenced media must be served even
|
||||
// though it is an input file; see MEDIA_REGEX above.
|
||||
const hasHtmlOutput = files.includes('output.html')
|
||||
|
||||
const outputFiles = []
|
||||
for (const path of files) {
|
||||
if (incomingResources.has(path)) continue
|
||||
if (incomingResources.has(path)) {
|
||||
if (!(hasHtmlOutput && MEDIA_REGEX.test(path))) continue
|
||||
}
|
||||
if (path === '.project-sync-state') continue
|
||||
outputFiles.push({
|
||||
path,
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import Path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import logger from '@overleaf/logger'
|
||||
import CommandRunner from './CommandRunner.js'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// Maps currently-running Quarto jobs: compileName → PID (or docker container id)
|
||||
const ProcessTable = {}
|
||||
|
||||
function runQuarto(compileName, options, callback) {
|
||||
const { directory, mainFile, image, environment, compileGroup } = options
|
||||
const timeout = options.timeout || 60000
|
||||
|
||||
logger.debug(
|
||||
{ directory, timeout, mainFile, compileGroup },
|
||||
'starting quarto compile'
|
||||
)
|
||||
|
||||
// For the standalone-HTML export we must render a deck whose frontmatter
|
||||
// carries embed-resources (it cannot be set from the CLI: Quarto only honours
|
||||
// embed-resources when it is nested under the format, and a document's own
|
||||
// format block fully overrides project/CLI metadata). So write a temporary
|
||||
// copy of the root .qmd with the options injected and render that instead.
|
||||
let renderTarget = mainFile
|
||||
if (options.exportMode === 'html-standalone') {
|
||||
renderTarget = _writeStandaloneVariant(directory, mainFile)
|
||||
}
|
||||
|
||||
// Where cached per-project venvs live (shared across projects, keyed by the
|
||||
// requirements.vrf hash). Must be on a persistent volume in production.
|
||||
const venvBaseDir =
|
||||
process.env.PYTHON_VENVS_DIR || '/var/lib/overleaf/data/python-venvs'
|
||||
const command = _buildQuartoCommand(
|
||||
renderTarget,
|
||||
options.exportMode,
|
||||
Boolean(options.allowPythonInstall),
|
||||
venvBaseDir
|
||||
)
|
||||
|
||||
ProcessTable[compileName] = CommandRunner.run(
|
||||
compileName,
|
||||
command,
|
||||
directory,
|
||||
image,
|
||||
timeout,
|
||||
environment || {},
|
||||
compileGroup,
|
||||
null,
|
||||
function (error, output) {
|
||||
delete ProcessTable[compileName]
|
||||
|
||||
// Propagate real process-level errors (killed, timed out) but NOT
|
||||
// ordinary non-zero exit codes from Quarto itself. A Quarto compile
|
||||
// failure (exit code 1) is not a server error — the absence of
|
||||
// output.pdf is sufficient for CompileController to return 'failure'.
|
||||
if (error && (error.terminated || error.timedout)) {
|
||||
return callback(error)
|
||||
}
|
||||
|
||||
// On exit-code-1 errors LocalCommandRunner attaches stdout to the
|
||||
// error object; merge it so _writeLogOutput can persist it.
|
||||
const combined = output || (error ? { stdout: error.stdout || '' } : null)
|
||||
_writeLogOutput(compileName, directory, combined, () =>
|
||||
_appendMissingResourceWarnings(directory, () =>
|
||||
callback(null, combined)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function _buildQuartoCommand(
|
||||
renderTarget,
|
||||
exportMode,
|
||||
allowPythonInstall,
|
||||
venvBaseDir
|
||||
) {
|
||||
// Run through a POSIX shell so stderr is merged into stdout (2>&1).
|
||||
// LocalCommandRunner replaces $COMPILE_DIR before the shell sees it.
|
||||
//
|
||||
// We do NOT pass --to or --output: let the YAML frontmatter decide the
|
||||
// output format (typst → output.pdf, revealjs → output.html, etc.).
|
||||
//
|
||||
// For a normal preview compile we do NOT embed resources. A self-contained
|
||||
// single-file HTML breaks reveal.js plugins that load/store resources at
|
||||
// runtime (e.g. chalkboard, multiplex) and is slow to transfer. Instead
|
||||
// Quarto emits the HTML plus a sibling "<basename>_files/" asset directory;
|
||||
// the HTML references it with relative paths. Both the html and the asset
|
||||
// dir are served from the same .../output/ path, so the relative links
|
||||
// resolve. For the 'html-standalone' export, runQuarto instead renders a
|
||||
// temporary copy of the deck (renderTarget) whose frontmatter enables
|
||||
// embed-resources, producing a single portable file.
|
||||
//
|
||||
// After render we rename the produced top-level file to output.pdf or
|
||||
// output.html. The asset directory keeps its "<basename>_files" name; the
|
||||
// renamed output.html still points at it via the unchanged relative refs.
|
||||
//
|
||||
// The extension merge (cp -rn, no-clobber so user extensions win) and the
|
||||
// trailing semicolon (so a missing /opt/quarto-extensions doesn't abort)
|
||||
// are kept. mv uses relative paths because LocalCommandRunner.replace()
|
||||
// only substitutes the FIRST $COMPILE_DIR and the shell CWD is the dir.
|
||||
const inputPath = `$COMPILE_DIR/${renderTarget}`
|
||||
const baseName = renderTarget.replace(/\.[^/.]+$/, '') // strip extension
|
||||
|
||||
let tail =
|
||||
`(mv ${baseName}.pdf output.pdf 2>/dev/null || ` +
|
||||
`mv ${baseName}.html output.html 2>/dev/null)`
|
||||
|
||||
if (exportMode === 'pdf-slides') {
|
||||
// After producing output.html, print it to output-slides.pdf with decktape
|
||||
// (headless Chromium via Puppeteer). The CLSI runtime user has no writable
|
||||
// HOME, so Chromium's crashpad can't create its database and the browser
|
||||
// dies on launch ("chrome_crashpad_handler: --database is required").
|
||||
// Point HOME / XDG dirs / the Chromium user-data-dir at a fresh writable
|
||||
// temp dir to give it somewhere to write.
|
||||
// --no-sandbox: Chromium can't sandbox as a non-root container user
|
||||
// --disable-dev-shm-usage: a tiny container /dev/shm crashes Chromium
|
||||
// --disable-gpu: there is no GPU in the container
|
||||
tail +=
|
||||
` && CHROME_HOME="$(mktemp -d)" && ` +
|
||||
`HOME="$CHROME_HOME" XDG_CONFIG_HOME="$CHROME_HOME" ` +
|
||||
`XDG_CACHE_HOME="$CHROME_HOME" decktape ` +
|
||||
`--chrome-arg=--no-sandbox ` +
|
||||
`--chrome-arg=--disable-dev-shm-usage ` +
|
||||
`--chrome-arg=--disable-gpu ` +
|
||||
`--chrome-arg=--user-data-dir="$CHROME_HOME/data" ` +
|
||||
`"$(pwd)/output.html" output-slides.pdf 2>&1`
|
||||
}
|
||||
|
||||
// For the standalone export, remove the temporary render copy afterwards so
|
||||
// it can't be mistaken for a project file or picked up by a later preview
|
||||
// compile. Runs regardless of render success (";").
|
||||
const cleanup =
|
||||
exportMode === 'html-standalone'
|
||||
? `; rm -rf ${baseName}.qmd ${baseName}_files`
|
||||
: ''
|
||||
|
||||
const venvPrep = allowPythonInstall ? _pythonVenvPrep(venvBaseDir) : ''
|
||||
|
||||
const cmd =
|
||||
`mkdir -p _extensions && ` +
|
||||
`cp -rn /opt/quarto-extensions/_extensions/. _extensions/ 2>/dev/null; ` +
|
||||
venvPrep +
|
||||
`quarto render ${inputPath} 2>&1 && ` +
|
||||
tail +
|
||||
cleanup
|
||||
return ['/bin/sh', '-c', cmd]
|
||||
}
|
||||
|
||||
// Shell snippet (run before `quarto render`, in the compile dir) that installs
|
||||
// a project's requirements.vrf into a venv cached by the file's sha256 and
|
||||
// points Quarto at it via QUARTO_PYTHON. Notes:
|
||||
// - The venv is shared across projects/compiles (keyed by content hash), so
|
||||
// identical dependency sets are built once.
|
||||
// - --system-site-packages keeps the bundled scientific stack + ipykernel
|
||||
// visible, so only the *extra* packages are installed.
|
||||
// - A per-hash flock serialises concurrent compiles building the same venv.
|
||||
// - Everything is merged to stdout so pip output/errors land in output.log;
|
||||
// on failure QUARTO_PYTHON is left unset and the render falls back to the
|
||||
// base interpreter (the missing-package error then surfaces normally).
|
||||
// - Only $-shell vars / $(...) are used (no ${...}) to avoid clashing with
|
||||
// JS template interpolation; only ${venvBaseDir} is substituted by JS.
|
||||
function _pythonVenvPrep(venvBaseDir) {
|
||||
return (
|
||||
`if [ -f requirements.vrf ]; then ` +
|
||||
`VBASE="${venvBaseDir}"; ` +
|
||||
`RHASH=$(sha256sum requirements.vrf 2>/dev/null | cut -d" " -f1); ` +
|
||||
`if [ -n "$RHASH" ]; then ` +
|
||||
`VDIR="$VBASE/$RHASH"; mkdir -p "$VBASE" 2>/dev/null; ` +
|
||||
`( flock 9 || exit 0; ` +
|
||||
`if [ ! -f "$VDIR/.verso-ready" ]; then ` +
|
||||
`echo "Installing Python packages from requirements.vrf..."; rm -rf "$VDIR"; ` +
|
||||
`python3 -m venv --system-site-packages "$VDIR" ` +
|
||||
`&& "$VDIR/bin/pip" install --no-input --disable-pip-version-check -r requirements.vrf ` +
|
||||
// Register a python3 kernelspec INSIDE the venv (argv -> the venv's python)
|
||||
// so Quarto runs the kernel in the venv, not the base /usr/bin/python3 from
|
||||
// the global kernelspec. ipykernel is visible via --system-site-packages.
|
||||
`&& "$VDIR/bin/python3" -m ipykernel install --sys-prefix --name python3 --display-name "Python 3" ` +
|
||||
`&& touch "$VDIR/.verso-ready" ` +
|
||||
`|| echo "ERROR: Failed to install Python packages from requirements.vrf"; ` +
|
||||
`fi ` +
|
||||
`) 9>"$VBASE/.$RHASH.lock" 2>&1; ` +
|
||||
`if [ -f "$VDIR/.verso-ready" ]; then export QUARTO_PYTHON="$VDIR/bin/python3"; fi; ` +
|
||||
`fi; ` +
|
||||
`fi; `
|
||||
)
|
||||
}
|
||||
|
||||
// Write a temporary copy of the root .qmd with embed-resources enabled in its
|
||||
// frontmatter, returning the temp filename to render. On any problem (no
|
||||
// frontmatter, not a nested revealjs deck, read/write error) it falls back to
|
||||
// the original mainFile — the export then just isn't self-contained, which is
|
||||
// no worse than before. The temp file lives in the same directory so relative
|
||||
// resources (images, _extensions) still resolve.
|
||||
function _writeStandaloneVariant(directory, mainFile) {
|
||||
try {
|
||||
const content = fs.readFileSync(Path.join(directory, mainFile), 'utf8')
|
||||
const transformed = _injectRevealjsStandaloneOptions(content)
|
||||
if (!transformed) return mainFile
|
||||
const base = mainFile.replace(/\.[^/.]+$/, '')
|
||||
const tempName = `${base}.verso-standalone.qmd`
|
||||
fs.writeFileSync(Path.join(directory, tempName), transformed)
|
||||
return tempName
|
||||
} catch (err) {
|
||||
logger.warn({ err, directory, mainFile }, 'could not prepare standalone qmd')
|
||||
return mainFile
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the self-contained options into the `revealjs:` block of a deck's
|
||||
// YAML frontmatter. embed-resources/self-contained-math inline all CSS/JS/
|
||||
// images/MathJax into one portable file; chalkboard must be off (it is
|
||||
// incompatible with embed-resources and would error the render). Keys already
|
||||
// present in the block are overwritten in place (so we never create duplicate
|
||||
// YAML keys, e.g. an existing `chalkboard: true`); missing keys are inserted.
|
||||
// Returns the new document text, or null if it isn't a nested-revealjs deck we
|
||||
// can safely edit.
|
||||
function _injectRevealjsStandaloneOptions(content) {
|
||||
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)/)
|
||||
if (!fmMatch) return null
|
||||
const [, open, body, close] = fmMatch
|
||||
const lines = body.split('\n')
|
||||
|
||||
const revealIdx = lines.findIndex(l => /^\s*revealjs:\s*$/.test(l))
|
||||
if (revealIdx === -1) return null // not a `format:\n revealjs:` deck
|
||||
|
||||
const revealIndent = lines[revealIdx].match(/^(\s*)/)[1]
|
||||
|
||||
// Determine the block's child indent (from the first more-indented line) and
|
||||
// where the block ends (the first later line indented at/under revealjs:).
|
||||
let childIndent = revealIndent + ' '
|
||||
let blockEnd = lines.length
|
||||
let seenChild = false
|
||||
for (let i = revealIdx + 1; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '') continue
|
||||
const indent = lines[i].match(/^(\s*)/)[1]
|
||||
if (indent.length <= revealIndent.length) {
|
||||
blockEnd = i
|
||||
break
|
||||
}
|
||||
if (!seenChild) {
|
||||
childIndent = indent
|
||||
seenChild = true
|
||||
}
|
||||
}
|
||||
|
||||
const desired = {
|
||||
'embed-resources': 'true',
|
||||
'self-contained-math': 'true',
|
||||
chalkboard: 'false',
|
||||
}
|
||||
|
||||
const present = new Set()
|
||||
for (let i = revealIdx + 1; i < blockEnd; i++) {
|
||||
const km = lines[i].match(/^\s*([A-Za-z0-9_-]+):/)
|
||||
if (km && Object.prototype.hasOwnProperty.call(desired, km[1])) {
|
||||
lines[i] = `${childIndent}${km[1]}: ${desired[km[1]]}`
|
||||
present.add(km[1])
|
||||
}
|
||||
}
|
||||
|
||||
const additions = Object.keys(desired)
|
||||
.filter(k => !present.has(k))
|
||||
.map(k => `${childIndent}${k}: ${desired[k]}`)
|
||||
if (additions.length) lines.splice(revealIdx + 1, 0, ...additions)
|
||||
|
||||
return open + lines.join('\n') + close + content.slice(fmMatch[0].length)
|
||||
}
|
||||
|
||||
function _writeLogOutput(compileName, directory, output, callback) {
|
||||
const content = (output && output.stdout) || ''
|
||||
if (!content) return callback()
|
||||
// Write to output.log so the PDF-preview log panel picks it up
|
||||
const logFile = Path.join(directory, 'output.log')
|
||||
fs.unlink(logFile, () => {
|
||||
fs.writeFile(logFile, content, { flag: 'wx' }, err => {
|
||||
if (err) {
|
||||
logger.error({ err, compileName, logFile }, 'error writing quarto log')
|
||||
}
|
||||
callback()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Quarto's HTML/RevealJS output is NOT self-contained (we deliberately dropped
|
||||
// --embed-resources so reveal plugins like chalkboard work). A side effect is
|
||||
// that pandoc no longer tries to fetch referenced media, so a missing image or
|
||||
// video produces no compile-time warning — it just renders broken in the
|
||||
// browser. To restore that feedback, scan the produced output.html for local
|
||||
// media references and emit a [WARNING] for any that don't exist on disk. The
|
||||
// [WARNING] prefix is understood by the Quarto/Typst log parser on the web
|
||||
// side, so these surface in the Warnings tab like any other.
|
||||
//
|
||||
// Only HTML output is scanned: PDF output (Typst) already hard-errors on a
|
||||
// missing image, so it needs no extra check.
|
||||
function _appendMissingResourceWarnings(directory, callback) {
|
||||
const htmlFile = Path.join(directory, 'output.html')
|
||||
fs.readFile(htmlFile, 'utf8', (err, html) => {
|
||||
if (err) return callback() // no HTML output (e.g. a PDF compile)
|
||||
|
||||
const missing = _extractLocalMediaRefs(html).filter(ref => {
|
||||
try {
|
||||
return !fs.existsSync(Path.join(directory, decodeURIComponent(ref)))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
if (missing.length === 0) return callback()
|
||||
|
||||
const warnings =
|
||||
missing
|
||||
.map(
|
||||
ref =>
|
||||
`[WARNING] Missing resource: ${ref} (referenced in the document ` +
|
||||
`but not found in the project — it will appear broken)`
|
||||
)
|
||||
.join('\n') + '\n'
|
||||
fs.appendFile(Path.join(directory, 'output.log'), '\n' + warnings, () =>
|
||||
callback()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Pull local media references (img/video/audio/iframe src, poster, RevealJS
|
||||
// data-background-*) out of the rendered HTML. External URLs, data URIs and
|
||||
// in-page anchors are ignored; Quarto's own generated assets (under
|
||||
// <basename>_files/) exist on disk, so they never get flagged.
|
||||
function _extractLocalMediaRefs(html) {
|
||||
const refs = new Set()
|
||||
const attrRegex =
|
||||
/(?:src|poster|data-background-image|data-background-video)\s*=\s*["']([^"']+)["']/gi
|
||||
let match
|
||||
while ((match = attrRegex.exec(html)) !== null) {
|
||||
const url = match[1].trim()
|
||||
if (!url) continue
|
||||
// Skip absolute URLs, protocol-relative, data/blob URIs and anchors.
|
||||
if (/^(?:[a-z]+:|\/\/|\/|#|data:|blob:)/i.test(url)) continue
|
||||
const clean = url.split(/[?#]/)[0] // drop query string / fragment
|
||||
if (clean) refs.add(clean)
|
||||
}
|
||||
return [...refs]
|
||||
}
|
||||
|
||||
function isRunning(compileName) {
|
||||
return ProcessTable[compileName] != null
|
||||
}
|
||||
|
||||
function killQuarto(compileName, callback) {
|
||||
logger.debug({ compileName }, 'killing running quarto compile')
|
||||
if (!isRunning(compileName)) {
|
||||
logger.warn({ compileName }, 'no such compile to kill')
|
||||
return callback(null)
|
||||
}
|
||||
CommandRunner.kill(ProcessTable[compileName], callback)
|
||||
}
|
||||
|
||||
export default {
|
||||
isRunning,
|
||||
runQuarto,
|
||||
killQuarto,
|
||||
promises: {
|
||||
runQuarto: promisify(runQuarto),
|
||||
killQuarto: promisify(killQuarto),
|
||||
},
|
||||
}
|
||||
@@ -2,7 +2,14 @@ import { promisify } from 'node:util'
|
||||
import settings from '@overleaf/settings'
|
||||
import OutputCacheManager from './OutputCacheManager.js'
|
||||
|
||||
const VALID_COMPILERS = ['pdflatex', 'latex', 'xelatex', 'lualatex']
|
||||
const VALID_COMPILERS = [
|
||||
'quarto',
|
||||
'typst',
|
||||
'pdflatex',
|
||||
'latex',
|
||||
'xelatex',
|
||||
'lualatex',
|
||||
]
|
||||
const MAX_TIMEOUT = 600
|
||||
const EDITOR_ID_REGEX = /^[a-f0-9-]{36}$/ // UUID
|
||||
const HISTORY_ID_REGEX = /^([0-9a-f]{24}|[1-9][0-9]{0,9})$/ // mongo id or postgres id
|
||||
@@ -36,7 +43,7 @@ function parse(body, callback) {
|
||||
}
|
||||
response.compiler = _parseAttribute('compiler', compile.options.compiler, {
|
||||
validValues: VALID_COMPILERS,
|
||||
default: 'pdflatex',
|
||||
default: 'quarto',
|
||||
type: 'string',
|
||||
})
|
||||
response.compileFromClsiCache = _parseAttribute(
|
||||
@@ -95,6 +102,20 @@ function parse(body, callback) {
|
||||
response.check = _parseAttribute('check', compile.options.check, {
|
||||
type: 'string',
|
||||
})
|
||||
// Verso: on-demand presentation export ('html-standalone' | 'pdf-slides'),
|
||||
// honoured by QuartoRunner; empty for a normal preview compile.
|
||||
response.exportMode = _parseAttribute(
|
||||
'exportMode',
|
||||
compile.options.exportMode,
|
||||
{ default: '', type: 'string' }
|
||||
)
|
||||
// Verso: whether QuartoRunner may install the project's requirements.txt
|
||||
// into a cached venv (gated by privilege on the web side).
|
||||
response.allowPythonInstall = _parseAttribute(
|
||||
'allowPythonInstall',
|
||||
compile.options.allowPythonInstall,
|
||||
{ default: false, type: 'boolean' }
|
||||
)
|
||||
response.flags = _parseAttribute('flags', compile.options.flags, {
|
||||
default: [],
|
||||
type: 'object',
|
||||
@@ -180,7 +201,7 @@ function parse(body, callback) {
|
||||
'rootResourcePath',
|
||||
compile.rootResourcePath,
|
||||
{
|
||||
default: 'main.tex',
|
||||
default: 'main.qmd',
|
||||
type: 'string',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import Path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import logger from '@overleaf/logger'
|
||||
import CommandRunner from './CommandRunner.js'
|
||||
import fs from 'node:fs'
|
||||
|
||||
// Maps currently-running Typst jobs: compileName → PID (or docker container id)
|
||||
const ProcessTable = {}
|
||||
|
||||
// Compiles a standalone Typst document (.typ) straight to output.pdf. We reuse
|
||||
// the Typst that ships inside Quarto via `quarto typst compile`, so there is no
|
||||
// extra binary to install. This is deliberately the simplest of the three
|
||||
// runners: Typst only ever produces a PDF, so there is no format detection,
|
||||
// no HTML asset directory and no extension merging (cf. QuartoRunner).
|
||||
function runTypst(compileName, options, callback) {
|
||||
const { directory, mainFile, image, environment, compileGroup } = options
|
||||
const timeout = options.timeout || 60000
|
||||
|
||||
logger.debug(
|
||||
{ directory, timeout, mainFile, compileGroup },
|
||||
'starting typst compile'
|
||||
)
|
||||
|
||||
const command = _buildTypstCommand(mainFile)
|
||||
|
||||
ProcessTable[compileName] = CommandRunner.run(
|
||||
compileName,
|
||||
command,
|
||||
directory,
|
||||
image,
|
||||
timeout,
|
||||
environment || {},
|
||||
compileGroup,
|
||||
null,
|
||||
function (error, output) {
|
||||
delete ProcessTable[compileName]
|
||||
|
||||
// Propagate real process-level errors (killed, timed out) but NOT
|
||||
// ordinary non-zero exit codes from Typst itself. A compile failure
|
||||
// (exit code 1) is not a server error — the absence of output.pdf is
|
||||
// enough for CompileController to return 'failure'.
|
||||
if (error && (error.terminated || error.timedout)) {
|
||||
return callback(error)
|
||||
}
|
||||
|
||||
// On exit-code-1 errors LocalCommandRunner attaches stdout to the error
|
||||
// object; merge it so _writeLogOutput can persist it.
|
||||
const combined = output || (error ? { stdout: error.stdout || '' } : null)
|
||||
_writeLogOutput(compileName, directory, combined, () =>
|
||||
callback(null, combined)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function _buildTypstCommand(mainFile) {
|
||||
// Run through a POSIX shell so stderr (where Typst writes its diagnostics)
|
||||
// is merged into stdout (2>&1). LocalCommandRunner replaces $COMPILE_DIR
|
||||
// before the shell sees it; the output path is relative because the shell
|
||||
// CWD is already the compile directory.
|
||||
const inputPath = `$COMPILE_DIR/${mainFile}`
|
||||
const cmd = `quarto typst compile ${inputPath} output.pdf 2>&1`
|
||||
return ['/bin/sh', '-c', cmd]
|
||||
}
|
||||
|
||||
function _writeLogOutput(compileName, directory, output, callback) {
|
||||
const content = (output && output.stdout) || ''
|
||||
if (!content) return callback()
|
||||
// Write to output.log so the PDF-preview log panel picks it up. Typst's
|
||||
// `error:`/`warning:` + `┌─ file:line:col` diagnostics are understood by the
|
||||
// Quarto/Typst log parser on the web side.
|
||||
const logFile = Path.join(directory, 'output.log')
|
||||
fs.unlink(logFile, () => {
|
||||
fs.writeFile(logFile, content, { flag: 'wx' }, err => {
|
||||
if (err) {
|
||||
logger.error({ err, compileName, logFile }, 'error writing typst log')
|
||||
}
|
||||
callback()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isRunning(compileName) {
|
||||
return ProcessTable[compileName] != null
|
||||
}
|
||||
|
||||
function killTypst(compileName, callback) {
|
||||
logger.debug({ compileName }, 'killing running typst compile')
|
||||
if (!isRunning(compileName)) {
|
||||
logger.warn({ compileName }, 'no such compile to kill')
|
||||
return callback(null)
|
||||
}
|
||||
CommandRunner.kill(ProcessTable[compileName], callback)
|
||||
}
|
||||
|
||||
export default {
|
||||
isRunning,
|
||||
runTypst,
|
||||
killTypst,
|
||||
promises: {
|
||||
runTypst: promisify(runTypst),
|
||||
killTypst: promisify(killTypst),
|
||||
},
|
||||
}
|
||||
@@ -1068,7 +1068,7 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
let flags
|
||||
let rootResourcePath = options.rootResourcePath
|
||||
let rootResourcePathOverride = null
|
||||
let hasMainFile = false
|
||||
let detectedMainFile = null
|
||||
let numberOfDocsInProject = 0
|
||||
|
||||
for (let path in docs) {
|
||||
@@ -1094,8 +1094,11 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
) {
|
||||
rootResourcePathOverride = path
|
||||
}
|
||||
if (path === 'main.tex') {
|
||||
hasMainFile = true
|
||||
// prefer main.qmd over main.tex as the default root document
|
||||
if (path === 'main.qmd') {
|
||||
detectedMainFile = 'main.qmd'
|
||||
} else if (path === 'main.tex' && detectedMainFile == null) {
|
||||
detectedMainFile = 'main.tex'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1103,8 +1106,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
rootResourcePath = rootResourcePathOverride
|
||||
}
|
||||
if (rootResourcePath == null) {
|
||||
if (hasMainFile) {
|
||||
rootResourcePath = 'main.tex'
|
||||
if (detectedMainFile != null) {
|
||||
rootResourcePath = detectedMainFile
|
||||
} else if (numberOfDocsInProject === 1) {
|
||||
// only one file, must be the main document
|
||||
for (const path in docs) {
|
||||
@@ -1145,6 +1148,8 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
imageName: project.imageName,
|
||||
draft: Boolean(options.draft),
|
||||
stopOnFirstError: Boolean(options.stopOnFirstError),
|
||||
exportMode: options.exportMode,
|
||||
allowPythonInstall: Boolean(options.allowPythonInstall),
|
||||
check: options.check,
|
||||
syncType: options.syncType,
|
||||
syncState: options.syncState,
|
||||
|
||||
@@ -7,6 +7,7 @@ import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
import { userCanInstallPython } from './PythonVenvGate.mjs'
|
||||
import { RateLimiter } from '../../infrastructure/RateLimiter.mjs'
|
||||
import Validation from '../../infrastructure/Validation.mjs'
|
||||
import Path from 'node:path'
|
||||
@@ -201,6 +202,11 @@ const _CompileController = {
|
||||
options.incrementalCompilesEnabled = true
|
||||
}
|
||||
|
||||
// Allow building a per-project Python venv from requirements.txt only for
|
||||
// the project owner and invited collaborators — never anonymous or
|
||||
// link-sharing users.
|
||||
options.allowPythonInstall = await userCanInstallPython(userId, projectId)
|
||||
|
||||
let {
|
||||
enablePdfCaching,
|
||||
pdfCachingMinChunkSize,
|
||||
|
||||
@@ -28,12 +28,17 @@ function generateBuildId() {
|
||||
}
|
||||
|
||||
async function compile(projectId, userId, options = {}) {
|
||||
const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
|
||||
projectId,
|
||||
userId
|
||||
)
|
||||
if (recentlyCompiled) {
|
||||
return { status: 'too-recently-compiled', outputFiles: [] }
|
||||
// Publishing a presentation needs a full output set on demand, so it can ask
|
||||
// to skip the debounce that suppresses compiles right after the editor's
|
||||
// auto-compile (which would otherwise return no output files).
|
||||
if (!options.bypassRecentCompileCheck) {
|
||||
const recentlyCompiled = await CompileManager._checkIfRecentlyCompiled(
|
||||
projectId,
|
||||
userId
|
||||
)
|
||||
if (recentlyCompiled) {
|
||||
return { status: 'too-recently-compiled', outputFiles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
import CompileManager from './CompileManager.mjs'
|
||||
import ClsiManager from './ClsiManager.mjs'
|
||||
import ProjectGetter from '../Project/ProjectGetter.mjs'
|
||||
import { userCanInstallPython } from './PythonVenvGate.mjs'
|
||||
|
||||
// On-demand export of a RevealJS deck from the editor's download menu.
|
||||
// - html → a single self-contained .html (embed-resources)
|
||||
// - pdf → a faithful slide-per-page PDF (decktape / headless Chromium)
|
||||
// Each triggers a one-off compile in the matching export mode, then streams the
|
||||
// produced file back as a download.
|
||||
const FORMATS = {
|
||||
html: {
|
||||
exportMode: 'html-standalone',
|
||||
file: 'output.html',
|
||||
ext: 'html',
|
||||
contentType: 'text/html',
|
||||
},
|
||||
pdf: {
|
||||
exportMode: 'pdf-slides',
|
||||
file: 'output-slides.pdf',
|
||||
ext: 'pdf',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
}
|
||||
|
||||
// Best-effort fetch of the export compile's output.log so export failures can
|
||||
// show the underlying error (e.g. decktape/Chromium output). Never throws.
|
||||
async function _fetchLog(projectId, userId, clsiServerId, buildId) {
|
||||
if (!buildId) return ''
|
||||
try {
|
||||
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
|
||||
const stream = await ClsiManager.promises.getOutputFileStream(
|
||||
projectId,
|
||||
compileAsUser,
|
||||
clsiServerId,
|
||||
buildId,
|
||||
'output.log'
|
||||
)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) chunks.push(chunk)
|
||||
const text = Buffer.concat(chunks).toString('utf8')
|
||||
// Keep it bounded — the tail holds the relevant error.
|
||||
return text.length > 8000 ? text.slice(-8000) : text
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPresentation(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const format = FORMATS[req.params.format]
|
||||
if (!format) return res.status(400).send('Unknown export format')
|
||||
|
||||
try {
|
||||
const { status, outputFiles, clsiServerId, buildId } =
|
||||
await CompileManager.promises.compile(projectId, userId, {
|
||||
exportMode: format.exportMode,
|
||||
bypassRecentCompileCheck: true,
|
||||
allowPythonInstall: await userCanInstallPython(userId, projectId),
|
||||
})
|
||||
|
||||
if (!buildId || !outputFiles?.some(f => f.path === format.file)) {
|
||||
// The expected artefact wasn't produced. Surface the compile log (which
|
||||
// for the PDF path includes decktape/Chromium's own stderr) as plain
|
||||
// text so the failure is diagnosable, instead of returning an HTML page
|
||||
// the browser saves as "pdf.htm".
|
||||
const log = await _fetchLog(projectId, userId, clsiServerId, buildId)
|
||||
res.status(400).type('text/plain')
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.send(
|
||||
`Export failed: the project did not produce ${format.file} ` +
|
||||
`(compile status: ${status}). This export is only available for ` +
|
||||
`RevealJS presentations.\n\n` +
|
||||
(log ? `--- compile log ---\n${log}` : '(no compile log available)')
|
||||
)
|
||||
}
|
||||
|
||||
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
|
||||
const stream = await ClsiManager.promises.getOutputFileStream(
|
||||
projectId,
|
||||
compileAsUser,
|
||||
clsiServerId,
|
||||
buildId,
|
||||
format.file
|
||||
)
|
||||
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
name: 1,
|
||||
})
|
||||
const safeName = (project?.name || 'presentation')
|
||||
.replace(/[^a-zA-Z0-9-_ ]+/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
res.setHeader('Content-Type', format.contentType)
|
||||
// Each export is a fresh render; never let the browser hand back a cached
|
||||
// copy of a previous export for the same URL.
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${safeName || 'presentation'}.${format.ext}"`
|
||||
)
|
||||
await pipeline(stream, res)
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, projectId, format: req.params.format },
|
||||
'presentation export failed'
|
||||
)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).send('Export failed. Please try compiling first.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
exportPresentation: expressify(exportPresentation),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
import ProjectEntityHandler from '../Project/ProjectEntityHandler.mjs'
|
||||
import EditorController from '../Editor/EditorController.mjs'
|
||||
|
||||
// The project's Python dependency list lives in a single Verso requirements
|
||||
// file at the project root. It is hidden from the file tree and edited through
|
||||
// the dedicated "Python packages" modal instead.
|
||||
const REQUIREMENTS_PATH = '/requirements.vrf'
|
||||
|
||||
async function getRequirements(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const docs = await ProjectEntityHandler.promises.getAllDocs(projectId)
|
||||
const doc = docs[REQUIREMENTS_PATH]
|
||||
res.json({ content: doc ? doc.lines.join('\n') : '' })
|
||||
}
|
||||
|
||||
async function setRequirements(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const content = typeof req.body.content === 'string' ? req.body.content : ''
|
||||
// Normalise line endings; an empty body still upserts an (empty) file, which
|
||||
// is harmless and keeps the editor state simple.
|
||||
const docLines = content.replace(/\r\n?/g, '\n').split('\n')
|
||||
await EditorController.promises.upsertDocWithPath(
|
||||
projectId,
|
||||
REQUIREMENTS_PATH,
|
||||
docLines,
|
||||
'python-requirements',
|
||||
userId
|
||||
)
|
||||
res.json({ content })
|
||||
}
|
||||
|
||||
export default {
|
||||
getRequirements: expressify(getRequirements),
|
||||
setRequirements: expressify(setRequirements),
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
|
||||
|
||||
// Whether this user may have the compiler install a project's requirements.txt
|
||||
// into a cached venv (so Quarto's Python cells can use libraries beyond the
|
||||
// bundled base set). Gated to the project owner + invited collaborators (any
|
||||
// role): ignorePublicAccess excludes link-sharing/public and anonymous users,
|
||||
// who fall back to the base Python interpreter. Returns false when the feature
|
||||
// is disabled or the privilege check fails.
|
||||
export async function userCanInstallPython(userId, projectId) {
|
||||
if (!Settings.enableProjectPythonVenv) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const privilegeLevel =
|
||||
await AuthorizationManager.promises.getPrivilegeLevelForProject(
|
||||
userId,
|
||||
projectId,
|
||||
null,
|
||||
{ ignorePublicAccess: true }
|
||||
)
|
||||
return Boolean(privilegeLevel)
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, projectId, userId },
|
||||
'could not determine python install privilege; defaulting to false'
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export default { userCanInstallPython }
|
||||
@@ -323,12 +323,59 @@ const _ProjectController = {
|
||||
req.body.projectName != null ? req.body.projectName.trim() : undefined
|
||||
const { template } = req.body
|
||||
|
||||
const project = await (template === 'example'
|
||||
? ProjectCreationHandler.promises.createExampleProject(
|
||||
// The "template" selects flavour (Quarto vs LaTeX) and kind (blank vs
|
||||
// example). 'example'/'none' are kept for backwards compatibility.
|
||||
let project
|
||||
switch (template) {
|
||||
case 'example':
|
||||
case 'example_latex':
|
||||
project = await ProjectCreationHandler.promises.createExampleProject(
|
||||
userId,
|
||||
projectName
|
||||
projectName,
|
||||
{},
|
||||
'latex'
|
||||
)
|
||||
: ProjectCreationHandler.promises.createBasicProject(userId, projectName))
|
||||
break
|
||||
case 'example_quarto':
|
||||
project = await ProjectCreationHandler.promises.createExampleProject(
|
||||
userId,
|
||||
projectName,
|
||||
{},
|
||||
'quarto'
|
||||
)
|
||||
break
|
||||
case 'example_typst':
|
||||
project = await ProjectCreationHandler.promises.createExampleProject(
|
||||
userId,
|
||||
projectName,
|
||||
{},
|
||||
'typst'
|
||||
)
|
||||
break
|
||||
case 'blank_latex':
|
||||
project = await ProjectCreationHandler.promises.createBasicProject(
|
||||
userId,
|
||||
projectName,
|
||||
'latex'
|
||||
)
|
||||
break
|
||||
case 'blank_typst':
|
||||
project = await ProjectCreationHandler.promises.createBasicProject(
|
||||
userId,
|
||||
projectName,
|
||||
'typst'
|
||||
)
|
||||
break
|
||||
case 'blank_quarto':
|
||||
case 'none':
|
||||
default:
|
||||
project = await ProjectCreationHandler.promises.createBasicProject(
|
||||
userId,
|
||||
projectName,
|
||||
'quarto'
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
ProjectAuditLogHandler.addEntryIfManagedInBackground(
|
||||
project._id,
|
||||
|
||||
@@ -85,11 +85,41 @@ async function createProjectFromSnippet(ownerId, projectName, docLines) {
|
||||
return project
|
||||
}
|
||||
|
||||
async function createBasicProject(ownerId, projectName) {
|
||||
const project = await _createBlankProject(ownerId, projectName)
|
||||
// Per-flavour blank-project template, root document name and stored compiler.
|
||||
function _flavourConfig(flavour) {
|
||||
switch (flavour) {
|
||||
case 'latex':
|
||||
return {
|
||||
templateName: 'mainbasic.tex',
|
||||
rootDocName: 'main.tex',
|
||||
compiler: 'pdflatex',
|
||||
}
|
||||
case 'typst':
|
||||
return {
|
||||
templateName: 'mainbasic.typ',
|
||||
rootDocName: 'main.typ',
|
||||
compiler: 'typst',
|
||||
}
|
||||
default:
|
||||
return {
|
||||
templateName: 'mainbasic.qmd',
|
||||
rootDocName: 'main.qmd',
|
||||
compiler: 'quarto',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const docLines = await _buildTemplate('mainbasic.tex', ownerId, projectName)
|
||||
await _createRootDoc(project, ownerId, docLines)
|
||||
async function createBasicProject(ownerId, projectName, flavour = 'quarto') {
|
||||
// Verso compiles .qmd with Quarto, .tex with latexmk and .typ with Typst;
|
||||
// the root file's extension selects the runner (see CompileManager in CLSI).
|
||||
// Each flavour is a choice of template + root name + the compiler stored on
|
||||
// the project (so the compiler dropdown reflects the engine and LatexRunner
|
||||
// never receives a non-LaTeX compiler for a .tex project).
|
||||
const { templateName, rootDocName, compiler } = _flavourConfig(flavour)
|
||||
const project = await _createBlankProject(ownerId, projectName, { compiler })
|
||||
|
||||
const docLines = await _buildTemplate(templateName, ownerId, projectName)
|
||||
await _createRootDoc(project, ownerId, docLines, rootDocName)
|
||||
|
||||
AnalyticsManager.recordEventForUserInBackground(ownerId, 'project-created', {
|
||||
projectId: project._id,
|
||||
@@ -163,20 +193,41 @@ async function populateClsiCacheForExampleProject(
|
||||
return projectId
|
||||
}
|
||||
|
||||
async function createExampleProject(ownerId, projectName, attributes = {}) {
|
||||
const project = await _createBlankProject(ownerId, projectName, attributes)
|
||||
async function createExampleProject(
|
||||
ownerId,
|
||||
projectName,
|
||||
attributes = {},
|
||||
flavour = 'latex'
|
||||
) {
|
||||
const { compiler } = _flavourConfig(flavour)
|
||||
const project = await _createBlankProject(ownerId, projectName, {
|
||||
...attributes,
|
||||
compiler,
|
||||
})
|
||||
|
||||
const { fileEntries, docEntries } = await _addExampleProjectFiles(
|
||||
ownerId,
|
||||
projectName,
|
||||
project
|
||||
)
|
||||
await populateClsiCacheForExampleProject(
|
||||
ownerId,
|
||||
project,
|
||||
fileEntries,
|
||||
docEntries
|
||||
)
|
||||
let result
|
||||
switch (flavour) {
|
||||
case 'quarto':
|
||||
result = await _addQuartoExampleProjectFiles(ownerId, projectName, project)
|
||||
break
|
||||
case 'typst':
|
||||
result = await _addTypstExampleProjectFiles(ownerId, projectName, project)
|
||||
break
|
||||
default:
|
||||
result = await _addExampleProjectFiles(ownerId, projectName, project)
|
||||
}
|
||||
const { fileEntries, docEntries } = result
|
||||
|
||||
if (flavour === 'latex') {
|
||||
// clsi-cache warming keys on a single static example; only do it for the
|
||||
// long-standing LaTeX example to avoid the "content is not static" guard.
|
||||
await populateClsiCacheForExampleProject(
|
||||
ownerId,
|
||||
project,
|
||||
fileEntries,
|
||||
docEntries
|
||||
)
|
||||
}
|
||||
|
||||
AnalyticsManager.recordEventForUserInBackground(ownerId, 'project-created', {
|
||||
projectId: project._id,
|
||||
@@ -191,7 +242,12 @@ async function _addExampleProjectFiles(ownerId, projectName, project) {
|
||||
ownerId,
|
||||
projectName
|
||||
)
|
||||
const rootDoc = await _createRootDoc(project, ownerId, mainDocLines)
|
||||
const rootDoc = await _createRootDoc(
|
||||
project,
|
||||
ownerId,
|
||||
mainDocLines,
|
||||
'main.tex'
|
||||
)
|
||||
|
||||
const bibDocLines = await _buildTemplate(
|
||||
`${templateProjectDir}/sample.bib`,
|
||||
@@ -229,6 +285,74 @@ async function _addExampleProjectFiles(ownerId, projectName, project) {
|
||||
}
|
||||
}
|
||||
|
||||
async function _addQuartoExampleProjectFiles(ownerId, projectName, project) {
|
||||
const mainDocLines = await _buildTemplate(
|
||||
'example-project-quarto/main.qmd',
|
||||
ownerId,
|
||||
projectName
|
||||
)
|
||||
const rootDoc = await _createRootDoc(
|
||||
project,
|
||||
ownerId,
|
||||
mainDocLines,
|
||||
'main.qmd'
|
||||
)
|
||||
|
||||
const imagePath = path.join(
|
||||
import.meta.dirname,
|
||||
'/../../../templates/project_files/example-project-quarto/frog.jpg'
|
||||
)
|
||||
const { fileRef } = await ProjectEntityUpdateHandler.promises.addFile(
|
||||
project._id,
|
||||
project.rootFolder[0]._id,
|
||||
'frog.jpg',
|
||||
imagePath,
|
||||
null,
|
||||
ownerId,
|
||||
null
|
||||
)
|
||||
return {
|
||||
fileEntries: [{ path: fileRef.name, file: fileRef }],
|
||||
docEntries: [
|
||||
{ path: 'main.qmd', doc: rootDoc, docLines: mainDocLines.join('\n') },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function _addTypstExampleProjectFiles(ownerId, projectName, project) {
|
||||
const mainDocLines = await _buildTemplate(
|
||||
'example-project-typst/main.typ',
|
||||
ownerId,
|
||||
projectName
|
||||
)
|
||||
const rootDoc = await _createRootDoc(
|
||||
project,
|
||||
ownerId,
|
||||
mainDocLines,
|
||||
'main.typ'
|
||||
)
|
||||
|
||||
const imagePath = path.join(
|
||||
import.meta.dirname,
|
||||
'/../../../templates/project_files/example-project-typst/frog.jpg'
|
||||
)
|
||||
const { fileRef } = await ProjectEntityUpdateHandler.promises.addFile(
|
||||
project._id,
|
||||
project.rootFolder[0]._id,
|
||||
'frog.jpg',
|
||||
imagePath,
|
||||
null,
|
||||
ownerId,
|
||||
null
|
||||
)
|
||||
return {
|
||||
fileEntries: [{ path: fileRef.name, file: fileRef }],
|
||||
docEntries: [
|
||||
{ path: 'main.typ', doc: rootDoc, docLines: mainDocLines.join('\n') },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async function _createBlankProject(
|
||||
ownerId,
|
||||
projectName,
|
||||
@@ -299,12 +423,17 @@ async function _createBlankProject(
|
||||
return project
|
||||
}
|
||||
|
||||
async function _createRootDoc(project, ownerId, docLines) {
|
||||
async function _createRootDoc(
|
||||
project,
|
||||
ownerId,
|
||||
docLines,
|
||||
rootDocName = 'main.qmd'
|
||||
) {
|
||||
try {
|
||||
const { doc } = await ProjectEntityUpdateHandler.promises.addDoc(
|
||||
project._id,
|
||||
project.rootFolder[0]._id,
|
||||
'main.tex',
|
||||
rootDocName,
|
||||
docLines,
|
||||
ownerId,
|
||||
null
|
||||
|
||||
@@ -679,7 +679,7 @@ async function _getProjects(
|
||||
const results = await Promise.all([
|
||||
ProjectGetter.promises.findAllUsersProjects(
|
||||
userId,
|
||||
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens'
|
||||
'name lastUpdated lastUpdatedBy publicAccesLevel archived trashed owner_ref tokens compiler'
|
||||
),
|
||||
TagsHandler.promises.getAllTags(userId),
|
||||
])
|
||||
@@ -823,6 +823,7 @@ function _formatProjectInfo(project, accessLevel, source, userId) {
|
||||
source,
|
||||
archived,
|
||||
trashed,
|
||||
compiler: project.compiler,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,6 +877,7 @@ async function _injectProjectUsers(projects) {
|
||||
? undefined
|
||||
: users[project.owner_ref.toString()],
|
||||
owner_ref: undefined,
|
||||
compiler: project.compiler,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -191,12 +191,14 @@ function _rootDocSort(a, b) {
|
||||
if (a.elements !== b.elements) {
|
||||
return a.elements - b.elements
|
||||
}
|
||||
// ensure main.tex is at the start of each folder
|
||||
if (a.name === 'main.tex' && b.name !== 'main.tex') {
|
||||
return -1
|
||||
}
|
||||
if (a.name !== 'main.tex' && b.name === 'main.tex') {
|
||||
return 1
|
||||
// prefer main.qmd, then main.tex, at the start of each folder
|
||||
const PREFERRED = ['main.qmd', 'main.tex']
|
||||
const aIdx = PREFERRED.indexOf(a.name)
|
||||
const bIdx = PREFERRED.indexOf(b.name)
|
||||
if (aIdx !== bIdx) {
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
}
|
||||
// prefer smaller files
|
||||
if (a.size !== b.size) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import Path from 'node:path'
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
import AuthorizationManager from '../Authorization/AuthorizationManager.mjs'
|
||||
import PublishedPresentationManager from './PublishedPresentationManager.mjs'
|
||||
|
||||
function _tokenUrl(token) {
|
||||
// Trailing slash so the deck's relative asset paths (e.g. main_files/...)
|
||||
// resolve under /p/:token/ rather than /p/.
|
||||
return `${Settings.siteUrl}/p/${token}/`
|
||||
}
|
||||
|
||||
function _serialize(record) {
|
||||
if (!record) return { published: false }
|
||||
return {
|
||||
published: true,
|
||||
publicUrl: _tokenUrl(record.publicToken),
|
||||
loginUrl: _tokenUrl(record.loginToken),
|
||||
memberUrl: _tokenUrl(record.memberToken),
|
||||
publishedAt: record.publishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
async function publish(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
try {
|
||||
const record = await PublishedPresentationManager.promises.publish(
|
||||
projectId,
|
||||
userId
|
||||
)
|
||||
res.json(_serialize(record))
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId }, 'failed to publish presentation')
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: err.message || 'failed to publish presentation' })
|
||||
}
|
||||
}
|
||||
|
||||
async function status(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const record =
|
||||
await PublishedPresentationManager.promises.getForProject(projectId)
|
||||
res.json(_serialize(record))
|
||||
}
|
||||
|
||||
async function regenerate(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const tier = req.body?.tier
|
||||
try {
|
||||
const record = await PublishedPresentationManager.promises.regenerateToken(
|
||||
projectId,
|
||||
tier
|
||||
)
|
||||
res.json(_serialize(record))
|
||||
} catch (err) {
|
||||
logger.error({ err, projectId, tier }, 'failed to regenerate deck link')
|
||||
res
|
||||
.status(400)
|
||||
.json({ message: err.message || 'failed to regenerate link' })
|
||||
}
|
||||
}
|
||||
|
||||
async function unpublish(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
await PublishedPresentationManager.promises.unpublish(projectId)
|
||||
res.sendStatus(204)
|
||||
}
|
||||
|
||||
// Public/standalone serving of a published deck and its assets. No editor
|
||||
// chrome. The token determines the access tier: 'public' is open, 'login'
|
||||
// needs any logged-in user, 'member' needs read access to the project.
|
||||
async function serve(req, res) {
|
||||
const { token } = req.params
|
||||
const file = req.params.file || 'index.html'
|
||||
|
||||
const record = await PublishedPresentationManager.promises.getByToken(token)
|
||||
if (!record) return res.status(404).send('Presentation not found')
|
||||
|
||||
// Normalise the bare token URL to a trailing slash so the deck's relative
|
||||
// asset references resolve under /p/:token/ instead of /p/.
|
||||
if (!req.params.file && !req.path.endsWith('/')) {
|
||||
return res.redirect(301, `/p/${encodeURIComponent(token)}/`)
|
||||
}
|
||||
|
||||
const tier = PublishedPresentationManager.tierForToken(record, token)
|
||||
if (tier !== 'public') {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
if (!userId) {
|
||||
return res.redirect(`/login?redir=${encodeURIComponent(req.originalUrl)}`)
|
||||
}
|
||||
if (tier === 'member') {
|
||||
const canRead = await AuthorizationManager.promises.canUserReadProject(
|
||||
userId,
|
||||
record.project_id,
|
||||
null
|
||||
)
|
||||
if (!canRead) {
|
||||
return res.status(403).send('You do not have access to this project')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dir = PublishedPresentationManager.getSnapshotDir(record.storageId)
|
||||
const root = Path.resolve(dir)
|
||||
const target = Path.resolve(root, file)
|
||||
if (target !== root && !target.startsWith(root + Path.sep)) {
|
||||
return res.status(400).send('Bad path')
|
||||
}
|
||||
|
||||
// This is a standalone site (reveal.js uses inline scripts); drop the app's
|
||||
// Content-Security-Policy so the deck renders as it does in the preview.
|
||||
res.removeHeader('Content-Security-Policy')
|
||||
|
||||
res.sendFile(target, err => {
|
||||
if (err) {
|
||||
if (!res.headersSent) res.status(404).send('Not found')
|
||||
logger.debug({ err, token, file }, 'published deck file not found')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
publish: expressify(publish),
|
||||
regenerate: expressify(regenerate),
|
||||
status: expressify(status),
|
||||
unpublish: expressify(unpublish),
|
||||
serve: expressify(serve),
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import Path from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { fetchStream } from '@overleaf/fetch-utils'
|
||||
import { callbackify } from 'node:util'
|
||||
import CompileManager from '../Compile/CompileManager.mjs'
|
||||
import { getOutputFileURL } from '../Compile/ClsiURLHelpers.mjs'
|
||||
import { userCanInstallPython } from '../Compile/PythonVenvGate.mjs'
|
||||
import { PublishedPresentation } from '../../models/PublishedPresentation.mjs'
|
||||
import ProjectGetter from '../Project/ProjectGetter.mjs'
|
||||
import Errors from '../Errors/Errors.js'
|
||||
|
||||
function _escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Wrapper page so a published PDF opens inline (browser PDF viewer) at the
|
||||
// snapshot root /p/:token, the same way an HTML deck does. The raw file stays
|
||||
// reachable at /p/:token/output.pdf for direct download.
|
||||
function _pdfIndexHtml(title) {
|
||||
const safeTitle = _escapeHtml(title || 'Document')
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${safeTitle}</title>
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; height: 100%; background: #525659; }
|
||||
iframe { display: block; border: 0; width: 100%; height: 100%; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<iframe src="output.pdf" title="${safeTitle}"></iframe>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
const PUBLISHED_DIR = Settings.path.publishedPresentationsFolder
|
||||
|
||||
// Output files we never want in a published deck: compile logs and LaTeX aux.
|
||||
// (OutputFileFinder already excludes the source .qmd/.tex; with the HTML-media
|
||||
// fix it keeps referenced images, so the rest of the output set is the deck.)
|
||||
const EXCLUDE_REGEX = /\.(log|blg|aux|fls|fdb_latexmk|synctex\.gz)$/i
|
||||
|
||||
function getSnapshotDir(storageId) {
|
||||
return Path.join(PUBLISHED_DIR, storageId)
|
||||
}
|
||||
|
||||
function _isWithin(root, target) {
|
||||
const resolvedRoot = Path.resolve(root)
|
||||
const resolvedTarget = Path.resolve(resolvedRoot, target)
|
||||
return (
|
||||
resolvedTarget === resolvedRoot ||
|
||||
resolvedTarget.startsWith(resolvedRoot + Path.sep)
|
||||
)
|
||||
}
|
||||
|
||||
async function _downloadOutputFile(
|
||||
projectId,
|
||||
userId,
|
||||
buildId,
|
||||
filePath,
|
||||
clsiServerId,
|
||||
destDir
|
||||
) {
|
||||
const url = getOutputFileURL(projectId, userId, buildId, filePath, clsiServerId)
|
||||
const dest = Path.join(destDir, filePath)
|
||||
if (!_isWithin(destDir, filePath)) {
|
||||
throw new Error(`unsafe output path: ${filePath}`)
|
||||
}
|
||||
await fs.promises.mkdir(Path.dirname(dest), { recursive: true })
|
||||
const stream = await fetchStream(url.href)
|
||||
await pipeline(stream, fs.createWriteStream(dest))
|
||||
}
|
||||
|
||||
// Compile the project, then copy the resulting HTML deck + assets into a stable
|
||||
// on-disk snapshot served at /p/:token. Re-publishing reuses the project's
|
||||
// existing tokens, so shared links stay stable.
|
||||
async function publish(projectId, userId) {
|
||||
const { status, outputFiles, clsiServerId, buildId } =
|
||||
await CompileManager.promises.compile(projectId, userId, {
|
||||
bypassRecentCompileCheck: true,
|
||||
allowPythonInstall: await userCanInstallPython(userId, projectId),
|
||||
})
|
||||
|
||||
const hasHtml = outputFiles?.some(f => f.path === 'output.html')
|
||||
const hasPdf = outputFiles?.some(f => f.path === 'output.pdf')
|
||||
if (!hasHtml && !hasPdf) {
|
||||
throw new Errors.InvalidError(
|
||||
`project did not produce an HTML or PDF output (compile status: ${status})`
|
||||
)
|
||||
}
|
||||
if (!buildId) {
|
||||
throw new Errors.InvalidError('compile produced no build id')
|
||||
}
|
||||
|
||||
// Output files are stored per-user unless per-user compiles are disabled;
|
||||
// mirror CompileManager so the download URLs resolve to the right location.
|
||||
const compileAsUser = Settings.disablePerUserCompiles ? undefined : userId
|
||||
|
||||
let record = await PublishedPresentation.findOne({ project_id: projectId })
|
||||
if (!record) {
|
||||
record = new PublishedPresentation({
|
||||
project_id: projectId,
|
||||
storageId: crypto.randomBytes(16).toString('hex'),
|
||||
publicToken: crypto.randomBytes(20).toString('hex'),
|
||||
loginToken: crypto.randomBytes(20).toString('hex'),
|
||||
memberToken: crypto.randomBytes(20).toString('hex'),
|
||||
})
|
||||
}
|
||||
|
||||
// Write a fresh snapshot (replace any previous one for this project).
|
||||
const destDir = getSnapshotDir(record.storageId)
|
||||
await fs.promises.rm(destDir, { recursive: true, force: true })
|
||||
await fs.promises.mkdir(destDir, { recursive: true })
|
||||
|
||||
for (const file of outputFiles) {
|
||||
if (EXCLUDE_REGEX.test(file.path)) continue
|
||||
await _downloadOutputFile(
|
||||
projectId,
|
||||
compileAsUser,
|
||||
buildId,
|
||||
file.path,
|
||||
clsiServerId,
|
||||
destDir
|
||||
)
|
||||
}
|
||||
|
||||
// Serve at the snapshot root: /p/:token → index.html. An HTML deck is its
|
||||
// own index; a PDF gets a tiny wrapper page that embeds output.pdf inline.
|
||||
try {
|
||||
if (hasHtml) {
|
||||
await fs.promises.copyFile(
|
||||
Path.join(destDir, 'output.html'),
|
||||
Path.join(destDir, 'index.html')
|
||||
)
|
||||
} else {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
name: 1,
|
||||
})
|
||||
await fs.promises.writeFile(
|
||||
Path.join(destDir, 'index.html'),
|
||||
_pdfIndexHtml(project?.name),
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err, projectId }, 'could not create index.html for deck')
|
||||
}
|
||||
|
||||
record.buildId = buildId
|
||||
record.publishedAt = new Date()
|
||||
await record.save()
|
||||
|
||||
logger.info(
|
||||
{ projectId, storageId: record.storageId },
|
||||
'published presentation'
|
||||
)
|
||||
return record
|
||||
}
|
||||
|
||||
async function unpublish(projectId) {
|
||||
const record = await PublishedPresentation.findOne({ project_id: projectId })
|
||||
if (!record) return
|
||||
await fs.promises
|
||||
.rm(getSnapshotDir(record.storageId), { recursive: true, force: true })
|
||||
.catch(err =>
|
||||
logger.warn({ err, projectId }, 'could not remove deck snapshot')
|
||||
)
|
||||
await PublishedPresentation.deleteOne({ _id: record._id })
|
||||
}
|
||||
|
||||
async function getByToken(token) {
|
||||
return await PublishedPresentation.findOne({
|
||||
$or: [
|
||||
{ publicToken: token },
|
||||
{ loginToken: token },
|
||||
{ memberToken: token },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
const TIER_FIELDS = {
|
||||
public: 'publicToken',
|
||||
login: 'loginToken',
|
||||
member: 'memberToken',
|
||||
}
|
||||
|
||||
// Which access tier a token grants for a record ('public' | 'login' | 'member').
|
||||
function tierForToken(record, token) {
|
||||
if (record.publicToken === token) return 'public'
|
||||
if (record.loginToken === token) return 'login'
|
||||
if (record.memberToken === token) return 'member'
|
||||
return null
|
||||
}
|
||||
|
||||
// Rotate a single tier's token, invalidating the old link for that tier only.
|
||||
// The snapshot and the other tiers' links are untouched.
|
||||
async function regenerateToken(projectId, tier) {
|
||||
const field = TIER_FIELDS[tier]
|
||||
if (!field) throw new Errors.InvalidError(`unknown link tier: ${tier}`)
|
||||
const record = await PublishedPresentation.findOne({ project_id: projectId })
|
||||
if (!record) throw new Errors.NotFoundError('presentation not published')
|
||||
record[field] = crypto.randomBytes(20).toString('hex')
|
||||
await record.save()
|
||||
return record
|
||||
}
|
||||
|
||||
async function getForProject(projectId) {
|
||||
return await PublishedPresentation.findOne({ project_id: projectId })
|
||||
}
|
||||
|
||||
export default {
|
||||
getSnapshotDir,
|
||||
tierForToken,
|
||||
publish,
|
||||
regenerateToken,
|
||||
unpublish,
|
||||
getByToken,
|
||||
getForProject,
|
||||
promises: {
|
||||
publish,
|
||||
regenerateToken,
|
||||
unpublish,
|
||||
getByToken,
|
||||
getForProject,
|
||||
},
|
||||
// callback-style for any legacy callers
|
||||
publishCb: callbackify(publish),
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import mongoose from '../infrastructure/Mongoose.mjs'
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
// A published snapshot of a project's compiled HTML/RevealJS presentation.
|
||||
// One per project, served standalone at /p/:token. Three stable tokens point at
|
||||
// the same on-disk snapshot (under storageId), one per access tier:
|
||||
// - publicToken → anyone with the link
|
||||
// - loginToken → any logged-in Verso user
|
||||
// - memberToken → only users who can read the project (collaborators)
|
||||
// All three always work; the author shares whichever they need, and can
|
||||
// regenerate any one independently (rotating its token invalidates the old
|
||||
// link for that tier only). Re-publishing overwrites the snapshot and keeps
|
||||
// the tokens, so shared links stay stable.
|
||||
const PublishedPresentationSchema = new Schema(
|
||||
{
|
||||
project_id: {
|
||||
type: ObjectId,
|
||||
ref: 'Project',
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
storageId: { type: String, required: true },
|
||||
publicToken: { type: String, required: true, unique: true },
|
||||
loginToken: { type: String, required: true, unique: true },
|
||||
memberToken: { type: String, required: true, unique: true },
|
||||
buildId: { type: String },
|
||||
publishedAt: { type: Date, default: Date.now },
|
||||
},
|
||||
{ minimize: false }
|
||||
)
|
||||
|
||||
export const PublishedPresentation = mongoose.model(
|
||||
'PublishedPresentation',
|
||||
PublishedPresentationSchema
|
||||
)
|
||||
|
||||
export { PublishedPresentationSchema }
|
||||
@@ -3,6 +3,9 @@ import ErrorController from './Features/Errors/ErrorController.mjs'
|
||||
import Features from './infrastructure/Features.mjs'
|
||||
import ProjectController from './Features/Project/ProjectController.mjs'
|
||||
import ProjectApiController from './Features/Project/ProjectApiController.mjs'
|
||||
import PublishedPresentationController from './Features/PublishedPresentation/PublishedPresentationController.mjs'
|
||||
import PresentationExportController from './Features/Compile/PresentationExportController.mjs'
|
||||
import PythonRequirementsController from './Features/Compile/PythonRequirementsController.mjs'
|
||||
import ProjectListController from './Features/Project/ProjectListController.mjs'
|
||||
import SpellingController from './Features/Spelling/SpellingController.mjs'
|
||||
import EditorRouter from './Features/Editor/EditorRouter.mjs'
|
||||
@@ -669,6 +672,53 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
CompileController.deleteAuxFiles
|
||||
)
|
||||
|
||||
// Publish the compiled presentation as a standalone, shareable snapshot.
|
||||
webRouter.get(
|
||||
'/project/:Project_id/publish-presentation',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
PublishedPresentationController.status
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/publish-presentation',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
PublishedPresentationController.publish
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/publish-presentation/regenerate',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
PublishedPresentationController.regenerate
|
||||
)
|
||||
webRouter.delete(
|
||||
'/project/:Project_id/publish-presentation',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
PublishedPresentationController.unpublish
|
||||
)
|
||||
// On-demand export of a RevealJS deck (download menu): html | pdf.
|
||||
webRouter.get(
|
||||
'/project/:Project_id/presentation-export/:format',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
PresentationExportController.exportPresentation
|
||||
)
|
||||
|
||||
// Read/write the project's Python requirements (requirements.vrf), edited via
|
||||
// the "Python packages" modal rather than as a file in the tree.
|
||||
webRouter.get(
|
||||
'/project/:Project_id/python-requirements',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
PythonRequirementsController.getRequirements
|
||||
)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/python-requirements',
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
PythonRequirementsController.setRequirements
|
||||
)
|
||||
|
||||
// Standalone viewer for a published presentation (no editor chrome).
|
||||
// Visibility is enforced inside the handler: 'public' is anonymous,
|
||||
// 'private' requires any logged-in Verso user.
|
||||
webRouter.get('/p/:token', PublishedPresentationController.serve)
|
||||
webRouter.get('/p/:token/:file(.*)', PublishedPresentationController.serve)
|
||||
webRouter.get(
|
||||
'/project/:Project_id/sync/code',
|
||||
AsyncLocalStorage.middleware,
|
||||
|
||||
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,84 @@
|
||||
---
|
||||
title: "<%= project_name %>"
|
||||
author: "<%= user.first_name %> <%= user.last_name %>"
|
||||
date: "<%= month %> <%= year %>"
|
||||
format:
|
||||
revealjs:
|
||||
theme: simple
|
||||
slide-number: true
|
||||
chalkboard: true
|
||||
---
|
||||
|
||||
## Welcome to Verso
|
||||
|
||||
This is an example **presentation** written in [Quarto](https://quarto.org)
|
||||
and rendered to interactive HTML slides with Reveal.js.
|
||||
|
||||
- Write your slides in plain Markdown
|
||||
- Mix in math, code, images and tables
|
||||
- Hit **Recompile** to see your changes instantly
|
||||
|
||||
::: aside
|
||||
Use the arrow keys to navigate, and press `f` for fullscreen.
|
||||
:::
|
||||
|
||||
## Mathematics
|
||||
|
||||
Inline math such as $E = mc^2$ renders inline, and display equations are
|
||||
centered automatically:
|
||||
|
||||
$$
|
||||
\int_{-\infty}^{\infty} e^{-x^2}\,dx = \sqrt{\pi}
|
||||
$$
|
||||
|
||||
. . .
|
||||
|
||||
You can reveal content step by step by separating it with `. . .`.
|
||||
|
||||
## Two columns
|
||||
|
||||
:::: {.columns}
|
||||
|
||||
::: {.column width="55%"}
|
||||
### Figures sit nicely beside text
|
||||
|
||||
Fenced columns let you place an explanation next to a figure — perfect for
|
||||
walking an audience through a diagram.
|
||||
:::
|
||||
|
||||
::: {.column width="45%"}
|
||||

|
||||
:::
|
||||
|
||||
::::
|
||||
|
||||
## Tables
|
||||
|
||||
| Engine | Output | Best for |
|
||||
|--------|:------:|-----------------------|
|
||||
| Typst | PDF | Fast, modern layout |
|
||||
| Reveal | HTML | Interactive slides |
|
||||
| LaTeX | PDF | Classic STEM articles |
|
||||
|
||||
: Quarto can target several output formats from one source. {.striped .hover}
|
||||
|
||||
## Code
|
||||
|
||||
Code blocks are syntax-highlighted out of the box:
|
||||
|
||||
```python
|
||||
def greet(name):
|
||||
return f"Hello, {name}!"
|
||||
|
||||
print(greet("Verso"))
|
||||
```
|
||||
|
||||
## Incremental lists {.incremental}
|
||||
|
||||
- Quarto turns Markdown into beautiful documents
|
||||
- The same source can become a PDF, a website or these slides
|
||||
- Now make this deck your own — edit `main.qmd`!
|
||||
|
||||
## Thank you {.center}
|
||||
|
||||
Questions? Start editing and explore what Quarto can do.
|
||||
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,57 @@
|
||||
#set document(
|
||||
title: "<%= project_name %>",
|
||||
author: "<%= user.first_name %> <%= user.last_name %>",
|
||||
)
|
||||
#set page(numbering: "1")
|
||||
#set heading(numbering: "1.1")
|
||||
#set par(justify: true)
|
||||
|
||||
#align(center)[
|
||||
#text(size: 22pt, weight: "bold")[<%= project_name %>] \
|
||||
#v(0.4em)
|
||||
<%= user.first_name %> <%= user.last_name %> · <%= month %> <%= year %>
|
||||
]
|
||||
|
||||
= Introduction
|
||||
|
||||
Welcome to *Typst* in Verso. Typst is a modern typesetting system that
|
||||
compiles in milliseconds — edit `main.typ` and hit *Recompile* to see the PDF
|
||||
update. This example shows off math, figures, tables and lists.
|
||||
|
||||
= Mathematics
|
||||
|
||||
Inline math such as $e^(i pi) + 1 = 0$ sits naturally in the text, while block
|
||||
equations are centred and can be numbered:
|
||||
|
||||
$ integral_(-oo)^(oo) e^(-x^2) dif x = sqrt(pi) $
|
||||
|
||||
= Figures
|
||||
|
||||
Images stored in the project are included with the `image` function:
|
||||
|
||||
#figure(
|
||||
image("frog.jpg", width: 50%),
|
||||
caption: [A friendly frog, loaded from a project file.],
|
||||
)
|
||||
|
||||
= Tables
|
||||
|
||||
#figure(
|
||||
table(
|
||||
columns: 3,
|
||||
align: (left, center, left),
|
||||
[*Engine*], [*Output*], [*Best for*],
|
||||
[Typst], [PDF], [Fast, modern layout],
|
||||
[Quarto], [PDF / HTML], [Reproducible documents],
|
||||
[LaTeX], [PDF], [Classic STEM articles],
|
||||
),
|
||||
caption: [Verso compiles three source formats side by side.],
|
||||
)
|
||||
|
||||
= Lists
|
||||
|
||||
- Write documents in concise, readable markup
|
||||
- Get near-instant PDF output
|
||||
- Script your layout with a real programming language
|
||||
|
||||
Now make this document your own!
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "<%= project_name %>"
|
||||
author: "<%= user.first_name %> <%= user.last_name %>"
|
||||
date: "<%= month %> <%= year %>"
|
||||
format: typst
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#set document(
|
||||
title: "<%= project_name %>",
|
||||
author: "<%= user.first_name %> <%= user.last_name %>",
|
||||
)
|
||||
#set page(numbering: "1")
|
||||
#set heading(numbering: "1.1")
|
||||
|
||||
#align(center)[
|
||||
#text(size: 20pt, weight: "bold")[<%= project_name %>] \
|
||||
#v(0.4em)
|
||||
<%= user.first_name %> <%= user.last_name %> · <%= month %> <%= year %>
|
||||
]
|
||||
|
||||
= Introduction
|
||||
@@ -138,7 +138,7 @@ link(rel='apple-touch-icon' href=buildBaseAssetPath() + 'apple-touch-icon.png')
|
||||
link(
|
||||
rel='mask-icon'
|
||||
href=buildBaseAssetPath() + 'mask-favicon.svg'
|
||||
color='#046530'
|
||||
color='#447099'
|
||||
)
|
||||
|
||||
//- Canonical Tag for SEO
|
||||
|
||||
@@ -1,60 +1,7 @@
|
||||
.fat-footer-base
|
||||
.fat-footer-base-section.fat-footer-base-meta
|
||||
.fat-footer-base-item
|
||||
.fat-footer-base-copyright © #{new Date().getFullYear()} Overleaf
|
||||
.fat-footer-base-copyright © #{new Date().getFullYear()} Verso
|
||||
a(href='/legal') #{translate('privacy_and_terms')}
|
||||
a(href='https://www.digital-science.com/security-certifications/') #{translate('compliance')}
|
||||
ul.fat-footer-base-item.list-unstyled.fat-footer-base-language
|
||||
include language-picker
|
||||
.fat-footer-base-section.fat-footer-base-social
|
||||
.fat-footer-base-item
|
||||
a.fat-footer-social.x-logo(href='https://x.com/overleaf')
|
||||
svg(
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 1200 1227'
|
||||
height='25'
|
||||
)
|
||||
path(
|
||||
d='M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z'
|
||||
)
|
||||
span.visually-hidden #{translate("app_on_x", {social: "X"})}
|
||||
a.fat-footer-social.facebook-logo(
|
||||
href='https://www.facebook.com/overleaf.editor'
|
||||
)
|
||||
svg(
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='0 0 666.66668 666.66717'
|
||||
height='25'
|
||||
)
|
||||
defs
|
||||
clipPath(id='a' clipPathUnits='userSpaceOnUse')
|
||||
path(d='M0 700h700V0H0Z')
|
||||
g(
|
||||
clip-path='url(#a)'
|
||||
transform='matrix(1.33333 0 0 -1.33333 -133.333 800)'
|
||||
)
|
||||
path.background(
|
||||
d='M0 0c0 138.071-111.929 250-250 250S-500 138.071-500 0c0-117.245 80.715-215.622 189.606-242.638v166.242h-51.552V0h51.552v32.919c0 85.092 38.508 124.532 122.048 124.532 15.838 0 43.167-3.105 54.347-6.211V81.986c-5.901.621-16.149.932-28.882.932-40.993 0-56.832-15.528-56.832-55.9V0h81.659l-14.028-76.396h-67.631v-171.773C-95.927-233.218 0-127.818 0 0'
|
||||
fill='#0866ff'
|
||||
transform='translate(600 350)'
|
||||
)
|
||||
path.text(
|
||||
d='m0 0 14.029 76.396H-67.63v27.019c0 40.372 15.838 55.899 56.831 55.899 12.733 0 22.981-.31 28.882-.931v69.253c-11.18 3.106-38.509 6.212-54.347 6.212-83.539 0-122.048-39.441-122.048-124.533V76.396h-51.552V0h51.552v-166.242a250.559 250.559 0 0 1 60.394-7.362c10.254 0 20.358.632 30.288 1.831V0Z'
|
||||
fill='#fff'
|
||||
transform='translate(447.918 273.604)'
|
||||
)
|
||||
span.visually-hidden #{translate("app_on_x", {social: "Facebook"})}
|
||||
a.fat-footer-social.linkedin-logo(
|
||||
href='https://www.linkedin.com/company/writelatex-limited'
|
||||
)
|
||||
svg(xmlns='http://www.w3.org/2000/svg' viewBox='0 0 72 72' height='25')
|
||||
g(fill='none' fill-rule='evenodd')
|
||||
path.background(
|
||||
fill='#2867b2'
|
||||
d='M8 72h56a8 8 0 0 0 8-8V8a8 8 0 0 0-8-8H8a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8'
|
||||
)
|
||||
path.text(
|
||||
fill='#FFF'
|
||||
d='M62 62H51.316V43.802c0-4.99-1.896-7.777-5.845-7.777-4.296 0-6.54 2.901-6.54 7.777V62H28.632V27.333H38.93v4.67s3.096-5.729 10.453-5.729c7.353 0 12.617 4.49 12.617 13.777zM16.35 22.794c-3.508 0-6.35-2.864-6.35-6.397C10 12.864 12.842 10 16.35 10c3.507 0 6.347 2.864 6.347 6.397 0 3.533-2.84 6.397-6.348 6.397ZM11.032 62h10.736V27.333H11.033V62'
|
||||
)
|
||||
span.visually-hidden #{translate("app_on_x", {social: "LinkedIn"})}
|
||||
|
||||
@@ -1,98 +1,6 @@
|
||||
footer.fat-footer.hidden-print.website-redesign-fat-footer
|
||||
.fat-footer-container
|
||||
.fat-footer-sections(class={hidden: hideFatFooter})
|
||||
#footer-brand.footer-section
|
||||
a.footer-brand(href='/' aria-label=settings.appName)
|
||||
|
||||
.footer-section
|
||||
h2.footer-section-heading #{translate('About')}
|
||||
|
||||
ul.list-unstyled
|
||||
li
|
||||
a(href='/about') #{translate('footer_about_us')}
|
||||
li
|
||||
a(href='https://digitalscience.pinpointhq.com/') #{translate('careers')}
|
||||
li
|
||||
a(href='/blog') #{translate('blog')}
|
||||
|
||||
.footer-section
|
||||
h2.footer-section-heading #{translate('solutions')}
|
||||
|
||||
ul.list-unstyled
|
||||
li
|
||||
a(href='/for/enterprises') #{translate('for_business')}
|
||||
li
|
||||
a(href='/for/universities') #{translate('for_universities')}
|
||||
li
|
||||
a(href='/for/government') #{translate('for_government')}
|
||||
li
|
||||
a(href='/for/publishers') #{translate('for_publishers')}
|
||||
li
|
||||
a(href='/about/customer-stories') #{translate('customer_stories')}
|
||||
|
||||
.footer-section
|
||||
h2.footer-section-heading #{translate('learn')}
|
||||
|
||||
ul.list-unstyled
|
||||
li
|
||||
a(
|
||||
href='https://learn.overleaf.com/101-get-started-with-latex-in-overleaf'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
) #{translate('get_started_with_latex')}
|
||||
li
|
||||
a(href='/latex/templates') #{translate('templates')}
|
||||
li
|
||||
a(
|
||||
href='https://learn.overleaf.com/calendar'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
) #{translate('webinars')}
|
||||
li
|
||||
a(
|
||||
href='https://learn.overleaf.com/'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
) #{translate('overleaf_learning_center')}
|
||||
li
|
||||
a(href='/learn/latex/Inserting_Images') #{translate('how_to_insert_images')}
|
||||
li
|
||||
a(href='/learn/latex/Tables') #{translate('how_to_create_tables')}
|
||||
|
||||
.footer-section
|
||||
h2.footer-section-heading !{translate('pricing')}
|
||||
|
||||
ul.list-unstyled
|
||||
li
|
||||
a(href='/user/subscription/plans?itm_referrer=footer-for-indv') #{translate('for_individuals')}
|
||||
li
|
||||
a(
|
||||
href='/user/subscription/plans?plan=group&itm_referrer=footer-for-groups'
|
||||
) !{translate('for_groups_and_organizations')}
|
||||
li
|
||||
a(
|
||||
href='/user/subscription/plans?itm_referrer=footer-for-students#student-annual'
|
||||
) #{translate('for_students')}
|
||||
|
||||
.footer-section
|
||||
h2.footer-section-heading #{translate('get_involved')}
|
||||
|
||||
ul.list-unstyled
|
||||
li
|
||||
a(href='https://forms.gle/67PSpN1bLnjGCmPQ9') #{translate('let_us_know_what_you_think')}
|
||||
if user
|
||||
li
|
||||
a(href='/beta/participate') #{translate('join_beta_program')}
|
||||
|
||||
.footer-section
|
||||
h2.footer-section-heading #{translate('help')}
|
||||
|
||||
ul.list-unstyled
|
||||
li
|
||||
a(href='/learn') #{translate('Documentation')}
|
||||
li
|
||||
a(href='/contact') #{translate('footer_contact_us')}
|
||||
li
|
||||
a(href='https://status.overleaf.com/') #{translate('website_status')}
|
||||
|
||||
include fat-footer-base
|
||||
footer.hidden-print.verso-footer(style='padding:1rem 1.5rem;border-top:1px solid #dee2e6;font-size:0.85rem;color:#6c757d')
|
||||
.verso-footer-inner(style='display:flex;flex-wrap:wrap;justify-content:space-between;gap:0.5rem 1.5rem;max-width:1200px;margin:0 auto')
|
||||
div
|
||||
| © #{new Date().getFullYear()} #[a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard] · Built on #[a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf]
|
||||
div
|
||||
| Distributed under the #[a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence] · #[a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code]
|
||||
|
||||
@@ -4,16 +4,20 @@ footer.site-footer
|
||||
.site-footer-content.hidden-print
|
||||
.row
|
||||
ul.site-footer-items.col-lg-9
|
||||
if !settings.nav.hide_powered_by
|
||||
li
|
||||
//- year of Server Pro release, static
|
||||
| © 2025
|
||||
|
|
||||
a(href='https://www.overleaf.com/for/enterprises') Powered by Overleaf
|
||||
li
|
||||
| © #{new Date().getFullYear()}
|
||||
|
|
||||
a(href='https://alocoq.fr' target='_blank' rel='noopener noreferrer') Aloïs Coquillard
|
||||
li
|
||||
strong.text-muted |
|
||||
li
|
||||
| Built on
|
||||
|
|
||||
a(href='https://github.com/overleaf/overleaf' target='_blank' rel='noopener noreferrer') Overleaf
|
||||
|
||||
if showLanguagePicker || hasCustomLeftNav
|
||||
li
|
||||
strong.text-muted |
|
||||
if showLanguagePicker || hasCustomLeftNav
|
||||
li
|
||||
strong.text-muted |
|
||||
|
||||
if showLanguagePicker
|
||||
include language-picker
|
||||
@@ -30,6 +34,13 @@ footer.site-footer
|
||||
| !{item.text}
|
||||
|
||||
ul.site-footer-items.col-lg-3.text-end
|
||||
li
|
||||
a(href='https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE' target='_blank' rel='noopener noreferrer') AGPL licence
|
||||
li
|
||||
strong.text-muted |
|
||||
li
|
||||
a(href='https://git.alocoq.fr/alois/verso' target='_blank' rel='noopener noreferrer') Source code
|
||||
|
||||
each item in nav.right_footer
|
||||
li
|
||||
if item.url
|
||||
|
||||
@@ -2,10 +2,19 @@ extends ../layout-website-redesign
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
- var suppressNavbar = true
|
||||
|
||||
block content
|
||||
main#main-content.content
|
||||
.container
|
||||
.row
|
||||
.col-12
|
||||
.text-center.mb-4
|
||||
img.verso-login-logo(
|
||||
src=buildImgPath('ol-brand/verso-logo.svg')
|
||||
alt='Verso'
|
||||
style='width:100%;max-width:480px;height:auto'
|
||||
)
|
||||
.row
|
||||
.col-lg-6.offset-lg-3.col-xl-4.offset-xl-4
|
||||
.page-header
|
||||
|
||||
@@ -55,6 +55,8 @@ const defaultTextExtensions = [
|
||||
'ldf',
|
||||
'rmd',
|
||||
'qmd',
|
||||
'typ',
|
||||
'vrf', // Verso requirements file (Python deps for Quarto venvs)
|
||||
'lua',
|
||||
'py',
|
||||
'gv',
|
||||
@@ -115,7 +117,14 @@ const httpPermissionsPolicy = {
|
||||
},
|
||||
}
|
||||
|
||||
const safeCompilers = ['xelatex', 'pdflatex', 'latex', 'lualatex']
|
||||
const safeCompilers = [
|
||||
'quarto',
|
||||
'typst',
|
||||
'xelatex',
|
||||
'pdflatex',
|
||||
'latex',
|
||||
'lualatex',
|
||||
]
|
||||
|
||||
module.exports = {
|
||||
env: 'server-ce',
|
||||
@@ -375,7 +384,7 @@ module.exports = {
|
||||
process.env.PROJECT_UPLOAD_TIMEOUT || '120000',
|
||||
10
|
||||
),
|
||||
maxUploadSize: 50 * 1024 * 1024, // 50 MB
|
||||
maxUploadSize: 500 * 1024 * 1024, // 500 MB
|
||||
multerOptions: {
|
||||
preservePath: process.env.MULTER_PRESERVE_PATH,
|
||||
},
|
||||
@@ -467,9 +476,15 @@ module.exports = {
|
||||
process.env.DEFAULT_LATEX_COMPILER
|
||||
)
|
||||
? process.env.DEFAULT_LATEX_COMPILER
|
||||
: 'pdflatex',
|
||||
: 'quarto',
|
||||
enableSubscriptions: false,
|
||||
restrictedCountries: [],
|
||||
|
||||
// When true, a project's requirements.txt is installed into a cached venv so
|
||||
// Quarto's Python cells can use libraries beyond the bundled base set. Gated
|
||||
// in CompileController to the project owner + invited collaborators only.
|
||||
enableProjectPythonVenv:
|
||||
process.env.OVERLEAF_ENABLE_PROJECT_PYTHON_VENV === 'true',
|
||||
enableOnboardingEmails: process.env.ENABLE_ONBOARDING_EMAILS === 'true',
|
||||
|
||||
enabledLinkedFileTypes: (process.env.ENABLED_LINKED_FILE_TYPES || '').split(
|
||||
@@ -779,6 +794,12 @@ module.exports = {
|
||||
// them to disk here).
|
||||
dumpFolder: Path.resolve(__dirname, '../data/dumpFolder'),
|
||||
uploadFolder: Path.resolve(__dirname, '../data/uploads'),
|
||||
// Verso: persisted snapshots of published presentations, served at
|
||||
// /p/:token. Point PUBLISHED_PRESENTATIONS_PATH at a persistent volume in
|
||||
// production so published links survive container restarts/redeploys.
|
||||
publishedPresentationsFolder:
|
||||
process.env.PUBLISHED_PRESENTATIONS_PATH ||
|
||||
Path.resolve(__dirname, '../data/published'),
|
||||
},
|
||||
|
||||
// Automatic Snapshots
|
||||
@@ -801,7 +822,7 @@ module.exports = {
|
||||
userId: process.env.SMOKE_TEST_USER_ID,
|
||||
},
|
||||
|
||||
appName: process.env.APP_NAME || 'Overleaf (Community Edition)',
|
||||
appName: process.env.APP_NAME || 'Verso',
|
||||
|
||||
adminEmail: process.env.ADMIN_EMAIL || 'placeholder@example.com',
|
||||
adminDomains: process.env.ADMIN_DOMAINS
|
||||
@@ -809,16 +830,12 @@ module.exports = {
|
||||
: undefined,
|
||||
|
||||
nav: {
|
||||
title: process.env.APP_NAME || 'Overleaf Community Edition',
|
||||
title: process.env.APP_NAME || 'Verso',
|
||||
|
||||
hide_powered_by: process.env.NAV_HIDE_POWERED_BY === 'true',
|
||||
left_footer: [],
|
||||
|
||||
right_footer: [
|
||||
{
|
||||
text: '<a href="https://github.com/overleaf/overleaf">Fork on GitHub!</a>',
|
||||
},
|
||||
],
|
||||
right_footer: [],
|
||||
|
||||
showSubscriptionLink: false,
|
||||
|
||||
@@ -889,7 +906,7 @@ module.exports = {
|
||||
process.env.FILE_IGNORE_PATTERN ||
|
||||
'**/{{__MACOSX,.git,.texpadtmp,.R}{,/**},.!(latexmkrc),*.{dvi,aux,log,toc,out,pdfsync,synctex,synctex(busy),fdb_latexmk,fls,nlo,ind,glo,gls,glg,bbl,blg,doc,docx,gz,swp}}',
|
||||
|
||||
validRootDocExtensions: ['tex', 'Rtex', 'ltx', 'Rnw'],
|
||||
validRootDocExtensions: ['qmd', 'typ', 'tex', 'Rtex', 'ltx', 'Rnw'],
|
||||
|
||||
emailConfirmationDisabled:
|
||||
process.env.EMAIL_CONFIRMATION_DISABLED === 'true' || false,
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
"add_another_email": "",
|
||||
"add_another_token": "",
|
||||
"add_comma_separated_emails_help": "",
|
||||
"add_collaborators": "",
|
||||
"add_comment": "",
|
||||
"add_comment_error_message": "",
|
||||
"add_comment_error_title": "",
|
||||
@@ -218,7 +219,10 @@
|
||||
"billing": "",
|
||||
"billing_period_sentence_case": "",
|
||||
"binary_history_error": "",
|
||||
"blank_latex_project": "",
|
||||
"blank_project": "",
|
||||
"blank_quarto_project": "",
|
||||
"blank_typst_project": "",
|
||||
"blocked_filename": "",
|
||||
"blog": "",
|
||||
"bold": "",
|
||||
@@ -527,7 +531,9 @@
|
||||
"download": "",
|
||||
"download_all": "",
|
||||
"download_as_pdf": "",
|
||||
"download_as_pdf_slides": "",
|
||||
"download_as_source_zip": "",
|
||||
"download_as_standalone_html": "",
|
||||
"download_csv": "",
|
||||
"download_metadata": "",
|
||||
"download_pdf": "",
|
||||
@@ -639,7 +645,10 @@
|
||||
"errors": "",
|
||||
"essential_cookies_only": "",
|
||||
"event_type": "",
|
||||
"example_latex_project": "",
|
||||
"example_project": "",
|
||||
"example_quarto_project": "",
|
||||
"example_typst_project": "",
|
||||
"existing_plan_active_until_term_end": "",
|
||||
"expand": "",
|
||||
"experiment_enabled_refresh_page": "",
|
||||
@@ -1428,6 +1437,8 @@
|
||||
"please_ask_the_project_owner_to_upgrade_to_track_changes": "",
|
||||
"please_change_primary_to_remove": "",
|
||||
"please_compile_pdf_before_download": "",
|
||||
"python_packages": "",
|
||||
"python_packages_help": "",
|
||||
"please_confirm_primary_email_or_edit": "",
|
||||
"please_confirm_secondary_email_or_edit": "",
|
||||
"please_confirm_your_email_before_making_it_default": "",
|
||||
@@ -1462,7 +1473,15 @@
|
||||
"premium_feature": "",
|
||||
"premium_plan_label": "",
|
||||
"preparing_for_export": "",
|
||||
"preparing_your_download": "",
|
||||
"presentation_export_can_take_a_moment": "",
|
||||
"presentation_export_failed": "",
|
||||
"presentation_link_members": "",
|
||||
"presentation_link_private": "",
|
||||
"presentation_link_public": "",
|
||||
"presentation_mode": "",
|
||||
"present": "",
|
||||
"present_publishes_and_opens_in_new_tab": "",
|
||||
"press_shift_space_for_suggestions": "",
|
||||
"press_space_to_open_the_ai_assistant": "",
|
||||
"preview": "",
|
||||
@@ -1642,6 +1661,7 @@
|
||||
"resend_link_sso": "",
|
||||
"resend_managed_user_invite": "",
|
||||
"resending_confirmation_code": "",
|
||||
"reset_link": "",
|
||||
"resize": "",
|
||||
"resolve_comment": "",
|
||||
"resolve_comment_error_message": "",
|
||||
@@ -1796,6 +1816,8 @@
|
||||
"settings": "",
|
||||
"setup_another_account_under_a_personal_email_address": "",
|
||||
"share": "",
|
||||
"share_compiled_presentation": "",
|
||||
"share_compiled_presentation_info": "",
|
||||
"share_feedback": "",
|
||||
"share_project": "",
|
||||
"share_project_name": "",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* EB Garamond (latin subset) — used for the Verso wordmark/instance name so
|
||||
the UI text matches the logo. Self-hosted; same subset embedded in the SVGs. */
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('EBGaramond-Regular.woff2') format('woff2');
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export default /** @type {const} */ ([
|
||||
'create_new_folder',
|
||||
'delete_forever',
|
||||
'delete',
|
||||
'deployed_code',
|
||||
'description',
|
||||
'domain',
|
||||
'edit',
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useTranslation } from 'react-i18next'
|
||||
import * as eventTracking from '../../../infrastructure/event-tracking'
|
||||
import { useFileTreeActionable } from '@/features/file-tree/contexts/file-tree-actionable'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useCommandProvider } from '@/features/ide-react/hooks/use-command-provider'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import FileTreeActionButton from './file-tree-action-button'
|
||||
import { useRailContext } from '../../ide-react/context/rail-context'
|
||||
import PythonRequirementsModal from './python-requirements-modal'
|
||||
|
||||
export default function FileTreeActionButtons({
|
||||
fileTreeExpanded,
|
||||
@@ -17,6 +18,7 @@ export default function FileTreeActionButtons({
|
||||
const { fileTreeReadOnly } = useFileTreeData()
|
||||
const { write } = usePermissionsContext()
|
||||
const { handlePaneCollapse } = useRailContext()
|
||||
const [showPythonModal, setShowPythonModal] = useState(false)
|
||||
|
||||
const {
|
||||
canCreate,
|
||||
@@ -110,6 +112,14 @@ export default function FileTreeActionButtons({
|
||||
iconType="delete"
|
||||
/>
|
||||
)}
|
||||
{write && (
|
||||
<FileTreeActionButton
|
||||
id="python-packages"
|
||||
description={t('python_packages')}
|
||||
onClick={() => setShowPythonModal(true)}
|
||||
iconType="deployed_code"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FileTreeActionButton
|
||||
@@ -118,6 +128,10 @@ export default function FileTreeActionButtons({
|
||||
onClick={handlePaneCollapse}
|
||||
iconType="close"
|
||||
/>
|
||||
<PythonRequirementsModal
|
||||
show={showPythonModal}
|
||||
onHide={() => setShowPythonModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ function FileTreeFolderList({
|
||||
dataTestId?: string
|
||||
}) {
|
||||
files = files.map(file => ({ ...file, isFile: true }))
|
||||
const docsAndFiles: (Doc | ExtendedFileRef)[] = [...docs, ...files]
|
||||
// The Verso requirements file (requirements.vrf) is managed through the
|
||||
// dedicated "Python packages" editor, so it is hidden from the file tree.
|
||||
const docsAndFiles: (Doc | ExtendedFileRef)[] = [...docs, ...files].filter(
|
||||
entity => !/\.vrf$/i.test(entity.name)
|
||||
)
|
||||
|
||||
return (
|
||||
<ul
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import { getJSON, postJSON } from '@/infrastructure/fetch-json'
|
||||
|
||||
// Editor for the project's Python dependencies (requirements.vrf), reached from
|
||||
// the file-tree toolbar. The file itself is hidden from the tree; this modal is
|
||||
// the only entry point. One package per line, pip syntax (e.g. `openpyxl==3.1.5`).
|
||||
export default function PythonRequirementsModal({
|
||||
show,
|
||||
onHide,
|
||||
}: {
|
||||
show: boolean
|
||||
onHide: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { projectId } = useProjectContext()
|
||||
const [content, setContent] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!show) return
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
getJSON<{ content: string }>(`/project/${projectId}/python-requirements`)
|
||||
.then(data => setContent(data.content || ''))
|
||||
.catch(() => setError(t('generic_something_went_wrong')))
|
||||
.finally(() => setLoading(false))
|
||||
}, [show, projectId, t])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
postJSON(`/project/${projectId}/python-requirements`, {
|
||||
body: { content },
|
||||
})
|
||||
.then(() => onHide())
|
||||
.catch(() => setError(t('generic_something_went_wrong')))
|
||||
.finally(() => setSaving(false))
|
||||
}, [projectId, content, onHide, t])
|
||||
|
||||
return (
|
||||
<OLModal show={show} onHide={onHide}>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>{t('python_packages')}</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
<p className="text-muted">{t('python_packages_help')}</p>
|
||||
{error && (
|
||||
<OLNotification type="error" content={error} className="mb-3" />
|
||||
)}
|
||||
{loading ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<textarea
|
||||
className="form-control"
|
||||
rows={10}
|
||||
spellCheck={false}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
value={content}
|
||||
onChange={e => setContent(e.target.value)}
|
||||
placeholder={'openpyxl==3.1.5\nrequests'}
|
||||
aria-label={t('python_packages')}
|
||||
/>
|
||||
)}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={onHide} disabled={saving}>
|
||||
{t('cancel')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading || saving}
|
||||
isLoading={saving}
|
||||
>
|
||||
{t('save')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useDetachCompileContext as useCompileContext } from '@/shared/context/detach-compile-context'
|
||||
|
||||
// One-click shortcut: publish the compiled presentation as a private
|
||||
// (logged-in-users-only) standalone page and open it in a new tab. For finer
|
||||
// control (public links, unpublish) use the Share dialog.
|
||||
//
|
||||
// Only meaningful for HTML/RevealJS decks — a PDF compile has nothing to
|
||||
// "present", so the button hides itself when the current output isn't HTML.
|
||||
export default function PresentationPreviewButton() {
|
||||
const { t } = useTranslation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const projectId = getMeta('ol-project_id')
|
||||
const { pdfFile } = useCompileContext()
|
||||
const isHtmlPresentation = pdfFile?.path === 'output.html'
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setLoading(true)
|
||||
// Open the tab synchronously inside the click handler to avoid popup
|
||||
// blockers, then redirect it once we have the published URL.
|
||||
const win = window.open('', '_blank')
|
||||
postJSON(`/project/${projectId}/publish-presentation`)
|
||||
.then((data: { loginUrl?: string }) => {
|
||||
const url = data?.loginUrl
|
||||
if (url && win) {
|
||||
win.location.href = url
|
||||
} else if (url) {
|
||||
window.open(url, '_blank')
|
||||
} else if (win) {
|
||||
win.close()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (win) win.close()
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
if (!isHtmlPresentation) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ide-redesign-toolbar-button-container">
|
||||
<OLTooltip
|
||||
id="presentation-present-button"
|
||||
description={t('present_publishes_and_opens_in_new_tab')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<OLButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
leadingIcon={<MaterialIcon type="slideshow" />}
|
||||
onClick={handleClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('present')}
|
||||
</OLButton>
|
||||
</OLTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ToolbarMenuBar } from './menu-bar'
|
||||
import { ToolbarProjectTitle } from './project-title'
|
||||
import { OnlineUsers } from './online-users'
|
||||
import ShareProjectButton from './share-project-button'
|
||||
import PresentationPreviewButton from './presentation-preview-button'
|
||||
import ChangeLayoutButton from './change-layout-button'
|
||||
import ShowHistoryButton from './show-history-button'
|
||||
import { useLayoutContext } from '@/shared/context/layout-context'
|
||||
@@ -59,6 +60,7 @@ export const Toolbar = () => {
|
||||
{shouldDisplaySubmitButton && cobranding && (
|
||||
<SubmitProjectButton cobranding={cobranding} />
|
||||
)}
|
||||
<PresentationPreviewButton />
|
||||
<ShareProjectButton />
|
||||
{getMeta('ol-showUpgradePrompt') && <UpgradeButton />}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
@@ -6,16 +7,174 @@ import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownToggle,
|
||||
} from '@/shared/components/dropdown/dropdown-menu'
|
||||
import {
|
||||
OLModal,
|
||||
OLModalBody,
|
||||
OLModalFooter,
|
||||
OLModalHeader,
|
||||
OLModalTitle,
|
||||
} from '@/shared/components/ol/ol-modal'
|
||||
import LoadingSpinner from '@/shared/components/loading-spinner'
|
||||
|
||||
type ExportFormat = 'html' | 'pdf'
|
||||
|
||||
function filenameFromDisposition(disposition: string | null, ext: string) {
|
||||
const match = disposition?.match(/filename="?([^"]+)"?/)
|
||||
return match ? match[1] : `presentation.${ext}`
|
||||
}
|
||||
|
||||
function PdfHybridDownloadButton() {
|
||||
const { pdfDownloadUrl, showLogs } = useCompileContext()
|
||||
const { pdfDownloadUrl, pdfFile, showLogs } = useCompileContext()
|
||||
const { sendEvent } = useEditorAnalytics()
|
||||
const { projectId } = useProjectContext()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Standalone-HTML / slide-PDF exports trigger an on-demand server-side
|
||||
// render that can take several seconds; surface that wait (and any failure
|
||||
// log) in a modal instead of leaving the browser silently spinning.
|
||||
const [exporting, setExporting] = useState<ExportFormat | null>(null)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
// Bumped whenever the user dismisses the modal, so a request that finishes
|
||||
// after they've closed it doesn't pop the modal back open.
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const dismiss = useCallback(() => {
|
||||
requestIdRef.current += 1
|
||||
setExporting(null)
|
||||
setExportError(null)
|
||||
}, [])
|
||||
|
||||
const startExport = useCallback(
|
||||
async (format: ExportFormat) => {
|
||||
const requestId = ++requestIdRef.current
|
||||
setExportError(null)
|
||||
setExporting(format)
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/project/${projectId}/presentation-export/${format}`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
if (requestId !== requestIdRef.current) return // dismissed meanwhile
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setExporting(null)
|
||||
setExportError(text || `Export failed (HTTP ${response.status})`)
|
||||
return
|
||||
}
|
||||
const blob = await response.blob()
|
||||
if (requestId !== requestIdRef.current) return
|
||||
const filename = filenameFromDisposition(
|
||||
response.headers.get('Content-Disposition'),
|
||||
format === 'pdf' ? 'pdf' : 'html'
|
||||
)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
setExporting(null)
|
||||
} catch (err) {
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setExporting(null)
|
||||
setExportError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
},
|
||||
[projectId]
|
||||
)
|
||||
|
||||
if (showLogs) {
|
||||
return null
|
||||
}
|
||||
|
||||
// A RevealJS deck compiles to output.html. For it we offer two export
|
||||
// choices instead of a single download: a self-contained HTML file, or a
|
||||
// faithful slide PDF (each triggers a one-off server-side export render).
|
||||
const isPresentation = pdfFile?.path === 'output.html'
|
||||
|
||||
if (isPresentation) {
|
||||
return (
|
||||
<>
|
||||
<Dropdown align="end">
|
||||
<DropdownToggle
|
||||
id="download-presentation"
|
||||
variant="link"
|
||||
className="pdf-toolbar-btn"
|
||||
aria-label={t('download')}
|
||||
>
|
||||
<MaterialIcon type="download" />
|
||||
</DropdownToggle>
|
||||
<DropdownMenu>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={() => startExport('html')}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{t('download_as_standalone_html')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={() => startExport('pdf')}
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{t('download_as_pdf_slides')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<OLModal
|
||||
show={exporting !== null || exportError !== null}
|
||||
onHide={dismiss}
|
||||
>
|
||||
<OLModalHeader closeButton>
|
||||
<OLModalTitle>
|
||||
{exportError
|
||||
? t('presentation_export_failed')
|
||||
: t('preparing_your_download')}
|
||||
</OLModalTitle>
|
||||
</OLModalHeader>
|
||||
<OLModalBody>
|
||||
{exporting !== null ? (
|
||||
<LoadingSpinner
|
||||
loadingText={t('presentation_export_can_take_a_moment')}
|
||||
/>
|
||||
) : (
|
||||
<pre
|
||||
style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: '50vh',
|
||||
overflow: 'auto',
|
||||
marginBottom: 0,
|
||||
}}
|
||||
>
|
||||
{exportError}
|
||||
</pre>
|
||||
)}
|
||||
</OLModalBody>
|
||||
<OLModalFooter>
|
||||
<OLButton variant="secondary" onClick={dismiss}>
|
||||
{t('close')}
|
||||
</OLButton>
|
||||
</OLModalFooter>
|
||||
</OLModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const description = pdfDownloadUrl
|
||||
? t('download_pdf')
|
||||
: t('please_compile_pdf_before_download')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, memo } from 'react'
|
||||
import { lazy, memo, useCallback, useRef } from 'react'
|
||||
import { useDetachCompileContext as useCompileContext } from '../../../shared/context/detach-compile-context'
|
||||
|
||||
const PdfJsViewer = lazy(
|
||||
@@ -8,10 +8,57 @@ const PdfJsViewer = lazy(
|
||||
function PdfViewer() {
|
||||
const { pdfUrl, pdfFile, pdfViewer } = useCompileContext()
|
||||
|
||||
// Remember the current RevealJS slide (kept in the deck's URL hash, e.g.
|
||||
// "#/3/2") so that recompiling — which swaps the iframe src for a fresh
|
||||
// build — reopens the deck on the same slide instead of jumping to the
|
||||
// start. Only works when the output is same-origin (the usual self-hosted
|
||||
// case); cross-origin reads fail silently and we just lose the position.
|
||||
const lastHashRef = useRef('')
|
||||
|
||||
const handlePresentationLoad = useCallback(
|
||||
(event: React.SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const win = event.currentTarget.contentWindow
|
||||
if (!win) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const capture = () => {
|
||||
try {
|
||||
lastHashRef.current = win.location.hash
|
||||
} catch {
|
||||
// cross-origin after navigation — ignore
|
||||
}
|
||||
}
|
||||
capture()
|
||||
win.addEventListener('hashchange', capture)
|
||||
} catch {
|
||||
// cross-origin: can't track the slide position
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
if (!pdfUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
// HTML outputs (RevealJS, etc.) must always use the native iframe;
|
||||
// PDF.js cannot render HTML.
|
||||
if (pdfUrl.includes('output.html')) {
|
||||
// Re-append the remembered slide hash so the new build opens where the
|
||||
// user left off. RevealJS reads the hash on load and navigates there.
|
||||
const src = `${pdfUrl}${lastHashRef.current}`
|
||||
return (
|
||||
<iframe
|
||||
title="Presentation Preview"
|
||||
src={src}
|
||||
onLoad={handlePresentationLoad}
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
sandbox="allow-scripts allow-same-origin allow-presentation allow-popups"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
switch (pdfViewer) {
|
||||
case 'native':
|
||||
return <iframe title="PDF Preview" src={pdfUrl} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import HumanReadableLogs from '../../../ide/human-readable-logs/HumanReadableLogs'
|
||||
import parseQuartoLog from '../../../ide/log-parser/quarto-log-parser'
|
||||
import BibLogParser, {
|
||||
BibLogEntry,
|
||||
} from '../../../ide/log-parser/bib-log-parser'
|
||||
@@ -24,7 +25,9 @@ export function handleOutputFiles(
|
||||
projectId: string,
|
||||
data: CompileResponseData
|
||||
): PDFFile | null {
|
||||
const outputFile = outputFiles.get('output.pdf')
|
||||
// Accept either a PDF or an HTML output (e.g. RevealJS presentation)
|
||||
const outputFile =
|
||||
outputFiles.get('output.pdf') ?? outputFiles.get('output.html')
|
||||
if (!outputFile) return null
|
||||
|
||||
outputFile.editorId = outputFile.editorId || EDITOR_SESSION_ID
|
||||
@@ -54,7 +57,7 @@ export function handleOutputFiles(
|
||||
params.set('popupDownload', 'true') // save PDF download as file
|
||||
params.set('editorId', outputFile.editorId)
|
||||
|
||||
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/output.pdf?${params}`
|
||||
outputFile.pdfDownloadUrl = `/download/project/${projectId}/build/${outputFile.build}/output/${outputFile.path}?${params}`
|
||||
}
|
||||
|
||||
return outputFile
|
||||
@@ -127,19 +130,28 @@ export async function handleLogFiles(
|
||||
MAX_LOG_SIZE
|
||||
)
|
||||
try {
|
||||
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
|
||||
result.log,
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
// Quarto (.qmd/.md/.Rmd) and bare Typst (.typ) compiles produce
|
||||
// Typst/Pandoc/Quarto diagnostics that the LaTeX log parser does not
|
||||
// understand. Route those to a dedicated parser so their errors and
|
||||
// warnings populate the log tabs like LaTeX ones.
|
||||
if (usesQuartoLogParser(data)) {
|
||||
const { errors, warnings, typesetting } = parseQuartoLog(result.log)
|
||||
accumulateResults({ errors, warnings, typesetting })
|
||||
} else {
|
||||
let { errors, warnings, typesetting } = HumanReadableLogs.parse(
|
||||
result.log,
|
||||
{
|
||||
ignoreDuplicates: true,
|
||||
}
|
||||
)
|
||||
|
||||
if (data.status === 'stopped-on-first-error') {
|
||||
// Hide warnings that could disappear after a second pass
|
||||
warnings = warnings.filter(warning => !isTransientWarning(warning))
|
||||
}
|
||||
)
|
||||
|
||||
if (data.status === 'stopped-on-first-error') {
|
||||
// Hide warnings that could disappear after a second pass
|
||||
warnings = warnings.filter(warning => !isTransientWarning(warning))
|
||||
accumulateResults({ errors, warnings, typesetting })
|
||||
}
|
||||
|
||||
accumulateResults({ errors, warnings, typesetting })
|
||||
} catch (e) {
|
||||
debugConsole.warn(e) // ignore failure to parse the log file, but log a warning
|
||||
}
|
||||
@@ -289,6 +301,15 @@ function isTransientWarning(warning: LatexLogEntry): boolean {
|
||||
return TRANSIENT_WARNING_REGEX.test(warning.message || '')
|
||||
}
|
||||
|
||||
// Mirrors CompileManager's runner dispatch in CLSI: both the Quarto runner
|
||||
// (.qmd/.md/.Rmd) and the Typst runner (.typ) emit Typst-style diagnostics, so
|
||||
// we pick the Quarto/Typst log parser for either, keyed on the root extension.
|
||||
const QUARTO_TYPST_ROOT_REGEX = /\.(qmd|md|rmd|typ)$/i
|
||||
|
||||
function usesQuartoLogParser(data: CompileResponseData): boolean {
|
||||
return QUARTO_TYPST_ROOT_REGEX.test(data.options?.rootResourcePath || '')
|
||||
}
|
||||
|
||||
async function fetchFileWithSizeLimit(
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
|
||||
@@ -192,24 +192,72 @@ function NewProjectButton({
|
||||
<DropdownItem
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'blank_project',
|
||||
dropdownMenuEvent: 'blank-project',
|
||||
modalVariant: 'blank_quarto',
|
||||
dropdownMenuEvent: 'blank-project-quarto',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('blank_project')}
|
||||
{t('blank_quarto_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'example_project',
|
||||
dropdownMenuEvent: 'example-project',
|
||||
modalVariant: 'blank_latex',
|
||||
dropdownMenuEvent: 'blank-project-latex',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('example_project')}
|
||||
{t('blank_latex_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'blank_typst',
|
||||
dropdownMenuEvent: 'blank-project-typst',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('blank_typst_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'example_quarto',
|
||||
dropdownMenuEvent: 'example-project-quarto',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('example_quarto_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'example_latex',
|
||||
dropdownMenuEvent: 'example-project-latex',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('example_latex_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
onClick={e =>
|
||||
handleModalMenuClick(e, {
|
||||
modalVariant: 'example_typst',
|
||||
dropdownMenuEvent: 'example-project-typst',
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('example_typst_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
|
||||
@@ -5,9 +5,14 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types'
|
||||
type BlankProjectModalProps = {
|
||||
onHide: () => void
|
||||
initialTags?: Tag[]
|
||||
template?: string
|
||||
}
|
||||
|
||||
function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
|
||||
function BlankProjectModal({
|
||||
onHide,
|
||||
initialTags,
|
||||
template = 'blank_quarto',
|
||||
}: BlankProjectModalProps) {
|
||||
return (
|
||||
<OLModal
|
||||
show
|
||||
@@ -16,7 +21,11 @@ function BlankProjectModal({ onHide, initialTags }: BlankProjectModalProps) {
|
||||
id="blank-project-modal"
|
||||
backdrop="static"
|
||||
>
|
||||
<ModalContentNewProjectForm onCancel={onHide} initialTags={initialTags} />
|
||||
<ModalContentNewProjectForm
|
||||
onCancel={onHide}
|
||||
template={template}
|
||||
initialTags={initialTags}
|
||||
/>
|
||||
</OLModal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import { Tag } from '../../../../../../app/src/Features/Tags/types'
|
||||
type ExampleProjectModalProps = {
|
||||
onHide: () => void
|
||||
initialTags?: Tag[]
|
||||
template?: string
|
||||
}
|
||||
|
||||
function ExampleProjectModal({
|
||||
onHide,
|
||||
initialTags,
|
||||
template = 'example_latex',
|
||||
}: ExampleProjectModalProps) {
|
||||
return (
|
||||
<OLModal
|
||||
@@ -21,7 +23,7 @@ function ExampleProjectModal({
|
||||
>
|
||||
<ModalContentNewProjectForm
|
||||
onCancel={onHide}
|
||||
template="example"
|
||||
template={template}
|
||||
initialTags={initialTags}
|
||||
/>
|
||||
</OLModal>
|
||||
|
||||
@@ -11,8 +11,12 @@ const UploadProjectModal = lazy(() => import('./upload-project-modal'))
|
||||
const ImportDocumentModal = lazy(() => import('./import-document-modal'))
|
||||
|
||||
export type NewProjectButtonModalVariant =
|
||||
| 'blank_project'
|
||||
| 'example_project'
|
||||
| 'blank_quarto'
|
||||
| 'blank_latex'
|
||||
| 'blank_typst'
|
||||
| 'example_quarto'
|
||||
| 'example_latex'
|
||||
| 'example_typst'
|
||||
| 'upload_project'
|
||||
| 'import_from_github'
|
||||
| 'import_docx'
|
||||
@@ -50,10 +54,54 @@ function NewProjectButtonModal({
|
||||
)
|
||||
|
||||
switch (modal) {
|
||||
case 'blank_project':
|
||||
return <BlankProjectModal onHide={onHide} initialTags={initialTags} />
|
||||
case 'example_project':
|
||||
return <ExampleProjectModal onHide={onHide} initialTags={initialTags} />
|
||||
case 'blank_quarto':
|
||||
return (
|
||||
<BlankProjectModal
|
||||
onHide={onHide}
|
||||
initialTags={initialTags}
|
||||
template="blank_quarto"
|
||||
/>
|
||||
)
|
||||
case 'blank_latex':
|
||||
return (
|
||||
<BlankProjectModal
|
||||
onHide={onHide}
|
||||
initialTags={initialTags}
|
||||
template="blank_latex"
|
||||
/>
|
||||
)
|
||||
case 'blank_typst':
|
||||
return (
|
||||
<BlankProjectModal
|
||||
onHide={onHide}
|
||||
initialTags={initialTags}
|
||||
template="blank_typst"
|
||||
/>
|
||||
)
|
||||
case 'example_quarto':
|
||||
return (
|
||||
<ExampleProjectModal
|
||||
onHide={onHide}
|
||||
initialTags={initialTags}
|
||||
template="example_quarto"
|
||||
/>
|
||||
)
|
||||
case 'example_latex':
|
||||
return (
|
||||
<ExampleProjectModal
|
||||
onHide={onHide}
|
||||
initialTags={initialTags}
|
||||
template="example_latex"
|
||||
/>
|
||||
)
|
||||
case 'example_typst':
|
||||
return (
|
||||
<ExampleProjectModal
|
||||
onHide={onHide}
|
||||
initialTags={initialTags}
|
||||
template="example_typst"
|
||||
/>
|
||||
)
|
||||
case 'upload_project':
|
||||
return (
|
||||
<Suspense fallback={<FullSizeLoadingSpinner delay={500} />}>
|
||||
|
||||
@@ -19,10 +19,7 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
|
||||
import Footer from '@/shared/components/footer/footer'
|
||||
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
|
||||
import SystemMessages from '@/shared/components/system-messages'
|
||||
import overleafLogo from '@/shared/svgs/overleaf-a-ds-solution-mallard.svg'
|
||||
import overleafLogoDark from '@/shared/svgs/overleaf-a-ds-solution-mallard-dark.svg'
|
||||
import CookieBanner from '@/shared/components/cookie-banner'
|
||||
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
|
||||
import { isSplitTestEnabled } from '@/utils/splitTestUtils'
|
||||
|
||||
export function ProjectListDsNav() {
|
||||
@@ -38,7 +35,6 @@ export function ProjectListDsNav() {
|
||||
tags,
|
||||
selectedTagId,
|
||||
} = useProjectListContext()
|
||||
const activeOverallTheme = useActiveOverallTheme()
|
||||
const isLibraryEnabled = isSplitTestEnabled('overleaf-library')
|
||||
|
||||
const selectedTag = tags.find(tag => tag._id === selectedTagId)
|
||||
@@ -87,13 +83,7 @@ export function ProjectListDsNav() {
|
||||
className={`project-ds-nav-page website-redesign${isLibraryEnabled ? ' library-enabled' : ''}`}
|
||||
>
|
||||
<SystemMessages />
|
||||
<DefaultNavbar
|
||||
{...navbarProps}
|
||||
overleafLogo={
|
||||
activeOverallTheme === 'dark' ? overleafLogoDark : overleafLogo
|
||||
}
|
||||
showCloseIcon
|
||||
/>
|
||||
<DefaultNavbar {...navbarProps} showCloseIcon />
|
||||
<div className="project-list-wrapper">
|
||||
<SidebarDsNav />
|
||||
<div className="project-ds-nav-content-and-messages">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Project } from '../../../../../../../types/project/dashboard/api'
|
||||
import { ProjectCompiler } from '../../../../../../../types/project-settings'
|
||||
|
||||
// Map the stored compiler engine to the document format the project produces.
|
||||
// CLSI dispatches the real engine from the root file's extension, but the
|
||||
// compiler field is a faithful, cheap proxy for the project's format.
|
||||
function formatLabel(compiler: ProjectCompiler | undefined): {
|
||||
label: string
|
||||
variant: 'quarto' | 'typst' | 'latex'
|
||||
} {
|
||||
switch (compiler) {
|
||||
case 'quarto':
|
||||
return { label: 'Quarto', variant: 'quarto' }
|
||||
case 'typst':
|
||||
return { label: 'Typst', variant: 'typst' }
|
||||
default:
|
||||
// pdflatex / latex / xelatex / lualatex (and the legacy default)
|
||||
return { label: 'LaTeX', variant: 'latex' }
|
||||
}
|
||||
}
|
||||
|
||||
type FormatCellProps = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export default function FormatCell({ project }: FormatCellProps) {
|
||||
const { label, variant } = formatLabel(project.compiler)
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`project-format-badge project-format-badge-${variant}`}
|
||||
translate="no"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { memo } from 'react'
|
||||
import InlineTags from './cells/inline-tags'
|
||||
import FormatCell from './cells/format-cell'
|
||||
import OwnerCell from './cells/owner-cell'
|
||||
import LastUpdatedCell from './cells/last-updated-cell'
|
||||
import ActionsCell from './cells/actions-cell'
|
||||
@@ -31,6 +32,9 @@ function ProjectListTableRow({ project, selected }: ProjectListTableRowProps) {
|
||||
<LastUpdatedCell project={project} />
|
||||
{ownerName ? <ProjectListOwnerName ownerName={ownerName} /> : null}
|
||||
</td>
|
||||
<td className="dash-cell-format d-none d-md-table-cell">
|
||||
<FormatCell project={project} />
|
||||
</td>
|
||||
<td className="dash-cell-owner d-none d-md-table-cell">
|
||||
<OwnerCell project={project} />
|
||||
</td>
|
||||
|
||||
@@ -94,6 +94,12 @@ function ProjectListTable() {
|
||||
>
|
||||
{t('date_and_owner')}
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-format d-none d-md-table-cell"
|
||||
aria-label={t('format')}
|
||||
>
|
||||
{t('format')}
|
||||
</th>
|
||||
<th
|
||||
className="dash-cell-owner d-none d-md-table-cell"
|
||||
aria-label={t('owner')}
|
||||
|
||||
@@ -113,22 +113,82 @@ function WelcomeMessageCreateNewProjectDropdown({
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(e, 'blank_project', 'blank-project')
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'blank_quarto',
|
||||
'blank-project-quarto'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('blank_project')}
|
||||
{t('blank_quarto_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(e, 'example_project', 'example-project')
|
||||
handleDropdownItemClick(e, 'blank_latex', 'blank-project-latex')
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('example_project')}
|
||||
{t('blank_latex_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(e, 'blank_typst', 'blank-project-typst')
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('blank_typst_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'example_quarto',
|
||||
'example-project-quarto'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('example_quarto_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'example_latex',
|
||||
'example-project-latex'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('example_latex_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
<DropdownItem
|
||||
as="button"
|
||||
onClick={e =>
|
||||
handleDropdownItemClick(
|
||||
e,
|
||||
'example_typst',
|
||||
'example-project-typst'
|
||||
)
|
||||
}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{t('example_typst_project')}
|
||||
</DropdownItem>
|
||||
</li>
|
||||
<li role="none">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useProjectSettingsContext } from '@/features/editor-left-menu/context/project-settings-context'
|
||||
import DropdownSetting from '../dropdown-setting'
|
||||
import type { Option } from '../dropdown-setting'
|
||||
@@ -6,39 +6,67 @@ import { useTranslation } from 'react-i18next'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { ProjectCompiler } from '@ol-types/project-settings'
|
||||
import { useSetCompilationSettingWithEvent } from '@/features/editor-left-menu/hooks/use-set-compilation-setting'
|
||||
import getMeta from '@/utils/meta'
|
||||
import _ from 'lodash'
|
||||
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
|
||||
|
||||
// Which compiler engines make sense for a given root-file extension. CLSI
|
||||
// dispatches the real engine from this extension (.qmd → Quarto, .typ → Typst,
|
||||
// .tex → latexmk), so offering, say, XeLaTeX for a .qmd root is meaningless.
|
||||
const ENGINES_BY_EXTENSION: Record<string, ProjectCompiler[]> = {
|
||||
tex: ['pdflatex', 'latex', 'xelatex', 'lualatex'],
|
||||
qmd: ['quarto'],
|
||||
rmd: ['quarto'],
|
||||
typ: ['typst'],
|
||||
}
|
||||
|
||||
function getCompilerOptions(): Option<ProjectCompiler>[] {
|
||||
const compilerOptions = ['pdfLaTeX', 'LaTeX', 'XeLaTeX', 'LuaLaTeX']
|
||||
const defaultCompiler = getMeta('ol-defaultLatexCompiler') as ProjectCompiler
|
||||
const sortedOptions = _.sortBy(
|
||||
compilerOptions,
|
||||
option => option.toLowerCase() !== defaultCompiler.toLowerCase()
|
||||
)
|
||||
return sortedOptions.map(option => ({
|
||||
value: option.toLowerCase() as ProjectCompiler,
|
||||
label: option,
|
||||
}))
|
||||
return [
|
||||
{ value: 'quarto', label: 'Quarto' },
|
||||
{ value: 'typst', label: 'Typst' },
|
||||
{ value: 'pdflatex', label: 'pdfLaTeX' },
|
||||
{ value: 'latex', label: 'LaTeX' },
|
||||
{ value: 'xelatex', label: 'XeLaTeX' },
|
||||
{ value: 'lualatex', label: 'LuaLaTeX' },
|
||||
]
|
||||
}
|
||||
|
||||
export default function CompilerSetting() {
|
||||
const { compiler, setCompiler } = useProjectSettingsContext()
|
||||
const { compiler, setCompiler, rootDocId } = useProjectSettingsContext()
|
||||
const [compilerOptions] = useState(() => getCompilerOptions())
|
||||
const { t } = useTranslation()
|
||||
const { write } = usePermissionsContext()
|
||||
const { docs } = useFileTreeData()
|
||||
const changeCompiler = useSetCompilationSettingWithEvent(
|
||||
'compiler',
|
||||
setCompiler
|
||||
)
|
||||
|
||||
// Disable the engines that don't apply to the current root file's extension.
|
||||
// The currently-selected engine is always left enabled so it keeps showing.
|
||||
const options = useMemo(() => {
|
||||
const rootDoc = rootDocId
|
||||
? docs?.find(doc => doc.doc.id === rootDocId)
|
||||
: undefined
|
||||
const extension = rootDoc?.doc.name.split('.').pop()?.toLowerCase()
|
||||
const allowed = extension ? ENGINES_BY_EXTENSION[extension] : undefined
|
||||
|
||||
if (!allowed) {
|
||||
// Unknown / no root file: don't restrict anything.
|
||||
return compilerOptions
|
||||
}
|
||||
|
||||
return compilerOptions.map(option => ({
|
||||
...option,
|
||||
disabled: !allowed.includes(option.value) && option.value !== compiler,
|
||||
}))
|
||||
}, [compilerOptions, docs, rootDocId, compiler])
|
||||
|
||||
return (
|
||||
<DropdownSetting
|
||||
id="compiler"
|
||||
label={t('compiler')}
|
||||
description={t('the_latex_engine_used_for_compiling')}
|
||||
disabled={!write}
|
||||
options={compilerOptions}
|
||||
options={options}
|
||||
onChange={changeCompiler}
|
||||
value={compiler}
|
||||
translateOptions="no"
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useProjectContext } from '@/shared/context/project-context'
|
||||
import {
|
||||
getJSON,
|
||||
postJSON,
|
||||
deleteJSON,
|
||||
getUserFacingMessage,
|
||||
} from '@/infrastructure/fetch-json'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
|
||||
type Tier = 'public' | 'login' | 'member'
|
||||
|
||||
type PublishState = {
|
||||
published: boolean
|
||||
publicUrl?: string
|
||||
loginUrl?: string
|
||||
memberUrl?: string
|
||||
publishedAt?: string
|
||||
}
|
||||
|
||||
// "Share only the compiled result": publish the compiled HTML/RevealJS deck as
|
||||
// a standalone, linkable page. Three stable links are offered, one per access
|
||||
// tier (project members / logged-in users / anyone), each independently
|
||||
// copyable and resettable. Re-publishing refreshes the deck while keeping the
|
||||
// links; Reset rotates a single link's token, breaking only that old URL.
|
||||
export default function PublishPresentationSection() {
|
||||
const { t } = useTranslation()
|
||||
const { project } = useProjectContext()
|
||||
const projectId = project?._id || getMeta('ol-project_id')
|
||||
|
||||
const [state, setState] = useState<PublishState>({ published: false })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState<Tier | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
getJSON(`/project/${projectId}/publish-presentation`)
|
||||
.then((data: PublishState) => setState(data))
|
||||
.catch(() => {})
|
||||
}, [projectId])
|
||||
|
||||
const run = useCallback((p: Promise<any>) => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
p.then((data: PublishState) => setState(data))
|
||||
.catch(err => setError(getUserFacingMessage(err) ?? 'error'))
|
||||
.finally(() => setBusy(false))
|
||||
}, [])
|
||||
|
||||
const publish = useCallback(
|
||||
() => run(postJSON(`/project/${projectId}/publish-presentation`)),
|
||||
[projectId, run]
|
||||
)
|
||||
|
||||
const regenerate = useCallback(
|
||||
(tier: Tier) =>
|
||||
run(
|
||||
postJSON(`/project/${projectId}/publish-presentation/regenerate`, {
|
||||
body: { tier },
|
||||
})
|
||||
),
|
||||
[projectId, run]
|
||||
)
|
||||
|
||||
const unpublish = useCallback(() => {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
deleteJSON(`/project/${projectId}/publish-presentation`)
|
||||
.then(() => setState({ published: false }))
|
||||
.catch(err => setError(getUserFacingMessage(err) ?? 'error'))
|
||||
.finally(() => setBusy(false))
|
||||
}, [projectId])
|
||||
|
||||
const copy = useCallback((tier: Tier, url?: string) => {
|
||||
if (url && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(url)
|
||||
setCopied(tier)
|
||||
setTimeout(() => setCopied(null), 2000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const tiers: { tier: Tier; label: string; url?: string }[] = [
|
||||
{ tier: 'member', label: t('presentation_link_members'), url: state.memberUrl },
|
||||
{ tier: 'login', label: t('presentation_link_private'), url: state.loginUrl },
|
||||
{ tier: 'public', label: t('presentation_link_public'), url: state.publicUrl },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="public-access-level mb-4">
|
||||
<h4>{t('share_compiled_presentation')}</h4>
|
||||
<p className="small text-muted mb-2">
|
||||
{t('share_compiled_presentation_info')}
|
||||
</p>
|
||||
|
||||
{state.published &&
|
||||
tiers.map(({ tier, label, url }) => (
|
||||
<div className="mb-2" key={tier}>
|
||||
<label className="small fw-bold mb-1 d-block">{label}</label>
|
||||
<div className="input-group">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
className="form-control"
|
||||
value={url || ''}
|
||||
onFocus={e => e.target.select()}
|
||||
/>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={() => copy(tier, url)}
|
||||
disabled={busy}
|
||||
>
|
||||
{copied === tier ? t('copied') : t('copy')}
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
onClick={() => regenerate(tier)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t('reset_link')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="d-flex gap-2 mt-2">
|
||||
<OLButton
|
||||
variant="primary"
|
||||
onClick={publish}
|
||||
disabled={busy || !projectId}
|
||||
isLoading={busy}
|
||||
>
|
||||
{t('publish')}
|
||||
</OLButton>
|
||||
{state.published && (
|
||||
<OLButton variant="secondary" onClick={unpublish} disabled={busy}>
|
||||
{t('unpublish')}
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2">
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
error === 'error' ? t('generic_something_went_wrong') : error
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import EditMember from './edit-member'
|
||||
import LinkSharing from './link-sharing'
|
||||
import Invite from './invite'
|
||||
@@ -15,6 +16,7 @@ import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import ErrorMessage from '@/features/share-project-modal/components/error-message'
|
||||
import ProjectAccess from '@/features/share-project-modal/components/project-access'
|
||||
import InvitedPeople from '@/features/share-project-modal/components/invited-people'
|
||||
import PublishPresentationSection from '@/features/share-project-modal/components/publish-presentation-section'
|
||||
|
||||
type ShareModalBodyProps = {
|
||||
isInvitedPeopleScreen: boolean
|
||||
@@ -27,6 +29,7 @@ export default function ShareModalBody({
|
||||
setIsInvitedPeopleScreen,
|
||||
error,
|
||||
}: ShareModalBodyProps) {
|
||||
const { t } = useTranslation()
|
||||
const { project, features } = useProjectContext()
|
||||
const { members, invites } = project || {}
|
||||
const { isProjectOwner } = useEditorContext()
|
||||
@@ -114,13 +117,18 @@ export default function ShareModalBody({
|
||||
|
||||
return (
|
||||
<>
|
||||
{isProjectOwner && <PublishPresentationSection />}
|
||||
|
||||
{isProjectOwner ? (
|
||||
<SendInvites
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
|
||||
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
|
||||
somePendingEditorsResolved={somePendingEditorsResolved}
|
||||
/>
|
||||
<>
|
||||
<h4>{t('add_collaborators')}</h4>
|
||||
<SendInvites
|
||||
canAddCollaborators={canAddCollaborators}
|
||||
hasExceededCollaboratorLimit={hasExceededCollaboratorLimit}
|
||||
haveAnyEditorsBeenDowngraded={haveAnyEditorsBeenDowngraded}
|
||||
somePendingEditorsResolved={somePendingEditorsResolved}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SendInvitesNotice />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCodeMirrorStateContext } from './codemirror-context'
|
||||
import React, { useEffect } from 'react'
|
||||
import { documentOutline } from '../languages/latex/document-outline'
|
||||
import { markdownDocumentOutline } from '../languages/markdown/document-outline'
|
||||
import { typstDocumentOutline } from '../languages/typst/document-outline'
|
||||
import { ProjectionStatus } from '../utils/tree-operations/projection'
|
||||
import useDebounce from '../../../shared/hooks/use-debounce'
|
||||
import { useOutlineContext } from '@/features/ide-react/context/outline-context'
|
||||
@@ -10,7 +12,11 @@ export const CodemirrorOutline = React.memo(function CodemirrorOutline() {
|
||||
|
||||
const state = useCodeMirrorStateContext()
|
||||
const debouncedState = useDebounce(state, 100)
|
||||
const outlineResult = debouncedState.field(documentOutline, false)
|
||||
// Use whichever outline StateField is active for the current language
|
||||
const outlineResult =
|
||||
debouncedState.field(documentOutline, false) ??
|
||||
debouncedState.field(markdownDocumentOutline, false) ??
|
||||
debouncedState.field(typstDocumentOutline, false)
|
||||
|
||||
// when the outline projection changes, calculate the flat outline
|
||||
useEffect(() => {
|
||||
|
||||
@@ -67,4 +67,11 @@ export const languages = [
|
||||
return import('@codemirror/lang-python').then(m => m.python())
|
||||
},
|
||||
}),
|
||||
LanguageDescription.of({
|
||||
name: 'typst',
|
||||
extensions: ['typ'],
|
||||
load: () => {
|
||||
return import('./typst').then(m => m.typst())
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
snippetCompletion,
|
||||
} from '@codemirror/autocomplete'
|
||||
|
||||
// Quarto code chunks: ```{lang} … ```
|
||||
const fenceLanguages = [
|
||||
'python',
|
||||
'r',
|
||||
'julia',
|
||||
'ojs',
|
||||
'bash',
|
||||
'sql',
|
||||
'mermaid',
|
||||
'dot',
|
||||
]
|
||||
|
||||
const fenceOptions: Completion[] = fenceLanguages.map(lang =>
|
||||
snippetCompletion(`\`\`\`{${lang}}\n\${}\n\`\`\``, {
|
||||
label: `\`\`\`{${lang}}`,
|
||||
detail: 'code chunk',
|
||||
type: 'keyword',
|
||||
})
|
||||
)
|
||||
|
||||
// Quarto callout / fenced div blocks (::: …)
|
||||
const calloutTypes = [
|
||||
'callout-note',
|
||||
'callout-tip',
|
||||
'callout-warning',
|
||||
'callout-important',
|
||||
'callout-caution',
|
||||
]
|
||||
|
||||
const divOptions: Completion[] = [
|
||||
...calloutTypes.map(type =>
|
||||
snippetCompletion(`::: {.${type}}\n\${content}\n:::`, {
|
||||
label: `::: {.${type}}`,
|
||||
detail: 'callout',
|
||||
type: 'class',
|
||||
})
|
||||
),
|
||||
snippetCompletion('::: {.column width="${50%}"}\n${content}\n:::', {
|
||||
label: '::: {.column}',
|
||||
detail: 'column',
|
||||
type: 'class',
|
||||
}),
|
||||
snippetCompletion('::: {.columns}\n${content}\n:::', {
|
||||
label: '::: {.columns}',
|
||||
detail: 'columns',
|
||||
type: 'class',
|
||||
}),
|
||||
snippetCompletion('::: {.panel-tabset}\n${content}\n:::', {
|
||||
label: '::: {.panel-tabset}',
|
||||
detail: 'tabset',
|
||||
type: 'class',
|
||||
}),
|
||||
]
|
||||
|
||||
// Cross-reference prefixes (@fig-, @tbl-, @sec-, @eq-, …)
|
||||
const refOptions: Completion[] = [
|
||||
{ label: '@fig-', detail: 'figure reference', type: 'variable' },
|
||||
{ label: '@tbl-', detail: 'table reference', type: 'variable' },
|
||||
{ label: '@sec-', detail: 'section reference', type: 'variable' },
|
||||
{ label: '@eq-', detail: 'equation reference', type: 'variable' },
|
||||
{ label: '@lst-', detail: 'listing reference', type: 'variable' },
|
||||
]
|
||||
|
||||
export const quartoCompletions = (
|
||||
context: CompletionContext
|
||||
): CompletionResult | null => {
|
||||
// Code chunk fence
|
||||
const fence = context.matchBefore(/```\{?[\w-]*\}?/)
|
||||
if (fence && fence.from !== fence.to) {
|
||||
return {
|
||||
from: fence.from,
|
||||
options: fenceOptions,
|
||||
validFor: /^```\{?[\w-]*\}?$/,
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced divs / callouts
|
||||
const div = context.matchBefore(/:::+\s*\{?\.?[\w-]*\}?/)
|
||||
if (div && div.from !== div.to) {
|
||||
return {
|
||||
from: div.from,
|
||||
options: divOptions,
|
||||
}
|
||||
}
|
||||
|
||||
// Cross references
|
||||
const ref = context.matchBefore(/@[\w-]*/)
|
||||
if (ref && (ref.from !== ref.to || context.explicit)) {
|
||||
return {
|
||||
from: ref.from,
|
||||
options: refOptions,
|
||||
validFor: /^@[\w-]*$/,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { SyntaxNodeRef } from '@lezer/common'
|
||||
import {
|
||||
FlatOutlineItem,
|
||||
NestingLevel,
|
||||
} from '../../utils/tree-operations/outline'
|
||||
import { NodeIntersectsChangeFn } from '../../utils/tree-operations/projection'
|
||||
import { makeProjectionStateField } from '../../utils/projection-state-field'
|
||||
|
||||
// Map Lezer Markdown node names to outline nesting levels
|
||||
const HEADING_LEVELS: Record<string, NestingLevel> = {
|
||||
ATXHeading1: NestingLevel.Section,
|
||||
ATXHeading2: NestingLevel.SubSection,
|
||||
ATXHeading3: NestingLevel.SubSubSection,
|
||||
ATXHeading4: NestingLevel.Paragraph,
|
||||
ATXHeading5: NestingLevel.SubParagraph,
|
||||
ATXHeading6: NestingLevel.SubParagraph,
|
||||
SetextHeading1: NestingLevel.Section,
|
||||
SetextHeading2: NestingLevel.SubSection,
|
||||
}
|
||||
|
||||
// Offset of the end of the YAML frontmatter block, or 0 if there is none.
|
||||
// Quarto/Pandoc documents start with a `---` line and close with `---` or
|
||||
// `...`. The Lezer Markdown grammar has no frontmatter support, so it parses
|
||||
// the closing `---` as a Setext heading underline and turns the last
|
||||
// frontmatter line (e.g. `format: typst`) into a bogus heading. We use this
|
||||
// boundary to ignore any heading that falls inside the frontmatter.
|
||||
function frontmatterEnd(state: EditorState): number {
|
||||
const firstLine = state.doc.line(1)
|
||||
if (firstLine.text.trim() !== '---') return 0
|
||||
for (let n = 2; n <= state.doc.lines; n++) {
|
||||
const line = state.doc.line(n)
|
||||
if (line.text.trim() === '---' || line.text.trim() === '...') {
|
||||
return line.to
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const enterMarkdownNode = (
|
||||
state: EditorState,
|
||||
node: SyntaxNodeRef,
|
||||
items: FlatOutlineItem[],
|
||||
nodeIntersectsChange: NodeIntersectsChangeFn
|
||||
): void => {
|
||||
const level = HEADING_LEVELS[node.name]
|
||||
if (level === undefined) return
|
||||
|
||||
// Skip "headings" that are really inside the YAML frontmatter (see above).
|
||||
if (node.from < frontmatterEnd(state)) return
|
||||
|
||||
if (!nodeIntersectsChange(node)) {
|
||||
// Node unchanged — already present in items from the previous projection
|
||||
return
|
||||
}
|
||||
|
||||
// In the Lezer Markdown grammar the heading text is NOT a child node —
|
||||
// the only children are HeaderMark nodes ('#'s for ATX, the '='/'-'
|
||||
// underline for Setext). The title text sits in the gaps between marks.
|
||||
// So we slice the whole heading and strip the marker characters:
|
||||
// - ATX: leading '#'s and any optional trailing '#'s
|
||||
// - Setext: the trailing underline line ('===' / '---')
|
||||
// We also strip a trailing Pandoc/Quarto attribute block, e.g.
|
||||
// `## Slide {.smaller auto-animate="true"}`.
|
||||
const raw = state.sliceDoc(node.from, node.to)
|
||||
const title = raw
|
||||
.replace(/^\s*#+\s*/, '') // ATX: leading ### markers
|
||||
.replace(/\s*#+\s*$/, '') // ATX: optional closing ### markers
|
||||
.replace(/\n[=-]+\s*$/, '') // Setext: underline line
|
||||
.replace(/\s*\{[^}]*\}\s*$/, '') // Pandoc/Quarto attributes
|
||||
.trim()
|
||||
|
||||
items.push({
|
||||
line: state.doc.lineAt(node.from).number,
|
||||
toLine: state.doc.lineAt(node.to).number,
|
||||
title,
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
level,
|
||||
} as FlatOutlineItem)
|
||||
}
|
||||
|
||||
export const markdownDocumentOutline =
|
||||
makeProjectionStateField<FlatOutlineItem>(enterMarkdownNode)
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
import { markdownDocumentOutline } from './document-outline'
|
||||
import { quartoCompletions } from './complete'
|
||||
|
||||
export const markdown = () => {
|
||||
const { language, support } = markdownLanguage({
|
||||
@@ -19,6 +21,8 @@ export const markdown = () => {
|
||||
support,
|
||||
shortcuts(),
|
||||
syntaxHighlighting(markdownHighlightStyle),
|
||||
markdownDocumentOutline,
|
||||
language.data.of({ autocomplete: quartoCompletions }),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionResult,
|
||||
snippetCompletion,
|
||||
} from '@codemirror/autocomplete'
|
||||
|
||||
// Common Typst functions and constructs. Snippets use ${} placeholders so Tab
|
||||
// jumps between fields. The leading '#' is included so completions work whether
|
||||
// the user has typed it yet or not (the matched range covers an optional '#').
|
||||
const snippets: Completion[] = [
|
||||
snippetCompletion('#import "${}": ${}', {
|
||||
label: '#import',
|
||||
detail: 'import module',
|
||||
type: 'keyword',
|
||||
}),
|
||||
snippetCompletion('#include "${}"', {
|
||||
label: '#include',
|
||||
detail: 'include file',
|
||||
type: 'keyword',
|
||||
}),
|
||||
snippetCompletion('#let ${name} = ${value}', {
|
||||
label: '#let',
|
||||
detail: 'binding',
|
||||
type: 'keyword',
|
||||
}),
|
||||
snippetCompletion('#set ${func}(${})', {
|
||||
label: '#set',
|
||||
detail: 'set rule',
|
||||
type: 'keyword',
|
||||
}),
|
||||
snippetCompletion('#show ${selector}: ${func}', {
|
||||
label: '#show',
|
||||
detail: 'show rule',
|
||||
type: 'keyword',
|
||||
}),
|
||||
snippetCompletion('#figure(\n ${body},\n caption: [${caption}],\n)', {
|
||||
label: '#figure',
|
||||
detail: 'figure with caption',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#image("${path}", width: ${width})', {
|
||||
label: '#image',
|
||||
detail: 'image',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion(
|
||||
'#table(\n columns: ${2},\n ${[a], [b]},\n)',
|
||||
{
|
||||
label: '#table',
|
||||
detail: 'table',
|
||||
type: 'function',
|
||||
}
|
||||
),
|
||||
snippetCompletion('#grid(\n columns: ${2},\n ${},\n)', {
|
||||
label: '#grid',
|
||||
detail: 'grid layout',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#text(${args})[${body}]', {
|
||||
label: '#text',
|
||||
detail: 'styled text',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#link("${url}")[${body}]', {
|
||||
label: '#link',
|
||||
detail: 'hyperlink',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#cite(<${key}>)', {
|
||||
label: '#cite',
|
||||
detail: 'citation',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#footnote[${body}]', {
|
||||
label: '#footnote',
|
||||
detail: 'footnote',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#bibliography("${path}")', {
|
||||
label: '#bibliography',
|
||||
detail: 'bibliography',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#pagebreak()', {
|
||||
label: '#pagebreak',
|
||||
detail: 'page break',
|
||||
type: 'function',
|
||||
}),
|
||||
snippetCompletion('#lorem(${50})', {
|
||||
label: '#lorem',
|
||||
detail: 'placeholder text',
|
||||
type: 'function',
|
||||
}),
|
||||
]
|
||||
|
||||
// Bare function/keyword names that complete to themselves (callable builtins
|
||||
// the user may reference inside arguments or set/show rules).
|
||||
const builtinNames = [
|
||||
'heading',
|
||||
'par',
|
||||
'page',
|
||||
'document',
|
||||
'list',
|
||||
'enum',
|
||||
'terms',
|
||||
'raw',
|
||||
'math',
|
||||
'align',
|
||||
'stack',
|
||||
'box',
|
||||
'block',
|
||||
'rect',
|
||||
'circle',
|
||||
'ellipse',
|
||||
'line',
|
||||
'columns',
|
||||
'colbreak',
|
||||
'strong',
|
||||
'emph',
|
||||
'underline',
|
||||
'overline',
|
||||
'strike',
|
||||
'smallcaps',
|
||||
'super',
|
||||
'sub',
|
||||
'highlight',
|
||||
'quote',
|
||||
'outline',
|
||||
'numbering',
|
||||
'counter',
|
||||
'measure',
|
||||
'repeat',
|
||||
'pad',
|
||||
'move',
|
||||
'rotate',
|
||||
'scale',
|
||||
'place',
|
||||
'hide',
|
||||
]
|
||||
|
||||
const builtinCompletions: Completion[] = builtinNames.map(name => ({
|
||||
label: name,
|
||||
type: 'function',
|
||||
detail: 'builtin',
|
||||
}))
|
||||
|
||||
const options: Completion[] = [...snippets, ...builtinCompletions]
|
||||
|
||||
export const typstCompletions = (
|
||||
context: CompletionContext
|
||||
): CompletionResult | null => {
|
||||
// Match an optional leading '#' plus an identifier, so suggestions appear
|
||||
// both in code mode (#fig…) and when referencing names in arguments.
|
||||
const word = context.matchBefore(/#?[\w-]*/)
|
||||
if (!word) {
|
||||
return null
|
||||
}
|
||||
if (word.from === word.to && !context.explicit) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options,
|
||||
validFor: /^#?[\w-]*$/,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { EditorState, StateField } from '@codemirror/state'
|
||||
import {
|
||||
ProjectionResult,
|
||||
ProjectionStatus,
|
||||
} from '../../utils/tree-operations/projection'
|
||||
import {
|
||||
FlatOutlineItem,
|
||||
NestingLevel,
|
||||
} from '../../utils/tree-operations/outline'
|
||||
|
||||
// Typst headings are '='-prefixed lines: '=' is a section, '==' a subsection,
|
||||
// and so on. Map heading depth to the shared outline nesting levels.
|
||||
const LEVELS: NestingLevel[] = [
|
||||
NestingLevel.Section,
|
||||
NestingLevel.SubSection,
|
||||
NestingLevel.SubSubSection,
|
||||
NestingLevel.Paragraph,
|
||||
NestingLevel.SubParagraph,
|
||||
]
|
||||
|
||||
// A heading is one or more leading '=' at column 0, a space, then the title.
|
||||
// '==' as an equality operator never starts a line at column 0 with a space
|
||||
// after it, so this stays clear of code.
|
||||
const HEADING_REGEX = /^(=+)[ \t]+(.*\S)[ \t]*$/
|
||||
|
||||
function computeOutline(
|
||||
state: EditorState
|
||||
): ProjectionResult<FlatOutlineItem> {
|
||||
const items: FlatOutlineItem[] = []
|
||||
|
||||
for (let n = 1; n <= state.doc.lines; n++) {
|
||||
const line = state.doc.line(n)
|
||||
const match = HEADING_REGEX.exec(line.text)
|
||||
if (!match) continue
|
||||
|
||||
const depth = match[1].length
|
||||
const level = LEVELS[Math.min(depth, LEVELS.length) - 1]
|
||||
// Strip a trailing label, e.g. '= Introduction <intro>'.
|
||||
const title = match[2].replace(/\s*<[\w-]+>\s*$/, '').trim()
|
||||
|
||||
items.push({
|
||||
line: n,
|
||||
toLine: n,
|
||||
title,
|
||||
from: line.from,
|
||||
to: line.to,
|
||||
level,
|
||||
} as FlatOutlineItem)
|
||||
}
|
||||
|
||||
return { items, status: ProjectionStatus.Complete }
|
||||
}
|
||||
|
||||
// The Typst language uses a StreamLanguage, whose syntax tree has no structural
|
||||
// heading nodes to project from, so we scan the document text directly. Typst
|
||||
// files are small, so a full rescan on each edit is cheap.
|
||||
export const typstDocumentOutline = StateField.define<
|
||||
ProjectionResult<FlatOutlineItem>
|
||||
>({
|
||||
create(state) {
|
||||
return computeOutline(state)
|
||||
},
|
||||
update(value, transaction) {
|
||||
if (transaction.docChanged) {
|
||||
return computeOutline(transaction.state)
|
||||
}
|
||||
return value
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
StreamLanguage,
|
||||
StreamParser,
|
||||
LanguageSupport,
|
||||
} from '@codemirror/language'
|
||||
import { tags as t } from '@lezer/highlight'
|
||||
import { typstCompletions } from './complete'
|
||||
import { typstDocumentOutline } from './document-outline'
|
||||
|
||||
const keywords = new Set([
|
||||
'let',
|
||||
'set',
|
||||
'show',
|
||||
'import',
|
||||
'include',
|
||||
'if',
|
||||
'else',
|
||||
'for',
|
||||
'while',
|
||||
'return',
|
||||
'break',
|
||||
'continue',
|
||||
'in',
|
||||
'as',
|
||||
'and',
|
||||
'or',
|
||||
'not',
|
||||
'context',
|
||||
])
|
||||
|
||||
const atoms = new Set(['none', 'auto', 'true', 'false'])
|
||||
|
||||
type TypstState = {
|
||||
inBlockComment: boolean
|
||||
}
|
||||
|
||||
// A lightweight stream tokenizer for Typst. It is not a full grammar, but it
|
||||
// gives sensible highlighting for the common constructs: comments, strings,
|
||||
// headings, code (#... functions and keywords), math delimiters, labels,
|
||||
// references, numbers with units and markup emphasis markers. Token names are
|
||||
// mapped to standard highlight tags via `tokenTable`, so the editor's global
|
||||
// class highlighter themes them automatically (light + dark).
|
||||
const parser: StreamParser<TypstState> = {
|
||||
startState() {
|
||||
return { inBlockComment: false }
|
||||
},
|
||||
|
||||
token(stream, state) {
|
||||
if (state.inBlockComment) {
|
||||
if (stream.match(/.*?\*\//)) {
|
||||
state.inBlockComment = false
|
||||
} else {
|
||||
stream.skipToEnd()
|
||||
}
|
||||
return 'comment'
|
||||
}
|
||||
|
||||
if (stream.eatSpace()) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Comments
|
||||
if (stream.match('/*')) {
|
||||
state.inBlockComment = true
|
||||
return 'comment'
|
||||
}
|
||||
if (stream.match('//')) {
|
||||
stream.skipToEnd()
|
||||
return 'comment'
|
||||
}
|
||||
|
||||
// Strings
|
||||
if (stream.match(/"(?:[^"\\]|\\.)*"/)) {
|
||||
return 'string'
|
||||
}
|
||||
|
||||
// Headings: one or more '=' at the start of a line
|
||||
if (stream.sol() && stream.match(/=+\s/)) {
|
||||
return 'heading'
|
||||
}
|
||||
|
||||
// Labels <name> and references @name
|
||||
if (stream.match(/<[\w-]+>/)) {
|
||||
return 'label'
|
||||
}
|
||||
if (stream.match(/@[\w-]+/)) {
|
||||
return 'ref'
|
||||
}
|
||||
|
||||
// Math delimiter
|
||||
if (stream.eat('$')) {
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
// Code mode: '#' introduces a function call or keyword
|
||||
if (stream.match(/#[A-Za-z_][\w-]*/)) {
|
||||
const word = stream.current().slice(1)
|
||||
return keywords.has(word) ? 'keyword' : 'function'
|
||||
}
|
||||
if (stream.eat('#')) {
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
// Identifiers, keywords and atoms (inside code blocks/args)
|
||||
if (stream.match(/[A-Za-z_][\w-]*/)) {
|
||||
const word = stream.current()
|
||||
if (keywords.has(word)) {
|
||||
return 'keyword'
|
||||
}
|
||||
if (atoms.has(word)) {
|
||||
return 'atom'
|
||||
}
|
||||
if (stream.peek() === '(') {
|
||||
return 'function'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Numbers, optionally with a unit
|
||||
if (stream.match(/\d+(?:\.\d+)?(?:pt|mm|cm|in|em|fr|deg|%)?/)) {
|
||||
return 'number'
|
||||
}
|
||||
|
||||
// Markup emphasis markers
|
||||
if (stream.eat('*') || stream.eat('_')) {
|
||||
return 'operator'
|
||||
}
|
||||
|
||||
stream.next()
|
||||
return null
|
||||
},
|
||||
|
||||
tokenTable: {
|
||||
comment: t.comment,
|
||||
string: t.string,
|
||||
keyword: t.keyword,
|
||||
number: t.number,
|
||||
function: t.function(t.variableName),
|
||||
heading: t.heading,
|
||||
label: t.labelName,
|
||||
ref: t.labelName,
|
||||
operator: t.operator,
|
||||
atom: t.atom,
|
||||
},
|
||||
|
||||
languageData: {
|
||||
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
|
||||
closeBrackets: { brackets: ['(', '[', '{', '"', '$'] },
|
||||
},
|
||||
}
|
||||
|
||||
export const TypstLanguage = StreamLanguage.define(parser)
|
||||
|
||||
export const typst = () => {
|
||||
return new LanguageSupport(TypstLanguage, [
|
||||
TypstLanguage.data.of({ autocomplete: typstCompletions }),
|
||||
typstDocumentOutline,
|
||||
])
|
||||
}
|
||||
@@ -24,7 +24,7 @@ export class FlatOutlineItem extends ProjectionItem {
|
||||
export type FlatOutline = FlatOutlineItem[]
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
enum NestingLevel {
|
||||
export enum NestingLevel {
|
||||
Book = 1,
|
||||
Part = 2,
|
||||
Chapter = 3,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import InviteNotValid from '@/features/share-project/invite-not-valid'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useFeatureFlag } from '@/shared/context/split-test-context'
|
||||
import LoadingBranded from '@/shared/components/loading-branded'
|
||||
|
||||
export const AccessAttemptScreen: FC<{
|
||||
loadingScreenBrandHeight: string
|
||||
@@ -12,6 +13,11 @@ export const AccessAttemptScreen: FC<{
|
||||
const { t } = useTranslation()
|
||||
const user = getMeta('ol-user')
|
||||
const isSharingUpdatesEnabled = useFeatureFlag('sharing-updates')
|
||||
// The brand height is a "% filled" string (e.g. "20%") driven by request
|
||||
// progress; map it to the Verso loader's colour-reveal saturation.
|
||||
const brandProgress = loadingScreenBrandHeight.endsWith('%')
|
||||
? parseFloat(loadingScreenBrandHeight)
|
||||
: 0
|
||||
|
||||
if (isSharingUpdatesEnabled) {
|
||||
if (accessError) {
|
||||
@@ -21,12 +27,7 @@ export const AccessAttemptScreen: FC<{
|
||||
return (
|
||||
<div className="vertically-centered-content">
|
||||
<div className="loading-screen">
|
||||
<div className="loading-screen-brand-container">
|
||||
<div
|
||||
className="loading-screen-brand"
|
||||
style={{ height: loadingScreenBrandHeight }}
|
||||
/>
|
||||
</div>
|
||||
<LoadingBranded loadProgress={brandProgress} hasError />
|
||||
|
||||
<h3 className="loading-screen-label text-center">
|
||||
{t('join_project')}
|
||||
@@ -39,12 +40,7 @@ export const AccessAttemptScreen: FC<{
|
||||
|
||||
return (
|
||||
<div className="loading-screen">
|
||||
<div className="loading-screen-brand-container">
|
||||
<div
|
||||
className="loading-screen-brand"
|
||||
style={{ height: loadingScreenBrandHeight }}
|
||||
/>
|
||||
</div>
|
||||
<LoadingBranded loadProgress={brandProgress} hasError />
|
||||
|
||||
<h3 className="loading-screen-label text-center">
|
||||
{t('join_project')}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { LatexLogEntry, ParseResult } from './latex-log-parser'
|
||||
|
||||
// Parser for the combined stdout/stderr that `quarto render` writes to
|
||||
// output.log (see services/clsi/app/js/QuartoRunner.js). Quarto orchestrates
|
||||
// several tools, each with its own diagnostic style:
|
||||
//
|
||||
// - Typst (the engine for .qmd -> PDF): emits
|
||||
// error: unexpected end of block comment
|
||||
// ┌─ main.typ:5:10
|
||||
// ...and the analogous `warning: ...` form. Older builds use `-->` instead
|
||||
// of the box-drawing arrow.
|
||||
// - Pandoc (markdown -> typst/html): emits `[WARNING] ...` / `[ERROR] ...`.
|
||||
// - Quarto CLI itself (YAML validation, project errors, Deno crashes): emits
|
||||
// `ERROR: ...` / `WARNING: ...` (upper-case) or `error: Uncaught ...`.
|
||||
// - knitr/R (.Rmd / executable cells): emits `Quitting from lines 3-7 (x.qmd)`
|
||||
// followed by an `Error: ...` message.
|
||||
//
|
||||
// This is deliberately a flat, line-oriented parser rather than the stateful
|
||||
// LaTeX one: Quarto's output has no nested-file `(...)` structure to track.
|
||||
// It returns the same ParseResult shape so the rest of the log pipeline
|
||||
// (HumanReadableLogs consumers, the errors/warnings tabs, editor annotations)
|
||||
// can treat Quarto entries exactly like LaTeX ones.
|
||||
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_REGEX = /\x1b\[[0-9;]*m/g
|
||||
|
||||
// Typst / Deno: `error: message`, `warning: message` (lower-case prefix)
|
||||
const LOWER_DIAG_REGEX = /^(error|warning): (.*)$/
|
||||
// Quarto CLI: `ERROR: message`, `WARNING: message` (upper-case prefix)
|
||||
const UPPER_DIAG_REGEX = /^(ERROR|WARNING): (.*)$/
|
||||
// Pandoc: `[WARNING] message`, `[ERROR] message`, `[INFO] message`
|
||||
const PANDOC_REGEX = /^\[(WARNING|ERROR|INFO)\] (.*)$/
|
||||
// knitr/R: `Quitting from lines 3-7 (slides.qmd)`
|
||||
const R_QUITTING_REGEX = /^Quitting from lines? (\d+)(?:-\d+)?\s*(?:\(([^)]+)\))?/
|
||||
// Python (Jupyter cell execution): a missing dependency, e.g.
|
||||
// ModuleNotFoundError: No module named 'pandas'
|
||||
// ImportError: No module named scipy
|
||||
const PY_MODULE_REGEX =
|
||||
/^(?:ModuleNotFoundError|ImportError): No module named ['"]?([\w.]+)['"]?/
|
||||
// Import (module) name -> PyPI package name, for the common cases where they
|
||||
// differ. Anything not listed defaults to the module name itself.
|
||||
const PY_MODULE_TO_PACKAGE: Record<string, string> = {
|
||||
cv2: 'opencv-python',
|
||||
sklearn: 'scikit-learn',
|
||||
skimage: 'scikit-image',
|
||||
PIL: 'Pillow',
|
||||
yaml: 'PyYAML',
|
||||
bs4: 'beautifulsoup4',
|
||||
Crypto: 'pycryptodome',
|
||||
OpenSSL: 'pyOpenSSL',
|
||||
dateutil: 'python-dateutil',
|
||||
dotenv: 'python-dotenv',
|
||||
serial: 'pyserial',
|
||||
usb: 'pyusb',
|
||||
cairo: 'pycairo',
|
||||
gi: 'PyGObject',
|
||||
win32com: 'pywin32',
|
||||
}
|
||||
// A typst diagnostic location line: ` ┌─ main.typ:5:10` / ` --> main.typ:5:10`
|
||||
const TYPST_LOCATION_REGEX = /(?:[┌╭]─|-->)\s*(.+?):(\d+):(\d+)/
|
||||
|
||||
function stripAnsi(line: string): string {
|
||||
return line.replace(ANSI_REGEX, '')
|
||||
}
|
||||
|
||||
function isDiagnosticStart(trimmed: string): boolean {
|
||||
return (
|
||||
LOWER_DIAG_REGEX.test(trimmed) ||
|
||||
UPPER_DIAG_REGEX.test(trimmed) ||
|
||||
PANDOC_REGEX.test(trimmed)
|
||||
)
|
||||
}
|
||||
|
||||
export default function parseQuartoLog(rawLog: string): ParseResult {
|
||||
const lines = rawLog.replace(/\r\n?/g, '\n').split('\n')
|
||||
const data: LatexLogEntry[] = []
|
||||
|
||||
let pendingLocation: { file?: string; line?: number } = {}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const clean = stripAnsi(lines[i])
|
||||
const trimmed = clean.trimStart()
|
||||
|
||||
// Remember the most recent knitr location; it precedes the `Error:` line.
|
||||
const quitting = trimmed.match(R_QUITTING_REGEX)
|
||||
if (quitting) {
|
||||
pendingLocation = {
|
||||
line: parseInt(quitting[1], 10),
|
||||
file: quitting[2],
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// A missing Python package when executing a {python} cell. Turn the raw
|
||||
// traceback line into an actionable message rather than letting it slip
|
||||
// through as an opaque error (or not be surfaced at all).
|
||||
const pyModule = trimmed.match(PY_MODULE_REGEX)
|
||||
if (pyModule) {
|
||||
const moduleName = pyModule[1]
|
||||
// Suggest the PyPI package for the top-level module (cv2 -> opencv-python).
|
||||
const topLevel = moduleName.split('.')[0]
|
||||
const suggestion = PY_MODULE_TO_PACKAGE[topLevel] || topLevel
|
||||
data.push({
|
||||
line: pendingLocation.line ?? null,
|
||||
file: pendingLocation.file,
|
||||
level: 'error',
|
||||
message: `Python module "${moduleName}" is not available`,
|
||||
content:
|
||||
`${clean}\n\n` +
|
||||
`If "${topLevel}" is a PyPI package, add \`${suggestion}\` to your ` +
|
||||
`Verso requirements file (requirements.vrf) and recompile as the ` +
|
||||
`project owner or a collaborator. If it is your own module, add its ` +
|
||||
`.py file(s) to the project instead.\n` +
|
||||
`Pre-installed: numpy, pandas, scipy, matplotlib, seaborn, ` +
|
||||
`scikit-learn, sympy, plotly, tabulate, opencv-python (cv2), tqdm.`,
|
||||
raw: clean,
|
||||
})
|
||||
pendingLocation = {}
|
||||
continue
|
||||
}
|
||||
|
||||
let level: LatexLogEntry['level'] | null = null
|
||||
let message: string | null = null
|
||||
|
||||
let m: RegExpMatchArray | null
|
||||
if ((m = trimmed.match(LOWER_DIAG_REGEX))) {
|
||||
level = m[1] === 'error' ? 'error' : 'warning'
|
||||
message = m[2]
|
||||
} else if ((m = trimmed.match(UPPER_DIAG_REGEX))) {
|
||||
level = m[1] === 'ERROR' ? 'error' : 'warning'
|
||||
message = m[2]
|
||||
} else if ((m = trimmed.match(PANDOC_REGEX))) {
|
||||
if (m[1] === 'INFO') continue // pandoc INFO lines are not actionable
|
||||
level = m[1] === 'ERROR' ? 'error' : 'warning'
|
||||
message = m[2]
|
||||
}
|
||||
|
||||
if (level === null || message === null) continue
|
||||
|
||||
// Accumulate any following indented/diagnostic lines (the typst box, a Deno
|
||||
// stack trace, R traceback) as the entry's content, and pick up a
|
||||
// file:line:col location from the typst box if present. Stop at a blank
|
||||
// line or the start of the next diagnostic.
|
||||
let file = pendingLocation.file
|
||||
let line: number | null = pendingLocation.line ?? null
|
||||
let content = clean
|
||||
let j = i + 1
|
||||
for (; j < lines.length; j++) {
|
||||
const next = stripAnsi(lines[j])
|
||||
if (next.trim() === '') break
|
||||
if (isDiagnosticStart(next.trimStart())) break
|
||||
content += '\n' + next
|
||||
const loc = next.match(TYPST_LOCATION_REGEX)
|
||||
if (loc && !file) {
|
||||
file = loc[1]
|
||||
line = parseInt(loc[2], 10)
|
||||
}
|
||||
}
|
||||
i = j - 1
|
||||
|
||||
data.push({
|
||||
line,
|
||||
file,
|
||||
level,
|
||||
message: message.trim(),
|
||||
content,
|
||||
raw: content,
|
||||
})
|
||||
|
||||
pendingLocation = {}
|
||||
}
|
||||
|
||||
return postProcess(data)
|
||||
}
|
||||
|
||||
function postProcess(data: LatexLogEntry[]): ParseResult {
|
||||
const all: LatexLogEntry[] = []
|
||||
const byLevel: Record<'error' | 'warning' | 'typesetting', LatexLogEntry[]> = {
|
||||
error: [],
|
||||
warning: [],
|
||||
typesetting: [],
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const entry of data) {
|
||||
if (seen.has(entry.raw)) continue
|
||||
seen.add(entry.raw)
|
||||
byLevel[entry.level]?.push(entry)
|
||||
all.push(entry)
|
||||
}
|
||||
|
||||
return {
|
||||
errors: byLevel.error,
|
||||
warnings: byLevel.warning,
|
||||
typesetting: byLevel.typesetting,
|
||||
all,
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
@@ -40,40 +40,43 @@ function Separator() {
|
||||
)
|
||||
}
|
||||
|
||||
function ThinFooter({
|
||||
showPoweredBy,
|
||||
subdomainLang,
|
||||
leftItems,
|
||||
rightItems,
|
||||
}: FooterMetadata) {
|
||||
function ThinFooter({ subdomainLang, leftItems, rightItems }: FooterMetadata) {
|
||||
const showLanguagePicker = Boolean(
|
||||
subdomainLang && Object.keys(subdomainLang).length > 1
|
||||
)
|
||||
|
||||
const hasCustomLeftNav = Boolean(leftItems && leftItems.length > 0)
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer-content d-print-none">
|
||||
<OLRow>
|
||||
<ul className="site-footer-items col-lg-9">
|
||||
{showPoweredBy ? (
|
||||
<>
|
||||
<li>
|
||||
{/* year of Server Pro release, static */}© 2025{' '}
|
||||
<a href="https://www.overleaf.com/for/enterprises">
|
||||
Powered by Overleaf
|
||||
</a>
|
||||
</li>
|
||||
{showLanguagePicker || hasCustomLeftNav ? <Separator /> : null}
|
||||
</>
|
||||
) : null}
|
||||
<li>
|
||||
© {new Date().getFullYear()}{' '}
|
||||
<a
|
||||
href="https://alocoq.fr"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Aloïs Coquillard
|
||||
</a>
|
||||
</li>
|
||||
<Separator />
|
||||
<li>
|
||||
Built on{' '}
|
||||
<a
|
||||
href="https://github.com/overleaf/overleaf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Overleaf
|
||||
</a>
|
||||
</li>
|
||||
{showLanguagePicker ? (
|
||||
<>
|
||||
<Separator />
|
||||
<li>
|
||||
<LanguagePicker showHeader />
|
||||
</li>
|
||||
{hasCustomLeftNav ? <Separator /> : null}
|
||||
</>
|
||||
) : null}
|
||||
{leftItems?.map(item => (
|
||||
@@ -81,6 +84,25 @@ function ThinFooter({
|
||||
))}
|
||||
</ul>
|
||||
<ul className="site-footer-items col-lg-3 text-end">
|
||||
<li>
|
||||
<a
|
||||
href="https://git.alocoq.fr/alois/verso/src/branch/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
AGPL licence
|
||||
</a>
|
||||
</li>
|
||||
<Separator />
|
||||
<li>
|
||||
<a
|
||||
href="https://git.alocoq.fr/alois/verso"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Source code
|
||||
</a>
|
||||
</li>
|
||||
{rightItems?.map(item => (
|
||||
<FooterItemLi key={item.text} {...item} />
|
||||
))}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function Interstitial({
|
||||
}: InterstitialProps) {
|
||||
return (
|
||||
<div className={classNames('interstitial', className)}>
|
||||
{showLogo && <img className="logo" src={overleafLogo} alt="Overleaf" />}
|
||||
{showLogo && <img className="logo" src={overleafLogo} alt="Verso" />}
|
||||
{title && <h1 className="h3 interstitial-header">{title}</h1>}
|
||||
<div className={classNames(contentClassName)}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CSSProperties } from 'react'
|
||||
|
||||
type LoadingBrandedTypes = {
|
||||
loadProgress: number // Percentage
|
||||
label?: string
|
||||
@@ -9,13 +11,81 @@ export default function LoadingBranded({
|
||||
label,
|
||||
hasError = false,
|
||||
}: LoadingBrandedTypes) {
|
||||
// Drive the colour reveal from the load progress: the four circles start
|
||||
// de-saturated and "warm up" to full colour as the project loads, while
|
||||
// each circle keeps drifting on its own little orbit (see the CSS).
|
||||
const saturation = Math.max(0, Math.min(1, loadProgress / 100))
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="loading-screen-brand-container">
|
||||
<div
|
||||
className="loading-screen-brand"
|
||||
style={{ height: `${loadProgress}%` }}
|
||||
/>
|
||||
<svg
|
||||
className="verso-loader"
|
||||
viewBox="0 0 200 200"
|
||||
role="img"
|
||||
aria-label="Verso"
|
||||
style={
|
||||
{
|
||||
'--verso-loader-saturation': saturation,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="verso-loader-clip">
|
||||
<rect x="0" y="0" width="200" height="200" rx="44" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#verso-loader-clip)">
|
||||
<rect x="0" y="0" width="200" height="200" fill="#0a0a0a" />
|
||||
<g className="verso-loader-circles">
|
||||
<circle
|
||||
className="verso-loader-c1"
|
||||
cx="72"
|
||||
cy="72"
|
||||
r="68"
|
||||
fill="#447099"
|
||||
fillOpacity="0.82"
|
||||
/>
|
||||
<circle
|
||||
className="verso-loader-c2"
|
||||
cx="128"
|
||||
cy="72"
|
||||
r="68"
|
||||
fill="#72994E"
|
||||
fillOpacity="0.82"
|
||||
/>
|
||||
<circle
|
||||
className="verso-loader-c3"
|
||||
cx="72"
|
||||
cy="128"
|
||||
r="68"
|
||||
fill="#EE6331"
|
||||
fillOpacity="0.75"
|
||||
/>
|
||||
<circle
|
||||
className="verso-loader-c4"
|
||||
cx="128"
|
||||
cy="128"
|
||||
r="68"
|
||||
fill="#4a6fa5"
|
||||
fillOpacity="0.80"
|
||||
/>
|
||||
</g>
|
||||
<text
|
||||
className="verso-loader-v"
|
||||
x="100"
|
||||
y="164"
|
||||
fontFamily="'EB Garamond', Georgia, serif"
|
||||
fontSize="170"
|
||||
fontWeight="400"
|
||||
textAnchor="middle"
|
||||
fill="white"
|
||||
fillOpacity="0.97"
|
||||
>
|
||||
V
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{!hasError && (
|
||||
|
||||
@@ -10,6 +10,9 @@ import { useContactUsModal } from '@/shared/hooks/use-contact-us-modal'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
import { AccountMenuItems } from '@/shared/components/navbar/account-menu-items'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import { useActiveOverallTheme } from '@/shared/hooks/use-active-overall-theme'
|
||||
import versoLogo from '@/shared/svgs/verso-logo.svg'
|
||||
import versoLogoDark from '@/shared/svgs/verso-logo-dark.svg'
|
||||
|
||||
export function SidebarLowerSection({
|
||||
showThemeToggle = false,
|
||||
@@ -30,6 +33,8 @@ export function SidebarLowerSection({
|
||||
autofillProjectUrl: false,
|
||||
})
|
||||
const { sessionUser, showSubscriptionLink, items } = getMeta('ol-navbar')
|
||||
const { appName } = getMeta('ol-ExposedSettings')
|
||||
const activeOverallTheme = useActiveOverallTheme()
|
||||
const helpItem = items.find(
|
||||
item => item.text === 'help_and_resources'
|
||||
) as NavbarDropdownItemData
|
||||
@@ -128,9 +133,18 @@ export function SidebarLowerSection({
|
||||
</Dropdown>
|
||||
)}
|
||||
</nav>
|
||||
<div className="ds-nav-ds-name" translate="no">
|
||||
<span>Digital Science</span>
|
||||
</div>
|
||||
<a
|
||||
href="/"
|
||||
className="ds-nav-verso-logo"
|
||||
aria-label={appName}
|
||||
style={{ display: 'block', marginTop: 'var(--spacing-03)' }}
|
||||
>
|
||||
<img
|
||||
src={activeOverallTheme === 'dark' ? versoLogoDark : versoLogo}
|
||||
alt={appName}
|
||||
style={{ width: '100%', height: 'auto' }}
|
||||
/>
|
||||
</a>
|
||||
<UserProvider>{contactUsModal}</UserProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -505,6 +505,15 @@ export const LocalCompileProvider: FC<React.PropsWithChildren> = ({
|
||||
// set the PDF context
|
||||
if (data.status === 'success') {
|
||||
setPdfFile(handleOutputFiles(outputFiles, projectId, data))
|
||||
} else {
|
||||
// For PDF output we intentionally keep the last good PDF visible
|
||||
// next to the error log (standard Overleaf behaviour). But for an
|
||||
// HTML deck rendered in an iframe, leaving the previous deck up is
|
||||
// confusing — it looks like the failed compile succeeded. So drop
|
||||
// a stale HTML output on any non-success status.
|
||||
setPdfFile(prev =>
|
||||
prev?.path === 'output.html' ? undefined : prev
|
||||
)
|
||||
}
|
||||
|
||||
setFileList(buildFileList(outputFiles, data))
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
<svg width="129" height="38" viewBox="0 0 129 38" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_2579_355" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="72" height="38">
|
||||
<path d="M71.7643 37.6327H0.0244141V0.0717773H71.7643V37.6327Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_2579_355)">
|
||||
<path d="M47.2509 26.4555C47.3948 27.7507 47.8985 28.7821 48.81 29.5257C49.6974 30.2692 50.8487 30.653 52.2638 30.653C53.1993 30.653 54.0387 30.4611 54.7823 30.0773C55.5258 29.6696 56.1255 29.1419 56.5572 28.4223H61.0664C60.2989 30.3891 59.1716 31.9002 57.6365 33.0035C56.1255 34.0829 54.3506 34.6345 52.3598 34.6345C51.0166 34.6345 49.7934 34.3947 48.666 33.915C47.5387 33.4352 46.5314 32.7397 45.6199 31.8043C44.7804 30.9168 44.1089 29.9094 43.6531 28.7341C43.1974 27.5589 42.9576 26.3836 42.9576 25.1603C42.9576 23.9131 43.1734 22.7138 43.6052 21.6105C44.0369 20.5072 44.6605 19.4998 45.5 18.6124C46.4114 17.629 47.4668 16.8854 48.6181 16.3817C49.7694 15.8541 50.9686 15.5902 52.1919 15.5902C53.7509 15.5902 55.214 15.95 56.5572 16.6456C57.9004 17.3651 59.0517 18.3485 60.0111 19.6437C60.5867 20.4113 61.0185 21.2747 61.3063 22.2581C61.5941 23.2175 61.714 24.3209 61.714 25.5681C61.714 25.664 61.714 25.8079 61.69 26.0238C61.69 26.2397 61.6661 26.3836 61.6661 26.4795H47.2509V26.4555ZM57.2048 23.1216C56.845 21.9223 56.2454 21.0109 55.4059 20.3873C54.5664 19.7637 53.4871 19.4519 52.2159 19.4519C51.0886 19.4519 50.1052 19.7876 49.2177 20.4592C48.3303 21.1308 47.7306 22.0183 47.4188 23.1216H57.2048ZM71.7638 19.7637C70.1328 19.8836 69.0055 20.3153 68.3579 21.0349C67.7103 21.7544 67.3985 23.0496 67.3985 24.9205V34.1068H63.2011V16.1179H67.1347V18.2046C67.7583 17.3891 68.4539 16.8135 69.2214 16.4297C69.9649 16.0459 70.8284 15.8541 71.7638 15.8541V19.7637ZM32.428 1.24705C27.3432 -0.743722 8.9465 -1.46328 8.92251 9.52196C3.54982 12.9519 0 18.5404 0 24.5367C0 31.7803 5.87638 37.6567 13.1199 37.6567C20.3635 37.6567 26.2399 31.7803 26.2399 24.5367C26.2399 18.9482 22.738 14.1511 17.797 12.2803C16.8376 11.9205 14.7749 11.2729 13.1439 11.4168C10.7934 12.9039 7.91513 15.974 6.57196 19.0441C8.58672 16.6216 11.7288 15.5662 14.5351 16.022C18.6365 16.6936 21.7786 20.2434 21.7786 24.5607C21.7786 29.3338 17.917 33.1954 13.1439 33.1954C10.5055 33.1954 8.15498 32.0201 6.57196 30.1733C4.19742 27.415 3.59779 24.4408 4.07749 21.5386C5.73247 11.3688 17.797 5.58838 26.7675 3.35775C23.8413 4.9168 18.5646 7.45923 14.8708 10.2175C25.6402 14.391 27.3911 5.30056 32.428 1.24705ZM36.7934 34.1308H33.5074L26.6716 16.1179H31.1328L35.3303 28.0865L39.6476 16.1179H43.9889L36.7934 34.1308Z" fill="#1B222C"/>
|
||||
</g>
|
||||
<path d="M83.6127 26.4556C83.7567 27.7508 84.2843 28.7822 85.1718 29.5257C86.0592 30.2692 87.2105 30.653 88.6257 30.653C89.5611 30.653 90.4006 30.4611 91.1441 30.0774C91.8877 29.6696 92.4873 29.1419 92.919 28.4224H97.4282C96.6607 30.3892 95.5334 31.9002 93.9984 33.0036C92.4873 34.0829 90.7124 34.6346 88.7216 34.6346C87.3784 34.6346 86.1552 34.3947 85.0279 33.915C83.9006 33.4353 82.8932 32.7397 81.9817 31.8043C81.1423 30.9168 80.4707 29.9095 80.015 28.7342C79.5353 27.5829 79.3194 26.3836 79.3194 25.1604C79.3194 23.9131 79.5353 22.7139 79.967 21.6106C80.3987 20.5072 81.0223 19.4999 81.8618 18.6124C82.7733 17.629 83.8286 16.8855 84.9799 16.3818C86.1312 15.8541 87.3305 15.5903 88.5537 15.5903C90.1128 15.5903 91.5758 15.95 92.919 16.6456C94.2622 17.3652 95.4135 18.3486 96.3729 19.6438C96.9485 20.4113 97.3803 21.2748 97.6681 22.2582C97.9559 23.2176 98.0758 24.3209 98.0758 25.5681C98.0758 25.6641 98.0758 25.808 98.0519 26.0238C98.0519 26.2397 98.0279 26.3836 98.0279 26.4796H83.6127V26.4556ZM93.5426 23.1216C93.1829 21.9224 92.5832 21.0109 91.7437 20.3873C90.9043 19.7637 89.8249 19.4519 88.5537 19.4519C87.4264 19.4519 86.443 19.7877 85.5556 20.4593C84.6681 21.1309 84.0685 22.0183 83.7567 23.1216H93.5426ZM114.698 34.1309V31.9242C114.194 32.8117 113.498 33.4833 112.587 33.915C111.675 34.3467 110.5 34.5626 109.085 34.5626C106.423 34.5626 104.192 33.6512 102.417 31.8283C100.642 30.0054 99.7308 27.7508 99.7308 25.0644C99.7308 23.7932 99.9467 22.594 100.402 21.4667C100.858 20.3393 101.482 19.332 102.321 18.4685C103.209 17.5091 104.216 16.8135 105.295 16.3578C106.375 15.9021 107.622 15.6862 108.989 15.6862C110.308 15.6862 111.436 15.9021 112.371 16.3338C113.306 16.7655 114.074 17.4371 114.65 18.3246V16.1419H118.727V34.1548H114.698V34.1309ZM104.024 24.9685C104.024 26.4796 104.528 27.7508 105.535 28.7822C106.543 29.8135 107.766 30.3172 109.229 30.3172C110.548 30.3172 111.699 29.8135 112.707 28.7822C113.714 27.7508 114.218 26.5515 114.218 25.1844C114.218 23.7213 113.714 22.474 112.707 21.4187C111.699 20.3633 110.524 19.8357 109.157 19.8357C107.742 19.8357 106.543 20.3393 105.535 21.3227C104.528 22.3301 104.024 23.5294 104.024 24.9685ZM129.904 16.1179V19.8596H126.882V34.1309H122.829V19.8596H120.694V16.1179H122.709V15.6382C122.709 13.7434 123.236 12.3283 124.268 11.3929C125.323 10.4574 126.906 10.0017 129.041 10.0017C129.113 10.0017 129.257 10.0017 129.449 10.0257C129.64 10.0257 129.784 10.0497 129.904 10.0497V13.8154H129.616C128.657 13.8154 127.985 13.9833 127.578 14.2711C127.17 14.5829 126.954 15.0866 126.954 15.8301V16.1659H129.904V16.1179ZM73.5869 34.1309H77.6884V10.2895H73.5869V34.1309Z" fill="#1B222C"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
|
||||
<defs>
|
||||
<clipPath id="icb"><circle cx="25" cy="25" r="23"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#icb)">
|
||||
<rect x="2" y="2" width="23" height="23" fill="#447099"/>
|
||||
<rect x="25" y="2" width="23" height="23" fill="#72994E"/>
|
||||
<rect x="2" y="25" width="23" height="23" fill="#EE6331"/>
|
||||
<rect x="25" y="25" width="23" height="23" fill="#1B3B6F"/>
|
||||
<polyline points="8,12 25,38 42,12"
|
||||
stroke="white" stroke-width="6" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="56" y="33"
|
||||
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
|
||||
font-size="26" font-weight="600" fill="#1B3B6F" letter-spacing="-0.5">Verso</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 786 B |
@@ -1,9 +1,17 @@
|
||||
<svg width="104" height="30" viewBox="0 0 104 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_2946_8679" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="58" height="30">
|
||||
<path d="M57.4311 30H0.132324V0H57.4311V30Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_2946_8679)">
|
||||
<path d="M37.8711 21.0606C37.9861 22.0943 38.3884 22.9174 39.1163 23.5108C39.8251 24.1042 40.7446 24.4105 41.8749 24.4105C42.622 24.4105 43.2925 24.2574 43.8863 23.9511C44.4802 23.6257 44.9591 23.2045 45.304 22.6303H48.9054C48.2924 24.1999 47.392 25.4059 46.166 26.2864C44.9591 27.1478 43.5415 27.5881 41.9515 27.5881C40.8787 27.5881 39.9017 27.3967 39.0014 27.0138C38.101 26.631 37.2964 26.0759 36.5685 25.3293C35.898 24.6211 35.3616 23.8171 34.9976 22.8791C34.6336 21.9412 34.4421 21.0032 34.4421 20.027C34.4421 19.0316 34.6145 18.0745 34.9593 17.1939C35.3041 16.3134 35.8022 15.5094 36.4727 14.8011C37.2006 14.0163 38.0435 13.4229 38.9631 13.0209C39.8826 12.5998 40.8404 12.3892 41.8174 12.3892C43.0626 12.3892 44.2312 12.6764 45.304 13.2315C46.3767 13.8058 47.2963 14.5906 48.0625 15.6243C48.5223 16.2368 48.8671 16.9259 49.097 17.7108C49.3269 18.4764 49.4226 19.357 49.4226 20.3524C49.4226 20.4289 49.4227 20.5438 49.4035 20.7161C49.4035 20.8883 49.3843 21.0032 49.3843 21.0798H37.8711V21.0606ZM45.8212 18.3999C45.5338 17.4428 45.0549 16.7154 44.3844 16.2177C43.7139 15.72 42.8519 15.4711 41.8366 15.4711C40.9362 15.4711 40.1508 15.7391 39.442 16.2751C38.7332 16.8111 38.2543 17.5193 38.0052 18.3999H45.8212ZM57.4493 15.72C56.1467 15.8157 55.2463 16.1602 54.7291 16.7345C54.2118 17.3088 53.9628 18.3424 53.9628 19.8355V27.167H50.6104V12.8104H53.7521V14.4757C54.2502 13.8249 54.8057 13.3655 55.4187 13.0592C56.0126 12.7529 56.7022 12.5998 57.4493 12.5998V15.72ZM26.0323 0.94225C21.971 -0.646546 7.27779 -1.22081 7.25864 7.54628C2.96752 10.2836 0.132324 14.7437 0.132324 19.5293C0.132324 25.3102 4.82573 30 10.6111 30C16.3964 30 21.0898 25.3102 21.0898 19.5293C21.0898 15.0691 18.2929 11.2407 14.3466 9.74763C13.5804 9.4605 11.9329 8.94366 10.6302 9.05851C8.75286 10.2453 6.45405 12.6955 5.38127 15.1457C6.99044 13.2124 9.49998 12.3701 11.7413 12.7338C15.0171 13.2698 17.5267 16.1028 17.5267 19.5484C17.5267 23.3577 14.4424 26.4396 10.6302 26.4396C8.52298 26.4396 6.64562 25.5016 5.38127 24.0277C3.48476 21.8263 3.00584 19.4527 3.38897 17.1365C4.71079 9.02023 14.3466 4.40698 21.5113 2.62676C19.1741 3.871 14.9597 5.90006 12.0095 8.10141C20.6109 11.4321 22.0093 4.17727 26.0323 0.94225ZM29.5188 27.1861H26.8943L21.4346 12.8104H24.9978L28.3502 22.3623L31.7984 12.8104H35.2658L29.5188 27.1861Z" fill="#046530"/>
|
||||
</g>
|
||||
<path d="M66.8949 21.0719C67.0098 22.1064 67.4313 22.9301 68.1401 23.524C68.8489 24.1179 69.7684 24.4244 70.8987 24.4244C71.6458 24.4244 72.3163 24.2711 72.9102 23.9646C73.5041 23.6389 73.983 23.2175 74.3278 22.6428H77.9293C77.3163 24.2137 76.4159 25.4206 75.1899 26.3018C73.983 27.1639 72.5654 27.6045 70.9753 27.6045C69.9025 27.6045 68.9255 27.4129 68.0251 27.0298C67.1248 26.6466 66.3202 26.0911 65.5922 25.3439C64.9217 24.6351 64.3853 23.8305 64.0213 22.8918C63.6382 21.9723 63.4658 21.0144 63.4658 20.0374C63.4658 19.0412 63.6382 18.0834 63.983 17.2022C64.3278 16.3209 64.8259 15.5163 65.4964 14.8075C66.2244 14.0221 67.0673 13.4282 67.9868 13.0259C68.9064 12.6045 69.8642 12.3937 70.8412 12.3937C72.0864 12.3937 73.255 12.6811 74.3278 13.2366C75.4006 13.8114 76.3201 14.5968 77.0864 15.6313C77.5462 16.2443 77.891 16.934 78.1209 17.7194C78.3508 18.4857 78.4466 19.3669 78.4466 20.3631C78.4466 20.4397 78.4466 20.5547 78.4274 20.7271C78.4274 20.8995 78.4083 21.0144 78.4083 21.0911H66.8949V21.0719ZM74.8259 18.4091C74.5385 17.4512 74.0596 16.7232 73.3891 16.2252C72.7186 15.7271 71.8566 15.478 70.8412 15.478C69.9408 15.478 69.1554 15.7462 68.4466 16.2826C67.7378 16.819 67.2589 17.5278 67.0098 18.4091H74.8259ZM91.7224 27.2022V25.4397C91.3201 26.1485 90.7646 26.6849 90.0366 27.0298C89.3086 27.3746 88.3699 27.547 87.2397 27.547C85.1132 27.547 83.3316 26.819 81.914 25.3631C80.4964 23.9071 79.7684 22.1064 79.7684 19.9608C79.7684 18.9455 79.9408 17.9876 80.3048 17.0872C80.6688 16.1868 81.1669 15.3822 81.8374 14.6926C82.5462 13.9263 83.3508 13.3707 84.2129 13.0068C85.0749 12.6428 86.0711 12.4704 87.1631 12.4704C88.2167 12.4704 89.1171 12.6428 89.8642 12.9876C90.6113 13.3324 91.2244 13.8688 91.6841 14.5776V12.8343H94.9408V27.2213H91.7224V27.2022ZM83.1975 19.8842C83.1975 21.0911 83.5998 22.1064 84.4044 22.9301C85.209 23.7539 86.186 24.1562 87.3546 24.1562C88.4083 24.1562 89.3278 23.7539 90.1324 22.9301C90.937 22.1064 91.3393 21.1485 91.3393 20.0566C91.3393 18.888 90.937 17.8918 90.1324 17.0489C89.3278 16.206 88.3891 15.7845 87.2972 15.7845C86.1669 15.7845 85.209 16.1868 84.4044 16.9723C83.5998 17.7769 83.1975 18.7347 83.1975 19.8842ZM103.868 12.8152V15.8037H101.454V27.2022H98.2167V15.8037H96.5117V12.8152H98.1209V12.432C98.1209 10.9186 98.5424 9.78837 99.3661 9.04124C100.209 8.29411 101.473 7.93013 103.178 7.93013C103.236 7.93013 103.351 7.93013 103.504 7.94929C103.657 7.94929 103.772 7.96844 103.868 7.96844V10.9761H103.638C102.872 10.9761 102.335 11.1102 102.01 11.3401C101.684 11.5891 101.512 11.9914 101.512 12.5853V12.8535H103.868V12.8152ZM58.8872 27.2022H62.1631V8.16002H58.8872V27.2022Z" fill="#046530"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
|
||||
<defs>
|
||||
<clipPath id="ic"><circle cx="25" cy="25" r="23"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#ic)">
|
||||
<rect x="2" y="2" width="23" height="23" fill="#447099"/>
|
||||
<rect x="25" y="2" width="23" height="23" fill="#72994E"/>
|
||||
<rect x="2" y="25" width="23" height="23" fill="#EE6331"/>
|
||||
<rect x="25" y="25" width="23" height="23" fill="#1B3B6F"/>
|
||||
<polyline points="8,12 25,38 42,12"
|
||||
stroke="white" stroke-width="6" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="56" y="33"
|
||||
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
|
||||
font-size="26" font-weight="600" fill="#447099" letter-spacing="-0.5">Verso</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 784 B |
@@ -1 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="120" viewBox="0 0 104 120" fill="none"><path d="M103.521 3.769C87.2881 -2.58618 28.56 -4.88324 28.4835 30.1851C11.3321 41.1344 0 58.9749 0 78.117C0 101.241 18.7593 120 41.883 120C65.0067 120 83.7659 101.241 83.7659 78.117C83.7659 60.2766 72.5869 44.9629 56.8138 38.9905C53.7511 37.842 47.1662 35.7746 41.9595 36.234C34.4558 40.9813 25.2676 50.7821 20.9798 60.5828C27.4115 52.8494 37.442 49.4804 46.4005 50.9352C59.4937 53.0791 69.5242 64.4113 69.5242 78.1936C69.5242 93.4307 57.1967 105.758 41.9595 105.758C33.537 105.758 26.0333 102.006 20.9798 96.1106C13.3995 87.3052 11.4853 77.8108 13.0166 68.546C18.2999 36.0809 56.8138 17.6279 85.4504 10.507C76.1091 15.484 59.264 23.6002 47.4725 32.4056C81.8517 45.7285 87.4412 16.7091 103.521 3.769Z" fill="#046530"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="104" viewBox="0 0 104 104">
|
||||
<defs>
|
||||
<clipPath id="c"><circle cx="52" cy="52" r="50"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#c)">
|
||||
<rect x="2" y="2" width="50" height="50" fill="#447099"/>
|
||||
<rect x="52" y="2" width="50" height="50" fill="#72994E"/>
|
||||
<rect x="2" y="52" width="50" height="50" fill="#EE6331"/>
|
||||
<rect x="52" y="52" width="50" height="50" fill="#1B3B6F"/>
|
||||
<polyline points="16,25 52,80 88,25"
|
||||
stroke="white" stroke-width="13" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 825 B After Width: | Height: | Size: 631 B |
@@ -1 +1,17 @@
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 542 157" enable-background="new 0 0 542 157"><style>.st0{filter:url(#Adobe_OpacityMaskFilter);} .st1{fill:#FFFFFF;} .st2{mask:url(#mask-2);fill:#FFFFFF;}</style><g id="Page-1"><g id="Overleaf"><g id="Group-3"><defs><filter id="Adobe_OpacityMaskFilter" filterUnits="userSpaceOnUse" x="0" y=".3" width="299.2" height="156.7"><feColorMatrix values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/></filter></defs><mask maskUnits="userSpaceOnUse" x="0" y=".3" width="299.2" height="156.7" id="mask-2"><g class="st0"><path id="path-1" class="st1" d="M299.2 156.9H.1V.3h299.1z"/></g></mask><path id="Fill-1" class="st2" d="M197 110.3c.6 5.4 2.7 9.7 6.5 12.8 3.7 3.1 8.5 4.7 14.4 4.7 3.9 0 7.4-.8 10.5-2.4 3.1-1.7 5.6-3.9 7.4-6.9h18.8c-3.2 8.2-7.9 14.5-14.3 19.1-6.3 4.5-13.7 6.8-22 6.8-5.6 0-10.7-1-15.4-3-4.7-2-8.9-4.9-12.7-8.8-3.5-3.7-6.3-7.9-8.2-12.8s-2.9-9.8-2.9-14.9c0-5.2.9-10.2 2.7-14.8 1.8-4.6 4.4-8.8 7.9-12.5 3.8-4.1 8.2-7.2 13-9.3 4.8-2.2 9.8-3.3 14.9-3.3 6.5 0 12.6 1.5 18.2 4.4 5.6 3 10.4 7.1 14.4 12.5 2.4 3.2 4.2 6.8 5.4 10.9 1.2 4 1.7 8.6 1.7 13.8 0 .4 0 1-.1 1.9 0 .9-.1 1.5-.1 1.9H197v-.1zm41.5-13.9c-1.5-5-4-8.8-7.5-11.4-3.5-2.6-8-3.9-13.3-3.9-4.7 0-8.8 1.4-12.5 4.2-3.7 2.8-6.2 6.5-7.5 11.1h40.8zm60.7-14c-6.8.5-11.5 2.3-14.2 5.3-2.7 3-4 8.4-4 16.2v38.3h-17.5v-75h16.4v8.7c2.6-3.4 5.5-5.8 8.7-7.4 3.1-1.6 6.7-2.4 10.6-2.4v16.3zm-164-77.2C114-3.1 37.3-6.1 37.2 39.7 14.8 54 0 77.3 0 102.3 0 132.5 24.5 157 54.7 157c30.2 0 54.7-24.5 54.7-54.7 0-23.3-14.6-43.3-35.2-51.1-4-1.5-12.6-4.2-19.4-3.6-9.8 6.2-21.8 19-27.4 31.8 8.4-10.1 21.5-14.5 33.2-12.6 17.1 2.8 30.2 17.6 30.2 35.6 0 19.9-16.1 36-36 36-11 0-20.8-4.9-27.4-12.6-9.9-11.5-12.4-23.9-10.4-36 6.9-42.4 57.2-66.5 94.6-75.8C99.4 20.5 77.4 31.1 62 42.6c44.9 17.4 52.2-20.5 73.2-37.4zm18.2 137.1h-13.7l-28.5-75.1h18.6l17.5 49.9 18-49.9h18.1l-30 75.1z"/></g><path id="Fill-4" class="st1" d="M348.6 110.3c.6 5.4 2.8 9.7 6.5 12.8 3.7 3.1 8.5 4.7 14.4 4.7 3.9 0 7.4-.8 10.5-2.4 3.1-1.7 5.6-3.9 7.4-6.9h18.8c-3.2 8.2-7.9 14.5-14.3 19.1-6.3 4.5-13.7 6.8-22 6.8-5.6 0-10.7-1-15.4-3-4.7-2-8.9-4.9-12.7-8.8-3.5-3.7-6.3-7.9-8.2-12.8-2-4.8-2.9-9.8-2.9-14.9 0-5.2.9-10.2 2.7-14.8 1.8-4.6 4.4-8.8 7.9-12.5 3.8-4.1 8.2-7.2 13-9.3 4.8-2.2 9.8-3.3 14.9-3.3 6.5 0 12.6 1.5 18.2 4.4 5.6 3 10.4 7.1 14.4 12.5 2.4 3.2 4.2 6.8 5.4 10.9 1.2 4 1.7 8.6 1.7 13.8 0 .4 0 1-.1 1.9 0 .9-.1 1.5-.1 1.9h-60.1v-.1zM390 96.4c-1.5-5-4-8.8-7.5-11.4-3.5-2.6-8-3.9-13.3-3.9-4.7 0-8.8 1.4-12.5 4.2-3.7 2.8-6.2 6.5-7.5 11.1H390zm88.2 45.9v-9.2c-2.1 3.7-5 6.5-8.8 8.3-3.8 1.8-8.7 2.7-14.6 2.7-11.1 0-20.4-3.8-27.8-11.4-7.4-7.6-11.2-17-11.2-28.2 0-5.3.9-10.3 2.8-15 1.9-4.7 4.5-8.9 8-12.5 3.7-4 7.9-6.9 12.4-8.8s9.7-2.8 15.4-2.8c5.5 0 10.2.9 14.1 2.7 3.9 1.8 7.1 4.6 9.5 8.3v-9.1h17v75.1h-16.8v-.1zm-44.5-38.2c0 6.3 2.1 11.6 6.3 15.9 4.2 4.3 9.3 6.4 15.4 6.4 5.5 0 10.3-2.1 14.5-6.4 4.2-4.3 6.3-9.3 6.3-15 0-6.1-2.1-11.3-6.3-15.7-4.2-4.4-9.1-6.6-14.8-6.6-5.9 0-10.9 2.1-15.1 6.2-4.2 4.2-6.3 9.2-6.3 15.2zm107.9-36.9v15.6H529v59.5h-16.9V82.8h-8.9V67.2h8.4v-2c0-7.9 2.2-13.8 6.5-17.7 4.4-3.9 11-5.8 19.9-5.8.3 0 .9 0 1.7.1.8 0 1.4.1 1.9.1v15.7h-1.2c-4 0-6.8.7-8.5 1.9-1.7 1.3-2.6 3.4-2.6 6.5v1.4h12.3v-.2zm-234.8 75.1h17.1V42.9h-17.1v99.4z"/></g></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
|
||||
<defs>
|
||||
<clipPath id="icw"><circle cx="25" cy="25" r="23"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#icw)">
|
||||
<rect x="2" y="2" width="23" height="23" fill="#75AADB"/>
|
||||
<rect x="25" y="2" width="23" height="23" fill="#A5C27E"/>
|
||||
<rect x="2" y="25" width="23" height="23" fill="#F5A383"/>
|
||||
<rect x="25" y="25" width="23" height="23" fill="#447099"/>
|
||||
<polyline points="8,12 25,38 42,12"
|
||||
stroke="white" stroke-width="6" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="56" y="33"
|
||||
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
|
||||
font-size="26" font-weight="600" fill="white" letter-spacing="-0.5">Verso</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 784 B |
@@ -1 +1,17 @@
|
||||
<svg width="542" height="157" viewBox="0 0 542 157" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path id="a" d="M299.24 156.94H.06V.28h299.18z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path d="M197.05 110.3c.58 5.4 2.74 9.7 6.45 12.78 3.7 3.13 8.5 4.7 14.37 4.7 3.9 0 7.4-.83 10.53-2.46 3.12-1.66 5.6-3.94 7.4-6.9h18.85c-3.2 8.18-7.94 14.54-14.3 19.08-6.34 4.52-13.67 6.78-22 6.78-5.6 0-10.75-.98-15.46-2.96-4.7-1.98-9-4.9-12.7-8.78-3.6-3.68-6.3-7.94-8.3-12.8-2-4.83-3-9.8-3-14.9 0-5.24.9-10.15 2.7-14.77 1.8-4.63 4.4-8.78 7.9-12.46 3.8-4 8.1-7.1 13-9.3 4.8-2.2 9.7-3.3 14.9-3.3 6.5 0 12.6 1.5 18.2 4.4 5.6 3 10.4 7.2 14.4 12.5 2.4 3.3 4.2 6.9 5.3 10.9s1.74 8.6 1.74 13.8c0 .4 0 1-.08 1.9-.07.9-.1 1.5-.1 1.9h-60zm41.4-13.87c-1.48-5-4-8.8-7.53-11.43-3.52-2.6-7.97-3.92-13.33-3.92-4.7 0-8.8 1.42-12.5 4.24-3.7 2.82-6.2 6.52-7.5 11.1h40.8zm60.8-14c-6.8.54-11.52 2.33-14.2 5.34-2.68 3-4.03 8.4-4.03 16.23v38.3h-17.47V67.22h16.37v8.67c2.64-3.4 5.52-5.9 8.67-7.5 3.1-1.6 6.6-2.4 10.6-2.4v16.3zM135.2 5.18c-21.16-8.25-97.87-11.3-98 34.47C14.82 53.98 0 77.35 0 102.33 0 132.53 24.48 157 54.68 157s54.68-24.48 54.68-54.67c0-23.34-14.63-43.28-35.2-51.1C70.2 49.7 61.6 47 54.73 47.58c-9.8 6.23-21.75 19.04-27.4 31.8 8.4-10.08 21.53-14.48 33.16-12.6 17.1 2.77 30.2 17.63 30.2 35.54 0 19.9-16.2 36.02-36.1 36.02-11 0-20.8-4.9-27.4-12.62-9.7-11.42-12.2-23.8-10.2-35.9C23.9 47.4 74.2 23.27 111.6 14 99.4 20.46 77.4 31.07 62 42.63c44.9 17.34 52.17-20.52 73.2-37.46zm18.2 137.12h-13.73l-28.52-75.08h18.62l17.47 49.9 18.03-49.9h18.14l-30 75.08z" fill="#4C4D41" mask="url(#b)"/><path d="M348.63 110.3c.58 5.4 2.75 9.7 6.45 12.78 3.7 3.13 8.5 4.7 14.38 4.7 3.9 0 7.4-.83 10.53-2.46 3.1-1.66 5.5-3.94 7.4-6.9h18.8c-3.2 8.18-8 14.54-14.3 19.08-6.4 4.52-13.7 6.78-22 6.78-5.6 0-10.8-.98-15.5-2.96-4.7-1.98-8.9-4.9-12.7-8.78-3.6-3.68-6.3-7.94-8.3-12.8-2-4.83-3-9.8-3-14.9 0-5.24.9-10.15 2.7-14.77 1.8-4.63 4.42-8.78 7.92-12.46 3.82-4 8.15-7.1 13-9.3 4.9-2.2 9.9-3.3 15-3.3 6.5 0 12.57 1.5 18.2 4.4 5.64 3 10.44 7.2 14.4 12.5 2.42 3.3 4.2 6.9 5.36 10.9s1.77 8.6 1.77 13.8c0 .4-.04 1-.08 1.9-.08.9-.1 1.5-.1 1.9h-60.1zm41.42-13.87c-1.5-5-4-8.8-7.55-11.43-3.52-2.6-7.96-3.92-13.32-3.92-4.66 0-8.8 1.42-12.5 4.24-3.7 2.82-6.16 6.52-7.44 11.1h40.8zm88.13 45.87v-9.22c-2.1 3.72-5.04 6.5-8.83 8.27-3.8 1.77-8.67 2.65-14.58 2.65-11.1 0-20.37-3.8-27.8-11.37-7.43-7.57-11.15-16.98-11.15-28.2 0-5.3.93-10.3 2.8-15.03 1.86-4.73 4.5-8.9 7.98-12.5 3.73-3.95 7.88-6.86 12.42-8.75 4.54-1.88 9.67-2.84 15.35-2.84 5.45 0 10.15 1 14.1 2.8 3.93 1.8 7.1 4.6 9.5 8.3v-9.1h17.07v75.1h-16.86zm-44.47-38.16c0 6.32 2.1 11.6 6.4 15.87 4.2 4.3 9.3 6.4 15.4 6.4 5.5 0 10.4-2.1 14.6-6.4 4.2-4.3 6.3-9.3 6.3-15 0-6.1-2.1-11.3-6.3-15.7-4.2-4.4-9.1-6.6-14.7-6.6-5.9 0-10.97 2.1-15.17 6.3-4.16 4.2-6.25 9.3-6.25 15.3zm108-36.92v15.56h-12.6v59.52h-16.9V82.78h-8.9V67.22h8.4v-2.05c0-7.9 2.2-13.8 6.6-17.7 4.4-3.87 11-5.83 19.9-5.83.4 0 1 .03 1.8.07.8.1 1.4.1 1.9.1v15.7h-1.2c-3.94 0-6.8.7-8.5 2-1.7 1.3-2.52 3.5-2.52 6.5v1.4h12.28zM306.9 142.3h17.06V42.92H306.9v99.38z" fill="#138A07"/></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 50">
|
||||
<defs>
|
||||
<clipPath id="ic"><circle cx="25" cy="25" r="23"/></clipPath>
|
||||
</defs>
|
||||
<g clip-path="url(#ic)">
|
||||
<rect x="2" y="2" width="23" height="23" fill="#447099"/>
|
||||
<rect x="25" y="2" width="23" height="23" fill="#72994E"/>
|
||||
<rect x="2" y="25" width="23" height="23" fill="#EE6331"/>
|
||||
<rect x="25" y="25" width="23" height="23" fill="#1B3B6F"/>
|
||||
<polyline points="8,12 25,38 42,12"
|
||||
stroke="white" stroke-width="6" fill="none"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<text x="56" y="33"
|
||||
font-family="'Helvetica Neue',Helvetica,Arial,sans-serif"
|
||||
font-size="26" font-weight="600" fill="#447099" letter-spacing="-0.5">Verso</text>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 784 B |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -68,11 +68,11 @@
|
||||
|
||||
@mixin editor-switcher-button {
|
||||
@include ol-button-variant(
|
||||
$color: var(--green-60),
|
||||
$color: var(--bg-accent-02),
|
||||
$background: var(--bg-accent-03),
|
||||
$border: var(--green-50),
|
||||
$border: var(--bg-accent-01),
|
||||
$hover-background: var(--bg-accent-03),
|
||||
$hover-border: var(--green-40),
|
||||
$hover-border: var(--bg-accent-01),
|
||||
$borderless: false
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
@use 'sass:color';
|
||||
|
||||
/* Website redesign */
|
||||
// Verso brand palette — based on Quarto's visual identity
|
||||
$verso-blue: #447099; // Quarto primary blue — main brand colour
|
||||
$verso-blue-dark: #1B3B6F; // Quarto dark navy
|
||||
$verso-blue-light: #75AADB; // Quarto light blue
|
||||
$verso-green: #72994E; // Quarto green
|
||||
$verso-orange: #EE6331; // Quarto orange accent
|
||||
|
||||
/* Website redesign (original Overleaf palette kept for layout compatibility) */
|
||||
$vivid-tangerine: #f1a695;
|
||||
$ceil: #9597c9;
|
||||
$caramel: #f9d38f;
|
||||
@@ -12,6 +19,13 @@ $green-bright: #13c965;
|
||||
$green-bright-tint-50: color.mix($green-bright, #fff, 50%);
|
||||
|
||||
:root {
|
||||
// Verso brand
|
||||
--verso-blue: #{$verso-blue};
|
||||
--verso-blue-dark: #{$verso-blue-dark};
|
||||
--verso-blue-light: #{$verso-blue-light};
|
||||
--verso-green: #{$verso-green};
|
||||
--verso-orange: #{$verso-orange};
|
||||
// Legacy layout vars
|
||||
--vivid-tangerine: #{$vivid-tangerine};
|
||||
--ceil: #{$ceil};
|
||||
--caramel: #{$caramel};
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
url('../../../public/img/ol-brand/overleaf-white.svg')
|
||||
);
|
||||
|
||||
// Title, when used instead of a logo
|
||||
--navbar-title-font-size: var(--font-size-05);
|
||||
// Title, when used instead of a logo (the Verso instance name + version).
|
||||
// Sits between --font-size-07 (30px) and --font-size-08 (36px): "7.5" ≈ 33px.
|
||||
--navbar-title-font-size: 2.0625rem;
|
||||
--navbar-title-color: var(--neutral-20);
|
||||
--navbar-title-color-hover: var(--neutral-40);
|
||||
|
||||
@@ -34,20 +35,20 @@
|
||||
calc(var(--navbar-btn-padding-h) + 1px);
|
||||
--navbar-subdued-color: var(--white);
|
||||
--navbar-subdued-hover-bg: var(--white);
|
||||
--navbar-subdued-hover-color: var(--green-50);
|
||||
--navbar-subdued-hover-color: var(--bg-accent-01);
|
||||
|
||||
// Properties of "primary" items
|
||||
--navbar-primary-color: var(--white);
|
||||
--navbar-primary-border-color: var(--green-50);
|
||||
--navbar-primary-bg: var(--green-50);
|
||||
--navbar-primary-hover-bg: var(--green-60);
|
||||
--navbar-primary-border-color: var(--bg-accent-01);
|
||||
--navbar-primary-bg: var(--bg-accent-01);
|
||||
--navbar-primary-hover-bg: var(--bg-accent-02);
|
||||
--navbar-primary-hover-border-color: var(--navbar-primary-hover-bg);
|
||||
|
||||
// Links
|
||||
--navbar-link-color: var(--white);
|
||||
--navbar-link-border-color: var(--navbar-link-color);
|
||||
--navbar-link-hover-color: var(--white);
|
||||
--navbar-link-hover-bg: var(--green-50);
|
||||
--navbar-link-hover-bg: var(--bg-accent-01);
|
||||
--navbar-link-hover-border-color: var(--navbar-link-hover-bg);
|
||||
|
||||
// Toggler
|
||||
|
||||