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.
Markdown(text) renders a small, predictable Markdown — the same at build
time and in the browser, so a hydrated page repaints what was painted:
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.
A link or a picture goes through the scheme check every other URL does
(Security): one whose URL a browser would run —
javascript:, vbscript:, a data: URL — is drawn as its words, with no
link and no picture. A picture carried in its address
(, data:image/svg+xml,…) is drawn, since
an img runs nothing it shows; a link to one is still its words. A site-relative URL (/about, /img.png) is
addressed from the site's base_path, as Link(to:)'s is.
A backslash before a punctuation mark shows the mark and stops it meaning
anything, as in CommonMark: \*not em\* is *not em*, \[not a link\](/x)
is no link, \# at the start of a line is no heading, 1\. is no list,
and \\ is one backslash. Every ASCII punctuation mark can be escaped this
way (\-, \{, \(, \_, \& and the rest), and an escaped one is only
the character it is: it makes no link, no emphasis and no entity. In a
link's words an escaped bracket is a bracket like any other
([a\]b](/c)), and they hold no unescaped [, so in [a [b](/c) only
b is the link. In a URL an escape is read as the character it is, so
[x](javascript\:alert\(1\)) is checked as the javascript:alert(1) URL
it says, and drawn as its words.
A backslash before anything else is shown, as C:\Users is, and so is
one in a code span or a fence, where nothing is Markdown. A line that ends
in a backslash breaks there, as above; the last line of a paragraph, a
heading or a list item has no line after it to break to, and shows its
backslash. A fence's language takes escapes too: c\+\+ after the
backticks is c++.
Documentation from a language server, a /// comment, a package's readme:
text written by somebody else, often with HTML entities in it, and with
links and pictures that should not be followed or fetched from where it is
shown. .inert draws it:
page Hover(path: "/") {
state docs = "Returns a list of <T> — see [the guide](https://example.com/guide).\n\n"
Markdown(docs).inert
}
It draws one paragraph, Returns a list of <T> — see the guide., with no
link in it, and leaves the badge out. Under .inert:
- Entities are decoded, then escaped:
<color>shows as<color>, and a<script>, written out or encoded, is shown as text. In`code`and in a fence an entity stays as it was written, as CommonMark leaves it. The named entities are the ones documentation is written with —<>&"' , the dashes, quotes and arrows, and a few signs — and any number works (<,<). - A link is drawn as its words, with no
<a>. - A picture is left out, with the space after it; a paragraph of nothing but pictures is left out with them.
- Everything else — paragraphs, fences,
code,**strong**,*em*and_em_, headings, lists, quotes, rules, backslash escapes — is drawn as plainMarkdowndraws it. A language server escapes its documentation this way: the CSS one writesmargin\-topand<length\-percentage>\{1,4\}, which.inertshows asmargin-topand<length-percentage>{1,4}. An escaped&begins no entity:\<shows the four characters<.
inert: takes a value too, Markdown(docs, inert: untrusted), and follows
it. Plain Markdown is unchanged: an entity in it shows as it was written,
so a page that wrote < to mean the four characters still shows them.
A Markdown file in src/ is a page. Its front matter names the page's
attributes; the rest is the body:
---
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):
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 38.)
For every page the build writes the standard head from the page's
attributes and meta.* in webfluent.app.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.
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:
{
"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:
{
"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.
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:
{ "meta": { "preload": ["/fonts/inter-latin.woff2"] } }/* 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.
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:
{ "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).
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.
- 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("…") 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.
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
}