Content
Markdown, pages written as .md files, code on the page, and what the build writes for search engines and link previews.
A site is also its words: pages of prose, a blog, docs. This chapter covers writing content in Markdown, showing code, and what the build writes for search engines and link previews. Pre-rendering is Static or single-page; pictures and video are Media.
The Markdown element
Markdown(text) renders a small, predictable Markdown — the same at build time and in the browser, so a hydrated page repaints what was painted:
wf
page Notes(path: "/") {
state body = "# Release notes\n\nVersion **3.3** adds:\n\n- Markdown pages\n- `wf docs`\n\n> Ship it."
Markdown(body)
Markdown("Inline *emphasis*, a [link](/about) and an .")
}
Blocks: #–###### headings, paragraphs (their lines run together, as in CommonMark — end a line with two spaces or \ to break it), fenced code with a language (` `js ), > quotes, -/ and 1. lists (one level), --- rules. Inline: code , strong, em*/em`, links, images. Raw HTML in the text is shown as text, not run — Markdown from a user or an API is safe to render.
.md pages
A Markdown file in src/ is a page. Its front matter names the page's attributes; the rest is the body:
markdown
---
path: /guide/install
title: Installing
description: Get wf on your machine in a minute.
layout: DocsShell
type: article
image: /install.png
image_alt: A terminal running wf build
---
# Installing
Download a release, or build from source:
```sh
cargo install webfluent
```
Then `wf init my-site`.
The front matter keys are the page's attributes: path (default: / for index.md, else /<file-name>), title, description, layout, image, type, noindex. The page's name is the file's stem, capitalised.
layout: DocsShell frames the body in a component with a default slot — the same one a .wf page names (chapter 6):
wf
component DocsShell {
slot
Row(gap: .lg) {
Sidebar {
Sidebar.Item(to: "/guide/install") { Text("Installing") }
Sidebar.Item(to: "/guide/pages") { Text("Pages") }
}
Container { children }
}
}
A docs site is a folder of .md files, one layout, and a .wf for the app. (wf docs is something else: a gallery of every component the project can use — chapter 37.)
Search and sharing
For every page the build writes the standard head from the page's attributes and meta.* in webfluent.app.json:
json
{
"meta": {
"title": "Acme",
"description": "Tools for small teams.",
"lang": "en",
"site_url": "https://acme.example",
"site_name": "Acme Inc.",
"owner": "organization",
"same_as": ["https://github.com/acme"],
"image": "/og-default.png",
"image_alt": "The Acme logo on a blue field",
"favicon": "/favicon.svg",
"touch_icon": "/apple-touch-icon.png",
"sitemap": true,
"fonts": ["https://fonts.googleapis.com/css2?family=Inter&display=swap"],
"stylesheets": ["/print.css"]
}
}
<title>and<meta name="description">from the page'stitle:anddescription:— the snippet a search result shows and a link preview quotes. Keep the description under ~150 characters.- A canonical link and
og:url, whensite_urlis set (a relative canonical is worse than none, so withoutsite_urlthe build emits none). - Open Graph and Twitter card tags:
og:title,og:description,og:image(the page'simage:or the site's),og:typefromtype:(websiteorarticle),og:site_name, andog:localein the form Open Graph reads —meta.langenisen_US,en-GBisen_GB,arisar_AR— with anog:locale:alternatefor each other locale. - The image's size and description:
og:image:widthandog:image:heightread from the file when the image is inpublic/(one on another origin is not fetched, so its card states no size), andog:image:altandtwitter:image:altfrom the page'simage_alt:, ormeta.image_altfor the site's image. - JSON-LD: the site's owner, a
WebSiteit publishes, aWebPageorArticle, and aBreadcrumbListderived from the route's segments — each a page a reader can open; a level no route answers is left out. hreflangalternates for each locale when the project has several.- The icons:
<link rel="icon">forfavicon(typed, so an SVG is read as one) and<link rel="apple-touch-icon">fortouch_icon, each linked from the site's root under thebase_path(/my-site/favicon.svg), so it stays right when the router moves the page to another address. A sharing image is best 1200×630. <meta name="robots" content="noindex">and no sitemap entry for anoindex: truepage.
Who the site belongs to
The owner is meta.site_name, published as an Organization unless meta.owner says it is a person. A personal site names the person, what they do and where else they are:
json
{
"meta": {
"site_url": "https://ada.example",
"site_name": "Ada Lovelace",
"owner": "person",
"job_title": "Analyst",
"same_as": ["https://github.com/ada", "https://www.linkedin.com/in/ada/"]
}
}
The JSON-LD then holds a Person (@id …/#person) with jobTitle and sameAs; the WebSite's publisher and every WebPage's about point at it. An organisation's WebSite names it as publisher too, and takes same_as for its profiles.
Anything else schema.org says about a person or an organisation goes in meta.owner_details, merged into the node as written:
json
{
"meta": {
"owner_details": {
"alternateName": "Augusta Ada King",
"worksFor": [{ "@type": "Organization", "name": "Analytical Engines" }],
"alumniOf": { "@type": "CollegeOrUniversity", "name": "University of London" },
"knowsAbout": ["Mathematics", "Computing"],
"knowsLanguage": ["en", "fr"]
}
}
}
What the node is — @type, @id, name, url — and what job_title and same_as write come from those settings; setting one here is E111. Structured data has to agree with what the pages show, and the build cannot check that: state here only what the site says too.
Fonts served from the site
A font named by a @font-face in a project .css file is only found once styles.css has arrived and been read. meta.preload asks for it with the page instead:
json
{ "meta": { "preload": ["/fonts/inter-latin.woff2"] } }
css
/* src/fonts.css */
@font-face {
font-family: "Inter";
font-weight: 400 800;
font-display: swap;
src: url("/fonts/inter-latin.woff2") format("woff2");
}
Each path becomes a <link rel="preload"> in every page's head, ahead of the stylesheets, with as from its extension — font (and crossorigin, which a font preload needs or the browser fetches it twice), style, script or image. Serving the files from public/ rather than a font service also takes a third-party stylesheet and two connections out of the way of the first paint. A path on another origin is E111: the policy the build ships would refuse it.
Addresses that do not redirect
A static build writes /contact as contact/index.html. Most static hosts — GitHub Pages among them — answer /contact with a 301 to /contact/, so a canonical link, a sitemap entry and every link that names /contact name an address that redirects, which a search engine reads as "not this page". build.clean_urls picks one of the two ways out:
json
{ "build": { "ssg": true, "clean_urls": "file" } }
"file"keeps/contactand writescontact.htmlbesidecontact/index.html, which the host serves for/contactdirectly. The page then addresses its scripts and sheets from the site's root (/app.js, under thebase_path), so the one file reads the same from either address."directory"names every route with its slash — the canonical link,og:url, the sitemap, the breadcrumbs, thehreflangalternates, and everyLink(to:),Sidebar.Item(to:)andnavigate()whose target is one of the site's pages (/contactbecomes/contact/; a file such as/cv.pdf, another origin, or an address worked out whole at run time is left as written).
Unset, the addresses are /contact and the files contact/index.html, as before — right for a host that serves the directory without a redirect (Netlify, Cloudflare Pages, Vercel with cleanUrls).
A page adds its own tags with head { } (chapter 6).
wf
type Post { slug: String, title: String, summary: String, cover: String, date: String, body: String }
data posts: [Post] = "posts.json"
page PostPage(path: "/posts/:slug", slug: String, type: "article", paths: posts.map(p => p.slug)) {
derived post = posts.find(p => p.slug == slug)
head {
meta(property: "og:title", content: post?.title ?? "Post")
meta(property: "og:image", content: post?.cover ?? "/og-default.png")
meta(property: "article:published_time", content: post?.date ?? "")
}
if let p = post {
Image(src: p.cover, alt: p.title)
Heading(p.title).h1
Text(format(p.date, .date, "long")).muted
Markdown(p.body)
}
}
The title: and description: of a :param page may name its parameters — title: "{slug} — Blog" — and each pre-rendered file, and the live page's tab, has that route's value. The title may also splice an expression over the parameters and the program's constants and data — title: "{posts.find(p => p.slug == slug)?.title ?? slug} — Blog" — which the build works out for each file and the router again on each visit. Anything else the head should say about the entry goes in head { }, as above; the runtime keeps those tags current as the parameter changes.
A checklist for being found
- Set
meta.site_url— without it there are no canonical links, no sitemap and no absolute sharing URLs. - Give every page a
title:and adescription:of about 150 characters (S01–S03warn when they are missing or too long). - Pre-render:
build.ssg: true, so the text is in the HTML. - One
h1per page, headings in order (A11,A12). - An
image:per page that is shared, ormeta.imagefor the site — 1200 × 630 pixels suits most previews — inpublic/, so its size is in the card, with animage_alt:(ormeta.image_alt) saying what it shows. - On GitHub Pages and hosts like it,
build.clean_urls, so the canonical address is never one that redirects. - A personal site:
meta.owner: "person", withjob_title,same_asand, for the rest of who they are,owner_details. - Web fonts from
public/, named inmeta.preload, rather than a font service's stylesheet. noindex: trueon pages that should not be found: sign-in, thanks, drafts.- Check the result: view a built page's source, and paste a URL into a link-preview debugger.
Code on the page
Code("…") is inline code; Code("…").block a block. language: colours it — wf, json, bash or css — at build time and in the browser alike, with the theme's syntax-* tokens; a Markdown fence with one of those languages is coloured the same way.
wf
page Snippets(path: "/") {
Text("Run ") Code("wf serve") Text(" to start.")
Code("page Home(path: \"/\") {\n Text(\"Hi\")\n}", language: "wf").block
Code("$ wf build", language: "bash").block
}
On this page
The Markdown element .md pages Search and sharing A checklist for being found Code on the pageChecked by the test suite
Every code block in the guide is parsed, checked and type-checked on each release.