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.