sdxc

Type to search, or start from one of these:

@sdxc/microformats

Parse, read and write microformats2

npm add @sdxc/microformats
pnpm add @sdxc/microformats
yarn add @sdxc/microformats
bun add @sdxc/microformats
Depends on
@standard-schema/specremix
Used by
blog

Parse, read and write microformats2.

Installation

npm add @sdxc/microformats

Fallible functions return @sdxc/result values; it installs alongside this package, as do @sdxc/html, which builds the tree the parser walks, and remix, whose remix/data-schema validates items and whose remix/ui renders the ./ui helpers.

Microformats2 is the vocabulary the IndieWeb reads a page through: 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's microformats to show a mention as a reply, a like or a repost; Micropub's JSON request body and its q=source answer are the same JSON.

  • @sdxc/microformats parses HTML into canonical mf2 JSON, reads and writes that JSON, and validates a single item with a remix/data-schema schema. Classic microformats (hentry, vcard, hreview, …) are read too, mapped onto their mf2 types.

  • @sdxc/microformats/vocabulary gives typed views of h-entry, h-card, h-feed and h-cite, and the IndieWeb living algorithms built on them: authorship, the representative h-card, Post Type Discovery, and which entry on a page responds to a URL.

  • @sdxc/microformats/ui gives remix/ui templates typed class names, as a mixin that sits in mix beside css(), and a <time> component.

The parser runs the official microformats test suite fixture by fixture. It departs from the suite on a handful of markup that the underlying DOM reads differently from an HTML5 parser, on the classic include pattern, and in two places where the suite contradicts itself.

Usage

Parse A Page

import { findItem, parse } from "@sdxc/microformats";
import { isFailure } from "@sdxc/result";

let response = await fetch("https://ada.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/ada"]
findItem(document, "h-entry")?.properties["in-reply-to"]; // ["https://example.com/articles/some-slug"]

The base URL is required: u-* values, implied photo and url, rel links and the URLs inside e-* markup all resolve against it (and against <base href>, when the page has one).

Read An Entry

import { findItem } from "@sdxc/microformats";
import { authorOf, postType, readEntry } from "@sdxc/microformats/vocabulary";
import { unwrap } from "@sdxc/result";

let item = findItem(document, "h-entry");
if (!item) return;

let entry = unwrap(readEntry(item));
entry.inReplyTo[0]?.url; // "https://example.com/articles/some-slug"
entry.published?.instant; // Date, or null when the page named no instant
postType(item); // "reply"
authorOf(item, document, response.url); // a card, { url } to fetch, or null

Mark Up A Template

import { mf, MicroTime } from "@sdxc/microformats/ui";

<article mix={[mf("h-entry"), css({ padding: 16 })]}>
	<h1 mix={[mf("p-name")]}>{post.title}</h1>
	<Link href={post.url} mix={[mf("u-url", "u-uid")]}>
		<MicroTime property="dt-published" value={post.publishedAt}>
			{post.publishedLabel}
		</MicroTime>
	</Link>
	<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 markup, a full page or a fragment, into the canonical document. Fails only for a source with no markup at all. options.backcompat (default true) controls whether classic roots are read where a subtree carries 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 a link check and the microformats read alike.

stringify(value: MF2.Document | MF2.Item): string

Writes canonical JSON text. A document's relUrls is written under its wire name 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. The error carries the data-schema issues (none when the text is not JSON at all).

ITEM_SCHEMA: Schema<unknown, MF2.Item>

Validates one mf2 item already decoded from JSON, which is a Micropub JSON create body. The output is always canonical: { html } content gains the value its markup reads as, and a nested item written without a value takes its first name, then its first url.

import { ITEM_SCHEMA } from "@sdxc/microformats";
import { parseSafe } from "remix/data-schema";

let result = parseSafe(ITEM_SCHEMA, await request.json());

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 failures of parse and of parseJSON and the vocabulary readers. MicroformatsShapeError.issues holds the Standard Schema issues.

MF2 namespace

Document (items, rels, relUrls), Item (type, properties, id?, lang?, children?), NestedItem (an item in a property, with its value and, for e-*, its html), Url ({ value, alt } for an image with alternative text), Embedded ({ html, value, lang? }), PropertyValue (the union of the four), RelUrl and ParseOptions. dt-* values stay strings in the specification's normalized form; the vocabulary's DateTime.instant converts where the page named an instant.

@sdxc/microformats/vocabulary

readEntry(item), readCard(item), readFeed(item)

Typed views (Vocabulary.Entry, Card, Feed), failing with a MicroformatsShapeError when the item is not of that type. A response property holding a nested h-cite or a bare URL both become a Vocabulary.Cite; an author that is a URL becomes a card with only url. A feed's entries are its h-entry children.

readItem(item, schema)

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, document, pageUrl): 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 ends at another page, which the caller may fetch.

representativeCard(document, pageUrl): 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 only card on the page when one of its urls is the page.

postType(entry): Vocabulary.PostType

Post Type Discovery: rsvp, repost, like, reply, bookmark, video, photo, then article when the name is not a prefix of the content, else note.

responseTo(document, target)

The entry that responds to target and how: repost, like, reply or bookmark by property, or mention when it links to the target anywhere else (a property value or a link in its markup). URLs compare after normalization.

@sdxc/microformats/ui

mf(...names: MF2UI.ClassName[]): MixinDescriptor

A mixin appending microformats class tokens to its host's className, after whatever a css() mixin 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 <time class="dt-published" datetime="2026-09-23T13:15:00.000Z">, the visible text being 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, their properties with the prefix the vocabulary gives them (u-in-reply-to, never p-in-reply-to), and the specification's -x- vendor escape (h-x-thing, p-x-mood). Anything else, a classic hentry included, is a type error.

Pattern: Label A Webmention From One Fetch

import { parseDocument } from "@sdxc/html/document";
import { fromDocument } from "@sdxc/microformats";
import { authorOf, readEntry, responseTo } from "@sdxc/microformats/vocabulary";
import { isFailure } from "@sdxc/result";

let tree = parseDocument(sourceHtml);
if (isFailure(tree)) return reject();

let document = fromDocument(tree.data, sourceUrl);
let response = responseTo(document, targetUrl);
if (!response) return reject(); // the source never links to the target

let entry = readEntry(response.entry);
let author = authorOf(response.entry, document, sourceUrl);

e-* markup comes back as authored, URLs resolved: it is somebody else's markup, so send it through HTML.sanitize from @sdxc/html before rendering it. authorOf returning { url } means another fetch; bound it the way you bound the first.

Pattern: Answer Micropub With The Same Shape

import { ITEM_SCHEMA, stringify } from "@sdxc/microformats";
import { postType } from "@sdxc/microformats/vocabulary";
import { parseSafe } from "remix/data-schema";

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", …

// later, for q=source:
return new Response(stringify(storedItem), { headers: { "Content-Type": "application/json" } });

Pattern: Verify A Template With The Parser

Pass the URL the page was served from, after redirects (response.url), so relative URLs resolve where a browser would resolve them.

import { findItem, parse } from "@sdxc/microformats";
import { readEntry } from "@sdxc/microformats/vocabulary";
import { unwrap } from "@sdxc/result";

let response = await handler(new Request("https://example.com/articles/hello"));
let document = unwrap(parse(await response.text(), response.url));
let entry = unwrap(readEntry(findItem(document, "h-entry")!));
expect(entry.author?.name).toBe("Ada Lovelace");