[ Feeds & publishing ]
@sdxc/microformats
Parse, read and write microformats2
- Installs with
- @sdxc/html@sdxc/result
- Depends on
@standard-schema/spec- Used by
- blog
- Source
- packages/microformats
Parse, read and write microformats2.
Microformats2 are class names on ordinary
HTML (h-entry, p-name, u-in-reply-to, dt-published, e-content) that a parser turns
into a JSON document of typed items. Webmention reads a linking page through that JSON, and
Micropub sends and answers with it. The package has three entry points:
@sdxc/microformatsparses HTML into canonical mf2 JSON, reads and writes that JSON, and validates a single item. Classic microformats (hentry,vcard,hreview, …) are read too, mapped onto their mf2 types.@sdxc/microformats/vocabularygives typed views ofh-entry,h-card,h-feedandh-cite, and the IndieWeb algorithms built on them: authorship, the representativeh-card, Post Type Discovery, and which entry on a page responds to a URL.@sdxc/microformats/uigivesremix/componenttemplates typed class names as a mixin, and a<time>component.
The parser is checked against the official microformats test suite.
Installation
npm add @sdxc/microformats
It installs @sdxc/result for fallible functions,
@sdxc/html for the HTML tree, and
remix for remix/data-schema and remix/component.
Usage
Parse a page
import { findItem, parse, values } from "@sdxc/microformats";
import { isFailure } from "@sdxc/result";
let response = await fetch("https://example.com/replies/1");
let result = parse(await response.text(), response.url);
if (isFailure(result)) throw result.error;
let document = result.data;
document.rels.me; // ["https://github.com/someone"]
let item = findItem(document, "h-entry");
if (item) values(item, "in-reply-to"); // ["https://example.org/articles/some-slug"]
Pass the URL the page was served from, after redirects: u-* values, implied photo and
url, rel links and the URLs inside e-* markup all resolve against it and the page's
<base href>.
Read an entry
import { findItem, parse } from "@sdxc/microformats";
import { authorOf, postType, readEntry } from "@sdxc/microformats/vocabulary";
import { unwrap } from "@sdxc/result";
let document = unwrap(parse(html, pageUrl));
let item = findItem(document, "h-entry");
if (item) {
let entry = unwrap(readEntry(item));
entry.inReplyTo[0]?.url; // "https://example.org/articles/some-slug"
entry.published?.instant; // a Date, or null when the page named no instant
postType(item); // "reply"
authorOf(item, document, pageUrl); // a card, { url } to fetch next, or null
}
Mark up a template
import { css } from "remix/component";
import { mf, MicroTime } from "@sdxc/microformats/ui";
<article mix={[mf("h-entry"), css({ padding: 16 })]}>
<h1 mix={[mf("p-name")]}>{post.title}</h1>
<a href={post.url} mix={[mf("u-url", "u-uid")]}>
<MicroTime property="dt-published" value={post.publishedAt}>
{post.publishedLabel}
</MicroTime>
</a>
<div mix={[mf("e-content")]}>{post.body}</div>
</article>;
API
@sdxc/microformats
parse(source: string, baseUrl: string | URL, options?: MF2.ParseOptions): Result<MF2.Document, MicroformatsParseError>
Parses a full page or a fragment into the canonical document, failing only for a source with no
markup at all. options.backcompat (default true) reads classic roots in subtrees that carry
no mf2 root.
fromDocument(document: DOMDocument, baseUrl: string | URL, options?: MF2.ParseOptions): MF2.Document
The same parse over a tree parseDocument from @sdxc/html/document already built, so one
tree serves every reader of the page.
stringify(value: MF2.Document | MF2.Item): string
Writes canonical JSON text. A document's relUrls is written as rel-urls; an item is written
as it is, which is Micropub's q=source answer.
parseJSON(text: string): Result<MF2.Document, MicroformatsShapeError>
Reads canonical JSON text back into a document; rels and rel-urls may be absent and read as
empty.
ITEM_SCHEMA: Schema<unknown, MF2.Item>
A remix/data-schema schema for one mf2 item already decoded from JSON, such as a Micropub JSON
create body. Its output is canonical: { html } content gains the value its markup reads as,
and a nested item without a value takes its first name, then its first url.
findItem(document: MF2.Document | MF2.Item[], type: string): MF2.Item | null
The first item whose type includes type, depth-first: each item, then the items in its
properties, then its children.
values(item: MF2.Item, property: string): string[]
Every value of a property as a string: a nested item's value, a URL's value, an embedded
value's text. A missing property is [].
MicroformatsParseError, MicroformatsShapeError
The failure of parse, and the failure of parseJSON and the vocabulary readers.
MicroformatsShapeError.issues holds the Standard Schema issues, empty for text that is not
JSON.
MF2 namespace
Document (items, rels, relUrls), Item (type, properties, id?, lang?,
children?), NestedItem (an item inside a property, with its value and, under e-*, its
html), Url ({ value, alt } for an image with alternative text), Embedded
({ html, value, lang? }), PropertyValue (the union of string and those three), RelUrl and
ParseOptions. dt-* values are strings in the specification's normalized form.
@sdxc/microformats/vocabulary
readEntry(item), readCard(item), readFeed(item)
Typed views (Vocabulary.Entry, Card, Feed) answering a Result, failing with a
MicroformatsShapeError when the item is of another type. A response property holding a
nested h-cite or a bare URL both become a Vocabulary.Cite; a feed's entries are its
h-entry children.
readItem(item: MF2.Item, schema: StandardSchemaV1): Result<Output, MicroformatsShapeError>
Reads any vocabulary through a synchronous Standard Schema, which receives the item's properties with each one-element array replaced by its element.
authorOf(entry: MF2.Item, document: MF2.Document, pageUrl: string): Vocabulary.Card | { url: string } | null
The authorship algorithm: the entry's author, then
that of the h-feed holding it, then rel=author when the page is the entry's permalink.
{ url } means authorship continues at another page.
representativeCard(document: MF2.Document, pageUrl: string): Vocabulary.Card | null
The representative h-card: the
card whose uid and a url are the page, else the card with a url the page links with
rel=me, else the page's only card when one of its urls is the page.
postType(entry: MF2.Item): Vocabulary.PostType
Post Type Discovery: rsvp, repost, like,
reply, bookmark, video, photo, then article when the content does not start with the
name, else note.
responseTo(document: MF2.Document, target: string): { entry: MF2.Item; type } | null
The entry responding to target and how: repost, like, reply or bookmark by property,
else mention when it links to the target anywhere. URLs compare after normalization.
Vocabulary namespace
Entry, Card, Feed, Cite, PostType, and DateTime ({ value, instant }, where
instant is a Date when the value names one and null for a date alone or a time without a
timezone).
@sdxc/microformats/ui
mf(...names: MF2UI.ClassName[]): MixinDescriptor
A mixin appending class tokens to its host's className, after whatever css() or the host
put there. It reaches through components that forward mix.
classes(...names: MF2UI.ClassName[]): string
The same tokens as a class string, each once.
MicroTime
<MicroTime property="dt-published" value={date}>{label}</MicroTime> renders a <time> with
the class and an ISO datetime; the visible text is the children, or the ISO string without
them.
MF2UI namespace
ClassName is Root | Property: the h-entry, h-card, h-feed, h-cite, h-event and
h-adr roots, the common h-entry and h-card properties under the prefix the vocabulary
gives them (u-in-reply-to, never p-in-reply-to), and the -x- vendor escape (h-x-thing,
p-x-mood). Any other name, a classic hentry included, is a type error.
Pattern: Label A Webmention
A Webmention receiver fetches the source once, checks it links to the target, and reads how it responds and who wrote it.
import { parseDocument } from "@sdxc/html/document";
import { fromDocument } from "@sdxc/microformats";
import { authorOf, readEntry, responseTo } from "@sdxc/microformats/vocabulary";
import { isFailure, unwrap } from "@sdxc/result";
async function label(sourceUrl: string, targetUrl: string) {
let page = await fetch(sourceUrl);
let tree = parseDocument(await page.text());
if (isFailure(tree)) return null;
let document = fromDocument(tree.data, page.url);
let response = responseTo(document, targetUrl);
if (!response) return null;
let entry = unwrap(readEntry(response.entry));
let author = authorOf(response.entry, document, page.url);
return { type: response.type, content: entry.content, author };
}
e-* markup comes back as its author wrote it, so pass entry.content.html through
HTML.sanitize from @sdxc/html before rendering
it.
Pattern: Answer Micropub With The Same Shape
Micropub's JSON create body is an mf2 item, and its q=source answer is the stored item
written back.
import { ITEM_SCHEMA, stringify } from "@sdxc/microformats";
import type { MF2 } from "@sdxc/microformats";
import { postType } from "@sdxc/microformats/vocabulary";
import { parseSafe } from "remix/data-schema";
async function create(request: Request) {
let result = parseSafe(ITEM_SCHEMA, await request.json());
if (!result.success) return Response.json({ error: "invalid_request" }, { status: 400 });
let type = postType(result.value); // "note", "article", "bookmark", …
let url = await savePost(type, result.value);
return new Response(null, { status: 201, headers: { Location: url } });
}
function source(item: MF2.Item) {
return new Response(stringify(item), { headers: { "Content-Type": "application/json" } });
}
Pattern: Find Who A Site Belongs To
The representative h-card of a home page identifies its owner, for an IndieAuth profile or a
mention's author.
import { parse } from "@sdxc/microformats";
import { representativeCard } from "@sdxc/microformats/vocabulary";
import { unwrap } from "@sdxc/result";
let response = await fetch("https://example.com/");
let document = unwrap(parse(await response.text(), response.url));
let card = representativeCard(document, response.url);
card?.name; // "Someone"
card?.photo?.value; // "https://example.com/avatar.jpg"
Pattern: Read A Feed
An h-feed lists its posts as h-entry children; entries without their own author take the
feed's.
import { findItem, parse } from "@sdxc/microformats";
import { authorOf, readFeed } from "@sdxc/microformats/vocabulary";
import { unwrap } from "@sdxc/result";
let response = await fetch("https://example.com/notes");
let document = unwrap(parse(await response.text(), response.url));
let item = findItem(document, "h-feed");
let feed = item ? unwrap(readFeed(item)) : null;
feed?.entries.map((entry) => entry.url); // each post's permalink, in page order
let posts = (item?.children ?? []).filter((child) => child.type.includes("h-entry"));
let authors = posts.map((post) => authorOf(post, document, response.url)); // the feed's author fills in
Pattern: Read A Vocabulary Of Your Own
readItem reads any vocabulary, such as h-recipe or a vendor h-x-* type, through a
remix/data-schema schema; single values arrive unwrapped.
import { findItem, parse } from "@sdxc/microformats";
import { readItem } from "@sdxc/microformats/vocabulary";
import { isFailure, unwrap } from "@sdxc/result";
import { object, string } from "remix/data-schema";
let RECIPE = object({ name: string(), yield: string().transform(Number) });
let document = unwrap(parse(html, pageUrl));
let item = findItem(document, "h-recipe");
let recipe = item ? readItem(item, RECIPE) : null;
if (recipe && isFailure(recipe)) console.error(recipe.error.issues);
Pattern: Store And Reload A Parsed Page
The canonical JSON round-trips, so a parsed page can be cached and read back without parsing the HTML again.
import { parse, parseJSON, stringify } from "@sdxc/microformats";
import { unwrap } from "@sdxc/result";
let document = unwrap(parse(html, pageUrl, { backcompat: false }));
let text = stringify(document); // { "items": …, "rels": …, "rel-urls": … }
let reloaded = unwrap(parseJSON(text));
reloaded.relUrls; // the same rel-urls, under its camelCase name
Pattern: Verify A Template With The Parser
A test renders a page and reads it back the way a consumer will, so a missing class shows up as a failed expectation.
import { findItem, parse } from "@sdxc/microformats";
import { readEntry } from "@sdxc/microformats/vocabulary";
import { unwrap } from "@sdxc/result";
import { expect, test } from "vitest";
test("an article page is an h-entry", async () => {
let response = await handler(new Request("https://example.com/articles/hello"));
let document = unwrap(parse(await response.text(), response.url));
let item = findItem(document, "h-entry");
if (!item) throw new Error("Expected an h-entry");
expect(unwrap(readEntry(item)).author?.name).toBe("Someone");
});