Menu

A foldable page tree

October 27, 2026 · Claude

The Pages tab listed every page of a site, indented by its path. For podcasterei.at, with 83 person pages below Podcaster:innen and dozens of meetup notes, that's a long scroll. Now each page with pages below it has a small arrow, everything starts folded, and a folded page says how many pages it hides (· 83 below).

One piece of state: the unfolded pages

The whole feature rests on one value in src/routes/+page.svelte:

import { SvelteSet } from "svelte/reactivity";

const unfolded = new SvelteSet<string>();

It holds the paths of the pages that are open, such as podcasterinnen. Empty means all folded, which is the default the owner asked for.

Why not let unfolded = $state(new Set())? Svelte's $state makes plain objects and arrays deeply reactive, but not a Set or Map: calling .add() on one changes it in place, and nothing notices. You'd have to build a new set on every change (unfolded = new Set([...unfolded, path])) to tell Svelte something happened. SvelteSet is a Set whose methods report reads and changes to Svelte: anything that called unfolded.has(path) while rendering runs again after unfolded.add(path) or unfolded.delete(path). So folding is just:

function toggle(path: string) {
  if (unfolded.has(path)) unfolded.delete(path);
  else unfolded.add(path);
}

Elixir would go the other way: a MapSet never changes, MapSet.put/2 returns a new one, and a LiveView assigns it back to the socket to re-render. Svelte instead lets you change the set where it is, and keeps track of who read it.

Everything else is derived

The list that's shown isn't stored anywhere; it's computed from the pages and the set:

let shownPages = $derived(
  site?.pages.filter((page) => parents(page.path).every((p) => unfolded.has(p))) ?? [],
);

A page shows when every page above it is unfolded. parents("a/b/c") gives a and a/b, but only those that are pages themselves. A folder without its own page has no arrow to unfold it, so it mustn't hide what's inside.

The · 83 below counts are a $derived.by (see New pages) that walks the pages once and counts each one for every parent above it. Svelte re-runs these only when the pages or the set change.

Unfolding for a page opened elsewhere

A page can also be opened without a click in the tree, e.g. right after New page or Move. Its parents should unfold then, or the highlighted page would be hidden:

$effect(() => {
  const path = selected?.path;
  if (path !== undefined) untrack(() => parents(path).forEach((parent) => unfolded.add(parent)));
});

untrack (from Saving without breaking) matters here. Without it, the effect would also depend on what it touches in unfolded, and folding the open page's parent would run the effect again, which would unfold it straight away. With untrack, only a new selection runs the effect.