Author SHA1 Message Date
Klagarge 7b34f5a589 feat(cs): add initial lazygit doc
Build site / build (push) Successful in 2m50s
Build site / deploy (push) Skipped
2026-10-12 14:06:04 +02:00
Klagarge 6436f52889 feat(cs): add initial git doc 2026-10-12 14:05:52 +02:00
Klagarge 49e6820eae feat(cs): add tools section 2026-10-12 14:05:28 +02:00
Klagarge f8350ba235 ops: adapt script to work on windows 2026-10-12 13:32:43 +02:00
Klagarge 09f0dc42e4 chore(home): add metadatas
Build site / build (push) Successful in 2m44s
Build site / deploy (push) Successful in 7s
2026-10-11 23:46:05 +02:00
Klagarge 8e2303fcdf refactor: change main categories
Build site / build (push) Successful in 2m48s
Build site / deploy (push) Successful in 6s
2026-10-11 23:32:36 +02:00
Klagarge 4917abe9e6 ops: add toc on side 2026-10-11 23:26:28 +02:00
Klagarge b52629d1f6 feat(home): add gardening with yuca and dracaena 2026-10-11 23:23:48 +02:00
Klagarge b9159581f3 ops: add strip-exif function in justfile 2026-10-11 23:23:14 +02:00
Klagarge 9e9163f01b chore: add IDE in gitignore 2026-10-11 23:22:57 +02:00
Klagarge 54a69b51ae ops: add lib with tag function 2026-10-11 23:22:42 +02:00
Klagarge 975ba4f312 fix: remove act check
Build site / build (push) Successful in 50s
Build site / deploy (push) Successful in 3s
2026-10-11 20:09:47 +02:00
Klagarge 6cd5ed61c4 Merge branch 'refactor: migrate to calepin'
Build site / build (push) Successful in 51s
Build site / deploy (push) Successful in 2s
Reviewed-on: #1
2026-10-11 18:06:17 +00:00
Klagarge 1adca36802 ci: publish to the pages branch on main pushes
Build site / build (push) Successful in 52s
Build site / deploy (push) Skipped
Assisted-by: Delta:zai-glm-5.3
2026-10-11 20:00:54 +02:00
Klagarge 3b1ef34bce feat: enable PDF output for pages
Assisted-by: Delta:zai-glm-5.3
2026-10-11 18:56:04 +02:00
Klagarge 6b33be6956 chore: add a recipe for local act runs
Assisted-by: Delta:zai-glm-5.3
2026-10-11 00:22:23 +02:00
Klagarge f94a5cbe17 ci: use upload-artifact v3 for gitea
Assisted-by: Delta:zai-glm-5.3
2026-10-11 00:22:23 +02:00
Klagarge af31b6165c chore: remove unused placeholder
Build site / build (push) Failing after 11s
2026-10-11 00:09:52 +02:00
Klagarge 946177a0b9 feat: style the landing page with the academic theme
Assisted-by: Delta:zai-glm-5.3
2026-10-11 00:00:42 +02:00
Klagarge 3361c9ee3d feat: scope the sidebar to the current category
Assisted-by: Delta:zai-glm-5.3
2026-10-10 23:53:55 +02:00
Klagarge ab802968ca chore: add custom placeholder and fine tune calepin-toml 2026-10-10 23:51:22 +02:00
Klagarge 096026a964 docs: describe the site menu in README
Assisted-by: Delta:zai-glm-5.3
2026-10-10 22:05:21 +02:00
Klagarge 6478741950 feat: add master categories to the site menu
Assisted-by: Delta:zai-glm-5.3
2026-10-10 22:05:08 +02:00
Klagarge eb995c7b69 feat: restructure content into master categories
Assisted-by: Delta:zai-glm-5.3
2026-10-10 22:04:31 +02:00
Klagarge 7207cfdbd0 docs: update README and AGENT.md for the scripts layout
Assisted-by: Delta:zai-glm-5.3
2026-10-10 21:02:59 +02:00
Klagarge 36b2fc4188 ci: build site via the toolchain scripts
Assisted-by: Delta:zai-glm-5.3
2026-10-10 21:02:41 +02:00
Klagarge 62c4619c5e docs: add example sub-pages
Assisted-by: Delta:zai-glm-5.3
2026-10-10 21:02:41 +02:00
Klagarge f7beeb5e15 fix: align site scaffold with calepin conventions
Assisted-by: Delta:zai-glm-5.3
2026-10-10 21:02:20 +02:00
Klagarge 99ceb89e48 refactor: move toolchain logic into scripts/
Assisted-by: Delta:zai-glm-5.3
2026-10-10 21:01:20 +02:00
Klagarge 7217702068 chore: ignore generated outputs and IDE files
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:39:55 +02:00
Klagarge 11bf721f21 fix: adjust Justfile and typst templates for calepin 0.0.61
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:39:46 +02:00
Klagarge 446c156338 docs: write README and AGENT.md
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:30:15 +02:00
Klagarge 909ea17f2f ci: add Gitea Actions workflow
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:30:08 +02:00
Klagarge 7a0df30ca1 docs: add example categories
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:30:00 +02:00
Klagarge a5f95bb6d7 feat: scaffold calepin website
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:29:53 +02:00
Klagarge f047389da9 chore: bootstrap pinned toolchain
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:29:36 +02:00
Klagarge b78f79e12e docs: move legacy docs to legacy_docs/
Assisted-by: Delta:zai-glm-5.3
2026-10-10 20:28:25 +02:00
63 changed files with 1174 additions and 1528 deletions

No files matched your search

-6
View File
@@ -1,6 +0,0 @@
.git
.delta
.idea
.vscode
docs
legacy_docs
-17
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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/
+17
View File
@@ -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>
+19 -79
View File
@@ -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
View File
@@ -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"]
+22 -43
View File
@@ -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
+62 -174
View File
@@ -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.
-12
View File
@@ -1,12 +0,0 @@
{
"/homelab/backup.html": [
"family"
],
"/security/ssh.html": [
"admin"
],
"/thermo/fluids.html": [
"family",
"admin"
]
}
-107
View File
@@ -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
}
-120
View File
@@ -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
}
View File
Whitespace-only changes.
-22
View File
@@ -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]
],
)
-11
View File
@@ -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...
-7
View File
@@ -1,7 +0,0 @@
#import "../template.typ": project
#show: project
= Self-hosting
Public page placeholder about self-hosting: Docker/Podman, reverse
proxies, DNS, VLANs...
-17
View File
@@ -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.
-7
View File
@@ -1,7 +0,0 @@
#import "../template.typ": project
#show: project
= Opsec basics
Public page placeholder about operational security: threat models,
password managers, 2FA...
-11
View File
@@ -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...
-44
View File
@@ -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
}
-11
View File
@@ -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...
-7
View File
@@ -1,7 +0,0 @@
#import "../template.typ": project
#show: project
= Heat pumps
Public page placeholder about heat pumps: COP, seasonal performance,
refrigerant cycles...
-26
View File
@@ -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
}
-10
View File
@@ -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
-6
View File
@@ -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=
-122
View File
@@ -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
}
-67
View File
@@ -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)")
}
}
-89
View File
@@ -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)
})
}
-141
View File
@@ -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
}
-50
View File
@@ -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")
}
}
-101
View File
@@ -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[:])
}
-118
View File
@@ -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
}
-33
View File
@@ -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)
+102
View File
@@ -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
+17
View File
@@ -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" "$@"
+9
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
#set document(title: [Not found])
#metadata((pdf: false)) <website-metadata>
#title()
The requested page does not exist.
View File
File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+43
View File
@@ -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

+93
View File
@@ -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" }] },
]
+7
View File
@@ -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.
+85
View File
@@ -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"
```
+25
View File
@@ -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

+6
View File
@@ -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.
+56
View File
@@ -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>
]
)
+7
View File
@@ -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.
+6
View File
@@ -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.
+7
View File
@@ -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.
+7
View File
@@ -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
+51
View File
@@ -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]
}
}
+11
View File
@@ -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!
+10
View File
@@ -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!
+43
View File
@@ -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: [])
]
+12
View File
@@ -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
+17
View File
@@ -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!
+23
View File
@@ -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>
+1
View File
@@ -0,0 +1 @@
extends = "calepin"