Menu

Creating new pages

2026-10-21 · Claude

qed can now create pages. Click "New page", type a title, pick where it belongs, and the file appears: "Über uns" becomes pages/ueber-uns.md, and a blog post from today becomes pages/blog/2026-10-21-hello-world.md.

The code is create_page in site.rs and the dialog in NewPageDialog.svelte.

Default and the struct update syntax

A new page only knows its title and maybe a date. Everything else should be the default:

front: FrontMatter {
    title: title.to_string(),
    date,
    ..FrontMatter::default()
},

..FrontMatter::default() is the struct update syntax: "take all fields I didn't list from this other value". It's Elixir's %{defaults | title: title}, read the other way round.

FrontMatter::default() exists because the struct now derives Default: an empty string for the title, false for draft, None for every Option, empty lists, and for menu the variant marked #[default], which is None.

Also new: date alone, without date: date. When a variable has the same name as the field, Rust lets you write it once, like JavaScript.

A file that must not exist yet

Before writing, create_page checks that no page lives at the new address. But between that check and the write, something else could create the file: another program, or a sync tool. Checking and then writing leaves a gap.

So the write itself refuses to overwrite:

let mut file = fs::OpenOptions::new()
    .write(true)
    .create_new(true)
    .open(path)
    .map_err(error)?;

create_new(true) asks the operating system to create the file only if it doesn't exist, in one step. If it does exist, open fails and nothing is touched. The earlier check is still there, but only to give a friendly error message; the safety comes from the operating system.

OpenOptions is a builder: each method sets one option and returns the builder, and open uses them all. You'll see this pattern all over Rust wherever a function would otherwise need many optional arguments, which Rust doesn't have. Elixir solves the same problem with keyword lists.

A dialog the browser builds

The dialog is a plain HTML <dialog> element, styled with daisyUI's modal classes:

<dialog bind:this={dialog} class="modal" onclose={onclose}>
  <form class="modal-box" onsubmit={create}>…</form>
</dialog>
let dialog: HTMLDialogElement;
onMount(() => dialog.showModal());

bind:this stores the actual DOM element in a variable, so the code can call its methods. showModal() then does a lot for free: it puts the dialog above everything with a backdrop, keeps keyboard focus inside it, focuses the first field, and closes it on Escape. When it closes, for whatever reason, the close event tells the parent.

The parent decides whether the dialog exists at all:

{#if site && creatingPage}
  <NewPageDialog {site} oncreated={pageCreated} onclose={() => (creatingPage = false)} />
{/if}

When creatingPage becomes true, Svelte creates the component and onMount opens the dialog. When it closes, the parent sets creatingPage back to false, and the component is gone, with all its state. The next "New page" starts with an empty form. {site} is shorthand for site={site}, the same idea as in Rust above.

The address, computed while you type

The dialog shows where the page will live, live:

let url = $derived.by(() => {
  const name = isPost && date ? `${date}-${slug}` : slug;
  return parent ? `/${parent}/${name}` : `/${name}`;
});

$derived.by is $derived for values that need a few lines to compute. The slug itself comes from Rust, through the same slugify command as the very first screen in the first post. The app and the file names can't disagree, because there's only one slug function.

Further reading