Summary#
Markdown documents often begin with a --- header:
---
title: Getting started
summary: How to create your first project.
stability: stable
---
## Overview
…For a document that header is rarely "metadata". It is usually most of the application's data — the title is the
navigation, the summary is the search snippet, the tags are the facets. [FrontMatter] says so:
entity DocPage {
[FrontMatter] string Title;
[FrontMatter] string? Summary;
Markdown Body;
}Assign Body and those members fill in. They are ordinary members, so everything else about them is ordinary too:
you can query them, index them, put them in a grid, secure them.
Signature#
[FrontMatter] <type> <Member>; // key = the member name, camelCased
[FrontMatter("<key>")] <type> <Member>; // key named exactlyDescription#
Which key a member takes#
A bare [FrontMatter] derives the key from the member name by camelCasing it. Osy# members are PascalCase and
front-matter keys conventionally are not, so this is usually all you need:
| Member | Key |
|---|---|
Title | title |
FormatVersion | formatVersion |
DeprecatedBy | deprecatedBy |
When a key does not follow that convention — or when the member has to be called something else — name it exactly:
[FrontMatter("format-version")] int? Version;That form is also the answer for a key called id. Every entity already has an Id, so a member cannot be named
that; call it something else and name the key:
[FrontMatter("id")] string Slug;Which document it reads#
The entity's Markdown member. You do not name it, because an entity has one document in the ordinary case and
repeating its name on every key would be ceremony.
An entity with no Markdown member is a compile error — there is nothing to read. An entity with more than
one is also a compile error, naming both, because guessing which document was meant would be a coin flip you never
see.
What binding does, and does not do#
It fills in declared members, on assignment. Every write of the whole document — page.Body = text — re-reads the
header and sets each bound member.
A key the document omits CLEARS its member. A member says what the document says now, not what an earlier document said. This is why a key that may be absent should be declared optional:
[FrontMatter] string? Stability; // the document may not carry itA required member makes its key mandatory. This is the useful consequence of the same rule: declare
[FrontMatter] string Title; without the ? and a document that omits title is refused at commit, with Title named.
The declaration is the schema — front-matter that is checked rather than merely stored.
A key you did NOT declare is preserved, untouched. The header is kept whole; declaring three of its ten keys binds three and leaves the other seven exactly as written. A reader that quietly dropped what it did not understand would corrupt the document it read.
Types#
The header is YAML, so its ordinary shapes work: quoted and unquoted strings, folded blocks (summary: >), flow
sequences (tags: [a, b]), block sequences, numbers, booleans, and an explicit null.
| Declared as | Takes |
|---|---|
string | a scalar (a folded block arrives as one line) |
int / decimal / bool / DateTime / Guid | a scalar that parses as one; anything else leaves the member unset |
| a child collection | a list-valued key, one row per item — see below |
Json | a list-valued key, keeping its items as a JSON array |
An explicit null (deprecatedBy: null) is nothing, not the text "null".
A list-valued key#
tags: [ui, authoring] is a list, and a list of what you want to query is a set of rows. Bind it to a child
collection and each item becomes one:
entity DocPage {
[FrontMatter] [ForeignKey(Page)] DocTag[] Tags;
Markdown Body;
}
entity DocTag {
DocPage Page;
[MaxLength(80)] string Name;
}The rows are ordinary child rows, so the things you wanted them for work with nothing extra:
DocPage.Where(p => p.Tags.Any(t => t.Name == "ui")).ToList()The element entity must have exactly one scalar member — the one each item becomes. None, or more than one, is a
compile error naming them, because there would be nothing to put the item in or no way to tell which member was meant.
The collection must be a real [ForeignKey] child collection: a bare DocTag[] is an in-memory projection with no
rows behind it, so binding one is a compile error too rather than a write that quietly goes nowhere.
Re-assigning the document reconciles: an item that is still there keeps its row (and its id, so anything pointing
at it survives), an item that arrived is created, one that is gone is deleted, and a key the new document omits empties
the collection. A repeated item is one row — a set of tags is a set. A scalar is a one-item list, so tags: ui works.
Use Json instead when the items are only ever read together and never queried one by one.
A malformed header binds nothing rather than failing the write. Front-matter is content — hand-written, and sometimes agent-written — and a mistyped header should still store its document.
Reading back#
Nothing changes about how you read the document itself: Body still returns the markdown, and Markdown — rendering markdown text
renders it.
Importing documents#
Assigning the member is what runs all of this, so anything that assigns it works — including a data file. A document can be given inline, or named as a file beside the data file, which is how a real corpus arrives:
{ "entity": "DocPage", "key": "Slug",
"files": { "Body": "" },
"rows": [ { "Body": "pages/controls.md" }, { "Body": "pages/slots.md" } ] }Two things about that are worth knowing:
- A document's file reference brings its TEXT, not a stored path. Unlike an image column — which stores the bytes
and records where they were put — a document has no path; it becomes sections. So it needs no
public/prefix. - The rows carry no
Slug. A page's identity is its ownid:key, and the import reads the key from the document's header when the row does not give it. That is what makes re-importing a corpus converge instead of doubling it, and it keeps one fact in one place — the document is the thing that is true.
If neither the row nor the header carries the key, the import says so and names both places it looked.
Examples#
A page whose header is its data — the title drives the nav, the summary the listing, and both are queryable:
entity DocPage {
[FrontMatter("id")] [MaxLength(200)] string Slug;
[FrontMatter] [MaxLength(200)] string Title;
[FrontMatter] string? Summary;
[FrontMatter] [MaxLength(50)] string? Stability;
[FrontMatter] [ForeignKey(Page)] DocTag[] Tags;
[MaxLength(50)] string Area;
Markdown Body;
security { allow create, read, update when IsAuthenticated || IsAnonymous; }
}
entity DocTag {
DocPage Page;
[MaxLength(80)] string Name;
security { allow create, read, update, delete when IsAuthenticated || IsAnonymous; }
}
// `Title` and `Slug` are required, so every document must carry `title:` and `id:`.
void Publish(string area, string markdown) {
new DocPage { Area = area, Body = markdown };
}
DocPage[] Preview() => DocPage.Where(p => p.Stability == "preview").ToList();
// `tags: [ui, …]` became rows, so a facet is an ordinary query.
DocPage[] Tagged(string tag) => DocPage.Where(p => p.Tags.Any(t => t.Name == tag)).ToList();See also#
- Markdown — rendering markdown text — rendering the document the header belongs to.
- entity members — declaring members, and what optional means.
- entity — the entity the members live on.
- Where / Single / Count — querying by a bound member, like any other.