Skip to content

Latest commit

 

History

History
379 lines (319 loc) · 15.2 KB

27-content.md

File metadata and controls

379 lines (319 loc) · 15.2 KB

27. Content

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:

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 ![image](/img.png).")
}

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 (![dot](data:image/png;base64,…), 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.

Backslash escapes

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++.

Text somebody else wrote: .inert

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![build](https://ci.example.com/badge.svg)"
    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: &lt;color&gt; 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 — &lt; &gt; &amp; &quot; &apos; &nbsp;, the dashes, quotes and arrows, and a few signs — and any number works (&#60;, &#x3C;).
  • 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 plain Markdown draws it. A language server escapes its documentation this way: the CSS one writes margin\-top and &lt;length\-percentage&gt;\{1,4\}, which .inert shows as margin-top and <length-percentage>{1,4}. An escaped & begins no entity: \&lt; shows the four characters &lt;.

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 &lt; to mean the four characters still shows them.

.md pages

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.)

Search and sharing

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's title: and description: — the snippet a search result shows and a link preview quotes. Keep the description under ~150 characters.
  • A canonical link and og:url, when site_url is set (a relative canonical is worse than none, so without site_url the build emits none).
  • Open Graph and Twitter card tags: og:title, og:description, og:image (the page's image: or the site's), og:type from type: (website or article), og:site_name, and og:locale in the form Open Graph reads — meta.lang en is en_US, en-GB is en_GB, ar is ar_AR — with an og:locale:alternate for each other locale.
  • The image's size and description: og:image:width and og:image:height read from the file when the image is in public/ (one on another origin is not fetched, so its card states no size), and og:image:alt and twitter:image:alt from the page's image_alt:, or meta.image_alt for the site's image.
  • JSON-LD: the site's owner, a WebSite it publishes, a WebPage or Article, and a BreadcrumbList derived from the route's segments — each a page a reader can open; a level no route answers is left out.
  • hreflang alternates for each locale when the project has several.
  • The icons: <link rel="icon"> for favicon (typed, so an SVG is read as one) and <link rel="apple-touch-icon"> for touch_icon, each linked from the site's root under the base_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 a noindex: true page.

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:

{
  "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.

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:

{ "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.

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:

{ "build": { "ssg": true, "clean_urls": "file" } }
  • "file" keeps /contact and writes contact.html beside contact/index.html, which the host serves for /contact directly. The page then addresses its scripts and sheets from the site's root (/app.js, under the base_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, the hreflang alternates, and every Link(to:), Sidebar.Item(to:) and navigate() whose target is one of the site's pages (/contact becomes /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.

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 a description: of about 150 characters (S01–S03 warn when they are missing or too long).
  • Pre-render: build.ssg: true, so the text is in the HTML.
  • One h1 per page, headings in order (A11, A12).
  • An image: per page that is shared, or meta.image for the site — 1200 × 630 pixels suits most previews — in public/, so its size is in the card, with an image_alt: (or meta.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", with job_title, same_as and, for the rest of who they are, owner_details.
  • Web fonts from public/, named in meta.preload, rather than a font service's stylesheet.
  • noindex: true on 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.

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
}

Next

Media.