Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b34f5a589
|
||
|
|
6436f52889
|
||
|
|
49e6820eae
|
||
|
|
f8350ba235
|
||
|
|
09f0dc42e4
|
||
|
|
8e2303fcdf
|
||
|
|
4917abe9e6
|
||
|
|
b52629d1f6
|
||
|
|
b9159581f3
|
||
|
|
9e9163f01b
|
||
|
|
54a69b51ae
|
||
|
|
975ba4f312
|
||
|
|
6cd5ed61c4 | ||
|
|
1adca36802
|
||
|
|
3b1ef34bce
|
||
|
|
6b33be6956
|
||
|
|
f94a5cbe17
|
||
|
|
af31b6165c
|
||
|
|
946177a0b9
|
||
|
|
3361c9ee3d
|
||
|
|
ab802968ca
|
||
|
|
096026a964
|
||
|
|
6478741950
|
||
|
|
eb995c7b69
|
||
|
|
7207cfdbd0
|
||
|
|
36b2fc4188
|
||
|
|
62c4619c5e
|
||
|
|
f7beeb5e15
|
||
|
|
99ceb89e48
|
||
|
|
7217702068 | ||
|
|
11bf721f21 | ||
|
|
446c156338 | ||
|
|
909ea17f2f | ||
|
|
7a0df30ca1 | ||
|
|
a5f95bb6d7 | ||
|
|
f047389da9 | ||
|
|
b78f79e12e |
No files matched your search
@@ -1,6 +0,0 @@
|
|||||||
.git
|
|
||||||
.delta
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
docs
|
|
||||||
legacy_docs
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Server configuration. Copy to .env (gitignored), export or source it.
|
|
||||||
|
|
||||||
# Address the server listens on.
|
|
||||||
LISTEN_ADDR=:8080
|
|
||||||
# Session lifetime.
|
|
||||||
SESSION_TTL=12h
|
|
||||||
# Set to false only when testing locally over plain http.
|
|
||||||
COOKIE_SECURE=false
|
|
||||||
|
|
||||||
# OIDC (Authentik). Unset: public pages still work, protected pages answer 503.
|
|
||||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/yggdrasil/
|
|
||||||
OIDC_CLIENT_ID=yggdrasil
|
|
||||||
OIDC_CLIENT_SECRET=change-me
|
|
||||||
OIDC_REDIRECT_URL=http://localhost:8080/callback
|
|
||||||
# Comma/space separated scopes. The groups claim must be covered by a scope
|
|
||||||
# mapping on the provider for group checks to work.
|
|
||||||
# OIDC_SCOPES=openid profile email groups
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Shell scripts and the Justfile must stay LF so they run in Git Bash on Windows.
|
||||||
|
*.sh text eol=lf
|
||||||
|
Justfile text eol=lf
|
||||||
+59
-37
@@ -1,48 +1,70 @@
|
|||||||
name: build
|
name: Build site
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
- push
|
||||||
branches: [main]
|
- workflow_dispatch
|
||||||
pull_request:
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
# NOTE: must match your act_runner labels (e.g. ubuntu-latest, ubuntu-24.04).
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Bootstrap pinned toolchain
|
||||||
uses: actions/setup-go@v5
|
run: ./scripts/bootstrap.sh
|
||||||
|
|
||||||
|
- name: Build site
|
||||||
|
run: ./scripts/calepin.sh compile site public
|
||||||
|
|
||||||
|
# The official upload-artifact@v4 refuses to run on Gitea (it detects a
|
||||||
|
# non-GitHub server and aborts with GHESNotSupportedError). v3 uses the
|
||||||
|
# artifact API that Gitea implements. On Gitea >= 1.22 with a current
|
||||||
|
# act_runner, christopherhx/gitea-upload-artifact@v4 is a drop-in
|
||||||
|
# replacement if you want the v4 API.
|
||||||
|
- name: Upload site artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
go-version-file: go.mod
|
name: public
|
||||||
|
path: public/
|
||||||
|
|
||||||
# shiroa bundles its own Typst compiler, so no separate typst install.
|
deploy:
|
||||||
- name: Install shiroa
|
needs: build
|
||||||
run: |
|
# Publish only from main; move the gate to publish from another branch.
|
||||||
curl -sSL -o /tmp/shiroa.tar.gz \
|
if: github.ref == 'refs/heads/main'
|
||||||
https://github.com/Myriad-Dreamin/shiroa/releases/download/v0.4.0-rc2/shiroa-x86_64-unknown-linux-musl.tar.gz
|
# Write access for the automatic Actions token to push the pages branch.
|
||||||
tar -xzf /tmp/shiroa.tar.gz -C /tmp
|
permissions:
|
||||||
install -m 755 /tmp/shiroa-x86_64-unknown-linux-musl/shiroa /usr/local/bin/shiroa
|
contents: write
|
||||||
shiroa --version
|
# NOTE: must match your act_runner labels (e.g. ubuntu-latest, ubuntu-24.04).
|
||||||
|
runs-on: ubuntu-latest
|
||||||
- name: Generate acl.json
|
steps:
|
||||||
run: go run ./cmd/parser -in docs -out acl.json
|
- name: Download site artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
- name: Build the book
|
|
||||||
run: shiroa build docs --dest-dir ../dist
|
|
||||||
|
|
||||||
- name: Test and build the server
|
|
||||||
run: |
|
|
||||||
go vet ./...
|
|
||||||
go test ./...
|
|
||||||
test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; }
|
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o yggdrasil-server ./cmd/server
|
|
||||||
|
|
||||||
# upload-artifact v4 needs a reasonably current Gitea/act_runner;
|
|
||||||
# fall back to v3 if your runner is too old to serve v4 artifacts.
|
|
||||||
- name: Upload server artifact
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: yggdrasil-server-linux-amd64
|
name: public
|
||||||
path: yggdrasil-server
|
path: public
|
||||||
|
|
||||||
|
- name: Check artifact layout
|
||||||
|
run: test -f public/index.html
|
||||||
|
|
||||||
|
# Publishing interfaces with prod; it never runs under local act tests
|
||||||
|
# (act sets env.ACT, the Gitea runner does not). The force-push to the
|
||||||
|
# `pages` branch is what triggers the git-pages webhook.
|
||||||
|
- name: Publish to pages branch
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$GITEA_TOKEN" ]; then
|
||||||
|
echo "::error::Missing GITEA_TOKEN (the automatic Actions token)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd public
|
||||||
|
git init -q -b pages .
|
||||||
|
git config user.name "Yggdrasil CI"
|
||||||
|
git config user.email "ci@noreply.kb28.ch"
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "Publish site from ${GITHUB_SHA}"
|
||||||
|
repo_host="${GITHUB_SERVER_URL#*://}"
|
||||||
|
repo_host="${repo_host%/}"
|
||||||
|
git push -q --force "https://${GITHUB_REPOSITORY_OWNER}:${GITEA_TOKEN}@${repo_host}/${GITHUB_REPOSITORY}.git" pages
|
||||||
+8
-17
@@ -1,21 +1,12 @@
|
|||||||
# build outputs (dist is regenerated by shiroa; .gitkeep keeps go:embed happy)
|
# Generated site and pinned toolchain
|
||||||
/dist/*
|
public/
|
||||||
!/dist/.gitkeep
|
/tools/
|
||||||
/server
|
|
||||||
yggdrasil-server
|
|
||||||
|
|
||||||
# secrets
|
# Calepin build artifacts
|
||||||
.env
|
.calepin/
|
||||||
|
.calepin-entry.*
|
||||||
|
|
||||||
# editors
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
.zed/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
# delta workspace
|
|
||||||
/.delta/
|
|
||||||
|
|
||||||
# pinned toolchain downloads (shiroa), managed by the Justfile
|
|
||||||
tools/
|
|
||||||
|
|
||||||
# shiroa serve default output
|
|
||||||
docs/dist/
|
|
||||||
Generated
+17
@@ -0,0 +1,17 @@
|
|||||||
|
<component name="ProjectRunConfigurationManager">
|
||||||
|
<configuration default="false" name="Docsify" type="ShConfigurationType">
|
||||||
|
<option name="SCRIPT_TEXT" value="start http://localhost:3000 ; docsify serve " />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
|
||||||
|
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/.idea/runConfigurations/run.sh" />
|
||||||
|
<option name="SCRIPT_OPTIONS" value="" />
|
||||||
|
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||||
|
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||||
|
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||||
|
<option name="INTERPRETER_PATH" value="powershell.exe" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||||
|
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||||
|
<envs />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
</component>
|
||||||
@@ -1,80 +1,20 @@
|
|||||||
# AGENT.md — operating rules for agents working on Yggdrasil
|
# Agent Rules for Yggdrasil Migration
|
||||||
|
|
||||||
Personal knowledge base: Typst sources (`docs/`, a shiroa book) compiled to
|
* **Commit Policy**: Small conventional commits. One logical change per
|
||||||
a static site, embedded into a Go binary that enforces per-page OIDC group
|
commit, committed only once that step is verified and working. End every
|
||||||
permissions (Authentik). README.md is the human-facing documentation; this
|
commit message with a blank line followed by an
|
||||||
file is the working rulebook. Follow it; when it conflicts with the user,
|
`Assisted-by: Delta:zai-glm-5.3` footer.
|
||||||
the user wins.
|
* **Layout**: Sources live in `site/` (`calepin.toml` + `.typ` pages),
|
||||||
|
generated output in `public/` (gitignored), pinned binaries in `tools/`
|
||||||
## Commands
|
(gitignored), helper scripts in `scripts/`, and the old Docsify
|
||||||
|
documentation stays untouched in `legacy_docs/`.
|
||||||
- `just` — shows the command overview (bare `just` never builds anything).
|
* **Toolchain**: `calepin` (0.0.61) and `typst` (0.15.1) are pinned in
|
||||||
- `just build` — parse ACL, compile the book, build `./server`.
|
`scripts/bootstrap.sh` and downloaded into `tools/` with checksum
|
||||||
- `just run` — build everything, then serve on :8080.
|
verification. The build must never depend on global installs. CI installs
|
||||||
- `just check` — go vet, go test, gofmt. Run it before every commit.
|
the same pinned versions by calling the same scripts.
|
||||||
- `just clean` — removes `dist/` and `server` (keeps `tools/` and
|
* **Verification**: Before committing, run a fresh cycle: `rm -rf tools`,
|
||||||
restores the committed `dist/.gitkeep`).
|
then `just clean`, then `just build` — the build must succeed with no
|
||||||
- The shiroa toolchain is pinned and self-bootstrapped into `tools/` by the
|
calepin on `PATH`. Verify the served site (`just preview`): home 200,
|
||||||
`tools` recipe. Never assume a global shiroa/typst install exists; do not
|
category page 200, sub-page 200, missing page shows the 404 page,
|
||||||
install shiroa globally; do not bump the pin casually (see Traps).
|
`sitemap.xml` present. After every test/clean cycle `git status` must stay
|
||||||
|
clean.
|
||||||
## Layout
|
|
||||||
|
|
||||||
- `docs/` — shiroa book. `book.typ` lists every built page; files not listed
|
|
||||||
there are not part of the site. Categories are `=` part headers in the
|
|
||||||
summary, pages are `# chapter("path.typ")[Title]` entries. Final URL =
|
|
||||||
source path with `.typ` replaced by `.html`.
|
|
||||||
- `cmd/parser` — build step: scans `docs/` for `#let access-groups = (..)`
|
|
||||||
tags and emits `acl.json`. Untagged pages are public.
|
|
||||||
- `cmd/server` + `embed.go` (repo root) — `//go:embed` of `dist/` and
|
|
||||||
`acl.json` into a single binary.
|
|
||||||
- `internal/acl` — ACL loading and page-key normalization (security critical).
|
|
||||||
- `internal/oidc` — OIDC authorization-code client.
|
|
||||||
- `internal/middleware` — sessions, login/callback/logout, access gate.
|
|
||||||
- `legacy_docs/` — frozen previous site. Never modify or migrate it unless
|
|
||||||
the user asks explicitly.
|
|
||||||
|
|
||||||
## Security invariants — do not break
|
|
||||||
|
|
||||||
- Fail closed: a malformed `acl.json` aborts startup; protected pages with
|
|
||||||
no configured IdP answer 503, never the content.
|
|
||||||
- Page keys: shiroa pages keep their rendered content in sibling
|
|
||||||
`<page>.<theme>.multi.sir.in` files fetched by client-side JS.
|
|
||||||
`acl.PageKey` reduces any URL to a page key so those files inherit the
|
|
||||||
page's protection. If the shiroa output format changes, re-verify this
|
|
||||||
mapping: content in any new sidecar file must be covered or blocked.
|
|
||||||
- `/searchindex.json` contains the text of protected pages: blocked at the
|
|
||||||
router, always.
|
|
||||||
- Session cookies: HttpOnly, SameSite=Lax, Secure unless COOKIE_SECURE=false.
|
|
||||||
OAuth2 states are random and single use; post-login redirect targets are
|
|
||||||
sanitized (open-redirect guard).
|
|
||||||
|
|
||||||
## Traps
|
|
||||||
|
|
||||||
- Shiroa version pairing: the CLI and the `@preview/shiroa:*` typst package
|
|
||||||
must match (CLI v0.4.0-rc2 pairs with package 0.4.0; the v0.3.0 release
|
|
||||||
CLI rejects 0.4.0 books). The pin lives in two places: the Justfile
|
|
||||||
`shiroa-version` and `.gitea/workflows/build.yml`. Bump all of them
|
|
||||||
together with the package import and rebuild.
|
|
||||||
- Typst call syntax: no space between `)` and the trailing content block —
|
|
||||||
`#chapter("x.typ")[Title]`, not `#chapter("x.typ") [Title]`.
|
|
||||||
- `dist/` and `acl.json` are embedded at Go build time; there is no runtime
|
|
||||||
reload. After changing docs or ACL, rebuild the binary.
|
|
||||||
- `go build` needs `dist/` and `acl.json` to exist: fresh clones carry
|
|
||||||
`dist/.gitkeep` and a committed `acl.json` for exactly this reason.
|
|
||||||
Never gitignore those two.
|
|
||||||
|
|
||||||
## Testing expectations
|
|
||||||
|
|
||||||
- Test what the user runs, not a proxy for it: from a clean state
|
|
||||||
(`rm -rf tools && just clean`), `just build` and `just run` must succeed
|
|
||||||
with no global shiroa on PATH. Test the bare `just` output too. After
|
|
||||||
any clean/test cycle, `git status` must be clean: `dist/.gitkeep` is
|
|
||||||
tracked, and committing a worktree where it is missing breaks fresh
|
|
||||||
clones (`go:embed` needs `dist/`).
|
|
||||||
- Server behavior: curl a public page (200), a protected page (503 without
|
|
||||||
OIDC env; it must never serve protected content), and `/searchindex.json`
|
|
||||||
(404). Extend the unit tests in `internal/acl` and `internal/middleware`
|
|
||||||
whenever touching those paths.
|
|
||||||
- A live OIDC round trip needs a real IdP and is not testable here. Say
|
|
||||||
that explicitly when reporting what was verified.
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
# Rootless image of the Yggdrasil server.
|
|
||||||
# Build after `just parse build-docs` so that dist/ and acl.json are current:
|
|
||||||
# just parse build-docs && docker build -t yggdrasil .
|
|
||||||
|
|
||||||
FROM golang:1.27 AS build
|
|
||||||
WORKDIR /src
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY . .
|
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server
|
|
||||||
|
|
||||||
FROM gcr.io/distroless/static-debian12:nonroot
|
|
||||||
COPY --from=build /out/server /server
|
|
||||||
EXPOSE 8080
|
|
||||||
USER nonroot:nonroot
|
|
||||||
ENTRYPOINT ["/server"]
|
|
||||||
@@ -1,52 +1,31 @@
|
|||||||
# Yggdrasil: Typst documentation behind an authenticated Go server.
|
# Show the available recipes
|
||||||
|
|
||||||
# Pinned shiroa toolchain, self-managed under tools/ (no global install).
|
|
||||||
# The CLI and the @preview/shiroa package version must match (see AGENT.md).
|
|
||||||
shiroa-version := "v0.4.0-rc2"
|
|
||||||
shiroa-target := if arch() == "x86_64" { "x86_64-unknown-linux-musl" } else if arch() == "aarch64" { "aarch64-unknown-linux-musl" } else { error("unsupported architecture " + arch()) }
|
|
||||||
shiroa-dir := "tools/shiroa"
|
|
||||||
shiroa-bin := shiroa-dir + "/shiroa-" + shiroa-target + "/shiroa"
|
|
||||||
shiroa-url := "https://github.com/Myriad-Dreamin/shiroa/releases/download/" + shiroa-version + "/shiroa-" + shiroa-target + ".tar.gz"
|
|
||||||
|
|
||||||
# Show the available commands.
|
|
||||||
default:
|
default:
|
||||||
@just --list
|
@just --list
|
||||||
|
|
||||||
# Build everything: acl.json, the static site, and the server binary.
|
# Bootstrap the pinned calepin + typst binaries into tools/
|
||||||
build: parse build-docs build-server
|
|
||||||
|
|
||||||
# Scan docs/ for access-groups tags and regenerate acl.json.
|
|
||||||
parse:
|
|
||||||
go run ./cmd/parser -in docs -out acl.json
|
|
||||||
|
|
||||||
# Download the pinned shiroa into tools/ if missing.
|
|
||||||
tools:
|
tools:
|
||||||
@mkdir -p {{shiroa-dir}}
|
./scripts/bootstrap.sh
|
||||||
@test -x {{shiroa-bin}} || { echo "Downloading shiroa {{shiroa-version}} ({{shiroa-target}})..."; curl -fsSL -o {{shiroa-dir}}/shiroa.tar.gz {{shiroa-url}}; tar -xzf {{shiroa-dir}}/shiroa.tar.gz -C {{shiroa-dir}}; rm {{shiroa-dir}}/shiroa.tar.gz; {{shiroa-bin}} --version; }
|
|
||||||
|
|
||||||
# Compile the Typst book into dist/.
|
# Compile the site into public/
|
||||||
# (--dest-dir is resolved relative to the book directory, docs/, hence ../dist)
|
build: tools
|
||||||
build-docs: tools
|
./scripts/calepin.sh compile site public
|
||||||
{{shiroa-bin}} build docs --dest-dir ../dist
|
|
||||||
|
|
||||||
# Build the server binary (embeds dist/ and acl.json).
|
# Build, then serve the site locally
|
||||||
build-server:
|
preview: build
|
||||||
CGO_ENABLED=0 go build -trimpath -o server ./cmd/server
|
./scripts/calepin.sh serve public
|
||||||
|
|
||||||
# Rebuild everything and run the server locally.
|
# Rebuild on change and serve the result
|
||||||
run: build
|
watch: tools
|
||||||
./server
|
./scripts/calepin.sh watch site public --serve
|
||||||
|
|
||||||
# Live-preview the book without authentication (shiroa dev server).
|
# Remove generated output and build caches
|
||||||
preview-docs: tools
|
|
||||||
{{shiroa-bin}} serve docs
|
|
||||||
|
|
||||||
# Run static checks and tests.
|
|
||||||
check:
|
|
||||||
go vet ./...
|
|
||||||
go test ./...
|
|
||||||
@test -z "$(gofmt -l cmd internal embed.go)" || { echo 'gofmt needed:'; gofmt -l cmd internal embed.go; exit 1; }
|
|
||||||
|
|
||||||
# Remove build artifacts (keeps tools/ and the committed dist/.gitkeep).
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf dist server && mkdir -p dist && touch dist/.gitkeep
|
./scripts/clean.sh
|
||||||
|
|
||||||
|
# Remove all EXIF metadata from a file
|
||||||
|
strip-exif file:
|
||||||
|
exiftool -all= -overwrite_original {{ quote(file) }}
|
||||||
|
|
||||||
|
# Run the CI workflow locally with act (requires act + docker)
|
||||||
|
ci:
|
||||||
|
act push -W .gitea/workflows/build.yml -j build --artifact-server-path /tmp/act-artifacts
|
||||||
@@ -1,200 +1,88 @@
|
|||||||
# Yggdrasil
|
# Yggdrasil
|
||||||
|
|
||||||
A personal knowledge base written in [Typst](https://typst.app), compiled to
|
Personal knowledge base built as a static website with
|
||||||
a static site by [shiroa](https://github.com/Myriad-Dreamin/shiroa), and
|
[Calepin](https://vincentarelbundock.github.io/calepin/), a Typst-based static
|
||||||
served by a small Go binary that embeds the whole site and gates protected
|
site generator.
|
||||||
pages behind OIDC login (Authentik) with per-page group permissions.
|
|
||||||
|
|
||||||
No search, no database, no Node.js: one binary, one configuration.
|
## Layout
|
||||||
|
|
||||||
## How it works
|
- `site/` — website sources: `calepin.toml` + `.typ` pages
|
||||||
|
- `site/themes/yggdrasil/` — local theme extending the bundled `calepin` theme (scoped sidebars, academic-styled landing)
|
||||||
```mermaid
|
- `public/` — generated site (gitignored)
|
||||||
flowchart TD
|
- `tools/` — pinned `calepin` and `typst` binaries (gitignored, downloaded on demand)
|
||||||
A["docs/*.typ (Typst sources)"] -->|"just build-docs (shiroa)"| B["dist/ (static site)"]
|
- `scripts/` — helper scripts used by the Justfile and CI
|
||||||
A -->|"just parse (cmd/parser, access-groups tags)"| C["acl.json"]
|
- `legacy_docs/` — the old Docsify documentation, kept unmodified for reference
|
||||||
B -->|"go build (//go:embed)"| E["server binary"]
|
|
||||||
C --> E
|
|
||||||
E --> F{"request /page.html"}
|
|
||||||
F -->|"public page"| G["serve page"]
|
|
||||||
F -->|"protected page"| H{"session + groups ok?"}
|
|
||||||
H -->|"no session"| I["redirect to /login, then Authentik"]
|
|
||||||
H -->|"not authorized"| J["403"]
|
|
||||||
H -->|"authorized"| G
|
|
||||||
```
|
|
||||||
|
|
||||||
- Pages are written in Typst under `docs/` and listed in `docs/book.typ`.
|
|
||||||
- A page can declare who may read it with a tag:
|
|
||||||
`#let access-groups = ("family", "admin")`. Pages without a tag are public.
|
|
||||||
- At build time, `cmd/parser` scans `docs/`, extracts the tags and generates
|
|
||||||
`acl.json` mapping final page URLs to allowed groups.
|
|
||||||
- The Go server embeds both `dist/` and `acl.json`. For every request it
|
|
||||||
reduces the URL to a *page key* and looks it up in the ACL: public pages
|
|
||||||
are served directly; protected pages require a session whose groups include
|
|
||||||
at least one of the allowed groups.
|
|
||||||
- Users authenticate with OIDC. The server reads the `groups` claim of the
|
|
||||||
verified ID token to build the session.
|
|
||||||
|
|
||||||
## Repository layout
|
|
||||||
|
|
||||||
```
|
|
||||||
docs/ Typst sources (shiroa book)
|
|
||||||
book.typ book metadata + chapter list (the sidebar)
|
|
||||||
template.typ page layout (from the shiroa minimal template)
|
|
||||||
index.typ home page (public)
|
|
||||||
thermo/, security/, homelab/ example categories
|
|
||||||
cmd/parser/ generates acl.json from access-groups tags
|
|
||||||
cmd/server/ the web server
|
|
||||||
internal/acl/ ACL loading, page-key normalization, group checks
|
|
||||||
internal/oidc/ OIDC client (authorization code flow)
|
|
||||||
internal/middleware/ sessions, login/logout/callback, access gate
|
|
||||||
dist/ compiled site (generated, embedded at build time)
|
|
||||||
tools/ pinned shiroa binary (downloaded by `just tools`)
|
|
||||||
acl.json generated ACL (embedded at build time)
|
|
||||||
legacy_docs/ the old documentation base (kept for manual migration)
|
|
||||||
.gitea/workflows/ Gitea Actions CI
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
Prerequisites: Go 1.27.1+ (the version required by `go.mod`) and
|
`just` is the only prerequisite — the toolchain is downloaded and
|
||||||
[just](https://github.com/casey/just). The
|
checksum-verified into `tools/` on first use (no global installs, no cargo):
|
||||||
pinned shiroa toolchain is downloaded automatically into `tools/` on the
|
|
||||||
first build (needs `curl` + `tar` and network access); nothing is
|
|
||||||
installed globally.
|
|
||||||
|
|
||||||
```sh
|
just # list recipes
|
||||||
just # show the available commands
|
just tools # download the pinned toolchain into tools/
|
||||||
just run # parse ACL, compile the book, build and start the server
|
just build # compile site/ into public/
|
||||||
```
|
just preview # build, then serve the site locally
|
||||||
|
just watch # rebuild on change and serve
|
||||||
|
just clean # remove public/ and build caches
|
||||||
|
just ci # run the CI workflow locally with act (needs act + docker)
|
||||||
|
|
||||||
Then open http://localhost:8080. The home page and public pages render;
|
## Adding a page
|
||||||
protected pages (`/thermo/fluids.html`, `/security/ssh.html`,
|
|
||||||
`/homelab/backup.html` in the example book) return **503** until OIDC is
|
|
||||||
configured.
|
|
||||||
|
|
||||||
For pure writing previews without authentication, use `just preview-docs`.
|
Create `site/<category>/<page>.typ`:
|
||||||
|
|
||||||
## Writing documentation
|
|
||||||
|
|
||||||
The book structure mirrors the URL structure: `docs/<path>.typ` is compiled
|
|
||||||
to `/<path>.html`. To add a page:
|
|
||||||
|
|
||||||
1. Create the file, e.g. `docs/security/wireguard.typ`, starting with:
|
|
||||||
|
|
||||||
```typst
|
```typst
|
||||||
#import "../template.typ": project
|
#set document(title: [Page title])
|
||||||
#show: project
|
#metadata((title: "Page title", translation_key: "category-page")) <website-metadata>
|
||||||
|
|
||||||
= WireGuard
|
#title()
|
||||||
|
|
||||||
Your content...
|
= Page title
|
||||||
|
|
||||||
|
Content…
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Reference it in `docs/book.typ`. A `=` heading creates a category
|
and reference it in `site/calepin.toml`:
|
||||||
(part) in the sidebar; each `- #chapter(...)` is a page:
|
|
||||||
|
|
||||||
```typst
|
```toml
|
||||||
= Security
|
[[sidebar.section.item]]
|
||||||
- #chapter("security/ssh.typ", section: "4")[SSH hardening]
|
target = "<category>/<page>.typ"
|
||||||
- #chapter("security/wireguard.typ", section: "5")[WireGuard]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Only pages listed in `book.typ` are built. Keep `section:` numbers
|
Sidebar labels come from the page's `#metadata(...)` line, not from the config.
|
||||||
consistent (sub-chapters like `"4.1"` nest under `"4"`).
|
|
||||||
|
|
||||||
To restrict a page, add the tag on the first line of its file:
|
Every page also renders a printable PDF twin (`pdf = true` in
|
||||||
|
`calepin.toml`, selectable in the page's view picker). Add `pdf: false` to a
|
||||||
|
page's `#metadata(...)` to skip its PDF, as `404.typ` does.
|
||||||
|
|
||||||
```typst
|
## Adding a category
|
||||||
// Only readable by members of these groups.
|
|
||||||
#let access-groups = ("family", "admin")
|
|
||||||
```
|
|
||||||
|
|
||||||
- The groups are compared against the `groups` claim of the OIDC ID token
|
1. Create `site/<category>/` with an `index.typ`, using
|
||||||
(in Authentik: your user's group memberships).
|
`#metadata((title: "Overview", ...))` for its sidebar label.
|
||||||
- A page without the tag is public.
|
2. Add a `[[sidebar.section]]` named after the category in `calepin.toml`,
|
||||||
- Everything served *for* a protected page is protected too: the shiroa
|
listing the index page and its sub-pages.
|
||||||
renderer fetches page content from `*.multi.sir.in` files, and those
|
3. Add a `[[menus.main]]` entry with the category's `index.typ` as `target`
|
||||||
reduce to the same ACL entry as the page itself.
|
and the category name as `label` (e.g. `Home`, `IT`), so it appears in the
|
||||||
- The search index (`searchindex.json`) would also contain protected text,
|
top navigation bar. The `label` is required here because the index page's
|
||||||
so the server never serves it.
|
metadata title is `Overview`.
|
||||||
|
|
||||||
## Configuration
|
Pages inside a category show only that category's sidebar section — the
|
||||||
|
local theme (`site/themes/yggdrasil/partials/site-nav-sidebar.html`) filters
|
||||||
|
the sidebar to the section containing the current page. Pages outside every
|
||||||
|
category (landing page, `404`, standalone pages) fall back to the full
|
||||||
|
sidebar.
|
||||||
|
|
||||||
Environment variables (see `.env.example`):
|
## Deployment
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
`just build` produces a fully static site in `public/`: HTML pages with
|
||||||
|---------------------|-----------------------------|---------|
|
printable PDF twins, plus `sitemap.xml`, `robots.txt`, and `llms.txt`. The
|
||||||
| `LISTEN_ADDR` | `:8080` | listen address |
|
Gitea Actions workflow in
|
||||||
| `SESSION_TTL` | `12h` | session lifetime |
|
`.gitea/workflows/build.yml` builds the site on every push with the same
|
||||||
| `COOKIE_SECURE` | `true` | `Secure` cookie flag; use `false` only on plain-http local tests |
|
pinned toolchain and uploads `public/` as an artifact.
|
||||||
| `OIDC_ISSUER_URL` | (unset) | e.g. `https://auth.example.com/application/o/yggdrasil/` |
|
|
||||||
| `OIDC_CLIENT_ID` | (unset) | OAuth2 client id |
|
|
||||||
| `OIDC_CLIENT_SECRET`| (unset) | OAuth2 client secret |
|
|
||||||
| `OIDC_REDIRECT_URL` | (unset) | e.g. `https://docs.example.com/callback` |
|
|
||||||
| `OIDC_SCOPES` | `openid profile email groups` | requested scopes |
|
|
||||||
|
|
||||||
Without the `OIDC_*` variables the server runs in public-only mode:
|
Pushes to `main` are also published: the `deploy` job force-pushes `public/`
|
||||||
public pages are served, protected pages answer 503. The server refuses to
|
to the repo's `pages` branch using the automatic Actions token (no secrets to
|
||||||
start on a malformed `acl.json` or an unreachable OIDC issuer (fail closed).
|
configure). A Gitea webhook on the repo then notifies the self-hosted
|
||||||
|
[git-pages](https://codeberg.org/git-pages/git-pages) server, which clones
|
||||||
### Setting up Authentik
|
the `pages` branch and serves the site. The push step is gated with
|
||||||
|
`if: ${{ !env.ACT }}` so local `act` runs never publish, and
|
||||||
1. **Applications -> Providers -> Create**: type *OAuth2/OpenID Connect*.
|
`workflow_dispatch` enables manual publishes from the Gitea UI.
|
||||||
2. Authorization flow: *implicit/explicit consent* (default is fine).
|
|
||||||
3. Redirect URI: exactly `https://your-docs-host/callback`.
|
|
||||||
4. Client type: *Confidential*; note the client id and secret.
|
|
||||||
5. **Scope mappings**: make sure the issued ID token contains a `groups`
|
|
||||||
claim. Recent Authentik versions ship a scope mapping that adds
|
|
||||||
`groups`; otherwise create a *Property Mapping (Scope)* with e.g.
|
|
||||||
`return {"groups": [g.name for g in user.ak_groups.all()]}` and add it
|
|
||||||
to the provider.
|
|
||||||
6. Note the issuer URL shown on the provider page
|
|
||||||
(`.../application/o/<slug>/`) and set `OIDC_ISSUER_URL` to it.
|
|
||||||
7. Create groups in **Directory -> Groups** (e.g. `family`, `admin`) whose
|
|
||||||
names match the `access-groups` in your pages, and add users to them.
|
|
||||||
|
|
||||||
The redirect after login returns the user to the page they originally
|
|
||||||
requested. Logout is available at `/logout`.
|
|
||||||
|
|
||||||
## Build and CI
|
|
||||||
|
|
||||||
Local commands (see `Justfile`): `just build`, `just run`, `just parse`,
|
|
||||||
`just check`, `just clean`, `just preview-docs`. Bare `just` prints the
|
|
||||||
command overview. The pinned shiroa lives in `tools/` (gitignored) and is
|
|
||||||
downloaded on demand.
|
|
||||||
|
|
||||||
`.gitea/workflows/build.yml` (Gitea Actions, not GitLab CI) runs on push to
|
|
||||||
`main` and on pull requests: installs shiroa, regenerates `acl.json`,
|
|
||||||
compiles the book, runs `go vet`/`go test`, builds the static binary and
|
|
||||||
uploads it as an artifact.
|
|
||||||
|
|
||||||
A rootless Docker/Podman image is available:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
just parse build-docs
|
|
||||||
docker build -t yggdrasil .
|
|
||||||
docker run --rm -p 8080:8080 --env-file .env yggdrasil
|
|
||||||
```
|
|
||||||
|
|
||||||
Note that the binary embeds the site at build time: publishing new or
|
|
||||||
edited pages always requires rebuilding and redeploying the server.
|
|
||||||
|
|
||||||
## Security notes and limitations
|
|
||||||
|
|
||||||
- Sessions live in memory: they vanish on restart and do not scale past
|
|
||||||
one instance (users simply log in again).
|
|
||||||
- Page *titles* and URLs of protected pages are visible in the sidebar of
|
|
||||||
public pages (that is inherent to a static site). Page *content* is not:
|
|
||||||
neither the HTML nor the renderer data files can be fetched without
|
|
||||||
authorization.
|
|
||||||
- Directory listing is enabled (file names only, e.g. `/thermo/`).
|
|
||||||
- Logout is a GET request (`/logout`), like most documentation sites.
|
|
||||||
- No search is implemented (`searchindex.json` is explicitly blocked).
|
|
||||||
- Static assets referenced by pages (theme CSS/JS, fonts, renderer wasm)
|
|
||||||
are public; they contain no page content.
|
|
||||||
|
|
||||||
## Legacy documentation
|
|
||||||
|
|
||||||
The previous documentation base lives in `legacy_docs/` and is kept only
|
|
||||||
for reference; migrate pages into `docs/` manually, then delete the folder.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"/homelab/backup.html": [
|
|
||||||
"family"
|
|
||||||
],
|
|
||||||
"/security/ssh.html": [
|
|
||||||
"admin"
|
|
||||||
],
|
|
||||||
"/thermo/fluids.html": [
|
|
||||||
"family",
|
|
||||||
"admin"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
// Command parser scans a directory of Typst sources (a shiroa book) for
|
|
||||||
// access-group tags and generates the acl.json consumed by the server.
|
|
||||||
//
|
|
||||||
// A page declares its access groups with a Typst let binding at the top of
|
|
||||||
// the file:
|
|
||||||
//
|
|
||||||
// #let access-groups = ("family", "admin")
|
|
||||||
//
|
|
||||||
// Pages without a tag are public. The generated acl.json maps the final URL
|
|
||||||
// of each page (e.g. "/thermo/fluids.html") to its authorized groups.
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"io/fs"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Matches: #let access-groups = ("group1", "group2")
|
|
||||||
var accessGroupsRe = regexp.MustCompile(`(?m)^\s*#let\s+access-groups\s*=\s*\(([^)]*)\)`)
|
|
||||||
|
|
||||||
// Files that are never pages: book metadata, page layout, and partials
|
|
||||||
// (files starting with "_") imported by other pages.
|
|
||||||
func isSkipped(base string) bool {
|
|
||||||
return base == "book.typ" || base == "template.typ" || strings.HasPrefix(base, "_")
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
inDir := flag.String("in", "docs", "input directory containing the shiroa book (.typ sources)")
|
|
||||||
outFile := flag.String("out", "acl.json", "output acl.json file")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
acl := make(map[string][]string)
|
|
||||||
|
|
||||||
err := filepath.WalkDir(*inDir, func(p string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
base := d.Name()
|
|
||||||
if d.IsDir() {
|
|
||||||
// Skip build output and hidden directories.
|
|
||||||
if p != *inDir && (base == "dist" || strings.HasPrefix(base, ".")) {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if filepath.Ext(base) != ".typ" || isSkipped(base) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(p)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading %s: %w", p, err)
|
|
||||||
}
|
|
||||||
groups := extractGroups(content)
|
|
||||||
if len(groups) == 0 {
|
|
||||||
return nil // public page, no rule needed
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath, err := filepath.Rel(*inDir, p)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// docs/<path>.typ compiles to dist/<path>.html, served at /<path>.html
|
|
||||||
urlPath := "/" + strings.TrimSuffix(filepath.ToSlash(relPath), ".typ") + ".html"
|
|
||||||
|
|
||||||
acl[urlPath] = groups
|
|
||||||
log.Printf("protected %s -> %s", urlPath, strings.Join(groups, ", "))
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("scanning %s: %v", *inDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := json.MarshalIndent(acl, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("encoding acl: %v", err)
|
|
||||||
}
|
|
||||||
out = append(out, '\n')
|
|
||||||
if err := os.WriteFile(*outFile, out, 0o644); err != nil {
|
|
||||||
log.Fatalf("writing %s: %v", *outFile, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("generated %s (%d protected pages)\n", *outFile, len(acl))
|
|
||||||
}
|
|
||||||
|
|
||||||
func extractGroups(content []byte) []string {
|
|
||||||
m := accessGroupsRe.FindSubmatch(content)
|
|
||||||
if m == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var groups []string
|
|
||||||
for _, part := range strings.Split(string(m[1]), ",") {
|
|
||||||
part = strings.TrimSpace(part)
|
|
||||||
part = strings.Trim(part, "\"'")
|
|
||||||
if part != "" {
|
|
||||||
groups = append(groups, part)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
// Command server is the Yggdrasil web server: a single static binary that
|
|
||||||
// embeds the compiled documentation (dist/) and its access control list
|
|
||||||
// (acl.json), serves public pages directly, and gates protected pages
|
|
||||||
// behind OIDC authentication (Authentik).
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Klagarge/yggdrasil"
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/acl"
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/middleware"
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/oidc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
listenAddr := envOr("LISTEN_ADDR", ":8080")
|
|
||||||
sessionTTL := envDuration("SESSION_TTL", 12*time.Hour)
|
|
||||||
cookieSecure := envOr("COOKIE_SECURE", "true") == "true"
|
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
list, err := acl.Load(yggdrasil.ACLData())
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("refusing to start: %v", err) // fail closed
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions := middleware.NewSessionStore(sessionTTL)
|
|
||||||
go sessions.StartJanitor(ctx, 10*time.Minute)
|
|
||||||
|
|
||||||
auth := setupOIDC(ctx)
|
|
||||||
|
|
||||||
dist, err := yggdrasil.DistFS()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("embedded dist: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gate := middleware.NewAccessControl(list, sessions, auth, cookieSecure)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /login", gate.Login)
|
|
||||||
mux.HandleFunc("GET /callback", gate.Callback)
|
|
||||||
mux.HandleFunc("GET /logout", gate.Logout)
|
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
|
||||||
})
|
|
||||||
// The search index contains the text of protected pages: never serve it.
|
|
||||||
mux.HandleFunc("/searchindex.json", http.NotFound)
|
|
||||||
mux.Handle("/", gate.Wrap(http.FileServerFS(dist)))
|
|
||||||
|
|
||||||
srv := &http.Server{
|
|
||||||
Addr: listenAddr,
|
|
||||||
Handler: gate.SecurityHeaders(mux),
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
ReadTimeout: 30 * time.Second,
|
|
||||||
WriteTimeout: 60 * time.Second,
|
|
||||||
IdleTimeout: 2 * time.Minute,
|
|
||||||
}
|
|
||||||
|
|
||||||
errCh := make(chan error, 1)
|
|
||||||
go func() { errCh <- srv.ListenAndServe() }()
|
|
||||||
log.Printf("listening on %s (%d protected pages)", listenAddr, len(list))
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-errCh:
|
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
|
||||||
log.Fatalf("server error: %v", err)
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
|
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
||||||
log.Printf("shutdown: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupOIDC builds the OIDC client from the environment. Without OIDC
|
|
||||||
// configuration the server still serves public pages; protected pages
|
|
||||||
// answer 503 instead of being exposed.
|
|
||||||
func setupOIDC(ctx context.Context) *oidc.Authenticator {
|
|
||||||
cfg, err := oidc.LoadConfigFromEnv()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("OIDC disabled (%v) - protected pages will answer 503", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
auth, err := oidc.NewAuthenticator(ctx, cfg)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("OIDC setup: %v", err)
|
|
||||||
}
|
|
||||||
log.Printf("OIDC enabled (issuer: %s)", cfg.IssuerURL)
|
|
||||||
return auth
|
|
||||||
}
|
|
||||||
|
|
||||||
func envOr(key, def string) string {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|
||||||
func envDuration(key string, def time.Duration) time.Duration {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
if d, err := time.ParseDuration(v); err == nil {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
log.Printf("invalid %s %q, using %s", key, v, def)
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
Vendored
Whitespace-only changes.
@@ -1,22 +0,0 @@
|
|||||||
#import "@preview/shiroa:0.4.0": *
|
|
||||||
#show: book
|
|
||||||
|
|
||||||
#book-meta(
|
|
||||||
title: "Yggdrasil",
|
|
||||||
authors: ("Klagarge",),
|
|
||||||
summary: [
|
|
||||||
#prefix-chapter("index.typ")[Home]
|
|
||||||
|
|
||||||
= Thermodynamics
|
|
||||||
- #chapter("thermo/heat-pumps.typ", section: "1")[Heat pumps]
|
|
||||||
- #chapter("thermo/fluids.typ", section: "2")[Fluid mechanics]
|
|
||||||
|
|
||||||
= Security
|
|
||||||
- #chapter("security/opsec.typ", section: "3")[Opsec basics]
|
|
||||||
- #chapter("security/ssh.typ", section: "4")[SSH hardening]
|
|
||||||
|
|
||||||
= Homelab
|
|
||||||
- #chapter("homelab/self-hosting.typ", section: "5")[Self-hosting]
|
|
||||||
- #chapter("homelab/backup.typ", section: "6")[Backups]
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Access control: only members of these Authentik groups can read this page.
|
|
||||||
// Delete this line to make the page public.
|
|
||||||
#let access-groups = ("family",)
|
|
||||||
|
|
||||||
#import "../template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= Backups
|
|
||||||
|
|
||||||
Protected page placeholder about backups: the 3-2-1 rule, restic,
|
|
||||||
off-site replication...
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#import "../template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= Self-hosting
|
|
||||||
|
|
||||||
Public page placeholder about self-hosting: Docker/Podman, reverse
|
|
||||||
proxies, DNS, VLANs...
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#import "template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= Yggdrasil
|
|
||||||
|
|
||||||
Welcome to the Yggdrasil knowledge base, built with Typst, Shiroa and a Go
|
|
||||||
server.
|
|
||||||
|
|
||||||
*How it works:*
|
|
||||||
|
|
||||||
- Pages are written in Typst under `docs/` and compiled to static HTML.
|
|
||||||
- A Go server embeds the HTML and an access-control list (`acl.json`).
|
|
||||||
- Pages tagged with `#let access-groups = ("family", "admin")` are only
|
|
||||||
visible to authenticated users in those groups; everything else is
|
|
||||||
public.
|
|
||||||
|
|
||||||
Browse the categories in the sidebar to get started.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#import "../template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= Opsec basics
|
|
||||||
|
|
||||||
Public page placeholder about operational security: threat models,
|
|
||||||
password managers, 2FA...
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Access control: only members of these Authentik groups can read this page.
|
|
||||||
// Delete this line to make the page public.
|
|
||||||
#let access-groups = ("admin",)
|
|
||||||
|
|
||||||
#import "../template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= SSH hardening
|
|
||||||
|
|
||||||
Protected page placeholder about SSH: key management, ciphers,
|
|
||||||
session hardening, bastion hosts...
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
#import "@preview/shiroa:0.4.0": get-page-width, is-html-target, is-pdf-target, is-web-target, templates, x-target
|
|
||||||
#import templates: *
|
|
||||||
|
|
||||||
// Metadata
|
|
||||||
#let page-width = get-page-width()
|
|
||||||
#let is-html-target = is-html-target() // target.starts-with("html")
|
|
||||||
#let is-pdf-target = is-pdf-target() // target.starts-with("pdf")
|
|
||||||
#let is-web-target = is-web-target() // target.starts-with("web")
|
|
||||||
|
|
||||||
#let project(body) = {
|
|
||||||
// set web/pdf page properties
|
|
||||||
set page(
|
|
||||||
width: page-width,
|
|
||||||
// for a website, we don't need pagination.
|
|
||||||
height: auto,
|
|
||||||
) if is-pdf-target or is-web-target
|
|
||||||
|
|
||||||
// remove margins for web target
|
|
||||||
set page(margin: (
|
|
||||||
// reserved beautiful top margin
|
|
||||||
top: 20pt,
|
|
||||||
// Typst is setting the page's bottom to the baseline of the last line of text. So bad :(.
|
|
||||||
bottom: 0.5em,
|
|
||||||
// remove rest margins.
|
|
||||||
rest: 0pt,
|
|
||||||
)) if is-web-target
|
|
||||||
|
|
||||||
let web-theme = if x-target.starts-with("html") and not x-target.starts-with("html-wrapper") {
|
|
||||||
"starlight"
|
|
||||||
} else {
|
|
||||||
"mdbook"
|
|
||||||
}
|
|
||||||
show: if web-theme == "starlight" {
|
|
||||||
import "@preview/shiroa-starlight:0.4.0": starlight
|
|
||||||
starlight.with(include "book.typ")
|
|
||||||
} else if web-theme == "mdbook" {
|
|
||||||
import "@preview/shiroa-mdbook:0.4.0": mdbook
|
|
||||||
mdbook.with(include "book.typ")
|
|
||||||
} else {
|
|
||||||
panic("Unknown web theme: " + web-theme)
|
|
||||||
}
|
|
||||||
|
|
||||||
body
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// Access control: only members of these Authentik groups can read this page.
|
|
||||||
// Delete this line to make the page public.
|
|
||||||
#let access-groups = ("family", "admin")
|
|
||||||
|
|
||||||
#import "../template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= Fluid mechanics
|
|
||||||
|
|
||||||
Protected page placeholder about fluid mechanics: statics, dynamics,
|
|
||||||
Bernoulli's principle, pressure losses...
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
#import "../template.typ": project
|
|
||||||
#show: project
|
|
||||||
|
|
||||||
= Heat pumps
|
|
||||||
|
|
||||||
Public page placeholder about heat pumps: COP, seasonal performance,
|
|
||||||
refrigerant cycles...
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// Package yggdrasil embeds the build artifacts: the static site compiled by
|
|
||||||
// shiroa (dist/) and the access control list (acl.json). Embedding them at
|
|
||||||
// the repository root lets //go:embed see both directories relative to this
|
|
||||||
// file; `go build` requires acl.json and dist/ to exist (see the Justfile).
|
|
||||||
package yggdrasil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"embed"
|
|
||||||
"io/fs"
|
|
||||||
)
|
|
||||||
|
|
||||||
//go:embed all:dist
|
|
||||||
var distFS embed.FS
|
|
||||||
|
|
||||||
//go:embed acl.json
|
|
||||||
var aclData []byte
|
|
||||||
|
|
||||||
// DistFS returns the embedded static site.
|
|
||||||
func DistFS() (fs.FS, error) {
|
|
||||||
return fs.Sub(distFS, "dist")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ACLData returns the embedded acl.json.
|
|
||||||
func ACLData() []byte {
|
|
||||||
return aclData
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
module github.com/Klagarge/yggdrasil
|
|
||||||
|
|
||||||
go 1.27.1
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0
|
|
||||||
golang.org/x/oauth2 v0.37.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
|
||||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
|
||||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// Package acl loads the access control list generated by cmd/parser and
|
|
||||||
// answers authorization questions for the HTTP middleware.
|
|
||||||
//
|
|
||||||
// acl.json maps the URL of each protected page ("/thermo/fluids.html") to
|
|
||||||
// the groups allowed to read it. URLs absent from the list are public.
|
|
||||||
//
|
|
||||||
// Keys are normalized to a "page key": the URL path reduced to the page it
|
|
||||||
// belongs to, e.g. "/thermo/fluids.html" and "/thermo/fluids.html" both
|
|
||||||
// become "/thermo/fluids". The shiroa renderer fetches the page content
|
|
||||||
// from companion files such as "/thermo/fluids.light.multi.sir.in", which
|
|
||||||
// reduce to the same key, so a protected page's content cannot be fetched
|
|
||||||
// directly while the page itself is protected.
|
|
||||||
package acl
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ACL maps page keys ("/thermo/fluids") to the groups allowed to read them.
|
|
||||||
type ACL map[string][]string
|
|
||||||
|
|
||||||
// Load parses acl.json and normalizes its keys. A malformed file is an error:
|
|
||||||
// the caller must refuse to start rather than serve protected pages as
|
|
||||||
// public (fail closed).
|
|
||||||
func Load(data []byte) (ACL, error) {
|
|
||||||
var raw map[string][]string
|
|
||||||
if err := json.Unmarshal(data, &raw); err != nil {
|
|
||||||
return nil, fmt.Errorf("parsing acl.json: %w", err)
|
|
||||||
}
|
|
||||||
acl := make(ACL, len(raw))
|
|
||||||
for urlPath, groups := range raw {
|
|
||||||
if len(groups) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
key, err := PageKey(urlPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("acl entry %q: %w", urlPath, err)
|
|
||||||
}
|
|
||||||
// Merge entries that reduce to the same page (e.g. a page and
|
|
||||||
// its directory index): require the union of their groups.
|
|
||||||
acl[key] = appendUnique(acl[key], groups...)
|
|
||||||
}
|
|
||||||
return acl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PageKey reduces a URL path to the page it belongs to.
|
|
||||||
//
|
|
||||||
// "/thermo/fluids.html" -> "/thermo/fluids"
|
|
||||||
// "/thermo/fluids.light.multi.sir.in" -> "/thermo/fluids"
|
|
||||||
// "/" -> "/index"
|
|
||||||
// "/thermo/" -> "/thermo/index"
|
|
||||||
func PageKey(urlPath string) (string, error) {
|
|
||||||
if !strings.HasPrefix(urlPath, "/") {
|
|
||||||
return "", fmt.Errorf("not an absolute path")
|
|
||||||
}
|
|
||||||
// A request for a directory serves that directory's index page.
|
|
||||||
if urlPath != "/" && strings.HasSuffix(urlPath, "/") {
|
|
||||||
urlPath += "index.html"
|
|
||||||
}
|
|
||||||
p := path.Clean(urlPath)
|
|
||||||
if p == "/" {
|
|
||||||
return "/index", nil
|
|
||||||
}
|
|
||||||
dir, base := path.Split(p)
|
|
||||||
if i := strings.Index(base, "."); i >= 0 {
|
|
||||||
base = base[:i]
|
|
||||||
}
|
|
||||||
if base == "" {
|
|
||||||
base = "index"
|
|
||||||
}
|
|
||||||
return path.Join(dir, base), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GroupsFor returns the union of the groups required by any of the given
|
|
||||||
// page keys, and whether at least one of them is protected.
|
|
||||||
func (a ACL) GroupsFor(keys ...string) (required []string, protected bool) {
|
|
||||||
seen := make(map[string]struct{})
|
|
||||||
for _, k := range keys {
|
|
||||||
for _, g := range a[k] {
|
|
||||||
if _, dup := seen[g]; !dup {
|
|
||||||
seen[g] = struct{}{}
|
|
||||||
required = append(required, g)
|
|
||||||
}
|
|
||||||
protected = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return required, protected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allows reports whether the user's groups include at least one of the
|
|
||||||
// required groups.
|
|
||||||
func Allows(userGroups, required []string) bool {
|
|
||||||
for _, u := range userGroups {
|
|
||||||
for _, r := range required {
|
|
||||||
if u == r {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendUnique(dst []string, groups ...string) []string {
|
|
||||||
for _, g := range groups {
|
|
||||||
if !contains(dst, g) {
|
|
||||||
dst = append(dst, g)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return dst
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(list []string, s string) bool {
|
|
||||||
for _, x := range list {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
package acl
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestPageKey(t *testing.T) {
|
|
||||||
cases := []struct{ in, want string }{
|
|
||||||
{"/", "/index"},
|
|
||||||
{"/index.html", "/index"},
|
|
||||||
{"/thermo/fluids.html", "/thermo/fluids"},
|
|
||||||
// The renderer fetches content from theme variants; they must map
|
|
||||||
// to the same page or the ACL would be bypassable.
|
|
||||||
{"/thermo/fluids.light.multi.sir.in", "/thermo/fluids"},
|
|
||||||
{"/thermo/fluids.ayu.multi.sir.in", "/thermo/fluids"},
|
|
||||||
// Directory requests resolve to the directory index page.
|
|
||||||
{"/thermo/", "/thermo/index"},
|
|
||||||
{"/thermo", "/thermo"},
|
|
||||||
// Path traversal is neutralized before matching.
|
|
||||||
{"/thermo/../security/ssh.html", "/security/ssh"},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
if got, err := PageKey(c.in); err != nil {
|
|
||||||
t.Errorf("PageKey(%q) error: %v", c.in, err)
|
|
||||||
} else if got != c.want {
|
|
||||||
t.Errorf("PageKey(%q) = %q, want %q", c.in, got, c.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := PageKey("relative.html"); err == nil {
|
|
||||||
t.Error("PageKey must reject relative paths")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadAndCheck(t *testing.T) {
|
|
||||||
// Keys in acl.json are final page URLs; Load normalizes them.
|
|
||||||
data := []byte(`{
|
|
||||||
"/thermo/fluids.html": ["family", "admin"],
|
|
||||||
"/index.html": ["admin"],
|
|
||||||
"/public.html": ["family"]
|
|
||||||
}`)
|
|
||||||
a, err := Load(data)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Load: %v", err)
|
|
||||||
}
|
|
||||||
if got, _ := a.GroupsFor("/thermo/fluids"); len(got) != 2 {
|
|
||||||
t.Errorf("expected 2 groups for /thermo/fluids, got %v", got)
|
|
||||||
}
|
|
||||||
if _, protected := a.GroupsFor("/thermo/fluids"); !protected {
|
|
||||||
t.Error("/thermo/fluids should be protected")
|
|
||||||
}
|
|
||||||
if _, protected := a.GroupsFor("/thermo/heat-pumps"); protected {
|
|
||||||
t.Error("/thermo/heat-pumps should be public")
|
|
||||||
}
|
|
||||||
if !Allows([]string{"family"}, []string{"family", "admin"}) {
|
|
||||||
t.Error("family should be allowed")
|
|
||||||
}
|
|
||||||
if Allows([]string{"guest"}, []string{"family", "admin"}) {
|
|
||||||
t.Error("guest should be denied")
|
|
||||||
}
|
|
||||||
if Allows(nil, []string{"admin"}) {
|
|
||||||
t.Error("no groups should never be allowed on a protected page")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadMalformed(t *testing.T) {
|
|
||||||
if _, err := Load([]byte("not json")); err == nil {
|
|
||||||
t.Fatal("malformed acl.json must be an error (fail closed)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
// Package middleware implements the access-control layer of the server:
|
|
||||||
// OIDC login/logout, cookie sessions, and the gate that decides for every
|
|
||||||
// request whether its URL may be served to the caller.
|
|
||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/acl"
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/oidc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
sessionCookie = "ygg_session"
|
|
||||||
stateTTL = 10 * time.Minute
|
|
||||||
)
|
|
||||||
|
|
||||||
// AccessControl bundles everything the gate needs.
|
|
||||||
type AccessControl struct {
|
|
||||||
ACL acl.ACL
|
|
||||||
Sessions *SessionStore
|
|
||||||
Auth *oidc.Authenticator // nil: OIDC disabled
|
|
||||||
CookieSecure bool
|
|
||||||
|
|
||||||
stateMu sync.Mutex
|
|
||||||
states map[string]loginState // pending OIDC flows, keyed by state
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAccessControl wires the gate. auth may be nil (OIDC disabled): public
|
|
||||||
// pages keep working and protected pages answer 503.
|
|
||||||
func NewAccessControl(a acl.ACL, sessions *SessionStore, auth *oidc.Authenticator, cookieSecure bool) *AccessControl {
|
|
||||||
return &AccessControl{
|
|
||||||
ACL: a,
|
|
||||||
Sessions: sessions,
|
|
||||||
Auth: auth,
|
|
||||||
CookieSecure: cookieSecure,
|
|
||||||
states: make(map[string]loginState),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wrap returns a handler that serves next only when the request is allowed.
|
|
||||||
//
|
|
||||||
// The request path is reduced to a page key (see acl.PageKey) and looked up
|
|
||||||
// in the ACL. Public pages pass through; protected pages require a session
|
|
||||||
// whose groups include at least one of the groups required by the ACL.
|
|
||||||
func (ac *AccessControl) Wrap(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
key, err := acl.PageKey(r.URL.Path)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid request path", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
required, protected := ac.ACL.GroupsFor(key)
|
|
||||||
if !protected {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if ac.Auth == nil {
|
|
||||||
http.Error(w, "this page is protected, but the server has no OIDC configuration",
|
|
||||||
http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sess, ok := ac.session(r)
|
|
||||||
if !ok {
|
|
||||||
http.Redirect(w, r, "/login?target="+url.QueryEscape(r.URL.RequestURI()), http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !acl.Allows(sess.Groups, required) {
|
|
||||||
http.Error(w, "403 Forbidden: your account does not have access to this page.",
|
|
||||||
http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SecurityHeaders sets conservative defaults on every response.
|
|
||||||
func (ac *AccessControl) SecurityHeaders(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
h := w.Header()
|
|
||||||
h.Set("X-Content-Type-Options", "nosniff")
|
|
||||||
h.Set("X-Frame-Options", "DENY")
|
|
||||||
h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/oidc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// loginState remembers where a pending OIDC flow must return.
|
|
||||||
type loginState struct {
|
|
||||||
target string
|
|
||||||
expires time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// session reads the session cookie and returns the session if valid.
|
|
||||||
func (ac *AccessControl) session(r *http.Request) (*Session, bool) {
|
|
||||||
c, err := r.Cookie(sessionCookie)
|
|
||||||
if err != nil || c.Value == "" {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return ac.Sessions.Get(c.Value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AccessControl) setSessionCookie(w http.ResponseWriter, id string) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: id,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: ac.CookieSecure,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: int(ac.Sessions.TTL() / time.Second),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AccessControl) clearSessionCookie(w http.ResponseWriter) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: sessionCookie,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
Secure: ac.CookieSecure,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: -1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login redirects to the OIDC provider, remembering where to return.
|
|
||||||
func (ac *AccessControl) Login(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if ac.Auth == nil {
|
|
||||||
http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target := sanitizeTarget(r.URL.Query().Get("target"))
|
|
||||||
state := randomID()
|
|
||||||
ac.saveState(state, target)
|
|
||||||
http.Redirect(w, r, ac.Auth.AuthCodeURL(state), http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Callback finishes the OIDC flow: it verifies the provider's response and
|
|
||||||
// starts a session.
|
|
||||||
func (ac *AccessControl) Callback(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if ac.Auth == nil {
|
|
||||||
http.Error(w, "OIDC is not configured on this server", http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
target, ok := ac.takeState(r.URL.Query().Get("state"))
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "unknown or expired login state; restart the login", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
http.Error(w, "missing authorization code", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token, err := ac.Auth.Exchange(r.Context(), code)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "token exchange failed", http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
idToken, err := ac.Auth.VerifyIDToken(r.Context(), token)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "invalid identity token", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
profile, err := oidc.ProfileFromToken(idToken)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "unreadable identity token claims", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ac.setSessionCookie(w, ac.Sessions.Create(profile))
|
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout ends the session.
|
|
||||||
func (ac *AccessControl) Logout(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
|
||||||
ac.Sessions.Delete(c.Value)
|
|
||||||
}
|
|
||||||
ac.clearSessionCookie(w)
|
|
||||||
http.Redirect(w, r, "/", http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AccessControl) saveState(state, target string) {
|
|
||||||
ac.stateMu.Lock()
|
|
||||||
now := time.Now()
|
|
||||||
for k, v := range ac.states { // opportunistic purge
|
|
||||||
if now.After(v.expires) {
|
|
||||||
delete(ac.states, k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ac.states[state] = loginState{target: target, expires: now.Add(stateTTL)}
|
|
||||||
ac.stateMu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ac *AccessControl) takeState(state string) (string, bool) {
|
|
||||||
if state == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
ac.stateMu.Lock()
|
|
||||||
defer ac.stateMu.Unlock()
|
|
||||||
s, ok := ac.states[state]
|
|
||||||
if ok {
|
|
||||||
delete(ac.states, state) // states are single use
|
|
||||||
}
|
|
||||||
if !ok || time.Now().After(s.expires) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return s.target, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// sanitizeTarget only accepts local redirect targets (open-redirect guard).
|
|
||||||
func sanitizeTarget(t string) string {
|
|
||||||
if t == "" || !strings.HasPrefix(t, "/") || strings.HasPrefix(t, "//") || strings.HasPrefix(t, "/\\") {
|
|
||||||
return "/"
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/oidc"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSanitizeTarget(t *testing.T) {
|
|
||||||
cases := map[string]string{
|
|
||||||
"": "/",
|
|
||||||
"/thermo/fluids.html": "/thermo/fluids.html",
|
|
||||||
"/a b/c.html?x=1": "/a b/c.html?x=1",
|
|
||||||
"//evil.example.com": "/",
|
|
||||||
"/\\evil.example.com": "/",
|
|
||||||
"https://evil.example.com": "/",
|
|
||||||
"relative.html": "/",
|
|
||||||
}
|
|
||||||
for in, want := range cases {
|
|
||||||
if got := sanitizeTarget(in); got != want {
|
|
||||||
t.Errorf("sanitizeTarget(%q) = %q, want %q", in, got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStateRoundTrip(t *testing.T) {
|
|
||||||
ac := NewAccessControl(nil, NewSessionStore(0), nil, false)
|
|
||||||
|
|
||||||
ac.saveState("s1", "/thermo/fluids.html")
|
|
||||||
target, ok := ac.takeState("s1")
|
|
||||||
if !ok || target != "/thermo/fluids.html" {
|
|
||||||
t.Fatalf("state round trip failed: %q %v", target, ok)
|
|
||||||
}
|
|
||||||
// States are single use.
|
|
||||||
if _, ok := ac.takeState("s1"); ok {
|
|
||||||
t.Error("state must be single use")
|
|
||||||
}
|
|
||||||
// Unknown states are rejected (CSRF guard).
|
|
||||||
if _, ok := ac.takeState("nope"); ok {
|
|
||||||
t.Error("unknown state must be rejected")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSessionExpiry(t *testing.T) {
|
|
||||||
store := NewSessionStore(0) // zero TTL: expires immediately
|
|
||||||
id := store.Create(oidc.Profile{Subject: "sub", Email: "user@example.com", Groups: []string{"family"}})
|
|
||||||
if _, ok := store.Get(id); ok {
|
|
||||||
t.Error("expired session must not validate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Klagarge/yggdrasil/internal/oidc"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Session is an authenticated user.
|
|
||||||
type Session struct {
|
|
||||||
Subject string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Groups []string
|
|
||||||
Expires time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// SessionStore keeps sessions in memory. Sessions do not survive server
|
|
||||||
// restarts; users simply log in again.
|
|
||||||
type SessionStore struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
sessions map[string]*Session
|
|
||||||
ttl time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewSessionStore creates a store with the given session lifetime.
|
|
||||||
func NewSessionStore(ttl time.Duration) *SessionStore {
|
|
||||||
return &SessionStore{
|
|
||||||
sessions: make(map[string]*Session),
|
|
||||||
ttl: ttl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTL reports the session lifetime.
|
|
||||||
func (s *SessionStore) TTL() time.Duration { return s.ttl }
|
|
||||||
|
|
||||||
// Create registers a new session and returns its ID.
|
|
||||||
func (s *SessionStore) Create(p oidc.Profile) string {
|
|
||||||
id := randomID()
|
|
||||||
s.mu.Lock()
|
|
||||||
s.sessions[id] = &Session{
|
|
||||||
Subject: p.Subject,
|
|
||||||
Email: p.Email,
|
|
||||||
Name: p.Name,
|
|
||||||
Groups: p.Groups,
|
|
||||||
Expires: time.Now().Add(s.ttl),
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the session for id if it exists and has not expired.
|
|
||||||
func (s *SessionStore) Get(id string) (*Session, bool) {
|
|
||||||
s.mu.RLock()
|
|
||||||
sess, ok := s.sessions[id]
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if !ok || time.Now().After(sess.Expires) {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return sess, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a session.
|
|
||||||
func (s *SessionStore) Delete(id string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.sessions, id)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartJanitor purges expired sessions periodically until ctx is done.
|
|
||||||
// Call it in a goroutine.
|
|
||||||
func (s *SessionStore) StartJanitor(ctx context.Context, every time.Duration) {
|
|
||||||
t := time.NewTicker(every)
|
|
||||||
defer t.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case now := <-t.C:
|
|
||||||
s.mu.Lock()
|
|
||||||
for id, sess := range s.sessions {
|
|
||||||
if now.After(sess.Expires) {
|
|
||||||
delete(s.sessions, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func randomID() string {
|
|
||||||
var b [32]byte
|
|
||||||
if _, err := rand.Read(b[:]); err != nil {
|
|
||||||
panic(err) // crypto/rand does not fail on supported platforms
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b[:])
|
|
||||||
}
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
// Package oidc implements the OIDC client (authorization code flow) used to
|
|
||||||
// authenticate users. It is the only place that talks to the identity
|
|
||||||
// provider (e.g. Authentik).
|
|
||||||
package oidc
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Config is the OIDC client configuration, read from the environment by
|
|
||||||
// LoadConfigFromEnv.
|
|
||||||
type Config struct {
|
|
||||||
ClientID string
|
|
||||||
ClientSecret string
|
|
||||||
IssuerURL string
|
|
||||||
RedirectURL string
|
|
||||||
Scopes string
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfigFromEnv reads the OIDC_* variables. It returns an error when
|
|
||||||
// OIDC is not (fully) configured; the caller decides whether that is fatal.
|
|
||||||
func LoadConfigFromEnv() (*Config, error) {
|
|
||||||
cfg := &Config{
|
|
||||||
ClientID: os.Getenv("OIDC_CLIENT_ID"),
|
|
||||||
ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
|
||||||
IssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
|
||||||
RedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
|
|
||||||
Scopes: os.Getenv("OIDC_SCOPES"),
|
|
||||||
}
|
|
||||||
if cfg.Scopes == "" {
|
|
||||||
cfg.Scopes = "openid profile email groups"
|
|
||||||
}
|
|
||||||
if cfg.ClientID == "" || cfg.IssuerURL == "" || cfg.RedirectURL == "" {
|
|
||||||
return nil, errors.New("OIDC_CLIENT_ID, OIDC_ISSUER_URL and OIDC_REDIRECT_URL must be set")
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authenticator drives the authorization code flow and verifies ID tokens.
|
|
||||||
type Authenticator struct {
|
|
||||||
oauth2 oauth2.Config
|
|
||||||
verifier *oidc.IDTokenVerifier
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewAuthenticator discovers the provider at the issuer URL. It fails fast
|
|
||||||
// when the issuer is unreachable so that a misconfigured server never
|
|
||||||
// starts serving protected content.
|
|
||||||
func NewAuthenticator(ctx context.Context, cfg *Config) (*Authenticator, error) {
|
|
||||||
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("querying OIDC provider %s: %w", cfg.IssuerURL, err)
|
|
||||||
}
|
|
||||||
return &Authenticator{
|
|
||||||
oauth2: oauth2.Config{
|
|
||||||
ClientID: cfg.ClientID,
|
|
||||||
ClientSecret: cfg.ClientSecret,
|
|
||||||
RedirectURL: cfg.RedirectURL,
|
|
||||||
Endpoint: provider.Endpoint(),
|
|
||||||
Scopes: strings.Fields(cfg.Scopes),
|
|
||||||
},
|
|
||||||
verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthCodeURL returns the URL the user is redirected to for login.
|
|
||||||
func (a *Authenticator) AuthCodeURL(state string) string {
|
|
||||||
return a.oauth2.AuthCodeURL(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exchange trades an authorization code for tokens.
|
|
||||||
func (a *Authenticator) Exchange(ctx context.Context, code string) (*oauth2.Token, error) {
|
|
||||||
return a.oauth2.Exchange(ctx, code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyIDToken extracts and verifies the ID token issued in the code
|
|
||||||
// exchange. Signature, issuer, audience and expiry are checked.
|
|
||||||
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
|
|
||||||
rawIDToken, ok := token.Extra("id_token").(string)
|
|
||||||
if !ok {
|
|
||||||
return nil, errors.New("no id_token in OAuth2 token response")
|
|
||||||
}
|
|
||||||
return a.verifier.Verify(ctx, rawIDToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile holds the user attributes used for sessions and logging.
|
|
||||||
type Profile struct {
|
|
||||||
Subject string
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
Groups []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProfileFromToken reads user attributes from a verified ID token. A missing
|
|
||||||
// groups claim yields nil groups, which denies every protected page
|
|
||||||
// (fail closed).
|
|
||||||
func ProfileFromToken(idToken *oidc.IDToken) (Profile, error) {
|
|
||||||
var claims struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Groups []string `json:"groups"`
|
|
||||||
}
|
|
||||||
if err := idToken.Claims(&claims); err != nil {
|
|
||||||
return Profile{}, fmt.Errorf("reading ID token claims: %w", err)
|
|
||||||
}
|
|
||||||
return Profile{
|
|
||||||
Subject: idToken.Subject,
|
|
||||||
Email: claims.Email,
|
|
||||||
Name: claims.Name,
|
|
||||||
Groups: claims.Groups,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<header>
|
|
||||||
<h1>Yggdrasil</h1>
|
|
||||||
</header>
|
|
||||||
Yggdrasil is a collections of notes around IT, OT and other geeks stuff
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
# [Bachelor in embedded system](/infotro/)
|
|
||||||
Here notes I take during my bachelor in embedded systems
|
|
||||||
|
|
||||||
Mainly some topics:
|
|
||||||
- [PTR - Programmation Temps Réel](/infotro/PTR/)
|
|
||||||
- [SDi - Système Distribué](/infotro/SDi/)
|
|
||||||
- [Eln - Électronique](/infotro/Eln/)
|
|
||||||
- [SEm - Systèmes Embarqué](/infotro/SEm/)
|
|
||||||
|
|
||||||
# [Master of science in engineering in embedded system](/MSE/)
|
|
||||||
Here note I take during my Master of science un engineering with embedded system as orientation
|
|
||||||
|
|
||||||
Mainly some topics:
|
|
||||||
- [SRE - Software Reverse Engineering](/MSE/SRE/)
|
|
||||||
|
|
||||||
# [Embedded Systems](/Embed/)
|
|
||||||
Here some note specific about some Embedded System
|
|
||||||
- [STM32](/Embed/STM32/)
|
|
||||||
- [nRF](/Embed/nRF/)
|
|
||||||
|
|
||||||
# [Working environment at HES-SO](/HES-SO/)
|
|
||||||
Here some note about the working environment at HES-SO
|
|
||||||
|
|
||||||
- [OneDrive - Linux](/HES-SO/OneDrive.md)
|
|
||||||
- [Teams - Linux](/HES-SO/Teams.md)
|
|
||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bootstrap the pinned toolchain (Calepin + Typst) into tools/.
|
||||||
|
# Idempotent: skips downloads when the binaries are already in place.
|
||||||
|
# Supports x86_64 Linux and Windows (run through Git Bash / MSYS2).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Pinned toolchain -- bump both binaries together and re-verify checksums.
|
||||||
|
calepin_version="0.0.61"
|
||||||
|
typst_version="0.15.1"
|
||||||
|
# Typst publishes no .sha256 assets; these digests come from the official
|
||||||
|
# release metadata (GitHub asset digests of the downloads below).
|
||||||
|
typst_sha256_linux="a6d077d0a95eed5a2eba715b2dae06be954f624ccbf85758a03f389ded33118c"
|
||||||
|
typst_sha256_windows="19ce3551153c2fe7ee9fa2f95208310c8f4d3209fedb699e0333faf8913f6736"
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
tools="$root/tools"
|
||||||
|
|
||||||
|
if [ "$(uname -m)" != "x86_64" ]; then
|
||||||
|
echo "error: unsupported architecture $(uname -m), pinned binaries are x86_64" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$(uname -s)" in
|
||||||
|
Linux*)
|
||||||
|
exe=""
|
||||||
|
calepin_name="calepin-x86_64-unknown-linux-gnu.tar.xz"
|
||||||
|
typst_name="typst-x86_64-unknown-linux-musl.tar.xz"
|
||||||
|
typst_sha256="$typst_sha256_linux"
|
||||||
|
;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*)
|
||||||
|
exe=".exe"
|
||||||
|
calepin_name="calepin-x86_64-pc-windows-msvc.zip"
|
||||||
|
typst_name="typst-x86_64-pc-windows-msvc.zip"
|
||||||
|
typst_sha256="$typst_sha256_windows"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "error: unsupported operating system $(uname -s)" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Extract a single binary from a zip into tools/. The zip may or may not nest
|
||||||
|
# it in a top-level directory. Git Bash ships no unzip, so fall back to the
|
||||||
|
# bsdtar.exe bundled with Windows 10+.
|
||||||
|
install_zip_binary() {
|
||||||
|
local archive="$1" bin="$2"
|
||||||
|
local tmp="$tools/.extract"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
mkdir -p "$tmp"
|
||||||
|
if command -v unzip >/dev/null 2>&1; then
|
||||||
|
unzip -q -o "$archive" -d "$tmp"
|
||||||
|
else
|
||||||
|
local wintar=""
|
||||||
|
if command -v cygpath >/dev/null 2>&1; then
|
||||||
|
wintar="$(cygpath -u "${SYSTEMROOT:-C:/Windows}")/System32/tar.exe"
|
||||||
|
fi
|
||||||
|
if [ -z "$wintar" ] || [ ! -x "$wintar" ]; then
|
||||||
|
echo "error: extracting $archive needs unzip or Windows tar.exe" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
"$wintar" -xf "$archive" -C "$tmp"
|
||||||
|
fi
|
||||||
|
local found
|
||||||
|
found="$(find "$tmp" -type f -name "$bin" -print -quit)"
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
echo "error: $bin not found in $archive" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
mv "$found" "$tools/$bin"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir -p "$tools"
|
||||||
|
|
||||||
|
if [ ! -x "$tools/calepin$exe" ]; then
|
||||||
|
# Calepin publishes .sha256 assets next to its downloads.
|
||||||
|
base="https://github.com/vincentarelbundock/calepin/releases/download/v$calepin_version"
|
||||||
|
curl -fsSLo "$tools/$calepin_name" "$base/$calepin_name"
|
||||||
|
curl -fsSLo "$tools/$calepin_name.sha256" "$base/$calepin_name.sha256"
|
||||||
|
(cd "$tools" && sha256sum -c "$calepin_name.sha256")
|
||||||
|
if [ "$exe" ]; then
|
||||||
|
install_zip_binary "$tools/$calepin_name" "calepin.exe"
|
||||||
|
else
|
||||||
|
tar -xJf "$tools/$calepin_name" -C "$tools" --strip-components=1 "${calepin_name%.tar.xz}/calepin"
|
||||||
|
fi
|
||||||
|
rm "$tools/$calepin_name" "$tools/$calepin_name.sha256"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -x "$tools/typst$exe" ]; then
|
||||||
|
curl -fsSLo "$tools/$typst_name" "https://github.com/typst/typst/releases/download/v$typst_version/$typst_name"
|
||||||
|
echo "$typst_sha256 $typst_name" > "$tools/$typst_name.sha256"
|
||||||
|
(cd "$tools" && sha256sum -c "$typst_name.sha256")
|
||||||
|
if [ "$exe" ]; then
|
||||||
|
install_zip_binary "$tools/$typst_name" "typst.exe"
|
||||||
|
else
|
||||||
|
tar -xJf "$tools/$typst_name" -C "$tools" --strip-components=1 "${typst_name%.tar.xz}/typst"
|
||||||
|
fi
|
||||||
|
rm "$tools/$typst_name" "$tools/$typst_name.sha256"
|
||||||
|
fi
|
||||||
|
|
||||||
|
"$tools/calepin$exe" --version
|
||||||
|
"$tools/typst$exe" --version
|
||||||
Executable
+17
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run the pinned Calepin CLI from tools/, bootstrapping the toolchain on demand.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
exe=""
|
||||||
|
case "$(uname -s)" in
|
||||||
|
MINGW*|MSYS*|CYGWIN*) exe=".exe" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -x "$root/tools/calepin$exe" ]; then
|
||||||
|
"$root/scripts/bootstrap.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PATH="$root/tools:$PATH"
|
||||||
|
exec "$root/tools/calepin$exe" "$@"
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Remove generated site output and build caches. Never touches tracked files.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
rm -rf "$root/public"
|
||||||
|
find "$root/site" -name '.calepin' -prune -exec rm -rf {} +
|
||||||
|
find "$root/site" -type f -name '.calepin-entry.*' -delete
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#set document(title: [Not found])
|
||||||
|
#metadata((pdf: false)) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
The requested page does not exist.
|
||||||
File renamed without changes.
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="83.380997mm"
|
||||||
|
height="83.36158mm"
|
||||||
|
viewBox="0 0 83.380997 83.36158"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
xml:space="preserve"
|
||||||
|
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||||
|
sodipodi:docname="yggdrasil.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="1.0544998"
|
||||||
|
inkscape:cx="-142.7217"
|
||||||
|
inkscape:cy="163.58467"
|
||||||
|
inkscape:window-width="1918"
|
||||||
|
inkscape:window-height="1008"
|
||||||
|
inkscape:window-x="953"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="0"
|
||||||
|
inkscape:current-layer="layer1" /><defs
|
||||||
|
id="defs2" /><g
|
||||||
|
inkscape:label="Calque 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-48.417524,-42.311191)"><path
|
||||||
|
style="fill:#000000;fill-opacity:1;stroke-width:0.264583"
|
||||||
|
d="m 84.403651,125.38487 c -11.277707,-1.62161 -21.59067,-7.82705 -28.163642,-16.94643 -2.120561,-2.94208 -4.8935,-8.42917 -5.950044,-11.773956 C 48.523252,91.071465 47.926554,83.146337 48.845694,77.482192 49.93327,70.780068 52.639578,64.396111 56.667168,59.031973 67.423912,44.70563 85.766835,38.878817 102.96132,44.326174 c 3.29203,1.042943 7.26188,2.961672 10.21602,4.937663 19.27665,12.893945 24.40963,38.757842 11.51548,58.023873 -6.09124,9.10134 -15.84844,15.57084 -26.688341,17.69569 -3.690904,0.72349 -10.054808,0.91134 -13.600828,0.40147 z m 10.989334,-6.5207 c 2.686786,-0.37701 2.725391,-0.43473 1.345679,-2.01195 -1.435587,-1.64109 -3.538548,-2.98982 -4.661781,-2.98982 -1.068729,0 -3.863835,1.97691 -5.118653,3.6203 -0.511633,0.67006 -0.930243,1.31456 -0.930243,1.43221 0,0.48213 5.791144,0.45075 9.364998,-0.0507 z m -12.308406,-2.23288 c 0.491178,-0.94989 1.416942,-2.30977 2.057257,-3.02195 0.716828,-0.79729 1.054034,-1.36297 0.877507,-1.47207 -0.458722,-0.28351 -3.691126,0.13469 -5.353923,0.69268 -1.772833,0.5949 -6.014517,2.51905 -6.014517,2.72835 0,0.38049 6.053839,2.73968 7.14375,2.78394 0.228987,0.009 0.774661,-0.71448 1.289926,-1.71095 z m 21.922391,-0.66565 c 1.32429,-0.61183 2.17999,-1.15009 2.03713,-1.28142 -0.89744,-0.82506 -6.75815,-2.40932 -8.912889,-2.40932 h -0.802899 l 1.313889,1.38925 c 0.722638,0.76408 1.653769,1.90284 2.069189,2.53057 0.8887,1.34293 0.89352,1.34267 4.29558,-0.22908 z m -31.746628,-4.6207 c 5.042327,-1.65497 9.255499,-3.75495 11.033473,-5.49945 2.135584,-2.09538 2.630556,-3.61771 2.64709,-8.141381 0.01273,-3.481216 -0.01109,-3.637899 -0.781344,-5.14404 -0.652878,-1.276601 -1.026305,-1.672767 -2.095314,-2.222897 -2.0984,-1.079873 -4.794002,-0.902705 -11.52452,0.757452 -4.376931,1.079619 -5.851818,1.33617 -7.672917,1.334672 -2.419566,-0.0021 -5.635238,-0.651939 -7.782001,-1.572895 -0.71182,-0.305369 -1.356009,-0.493427 -1.43153,-0.417904 -0.198855,0.198855 0.419965,2.868422 1.19789,5.167654 1.894982,5.600809 5.871765,11.438149 10.391401,15.253069 1.091406,0.92123 2.072997,1.67616 2.181314,1.67762 0.108316,0.002 1.834723,-0.5349 3.836458,-1.1919 z m 41.755458,-2.57577 c 3.98707,-4.04651 6.77652,-8.5431 8.41183,-13.559894 0.65571,-2.011585 1.53407,-5.73677 1.38728,-5.883558 -0.0378,-0.03778 -0.77417,0.217977 -1.63644,0.568348 -1.97533,0.802648 -4.85026,1.391135 -7.91402,1.619967 -2.63428,0.196755 -5.34063,-0.171648 -9.83252,-1.338458 -5.10034,-1.324859 -7.486541,-1.144257 -8.407312,0.636318 -0.522383,1.010179 -1.01409,5.134496 -0.808157,6.778633 0.867765,6.928104 5.122759,11.319124 12.960829,13.375194 2.98126,0.78204 2.85796,0.82843 5.83851,-2.19655 z M 69.067334,88.753522 c 0.785339,-0.318225 1.947269,-0.951582 2.582067,-1.407459 l 1.154177,-0.828866 -0.552597,-0.588214 c -0.762039,-0.811154 -3.235689,-1.870228 -5.140703,-2.200955 -1.678927,-0.291475 -10.077076,-1.086461 -11.477263,-1.086461 -0.82324,0 -0.825862,0.0046 -0.825862,1.434907 0,1.131462 0.135254,1.595649 0.639572,2.194997 0.761174,0.904605 2.866943,2.092999 4.902132,2.766528 2.08043,0.688499 6.688349,0.538147 8.718477,-0.284477 z m 50.863986,-1.442054 c 1.25709,-0.374959 2.95373,-0.977217 3.77031,-1.338347 l 1.48469,-0.6566 V 83.08766 c 0,-1.225873 -0.18263,-3.286255 -0.40585,-4.578623 -0.43601,-2.52445 -1.17016,-5.39247 -1.38034,-5.39247 -0.0709,0 -0.81844,0.418835 -1.66114,0.930746 -0.90567,0.550169 -2.3141,1.821365 -3.44446,3.108854 -4.16097,4.73936 -7.11814,6.862702 -11.69519,8.397521 -0.98226,0.329382 -1.78594,0.678349 -1.78594,0.775486 0,0.247123 2.192,1.008276 4.23551,1.470744 2.7024,0.611582 7.99014,0.374246 10.88241,-0.48845 z M 83.761875,83.983222 c 1.482519,-0.243309 2.266112,-0.981247 2.266112,-2.134079 0,-0.40359 -0.478081,-1.688976 -1.062403,-2.856413 -1.828768,-3.653772 -5.014299,-6.267254 -9.742194,-7.992721 -1.196372,-0.436624 -2.320827,-0.793859 -2.498788,-0.793859 -0.177961,0 -0.619081,0.557051 -0.980265,1.23789 -0.85316,1.608223 -4.006178,4.820484 -5.692392,5.79935 -1.256203,0.729242 -1.476359,0.767323 -4.365626,0.75515 -1.944292,-0.0082 -3.589412,-0.165622 -4.557125,-0.436092 l -1.514417,-0.423272 -0.168301,0.89712 c -0.288931,1.540134 -0.178452,1.602422 3.212515,1.811239 7.690604,0.473594 9.811887,0.810062 14.217736,2.25515 6.282295,2.060549 7.990334,2.35563 10.885148,1.880537 z m 21.718565,-1.70688 c 3.55447,-1.135328 7.08989,-3.616397 9.42867,-6.616799 1.95582,-2.509107 3.72573,-4.128383 5.42887,-4.966837 0.92034,-0.453081 1.67334,-0.938086 1.67334,-1.077789 0,-0.30966 -1.98551,-4.112838 -2.50079,-4.790185 -0.34453,-0.452895 -0.55038,-0.305629 -2.9696,2.124496 -2.56587,2.577443 -5.413,4.708495 -7.40044,5.539176 -0.56089,0.234431 -2.24501,0.770331 -3.7425,1.190889 -4.54688,1.276956 -8.651044,3.452133 -9.458039,5.01269 -0.675886,1.307018 -0.367083,3.395645 0.617138,4.17408 0.767993,0.60742 6.325171,0.24016 8.923351,-0.589721 z M 90.412503,75.838005 c 0.403259,-0.403259 0.435912,-0.894781 0.294405,-4.431771 -0.183579,-4.588598 -0.714886,-7.121012 -2.229903,-10.62857 -1.904016,-4.4081Line truncated
|
||||||
|
id="path304" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
title = "Yggdrasil"
|
||||||
|
description = "Personal knowledge base."
|
||||||
|
base-url = "https://yggdrasil.kb28.ch"
|
||||||
|
theme = "themes/yggdrasil"
|
||||||
|
pdf = true
|
||||||
|
minify = true
|
||||||
|
search = "pagefind"
|
||||||
|
logo = "assets/yggdrasil.svg"
|
||||||
|
favicon = "assets/yggdrasil.ico"
|
||||||
|
|
||||||
|
[robots]
|
||||||
|
enabled = false
|
||||||
|
|
||||||
|
[pages]
|
||||||
|
include = ["tags.typ"]
|
||||||
|
exclude = ["drafts/**"]
|
||||||
|
|
||||||
|
[static]
|
||||||
|
include = [
|
||||||
|
"assets/**",
|
||||||
|
"CNAME"
|
||||||
|
]
|
||||||
|
exclude = ["drafts/**"]
|
||||||
|
|
||||||
|
[toc]
|
||||||
|
enabled = true
|
||||||
|
depth = 2
|
||||||
|
floating = true
|
||||||
|
|
||||||
|
[menus]
|
||||||
|
main = [
|
||||||
|
{ label = "Home", target = "home/index.typ" },
|
||||||
|
{ label = "Computer Science", target = "cs/index.typ" },
|
||||||
|
{ label = "HomeLab", target = "homelab/index.typ" },
|
||||||
|
{ label = "Medias", target = "medias/index.typ" },
|
||||||
|
]
|
||||||
|
social = [
|
||||||
|
{ label = "{icon:simple-icons:gitea}", target = "https://git.kb28.ch/Klagarge/Yggdrasil", aria-label = "Gitea" },
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[footer]
|
||||||
|
item = [
|
||||||
|
{ label = "© 2026" },
|
||||||
|
{ label = "@Klagarge", target = "https://git.kb28.ch/Klagarge" },
|
||||||
|
{ label = "@Fastium", target = "https://github.com/Fastium" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[sidebar]
|
||||||
|
fold = true
|
||||||
|
|
||||||
|
[[sidebar.section]]
|
||||||
|
title = "Home"
|
||||||
|
item = [
|
||||||
|
{ target = "home/index.typ" },
|
||||||
|
{ glob = "home/*" }
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[[sidebar.section]]
|
||||||
|
title = "Computer Science"
|
||||||
|
item = [
|
||||||
|
{ target = "cs/index.typ" },
|
||||||
|
{ glob = "cs/*" }
|
||||||
|
]
|
||||||
|
section = [
|
||||||
|
{ title = "Tools", item = [{ glob = "cs/tools/*.typ" }] }
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[[sidebar.section]]
|
||||||
|
title = "HomeLab"
|
||||||
|
item = [
|
||||||
|
{ target = "homelab/index.typ" },
|
||||||
|
{ glob = "homelab/*" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[[sidebar.section]]
|
||||||
|
title = "Medias"
|
||||||
|
target = "medias/index.typ"
|
||||||
|
item = [
|
||||||
|
# { glob = "medias/films/*.typ" },
|
||||||
|
# { glob = "medias/series/*.typ" },
|
||||||
|
# { glob = "medias/books/*.typ" },
|
||||||
|
{ target = "medias/index.typ" },
|
||||||
|
{ glob = "medias/*.typ" },
|
||||||
|
]
|
||||||
|
section = [
|
||||||
|
{ title = "Films", item = [{ glob = "medias/films/*.typ" }] },
|
||||||
|
{ title = "Series", item = [{ glob = "medias/series/*.typ" }] },
|
||||||
|
{ title = "Books", item = [{ glob = "medias/books/*.typ" }] },
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#set document(title: [Computer Science])
|
||||||
|
#metadata((title: "Overview", translation_key: "cs-index")) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Placeholder page for the Computer Science category.
|
||||||
|
The real content comes later.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
#import "/lib/lib.typ": *
|
||||||
|
#set document(title: [Git])
|
||||||
|
#metadata((
|
||||||
|
title: "Git",
|
||||||
|
translation_key: "git",
|
||||||
|
summary: "What's git and lot of usefull command"
|
||||||
|
)) <website-metadata>
|
||||||
|
#calepin.setup(eval: false)
|
||||||
|
|
||||||
|
= What's Git
|
||||||
|
This is git
|
||||||
|
|
||||||
|
= Bascis command
|
||||||
|
TODO:
|
||||||
|
- fetch
|
||||||
|
- pull
|
||||||
|
- push
|
||||||
|
- commit (with sign)
|
||||||
|
- checkout
|
||||||
|
- rebase
|
||||||
|
|
||||||
|
|
||||||
|
= Config
|
||||||
|
Git config live in `~/.gitconfig`
|
||||||
|
|
||||||
|
== User
|
||||||
|
In User section, we can specify default `name` and `email`:
|
||||||
|
```yaml
|
||||||
|
[user]
|
||||||
|
name = Rémi Heredero
|
||||||
|
email = remi@heredero.ch
|
||||||
|
```
|
||||||
|
|
||||||
|
== Signing
|
||||||
|
To sign commit and tag by default, you can set the flag on the configfile:
|
||||||
|
```yaml
|
||||||
|
[commit]
|
||||||
|
gpgsign = true
|
||||||
|
[tag]
|
||||||
|
gpgSign = true
|
||||||
|
```
|
||||||
|
You also probably want to specify your default gpg key in the user section:
|
||||||
|
```yaml
|
||||||
|
[user]
|
||||||
|
signingkey = 4A8C1907D8B15B47
|
||||||
|
```
|
||||||
|
|
||||||
|
Sometime (often in windows), you need to specify the path of the gpg program with:
|
||||||
|
```yaml
|
||||||
|
[gpg]
|
||||||
|
program = C:/Users/her/dev/GnuPG/bin/gpg.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
== Default editor
|
||||||
|
You can define a default editor to edit commit message or solve conflict. In my case, my favourite code editor is Zed, so:
|
||||||
|
```yaml
|
||||||
|
[core]
|
||||||
|
editor = zed --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
== Include If
|
||||||
|
You maybe want a different config for some repo. It's easy to do that with `includeIf`. You can define a gitconfig overlay to apply to a specific repository:
|
||||||
|
```yaml
|
||||||
|
[includeIf "gitdir:<path-to-work-repo>"]
|
||||||
|
path = ~/.gitconfig-work
|
||||||
|
```
|
||||||
|
= Advanced command
|
||||||
|
|
||||||
|
== Reset author
|
||||||
|
If you didn't use the right config for commiting somewhere, you can reset the author of commit with the following command.
|
||||||
|
|
||||||
|
=== Single commit
|
||||||
|
```sh
|
||||||
|
git commit --amend --no-edit --reset-author
|
||||||
|
```
|
||||||
|
|
||||||
|
=== Entire project history
|
||||||
|
```sh
|
||||||
|
git rebase -r --root --exec "git commit --amend --no-edit --reset-author"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== From a specific commit
|
||||||
|
```sh
|
||||||
|
git rebase -r <hash> --exec "git commit --amend --no-edit --reset-author"
|
||||||
|
```
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#import "/lib/lib.typ": *
|
||||||
|
#set document(title: [Lazygit])
|
||||||
|
#metadata((
|
||||||
|
title: "Lazygit",
|
||||||
|
translation_key: "lazygit",
|
||||||
|
summary: "Simple TUI for git commands build in GO"
|
||||||
|
)) <website-metadata>
|
||||||
|
#calepin.setup(eval: false)
|
||||||
|
|
||||||
|
= Install
|
||||||
|
TODO
|
||||||
|
|
||||||
|
= Config file
|
||||||
|
On linux:
|
||||||
|
`~/.config/lazygit/config.yml`
|
||||||
|
|
||||||
|
On Windows:
|
||||||
|
`%USERPROFILE%\AppData\Local\lazygit\config.yml `
|
||||||
|
|
||||||
|
= Disable auto-fetch
|
||||||
|
Add the following code in your config gile:
|
||||||
|
```yaml
|
||||||
|
git:
|
||||||
|
autoFetch: false
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 MiB |
@@ -0,0 +1,6 @@
|
|||||||
|
#set document(title: [Cooking])
|
||||||
|
#metadata((title: "Cooking", translation_key: "home-cooking")) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Placeholder page — the real content comes later.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#import "/lib/lib.typ": *
|
||||||
|
#set document(title: [Gardening])
|
||||||
|
#metadata((
|
||||||
|
title: "Gardening",
|
||||||
|
translation_key: "home-gardening",
|
||||||
|
date: "2026-10-11",
|
||||||
|
tags: ("plants", "yucca", "dracaena", "light", "water"),
|
||||||
|
author: "Rémi Herdero",
|
||||||
|
summary: "Some plants description and advices for keep them alive"
|
||||||
|
)) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
= Yucca elephantipes <home:gardening:yucca>
|
||||||
|
#calepin.elements.columns(
|
||||||
|
columns: 2,
|
||||||
|
wrap: true,
|
||||||
|
[
|
||||||
|
Yucca (@fig:plant:yucca_elephantipes) from central America is accustomed to arid conditions.
|
||||||
|
|
||||||
|
#tag(fill: yellow.lighten(30%))[Light ++]
|
||||||
|
#tag(fill: blue.lighten(50%))[Water --]
|
||||||
|
|
||||||
|
This plant love light and should be placed on one of the most brightful parts of the room. It even tolerates direct sunlight very well.
|
||||||
|
|
||||||
|
The Yucca stores water in its thick trunk. You need to let the soil dry out almost completely between waterings. Generally speaking, watering once *every 2 to 3 weeks* is more than enough. It's better to forget about it for a while than to overwater it, otherwise its roots will decay. Usually we can put around #frackable(1, 2) liter every 2 Weeks. But it's important to check the dirt is dry before watering.
|
||||||
|
|
||||||
|
],[
|
||||||
|
#figure(
|
||||||
|
image("assets/yucca_elephantipes.jpg", width: 60%),
|
||||||
|
caption: "Yucca elephantipes"
|
||||||
|
) <fig:plant:yucca_elephantipes>
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
= Dracaena marginata <home:gardening:dracaena>
|
||||||
|
#calepin.elements.columns(
|
||||||
|
columns: 2,
|
||||||
|
wrap: true,
|
||||||
|
[
|
||||||
|
The Dracaena (@fig:plant:dracaena_marginata) come from Madagascar.
|
||||||
|
|
||||||
|
#tag(fill: yellow.lighten(30%))[Light +]
|
||||||
|
#tag(fill: blue.lighten(50%))[Water -]
|
||||||
|
|
||||||
|
This plant prefer lot of indirect light. Avoid to place it in direct sunlight because it can burn its leaves.
|
||||||
|
|
||||||
|
The Dracaena don't need a lot of water. Before watering, make sure the first 3 or 4 centimeters of soil are dry. In average, water the plant once with #frackable(1, 2) litre *every 10 to 14 days*. The extremity of the leaves tend to turn brown when the air is too dry or the water contain too much calcium or chlorine.
|
||||||
|
|
||||||
|
], [
|
||||||
|
#figure(
|
||||||
|
image("assets/dracaena_marginata.jpg", width: 60%),
|
||||||
|
caption: [Dracaena marginata]
|
||||||
|
) <fig:plant:dracaena_marginata>
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#set document(title: [Home])
|
||||||
|
#metadata((title: "Overview", translation_key: "home-index")) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Placeholder page for the home category: cooking, washing clothes, gardening.
|
||||||
|
The real content comes later.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
#set document(title: [Backup])
|
||||||
|
#metadata((title: "Backup", translation_key: "homelab-backup")) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Placeholder page — the real content comes later.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#set document(title: [HomeLab])
|
||||||
|
#metadata((title: "Overview", translation_key: "homelab")) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Placeholder page for the homelab category.
|
||||||
|
The real content comes later.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#set document(title: [Yggdrasil])
|
||||||
|
#metadata((title: "Home", translation_key: "home", layout: "layouts/site-landing.html")) <website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Welcome to Yggdrasil, my personal knowledge base.
|
||||||
|
Pick a master category from the menu
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// #import "@preview/calepin:0.1.0" as calepin
|
||||||
|
#import "@preview/hes-so-core:0.1.0"
|
||||||
|
#import "@preview/frackable:0.2.0": *
|
||||||
|
|
||||||
|
// #import "@preview/cetz:0.5.2"
|
||||||
|
//
|
||||||
|
// #let drawing(..args) = {
|
||||||
|
// let canvas = cetz.canvas(..args)
|
||||||
|
// context if target() == "html" {
|
||||||
|
// html.frame(canvas)
|
||||||
|
// } else {
|
||||||
|
// canvas
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
#let tag(body, fill: yellow, stroke: auto) = {
|
||||||
|
let stroke-color = if stroke == auto {
|
||||||
|
fill.darken(25%)
|
||||||
|
} else if type(stroke) == color {
|
||||||
|
stroke
|
||||||
|
} else if type(stroke) == stroke {
|
||||||
|
stroke.paint
|
||||||
|
} else {
|
||||||
|
fill.darken(25%)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stroke-rule = if stroke == auto {
|
||||||
|
1pt + stroke-color
|
||||||
|
} else if type(stroke) == color {
|
||||||
|
1pt + stroke
|
||||||
|
} else {
|
||||||
|
stroke
|
||||||
|
}
|
||||||
|
|
||||||
|
context if target() == "html" {
|
||||||
|
let bg-hex = fill.to-hex()
|
||||||
|
let border-hex = stroke-color.to-hex()
|
||||||
|
html.elem("span", attrs: (
|
||||||
|
style: "display: inline-block; background-color: " + bg-hex
|
||||||
|
+ "; border: 1px solid " + border-hex
|
||||||
|
+ "; color: #1f2933; border-radius: 1em; padding: 0.2em 0.6em; font-size: 0.85em; font-weight: 500; line-height: 1.2;"
|
||||||
|
))[#body]
|
||||||
|
} else {
|
||||||
|
box(
|
||||||
|
fill: fill,
|
||||||
|
stroke: stroke-rule,
|
||||||
|
radius: 1em,
|
||||||
|
inset: (x: 0.5em, y: 0.5em)
|
||||||
|
)[#body]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#set document(title: [Le Grand Cahier])
|
||||||
|
#metadata((
|
||||||
|
translation_key: "le_grand_cahier",
|
||||||
|
date: "2026-10-10",
|
||||||
|
author: "Rémi Heredero",
|
||||||
|
pdf: true
|
||||||
|
))<website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
This book is awesome!
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#set document(title: [H2G2])
|
||||||
|
#metadata((
|
||||||
|
translation_key: "h2g2",
|
||||||
|
date: "2026-10-10",
|
||||||
|
author: "Rémi Heredero",
|
||||||
|
pdf: true
|
||||||
|
))<website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
One of my favourites films!
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
#set document(title: [Medias])
|
||||||
|
#metadata((
|
||||||
|
title: "Overview",
|
||||||
|
translation_key: "medias-overview",
|
||||||
|
date: "2026-10-10",
|
||||||
|
tags: ("medias", "films", "series", "books"),
|
||||||
|
author: "Rémi Heredero",
|
||||||
|
summary: "My very opinionned ideas about somes films, series and books",
|
||||||
|
draft: false,
|
||||||
|
pdf: false
|
||||||
|
))<website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
Placeholder page for the medias category.
|
||||||
|
The real content comes later.
|
||||||
|
|
||||||
|
#import "@preview/calepin:0.1.0" as calepin
|
||||||
|
#show: calepin.document
|
||||||
|
|
||||||
|
== Films
|
||||||
|
#let posts = calepin.pages().filter(p => p.path.starts-with("medias/films/")).filter(p => not p.meta.at("draft", default: false)).sorted(key: p => p.meta.at("date", default: "")).rev()
|
||||||
|
|
||||||
|
#for post in posts [
|
||||||
|
- #link(post.href)[#post.title] \
|
||||||
|
#post.meta.at("summary", default: [])
|
||||||
|
]
|
||||||
|
|
||||||
|
== Series
|
||||||
|
#let posts = calepin.pages().filter(p => p.path.starts-with("medias/series/")).filter(p => not p.meta.at("draft", default: false)).sorted(key: p => p.meta.at("date", default: "")).rev()
|
||||||
|
|
||||||
|
#for post in posts [
|
||||||
|
- #link(post.href)[#post.title] \
|
||||||
|
#post.meta.at("summary", default: [])
|
||||||
|
]
|
||||||
|
|
||||||
|
== Books
|
||||||
|
#let posts = calepin.pages().filter(p => p.path.starts-with("medias/books/")).filter(p => not p.meta.at("draft", default: false)).sorted(key: p => p.meta.at("date", default: "")).rev()
|
||||||
|
|
||||||
|
#for post in posts [
|
||||||
|
- #link(post.href)[#post.title] \
|
||||||
|
#post.meta.at("summary", default: [])
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#set document(title: [Plex])
|
||||||
|
#metadata((
|
||||||
|
translation_key: "plex",
|
||||||
|
date: "2026-10-10",
|
||||||
|
author: "Rémi Heredero",
|
||||||
|
summary: "Plex is a media server",
|
||||||
|
pdf: true
|
||||||
|
))<website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
I think I'll switch to Jellyfin not in so long time
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#set document(title: [The Expanse])
|
||||||
|
#metadata((
|
||||||
|
translation_key: "the_expanse",
|
||||||
|
date: "2026-10-10",
|
||||||
|
author: "Rémi Heredero",
|
||||||
|
summary: " - S01: 9/10
|
||||||
|
- S02: 9/10
|
||||||
|
- S03: 8/10
|
||||||
|
- S04: 8/10
|
||||||
|
- S05: 8/10
|
||||||
|
- S06: 8/10",
|
||||||
|
pdf: true
|
||||||
|
))<website-metadata>
|
||||||
|
|
||||||
|
#title()
|
||||||
|
|
||||||
|
I love hard science!
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#import "/.calepin/calepin.typ" as calepin
|
||||||
|
#show: calepin.document
|
||||||
|
|
||||||
|
#let taxonomy(field) = {
|
||||||
|
let pages = calepin.pages()
|
||||||
|
.filter(page => page.meta.at(field, default: ()) != ())
|
||||||
|
let terms = pages
|
||||||
|
.map(page => page.meta.at(field))
|
||||||
|
.flatten()
|
||||||
|
.dedup()
|
||||||
|
.sorted(key: term => lower(term))
|
||||||
|
|
||||||
|
for term in terms {
|
||||||
|
heading(term, level: 2)
|
||||||
|
for page in pages.filter(
|
||||||
|
page => term in page.meta.at(field)
|
||||||
|
) [
|
||||||
|
- #link(page.href)[#page.title]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#taxonomy("tags")
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
{% set calepin_theme_storage_key = "calepin-website-theme" %}
|
||||||
|
{% set calepin_theme_param = "theme" %}
|
||||||
|
{% include "partials/site-head.html" %}
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* Academic theme styling, applied to the landing page only.
|
||||||
|
* Copied from the bundled academic theme (css/50_main.css, calepin 0.0.61).
|
||||||
|
*/
|
||||||
|
:root {
|
||||||
|
--calepin-page-width: calc(var(--calepin-content-width) + var(--calepin-margin-gap) + var(--calepin-margin-width));
|
||||||
|
--calepin-margin-width: 16rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-topbar {
|
||||||
|
transition: transform 180ms ease;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-topbar.is-hidden {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-document-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: min(100% - 2rem, var(--calepin-content-width));
|
||||||
|
margin: 1rem auto 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
width: min(100% - 2rem, var(--calepin-page-width));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-nav .calepin-site-brand {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-menu {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-nav .calepin-site-language-picker {
|
||||||
|
width: auto;
|
||||||
|
min-width: 7rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.3rem 1.8rem 0.3rem 0.6rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-page {
|
||||||
|
width: min(100% - 2rem, var(--calepin-content-width));
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-block: 3.2rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-main {
|
||||||
|
width: min(100%, var(--calepin-content-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-page.has-toc {
|
||||||
|
width: min(100% - 2rem, var(--calepin-page-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Academic only positions the shared TOC in its sidenote-style margin. */
|
||||||
|
.academic-page .calepin-website-toc {
|
||||||
|
float: right;
|
||||||
|
clear: right;
|
||||||
|
width: var(--calepin-margin-width);
|
||||||
|
margin: 0.3rem 0 1.5rem var(--calepin-margin-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-document-main {
|
||||||
|
width: min(100% - 2rem, var(--calepin-content-width));
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-block: 2.4rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.academic-document-main .calepin-sidenote) .academic-document-main,
|
||||||
|
body:has(.academic-document-main .calepin-sidefigure) .academic-document-main {
|
||||||
|
width: min(100% - 2rem, var(--calepin-page-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-document-main > :not(.fullwidth):not(.full-width):not(.calepin-figure-grid) {
|
||||||
|
max-width: var(--calepin-content-width);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-document-main > .calepin-figure-width {
|
||||||
|
margin-inline: 0 auto !important;
|
||||||
|
max-width: var(--calepin-content-width) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-sidenote,
|
||||||
|
.calepin-sidefigure {
|
||||||
|
display: block;
|
||||||
|
float: right;
|
||||||
|
clear: right;
|
||||||
|
width: var(--calepin-margin-width);
|
||||||
|
color: var(--calepin-color-muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
scroll-margin-top: calc(var(--calepin-topbar-height) + 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-sidenote {
|
||||||
|
margin: 0.25rem calc(-1 * (var(--calepin-margin-width) + var(--calepin-margin-gap))) 1rem var(--calepin-margin-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-sidenote p,
|
||||||
|
.calepin-sidefigure figcaption {
|
||||||
|
margin: 0 0 0.7rem;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-sidenote-numbered::before {
|
||||||
|
content: "[" attr(data-sidenote-number) "]";
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.28rem;
|
||||||
|
color: var(--calepin-color-accent);
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* In-text marker for a numbered sidenote. The number is emitted by the sidenote
|
||||||
|
element; CSS only renders it as a superscript. */
|
||||||
|
[role="doc-noteref"] {
|
||||||
|
color: var(--calepin-color-accent);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: super;
|
||||||
|
line-height: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A figure, table, or code output placed in the margin. */
|
||||||
|
.calepin-sidefigure {
|
||||||
|
margin: 0.4rem 0 1rem var(--calepin-margin-gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-sidefigure figure,
|
||||||
|
.calepin-sidefigure img,
|
||||||
|
.calepin-sidefigure svg,
|
||||||
|
.calepin-sidefigure table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-sidefigure table {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-site-footer {
|
||||||
|
width: min(100% - 2rem, var(--calepin-page-width));
|
||||||
|
margin: 2.25rem auto 0;
|
||||||
|
padding-top: 1.3rem;
|
||||||
|
border-top: 1px solid var(--calepin-color-border);
|
||||||
|
color: var(--calepin-color-muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-site-footer-inner {
|
||||||
|
width: min(100%, var(--calepin-page-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-site-footer-links {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
list-style-type: none;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.6rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calepin-site-footer-links li {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.academic-landing {
|
||||||
|
width: min(100% - 2rem, var(--calepin-page-width));
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-block: 3rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-landing .landing-hero {
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: var(--calepin-content-width);
|
||||||
|
min-height: min(52vh, 32rem);
|
||||||
|
border-bottom: 1px solid var(--calepin-color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-landing .landing-hero h1,
|
||||||
|
.academic-landing .landing-hero h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: calc(39rem + 16rem + 2rem + 2rem)) {
|
||||||
|
.calepin-sidenote,
|
||||||
|
.calepin-sidefigure {
|
||||||
|
display: block;
|
||||||
|
float: none;
|
||||||
|
width: auto;
|
||||||
|
margin: 0.75rem 0 1.2rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border: 1px solid var(--calepin-color-border);
|
||||||
|
border-radius: var(--calepin-radius-sm);
|
||||||
|
background: var(--calepin-surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-page .calepin-website-toc {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-page.has-toc {
|
||||||
|
width: min(100% - 2rem, var(--calepin-content-width));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 48rem) {
|
||||||
|
.academic-nav {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-nav-toggle {
|
||||||
|
display: inline-grid;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-menu {
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(var(--calepin-topbar-height) - 0.35rem) 0 auto auto;
|
||||||
|
display: none;
|
||||||
|
width: max-content;
|
||||||
|
max-width: calc(100vw - 2rem);
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--calepin-color-border);
|
||||||
|
border-radius: var(--calepin-radius-sm);
|
||||||
|
background: var(--calepin-color-background);
|
||||||
|
box-shadow: var(--calepin-shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-menu.is-open {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-menu a {
|
||||||
|
display: flex;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.25rem 0.2rem;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-page,
|
||||||
|
.academic-document-controls,
|
||||||
|
.academic-document-main,
|
||||||
|
.academic-landing {
|
||||||
|
width: min(100% - 1.25rem, var(--calepin-content-width));
|
||||||
|
}
|
||||||
|
|
||||||
|
.academic-page,
|
||||||
|
.academic-document-main,
|
||||||
|
.academic-landing {
|
||||||
|
padding-block: 2rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.academic-topbar {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ doc.body_open }}
|
||||||
|
{% set show_site_sidebar = false %}
|
||||||
|
{% include "partials/site-nav.html" %}
|
||||||
|
<main class="academic-landing calepin-landing calepin-content" data-pagefind-body>
|
||||||
|
{{ doc.body }}
|
||||||
|
</main>
|
||||||
|
{% include "partials/site-footer.html" %}
|
||||||
|
{% include "partials/site-search.html" %}
|
||||||
|
{% include "partials/theme-scripts.html" %}
|
||||||
|
{{ doc.body_close }}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<aside id="calepin-website-sidebar" class="calepin-website-sidebar">
|
||||||
|
<nav class="calepin-website-mobile-drawer-nav" aria-label="Site navigation">
|
||||||
|
<p><strong>Site</strong></p>
|
||||||
|
<ul>
|
||||||
|
{% for item in site.menus.main %}
|
||||||
|
{% include "partials/site-nav-item.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for item in site.menus.social %}
|
||||||
|
{% include "partials/site-nav-item.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="calepin-website-mobile-drawer-actions" aria-label="Site controls">
|
||||||
|
{% set calepin_theme_toggle_storage_key = "calepin-website-theme" %}
|
||||||
|
{% set calepin_theme_toggle_param = "theme" %}
|
||||||
|
{% include "partials/theme-toggle-button.html" %}
|
||||||
|
{% include "partials/site-language-picker.html" %}
|
||||||
|
{% if site.page_source or site.page_pdf %}
|
||||||
|
<select class="calepin-website-view-mode" aria-label="Choose page view" data-calepin-view-mode>
|
||||||
|
<option value="rendered">HTML</option>
|
||||||
|
{% if site.page_source %}<option value="source">Source</option>{% endif %}
|
||||||
|
{% if site.page_pdf %}<option value="pdf">PDF</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<nav aria-label="Documentation">
|
||||||
|
{#- Scope the sidebar to the current master category: render only
|
||||||
|
the active section(s). Pages outside every section (landing,
|
||||||
|
404, standalone pages) fall back to the full sidebar. -#}
|
||||||
|
{% set active_sections = site.sidebar_sections | selectattr("active") | list %}
|
||||||
|
{% if active_sections %}
|
||||||
|
{% for section in active_sections %}
|
||||||
|
{% include "partials/site-sidebar-section.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% elif site.sidebar_sections %}
|
||||||
|
{% for section in site.sidebar_sections %}
|
||||||
|
{% include "partials/site-sidebar-section.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
{% elif site.sidebar %}
|
||||||
|
<ul>
|
||||||
|
{% for item in site.sidebar %}
|
||||||
|
{% include "partials/site-sidebar-item.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
extends = "calepin"
|
||||||
Reference in new issue
Block a user