code.pablomurad.com

indiecheck

Lists the microformats, rel links and IndieWeb endpoints on a page, and how to fix them

feat: microformats and IndieWeb checker
author
Pablo Murad
date
commit
ddd953bb2ea39e27c06f859272343e8fb2c1237b
changes
35 files +4143 −0
@@ -0,0 +1,9 @@+/vendor/
+/.playwright-mcp/
+
+.env
+*.log
+
+.DS_Store
+Thumbs.db
+desktop.ini
@@ -0,0 +1,73 @@+# indiechecker
+
+Point it at a web page and it lists every microformat, rel link, IndieWeb endpoint and meta tag it finds there, then tells you what to fix, with the HTML to fix it.
+
+
+## What it reads
+
+**microformats2.** The vocabularies on the wiki, stable and draft: h-card, h-entry, h-feed, h-event, h-cite, h-adr, h-geo, h-product, h-recipe, h-review, h-review-aggregate, h-resume, h-item, h-listing, h-measure, h-app, h-breadcrumb, h-org and h-food. Anything under `h-x-` shows up as experimental. Types nobody has heard of still get parsed, with a note saying so.
+
+**Classic microformats.** The pre-2010 class names: vcard, adr, geo, hentry, hfeed, hnews, vevent, vcalendar, hreview, hreview-aggregate, hrecipe, hproduct, hresume, hlisting, hmedia, haudio and xoxo. Plus rel-tag, rel-license, rel-nofollow, XFN and VoteLinks. Items that came from classic markup are marked with the class they came from.
+
+**IndieWeb.** The representative h-card, who wrote each h-entry (following the authorship rules: the entry, then the parent h-feed, then rel=author), rel=me, Webmention, Micropub, Microsub, IndieAuth and WebSub. Endpoints are read from the HTML and from the HTTP `Link` header, which is where some sites keep them.
+
+**Meta tags.** Title, description, lang, charset, viewport, canonical, Open Graph, Twitter cards, `fediverse:creator`, theme-color, icons, the manifest and feeds. Declared feeds get requested, so a feed that answers 401 or comes back as `text/plain` gets flagged.
+
+**Advice.** Errors break parsing, like a `dt-published` that isn't a date or a class called `hcard`. Warnings leave out something tools look for, like an h-entry without an author. Tips are nice to have. Each one says where the problem is and comes with a snippet.
+
+## Running it
+
+You need PHP 8.2 or newer with the curl, dom and mbstring extensions, and Composer.
+
+```bash
+composer install
+php -S 127.0.0.1:8123 -t public
+```
+
+Then open http://127.0.0.1:8123.
+
+To put it online, point the web server's document root at `public/`. Nothing outside that folder should be reachable from the web.
+
+## JSON API
+
+Add `format=json` to any check:
+
+```bash
+curl "http://127.0.0.1:8123/?url=https://tantek.com/&format=json"
+```
+
+Or POST the HTML itself, with an optional `base` URL for resolving relative links:
+
+```bash
+curl -X POST http://127.0.0.1:8123/ \
+  --data-urlencode "html@page.html" \
+  -d "base=https://example.com/post" \
+  -d "format=json"
+```
+
+The canonical mf2 parse is under `mf2`. The rest of the response is what the page shows: `summary`, `indieweb`, `rels`, `metadata`, `feed-checks` and `advice`. A failed fetch answers 422 with `{"error": "..."}`.
+
+## Fetching
+
+The checker requests pages from the server it runs on, so it's careful about where it goes:
+
+- only http and https, on ports 80, 443, 8080 and 8443
+- no private, loopback, link-local or otherwise reserved addresses, checked again after every redirect, and cURL is pinned to the IP that passed the check
+- at most 5 redirects, 2 MB and 20 seconds per page
+- up to 4 feeds per page, with 8 seconds each (only the headers are read)
+
+A site on your own network can't be fetched. Paste its HTML instead.
+
+## Where things are
+
+- `public/index.php` takes the request and answers with a page or JSON
+- `src/Fetcher.php` does the HTTP requests and the address checks
+- `src/Mf2Parser.php` extends php-mf2 so that top-level `geo`, `hreview-aggregate` and `hnews` get parsed, and marks items that came from classic markup
+- `src/Microformats.php`, `src/Rels.php` and `src/Metadata.php` read the page
+- `src/Advice.php` has every rule behind the advice list
+- `src/Vocabulary.php` lists the vocabularies, their properties and the known rel values
+- `views/` holds the PHP templates
+
+## Credits
+
+Parsing by [php-mf2](https://github.com/microformats/php-mf2). The layout follows the [Minimal](https://github.com/orderedlist/minimal) GitHub Pages theme by Steve Smith, rewritten from scratch with darker link and label colors so the text passes contrast checks. Type is [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) and [IBM Plex Mono](https://github.com/IBM/plex), both under the SIL Open Font License and served from `public/fonts/`.
@@ -0,0 +1,26 @@+{
+    "name": "pablomurad/indiechecker",
+    "description": "Finds every microformat, rel and IndieWeb endpoint on a web page.",
+    "type": "project",
+    "authors": [
+        {
+            "name": "Pablo Murad",
+            "email": "pablo@pablomurad.com"
+        }
+    ],
+    "require": {
+        "php": ">=8.2",
+        "ext-curl": "*",
+        "ext-dom": "*",
+        "ext-mbstring": "*",
+        "mf2/mf2": "^0.5.0"
+    },
+    "autoload": {
+        "psr-4": {
+            "Indiechecker\\": "src/"
+        },
+        "files": [
+            "src/helpers.php"
+        ]
+    }
+}
@@ -0,0 +1,86 @@+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "b1b68a9e7cded45df0f4a4e8973f4966",
+    "packages": [
+        {
+            "name": "mf2/mf2",
+            "version": "v0.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/microformats/php-mf2.git",
+                "reference": "ddc56de6be62ed4a21f569de9b80e17af678ca50"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/microformats/php-mf2/zipball/ddc56de6be62ed4a21f569de9b80e17af678ca50",
+                "reference": "ddc56de6be62ed4a21f569de9b80e17af678ca50",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6.0"
+            },
+            "require-dev": {
+                "dealerdirect/phpcodesniffer-composer-installer": "^0.7",
+                "mf2/tests": "dev-master#e9e2b905821ba0a5b59dab1a8eaf40634ce9cd49",
+                "phpcompatibility/php-compatibility": "^9.3",
+                "phpunit/phpunit": "^5.7",
+                "squizlabs/php_codesniffer": "^3.6.2"
+            },
+            "suggest": {
+                "barnabywalters/mf-cleaner": "To more easily handle the canonical data php-mf2 gives you",
+                "masterminds/html5": "Alternative HTML parser for PHP, for better HTML5 support."
+            },
+            "bin": [
+                "bin/fetch-mf2",
+                "bin/parse-mf2"
+            ],
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Mf2/Parser.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "CC0-1.0"
+            ],
+            "authors": [
+                {
+                    "name": "Barnaby Walters",
+                    "homepage": "http://waterpigs.co.uk"
+                }
+            ],
+            "description": "A pure, generic microformats2 parser — makes HTML as easy to consume as a JSON API",
+            "keywords": [
+                "html",
+                "microformats",
+                "microformats 2",
+                "parser",
+                "semantic"
+            ],
+            "support": {
+                "issues": "https://github.com/microformats/php-mf2/issues",
+                "source": "https://github.com/microformats/php-mf2/tree/v0.5.0"
+            },
+            "time": "2022-02-10T01:05:27+00:00"
+        }
+    ],
+    "packages-dev": [],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {
+        "php": ">=8.2",
+        "ext-curl": "*",
+        "ext-dom": "*",
+        "ext-mbstring": "*"
+    },
+    "platform-dev": {},
+    "plugin-api-version": "2.9.0"
+}
@@ -0,0 +1,20 @@+const buttons = document.querySelectorAll('form button[type="submit"]');
+
+buttons.forEach((button) => {
+    button.dataset.label = button.textContent;
+    button.form.addEventListener('submit', (event) => {
+        if (button.getAttribute('aria-busy') === 'true') {
+            event.preventDefault();
+            return;
+        }
+        button.setAttribute('aria-busy', 'true');
+        button.textContent = 'Checking…';
+    });
+});
+
+window.addEventListener('pageshow', () => {
+    buttons.forEach((button) => {
+        button.removeAttribute('aria-busy');
+        button.textContent = button.dataset.label;
+    });
+});
@@ -0,0 +1,4 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
+  <rect x="3" y="3" width="58" height="58" rx="10" fill="#f4f4f4" stroke="#d6d6d6" stroke-width="2"/>
+  <path d="M18 33l9 9 19-20" fill="none" stroke="#237ba9" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
@@ -0,0 +1,62 @@+<?php
+
+use Indiechecker\FetchError;
+use Indiechecker\Fetcher;
+use Indiechecker\Report;
+
+use function Indiechecker\view;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+set_time_limit(90);
+
+$fetcher = new Fetcher();
+$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
+$format = $isPost ? ($_POST['format'] ?? '') : ($_GET['format'] ?? '');
+$url = trim($_GET['url'] ?? '');
+$html = $isPost ? (string) ($_POST['html'] ?? '') : null;
+$base = $isPost ? (string) ($_POST['base'] ?? '') : '';
+$report = null;
+$error = null;
+
+try {
+    if ($html !== null) {
+        $report = Report::fromHtml($html, $base, $fetcher);
+    } elseif ($url !== '') {
+        $report = Report::fromUrl($url, $fetcher);
+    }
+} catch (FetchError $exception) {
+    $error = $exception->getMessage();
+}
+
+header('X-Content-Type-Options: nosniff');
+header('Referrer-Policy: no-referrer');
+
+if ($format === 'json') {
+    header('Content-Type: application/json; charset=utf-8');
+    header('Access-Control-Allow-Origin: *');
+
+    $body = match (true) {
+        $error !== null => ['error' => $error],
+        $report !== null => $report->toArray(),
+        default => ['usage' => 'GET /?url=https://example.com/&format=json, or POST html (and optionally base) with format=json.'],
+    };
+    http_response_code($error !== null ? 422 : ($report === null ? 400 : 200));
+    echo json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
+
+    return;
+}
+
+header("Content-Security-Policy: default-src 'none'; style-src 'self'; font-src 'self'; img-src * data:; script-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
+
+if ($error !== null) {
+    http_response_code(422);
+}
+
+echo view('page', [
+    'report' => $report,
+    'error' => $error,
+    'url' => $url,
+    'html' => $html,
+    'base' => $base,
+]);
@@ -0,0 +1,1279 @@+@font-face {
+    font-family: "Noto Sans";
+    font-style: normal;
+    font-weight: 400;
+    font-display: swap;
+    src: url("fonts/noto-sans-latin-400-normal.woff2") format("woff2");
+}
+
+@font-face {
+    font-family: "Noto Sans";
+    font-style: normal;
+    font-weight: 500;
+    font-display: swap;
+    src: url("fonts/noto-sans-latin-500-normal.woff2") format("woff2");
+}
+
+@font-face {
+    font-family: "Noto Sans";
+    font-style: normal;
+    font-weight: 700;
+    font-display: swap;
+    src: url("fonts/noto-sans-latin-700-normal.woff2") format("woff2");
+}
+
+@font-face {
+    font-family: "IBM Plex Mono";
+    font-style: normal;
+    font-weight: 400;
+    font-display: swap;
+    src: url("fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2");
+}
+
+:root {
+    --paper: #fff;
+    --ink: #595959;
+    --ink-strong: #222;
+    --ink-heading: #393939;
+    --ink-sub: #494949;
+    --muted: #6b6b6b;
+    --line: #e5e5e5;
+    --line-faint: #f0f0f0;
+    --link: #237ba9;
+    --link-hover: #069;
+    --bar: #f4f4f4;
+    --bar-line: #e0e0e0;
+    --bar-press: #f0f0f0;
+    --code-bg: #f8f8f8;
+    --code-ink: #333;
+    --field-line: #949494;
+
+    --error-bg: #fcebea;
+    --error-line: #efc9c5;
+    --error-ink: #a3261c;
+    --warning-bg: #fdf6d8;
+    --warning-line: #e8da8c;
+    --warning-ink: #444;
+    --tip-bg: #eef5fa;
+    --tip-line: #cfe0ec;
+    --tip-ink: #1f5f8b;
+    --ok-bg: #edf7ea;
+    --ok-line: #cde6c5;
+    --ok-ink: #2e6b22;
+
+    --sans: "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
+    --mono: "IBM Plex Mono", Monaco, Consolas, "Liberation Mono", monospace;
+    --radius: 5px;
+    --side: 270px;
+}
+
+*,
+*::before,
+*::after {
+    box-sizing: border-box;
+}
+
+html {
+    -webkit-text-size-adjust: 100%;
+    scrollbar-color: #c8c8c8 var(--paper);
+}
+
+body {
+    margin: 0;
+    padding: 50px;
+    font: 400 15px/1.55 var(--sans);
+    color: var(--ink);
+    background: var(--paper);
+    caret-color: var(--link);
+}
+
+::selection {
+    background: #d6eaf5;
+    color: #1a1a1a;
+}
+
+a {
+    color: var(--link);
+    text-decoration: underline;
+    text-decoration-color: rgba(35, 123, 169, 0.3);
+    text-decoration-thickness: 1px;
+    text-underline-offset: 3px;
+}
+
+a:hover {
+    color: var(--link-hover);
+    text-decoration-color: currentColor;
+}
+
+:focus-visible {
+    outline: 2px solid var(--link);
+    outline-offset: 2px;
+}
+
+h1,
+h2,
+h3,
+h4 {
+    margin: 0 0 16px;
+    font-weight: 500;
+    line-height: 1.2;
+    text-wrap: balance;
+}
+
+h2 {
+    font-size: 22px;
+    color: var(--ink-heading);
+}
+
+h3 {
+    margin-top: 32px;
+    font-size: 17px;
+    color: var(--ink-sub);
+}
+
+h4 {
+    margin: 22px 0 8px;
+    font-size: 15px;
+    color: var(--ink-sub);
+}
+
+p,
+ul,
+ol,
+dl,
+table,
+pre {
+    margin: 0 0 20px;
+}
+
+p {
+    max-width: 70ch;
+}
+
+strong {
+    color: var(--ink-strong);
+    font-weight: 700;
+}
+
+code,
+pre {
+    font-family: var(--mono);
+    color: var(--code-ink);
+}
+
+code {
+    font-size: 0.87em;
+}
+
+small {
+    font-size: 12px;
+}
+
+.sprite {
+    position: absolute;
+    width: 0;
+    height: 0;
+    overflow: hidden;
+}
+
+.icon {
+    width: 1em;
+    height: 1em;
+    flex: none;
+    vertical-align: -0.125em;
+}
+
+.visually-hidden {
+    position: absolute;
+    width: 1px;
+    height: 1px;
+    margin: -1px;
+    padding: 0;
+    overflow: hidden;
+    clip: rect(0 0 0 0);
+    white-space: nowrap;
+    border: 0;
+}
+
+.muted,
+.empty {
+    color: var(--muted);
+}
+
+.wrapper {
+    display: grid;
+    grid-template-columns: var(--side) minmax(0, 1fr);
+    gap: 60px;
+    max-width: 1100px;
+    margin: 0 auto;
+}
+
+.side {
+    position: sticky;
+    top: 50px;
+    align-self: start;
+    max-height: calc(100vh - 230px);
+    overflow-y: auto;
+    padding: 2px;
+    margin: -2px;
+    scrollbar-width: thin;
+}
+
+.brand {
+    margin: 0 0 6px;
+    font-size: 28px;
+}
+
+.brand a {
+    color: var(--ink-strong);
+    text-decoration: none;
+}
+
+.brand a:hover {
+    color: var(--link-hover);
+}
+
+.tagline {
+    margin-bottom: 22px;
+}
+
+.check {
+    display: flex;
+    gap: 6px;
+}
+
+.check input {
+    flex: 1 1 auto;
+    min-width: 0;
+}
+
+input[type="text"],
+textarea {
+    width: 100%;
+    padding: 7px 14px;
+    font: inherit;
+    font-size: 15px;
+    color: var(--ink-strong);
+    background: var(--paper);
+    border: 1px solid var(--field-line);
+    border-radius: 999px;
+}
+
+textarea {
+    display: block;
+    padding: 8px 10px;
+    border-radius: var(--radius);
+    min-height: 150px;
+    font-family: var(--mono);
+    font-size: 12.5px;
+    line-height: 1.5;
+    resize: vertical;
+}
+
+input[type="text"]::placeholder,
+textarea::placeholder {
+    color: #767676;
+}
+
+input[type="text"]:focus,
+textarea:focus {
+    outline: 2px solid var(--link);
+    outline-offset: -1px;
+    border-color: var(--link);
+}
+
+.button {
+    padding: 7px 18px;
+    font: inherit;
+    font-size: 14px;
+    font-weight: 700;
+    color: var(--ink-strong);
+    background: var(--bar);
+    border: 1px solid #cfcfcf;
+    border-radius: 999px;
+    cursor: pointer;
+    white-space: nowrap;
+}
+
+.button:hover {
+    color: var(--link-hover);
+    border-color: var(--link);
+}
+
+.button:active {
+    background: var(--bar-press);
+}
+
+.button[aria-busy="true"] {
+    color: var(--muted);
+    cursor: progress;
+}
+
+.paste {
+    margin-top: 12px;
+}
+
+.paste summary {
+    display: inline-block;
+    font-size: 14px;
+    color: var(--link);
+    cursor: pointer;
+}
+
+.paste summary:hover {
+    color: var(--link-hover);
+}
+
+.paste form {
+    display: grid;
+    gap: 6px;
+    margin-top: 10px;
+}
+
+.paste label {
+    margin-top: 6px;
+    font-size: 13px;
+    font-weight: 500;
+    color: var(--ink-sub);
+}
+
+.paste .button {
+    justify-self: start;
+    margin-top: 6px;
+}
+
+.optional {
+    font-weight: 400;
+    color: var(--muted);
+}
+
+.bar {
+    display: flex;
+    flex-direction: column;
+    margin-top: 28px;
+    background: var(--bar);
+    border: 1px solid var(--bar-line);
+    border-radius: var(--radius);
+}
+
+.bar a {
+    display: block;
+    padding: 7px 12px 8px;
+    line-height: 1.25;
+    text-decoration: none;
+}
+
+.bar a + a {
+    border-top: 1px solid var(--bar-line);
+}
+
+.bar a:first-child {
+    border-radius: var(--radius) var(--radius) 0 0;
+}
+
+.bar a:last-child {
+    border-radius: 0 0 var(--radius) var(--radius);
+}
+
+.bar-label {
+    display: block;
+    font-size: 11.5px;
+    color: var(--muted);
+    font-variant-numeric: tabular-nums;
+}
+
+.bar strong {
+    display: block;
+    font-size: 14px;
+}
+
+.bar a:hover strong {
+    color: var(--link-hover);
+}
+
+.bar a:active {
+    background: var(--bar-press);
+}
+
+.site-footer {
+    position: fixed;
+    bottom: 50px;
+    left: max(50px, calc(50px + (100% - 1200px) / 2));
+    width: var(--side);
+    font-size: 12.5px;
+    color: var(--muted);
+}
+
+.site-footer p {
+    margin-bottom: 10px;
+}
+
+.view a {
+    font-size: 14px;
+    text-decoration: none;
+}
+
+.view small {
+    display: block;
+    margin-top: -1px;
+    color: var(--muted);
+}
+
+.content {
+    min-width: 0;
+}
+
+.alert {
+    display: flex;
+    gap: 8px;
+    align-items: flex-start;
+    margin-bottom: 20px;
+    padding: 10px 14px;
+    color: var(--error-ink);
+    background: var(--error-bg);
+    border: 1px solid var(--error-line);
+    border-radius: var(--radius);
+}
+
+.alert .icon {
+    margin-top: 4px;
+}
+
+.alert p {
+    margin: 0;
+    font-weight: 500;
+    overflow-wrap: anywhere;
+}
+
+.intro h2 {
+    font-size: 28px;
+    color: var(--ink-strong);
+}
+
+.intro .lede {
+    font-size: 17px;
+    max-width: 60ch;
+}
+
+.reads {
+    margin: 0;
+}
+
+.reads > div {
+    display: grid;
+    grid-template-columns: 11rem minmax(0, 1fr);
+    gap: 16px;
+    padding: 12px 0;
+    border-top: 1px solid var(--line);
+}
+
+.reads dt {
+    font-weight: 500;
+    color: var(--ink-strong);
+}
+
+.reads dd {
+    margin: 0;
+}
+
+.pills {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 6px;
+    margin: 0 0 20px;
+    padding: 0;
+    list-style: none;
+}
+
+.reads .pills {
+    margin: 0;
+}
+
+.pills li {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+}
+
+.more {
+    font-size: 13px;
+    color: var(--muted);
+}
+
+.type {
+    display: inline-flex;
+    align-items: center;
+    padding: 0 7px;
+    font-family: var(--mono);
+    font-size: 12.5px;
+    line-height: 1.8;
+    color: var(--code-ink);
+    text-decoration: none;
+    background: var(--code-bg);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+}
+
+a.type:hover {
+    color: var(--link-hover);
+    border-color: #c9c9c9;
+}
+
+.type-classic {
+    background: var(--paper);
+    border-style: dashed;
+    border-color: #c9c9c9;
+}
+
+.count {
+    margin-left: 6px;
+    font-family: var(--sans);
+    font-weight: 500;
+    color: var(--muted);
+    font-variant-numeric: tabular-nums;
+}
+
+.badge,
+.level {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    padding: 0 7px;
+    font-size: 12.5px;
+    font-weight: 500;
+    line-height: 1.8;
+    text-decoration: none;
+    white-space: nowrap;
+    border: 1px solid;
+    border-radius: var(--radius);
+}
+
+.badge {
+    font-size: 11.5px;
+    line-height: 1.6;
+}
+
+.badge-draft,
+.level-warning {
+    color: var(--warning-ink);
+    background: var(--warning-bg);
+    border-color: var(--warning-line);
+}
+
+.badge-experimental,
+.badge-header,
+.level-tip {
+    color: var(--tip-ink);
+    background: var(--tip-bg);
+    border-color: var(--tip-line);
+}
+
+.badge-unknown,
+.level-error {
+    color: var(--error-ink);
+    background: var(--error-bg);
+    border-color: var(--error-line);
+}
+
+.badge-classic,
+.level-zero {
+    color: var(--muted);
+    background: var(--bar);
+    border-color: var(--bar-line);
+}
+
+a.level:hover {
+    border-color: currentColor;
+}
+
+.summary .checked {
+    margin: 0;
+    font-size: 17px;
+    font-weight: 500;
+    color: var(--ink-strong);
+    overflow-wrap: anywhere;
+}
+
+.facts {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 2px 14px;
+    margin: 4px 0 0;
+    font-size: 13px;
+    color: var(--muted);
+    font-variant-numeric: tabular-nums;
+}
+
+.tally {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 6px 8px;
+    margin: 14px 0 0;
+    max-width: none;
+}
+
+.tally > span {
+    flex-basis: 100%;
+}
+
+.section {
+    margin-top: 40px;
+    padding-top: 30px;
+    border-top: 1px solid var(--line);
+}
+
+[id] {
+    scroll-margin-top: 24px;
+}
+
+.table-wrap {
+    overflow-x: auto;
+    margin-bottom: 20px;
+}
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: 14px;
+}
+
+.table-wrap table {
+    margin: 0;
+}
+
+th,
+td {
+    padding: 6px 12px 6px 0;
+    text-align: left;
+    vertical-align: top;
+    border-bottom: 1px solid var(--line);
+    overflow-wrap: break-word;
+}
+
+td a {
+    overflow-wrap: anywhere;
+}
+
+thead th {
+    font-size: 13px;
+    font-weight: 500;
+    color: #444;
+}
+
+tbody th {
+    width: 34%;
+    font-weight: 400;
+}
+
+.num {
+    font-variant-numeric: tabular-nums;
+    white-space: nowrap;
+}
+
+.rels td:first-child {
+    width: 36%;
+}
+
+.about {
+    display: block;
+    font-size: 13px;
+    color: var(--muted);
+}
+
+.links,
+.plain {
+    margin: 0;
+    padding: 0;
+    list-style: none;
+    overflow-wrap: anywhere;
+}
+
+.plain {
+    margin-bottom: 20px;
+}
+
+.links li + li,
+.plain li + li {
+    margin-top: 2px;
+}
+
+.mf-root {
+    border-top: 1px solid var(--line);
+}
+
+.mf-root:last-of-type {
+    border-bottom: 1px solid var(--line);
+}
+
+.mf-root + p {
+    margin-top: 10px;
+}
+
+.mf-root > summary {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: 6px;
+    padding: 9px 0;
+    list-style: none;
+    cursor: pointer;
+}
+
+.mf-root > summary::-webkit-details-marker {
+    display: none;
+}
+
+.mf-root > summary::before {
+    content: "";
+    width: 6px;
+    height: 6px;
+    margin: 0 8px 0 2px;
+    border-right: 1.5px solid var(--muted);
+    border-bottom: 1.5px solid var(--muted);
+    transform: rotate(-45deg);
+    transition: transform 150ms ease-out;
+}
+
+.mf-root[open] > summary::before {
+    transform: rotate(45deg);
+}
+
+.mf-root > summary:hover .mf-name {
+    color: var(--link-hover);
+}
+
+.mf-name {
+    font-weight: 500;
+    color: var(--ink-strong);
+    overflow-wrap: anywhere;
+}
+
+.mf {
+    padding: 0 0 14px 20px;
+}
+
+.mf-nested {
+    margin: 4px 0;
+    padding: 2px 0 2px 14px;
+    border-left: 1px solid var(--line);
+}
+
+.mf-head {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin: 2px 0 4px;
+}
+
+.mf-attrs {
+    margin: 0 0 4px;
+    font-size: 13px;
+    color: var(--muted);
+}
+
+.mf-attrs code + code {
+    margin-left: 8px;
+}
+
+.props {
+    margin: 0;
+}
+
+.prop {
+    display: grid;
+    grid-template-columns: minmax(8rem, 11rem) minmax(0, 1fr);
+    gap: 12px;
+    padding: 5px 0;
+}
+
+.prop + .prop {
+    border-top: 1px solid var(--line-faint);
+}
+
+.prop dt code {
+    color: var(--code-ink);
+    overflow-wrap: anywhere;
+}
+
+.prefix {
+    color: var(--muted);
+}
+
+.prop dd {
+    margin: 0;
+    min-width: 0;
+}
+
+.value {
+    margin: 0;
+    overflow-wrap: anywhere;
+}
+
+.value + .value {
+    margin-top: 6px;
+}
+
+.value p {
+    margin: 0;
+}
+
+.value-text {
+    font-weight: 500;
+    color: var(--ink-strong);
+}
+
+.value-image {
+    display: flex;
+    gap: 10px;
+    align-items: flex-start;
+}
+
+.value-image img {
+    flex: none;
+    width: 48px;
+    height: 48px;
+    object-fit: cover;
+    background: var(--code-bg);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+}
+
+.value-e details summary,
+.json summary {
+    font-size: 13px;
+    color: var(--link);
+    cursor: pointer;
+}
+
+.value-e details summary:hover,
+.json summary:hover {
+    color: var(--link-hover);
+}
+
+.children {
+    margin-top: 8px;
+}
+
+.children-label {
+    margin: 0 0 2px;
+    font-size: 13px;
+    color: var(--muted);
+}
+
+.code {
+    margin: 10px 0 0;
+    padding: 8px 15px;
+    overflow-x: auto;
+    font-size: 12.5px;
+    line-height: 1.55;
+    background: var(--code-bg);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+    tab-size: 2;
+}
+
+.code code {
+    font-size: inherit;
+}
+
+.json .code {
+    max-height: 70vh;
+}
+
+.checklist {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, minmax(min(100%, 270px), 1fr));
+    column-gap: 30px;
+    margin: 0 0 20px;
+    padding: 0;
+    list-style: none;
+}
+
+.checklist > li {
+    display: flex;
+    gap: 10px;
+    padding: 9px 0;
+    border-top: 1px solid var(--line);
+    min-width: 0;
+}
+
+.checklist > li > div {
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+
+.checklist strong {
+    font-weight: 500;
+}
+
+.checklist .detail {
+    display: block;
+    font-size: 13.5px;
+    color: var(--muted);
+}
+
+.checklist .links {
+    margin-top: 3px;
+    font-size: 13.5px;
+}
+
+.mark {
+    display: grid;
+    flex: none;
+    place-items: center;
+    width: 20px;
+    height: 20px;
+    margin-top: 1px;
+    font-size: 13px;
+    border: 1px solid;
+    border-radius: 50%;
+}
+
+.is-ok .mark {
+    color: var(--ok-ink);
+    background: var(--ok-bg);
+    border-color: var(--ok-line);
+}
+
+.is-missing .mark,
+.is-unknown .mark {
+    color: var(--muted);
+    background: var(--bar);
+    border-color: var(--bar-line);
+}
+
+.is-bad .mark {
+    color: var(--error-ink);
+    background: var(--error-bg);
+    border-color: var(--error-line);
+}
+
+.person {
+    display: flex;
+    gap: 14px;
+    align-items: flex-start;
+    margin-bottom: 20px;
+    overflow-wrap: anywhere;
+}
+
+.person img {
+    flex: none;
+    object-fit: cover;
+    background: var(--code-bg);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+}
+
+.person p {
+    margin: 0 0 2px;
+}
+
+.person-name {
+    font-size: 16px;
+    font-weight: 500;
+    color: var(--ink-strong);
+}
+
+.meta-top {
+    display: grid;
+    grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
+    gap: 30px;
+    align-items: start;
+}
+
+.share {
+    margin: 0;
+}
+
+.share-card {
+    overflow: hidden;
+    background: var(--paper);
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+}
+
+.share-card img,
+.share-noimage {
+    display: block;
+    width: 100%;
+    aspect-ratio: 1.91 / 1;
+    object-fit: cover;
+    background: var(--code-bg);
+}
+
+.share-noimage {
+    display: grid;
+    place-items: center;
+    font-size: 13px;
+    color: var(--muted);
+    background: repeating-linear-gradient(135deg, var(--code-bg) 0 8px, var(--line-faint) 8px 16px);
+    border-bottom: 1px solid var(--line);
+}
+
+.share-text {
+    padding: 9px 12px 11px;
+}
+
+.share-text p {
+    margin: 0;
+    overflow-wrap: anywhere;
+}
+
+.share-host {
+    font-size: 12.5px;
+    color: var(--muted);
+}
+
+.share-title {
+    font-weight: 500;
+    color: var(--ink-strong);
+}
+
+.share-description {
+    display: -webkit-box;
+    margin-top: 2px;
+    font-size: 13.5px;
+    color: var(--muted);
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+}
+
+.share figcaption {
+    margin-top: 8px;
+    font-size: 12.5px;
+    color: var(--muted);
+}
+
+.facts-list {
+    margin: 0;
+}
+
+.facts-list > div {
+    display: grid;
+    grid-template-columns: 8.5rem minmax(0, 1fr);
+    gap: 10px;
+    padding: 5px 0;
+    border-bottom: 1px solid var(--line);
+}
+
+.facts-list > div:first-child {
+    border-top: 1px solid var(--line);
+}
+
+.facts-list dt {
+    padding-top: 2px;
+    font-family: var(--mono);
+    font-size: 12.5px;
+    color: var(--muted);
+}
+
+.facts-list dd {
+    margin: 0;
+    overflow-wrap: anywhere;
+}
+
+.missing {
+    display: inline-flex;
+    align-items: center;
+    gap: 4px;
+    color: var(--muted);
+    font-style: italic;
+}
+
+.swatch {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    margin-right: 12px;
+}
+
+.swatch svg {
+    border-radius: 3px;
+    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
+}
+
+.icons {
+    display: grid;
+    gap: 8px;
+    margin: 0 0 20px;
+    padding: 0;
+    list-style: none;
+}
+
+.icons li {
+    display: flex;
+    gap: 12px;
+    align-items: center;
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+
+.icon-frame {
+    display: grid;
+    flex: none;
+    place-items: center;
+    width: 40px;
+    height: 40px;
+    background: conic-gradient(var(--line-faint) 25%, var(--paper) 0 50%, var(--line-faint) 0 75%, var(--paper) 0) 0 0 / 10px 10px;
+    border: 1px solid var(--line);
+    border-radius: var(--radius);
+}
+
+.icon-frame img {
+    max-width: 30px;
+    max-height: 30px;
+}
+
+.advice {
+    padding: 16px 0 18px;
+    border-top: 1px solid var(--line);
+}
+
+.advice:last-child {
+    border-bottom: 1px solid var(--line);
+}
+
+.advice header {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: baseline;
+    gap: 4px 10px;
+}
+
+.advice h3 {
+    flex: 1 1 16rem;
+    margin: 0;
+    font-size: 16px;
+    color: var(--ink-strong);
+    overflow-wrap: anywhere;
+}
+
+.area {
+    margin-left: 4px;
+    font-size: 13px;
+    font-weight: 400;
+    color: var(--muted);
+}
+
+.advice > p {
+    margin: 6px 0 0;
+}
+
+.where {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 6px;
+    margin: 10px 0 0;
+    padding: 0;
+    list-style: none;
+}
+
+.where code {
+    display: inline-block;
+    max-width: 100%;
+    padding: 0 6px;
+    font-size: 12px;
+    background: var(--code-bg);
+    border: 1px solid var(--line);
+    border-radius: 4px;
+    overflow-wrap: anywhere;
+}
+
+.where .muted {
+    align-self: center;
+    font-size: 13px;
+}
+
+@media (max-width: 960px) {
+    body {
+        padding: 30px;
+    }
+
+    .wrapper {
+        grid-template-columns: minmax(0, 1fr);
+        gap: 30px;
+    }
+
+    .side {
+        position: static;
+        max-height: none;
+        overflow: visible;
+        padding: 0;
+        margin: 0;
+    }
+
+    .site-footer {
+        position: static;
+        width: auto;
+        margin-top: 10px;
+        padding-top: 20px;
+        border-top: 1px solid var(--line);
+    }
+
+    .bar {
+        flex-direction: row;
+        overflow-x: auto;
+        scrollbar-width: none;
+    }
+
+    .bar a {
+        flex: 1 0 auto;
+    }
+
+    .bar a + a {
+        border-top: 0;
+        border-left: 1px solid var(--bar-line);
+    }
+
+    .bar a:first-child {
+        border-radius: var(--radius) 0 0 var(--radius);
+    }
+
+    .bar a:last-child {
+        border-radius: 0 var(--radius) var(--radius) 0;
+    }
+}
+
+@media (max-width: 720px) {
+    body {
+        padding: 20px 16px;
+    }
+
+    input[type="text"],
+    textarea {
+        font-size: 16px;
+    }
+
+    .meta-top,
+    .reads > div,
+    .prop {
+        grid-template-columns: minmax(0, 1fr);
+    }
+
+    .reads > div {
+        gap: 6px;
+    }
+
+    .prop {
+        gap: 0;
+    }
+
+    .mf {
+        padding-left: 12px;
+    }
+
+    .rels td:first-child {
+        width: 42%;
+    }
+}
+
+@media (prefers-reduced-motion: reduce) {
+    *,
+    *::before,
+    *::after {
+        transition: none !important;
+    }
+}
@@ -0,0 +1,368 @@+<?php
+
+namespace Indiechecker;
+
+final class Advice
+{
+    private const LEVELS = ['error' => 0, 'warning' => 1, 'tip' => 2];
+    private const DURATIONS = ['duration'];
+    private const LOOSE_URLS = ['email', 'tel', 'impp', 'key', 'uid'];
+
+    private array $items = [];
+
+    public function __construct(
+        private Microformats $microformats,
+        private Rels $rels,
+        private Metadata $metadata,
+        private array $feedChecks,
+        private ?string $pageUrl,
+        private ?string $headerCharset,
+    ) {
+        $this->checkMicroformats();
+        $this->checkIndieWeb();
+        $this->checkMetadata();
+    }
+
+    public function all(): array
+    {
+        $items = array_values($this->items);
+        usort($items, fn (array $a, array $b) => self::LEVELS[$a['level']] <=> self::LEVELS[$b['level']]);
+
+        return $items;
+    }
+
+    public function count(string $level): int
+    {
+        return count(array_filter($this->items, fn (array $item) => $item['level'] === $level));
+    }
+
+    private function add(string $key, string $level, string $area, string $title, string $detail, ?string $fix = null, ?string $where = null): void
+    {
+        $this->items[$key] ??= compact('level', 'area', 'title', 'detail', 'fix') + ['where' => []];
+        if ($where !== null && !in_array($where, $this->items[$key]['where'], true)) {
+            $this->items[$key]['where'][] = $where;
+        }
+    }
+
+    private function checkMicroformats(): void
+    {
+        $mf = $this->microformats;
+
+        if ($mf->isEmpty() && !$mf->classic) {
+            $this->add('no-mf', 'warning', 'Microformats', 'No microformats on this page',
+                'Nothing here is marked up with microformats, so readers, Webmention receivers and IndieWeb tools can’t tell who wrote it or what it is. An h-card is the usual first step.',
+                $this->cardSnippet());
+        }
+
+        foreach ($mf->classic as $class => $found) {
+            $root = Vocabulary::CLASSIC_ROOTS[$class];
+            $alone = $found['count'] - $found['alongsideMf2'];
+            if ($alone === 0) {
+                continue;
+            }
+            if ($root['mf2'] === null) {
+                continue;
+            }
+            $this->add("classic-{$class}", 'tip', 'Microformats', "Classic {$root['name']} without {$root['mf2']}",
+                "{$alone} element(s) use the classic “{$class}” class. Parsers still read it through backward compatibility, but mf2 properties only work under an mf2 root. Keep the old class and add {$root['mf2']} next to it, then move the properties to their mf2 names.",
+                "<div class=\"{$class} {$root['mf2']}\">…</div>", $found['example']);
+        }
+
+        if (isset($mf->classic['hlisting'])) {
+            $this->add('classic-hlisting-parse', 'tip', 'Microformats', 'hListing isn’t parsed by mf2 parsers',
+                'mf2 parsers have no backward compatibility rules for hListing, so these listings are invisible to them. Mark them up with h-listing.',
+                '<div class="hlisting h-listing">…</div>');
+        }
+
+        foreach ($mf->mistakes as $mistake) {
+            $this->add("mistake-{$mistake['class']}", 'error', 'Microformats', "“{$mistake['class']}” isn’t a microformats class",
+                "Parsers ignore it. Use {$mistake['use']}.", null, $mistake['snippet']);
+        }
+
+        foreach ($mf->orphans as $orphan) {
+            $this->add('orphans', 'warning', 'Microformats', 'Properties outside any h-* root',
+                'These elements have microformats property classes (p-, u-, dt-, e-) but no h-* element around them, so parsers skip them. Wrap them in the right root, like h-entry or h-card.',
+                "<article class=\"h-entry\">\n  <!-- the p-*, u-*, dt-* and e-* elements go in here -->\n</article>", $orphan['snippet']);
+        }
+
+        $topEntries = array_filter($mf->parsed['items'], fn (array $item) => in_array('h-entry', $item['type'], true));
+        if (count($topEntries) > 1 && !$mf->ofType('h-feed')) {
+            $this->add('entries-no-feed', 'tip', 'Microformats', 'Several h-entry posts without an h-feed',
+                'Readers treat a page of top-level h-entry items as an implied feed, but an explicit h-feed lets you give it a name and an author.',
+                "<main class=\"h-feed\">\n  <h1 class=\"p-name\">Posts</h1>\n  <article class=\"h-entry\">…</article>\n</main>");
+        }
+
+        foreach ($mf->all as $found) {
+            $this->checkItem($found);
+        }
+    }
+
+    private function checkItem(array $found): void
+    {
+        $item = $found['item'];
+        $types = $item['type'];
+        $properties = $item['properties'];
+        $where = $found['path'];
+
+        foreach ($types as $type) {
+            $status = Vocabulary::status($type);
+            if ($status === 'unknown') {
+                $this->add("unknown-type-{$type}", 'tip', 'Microformats', "“{$type}” isn’t a known vocabulary",
+                    'Parsers still read it, but no consumer will understand it. Check the spelling, or use the h-x- prefix for experiments.', null, $where);
+            }
+            if ($status === 'experimental' && $type === 'h-x-app') {
+                $this->add('h-x-app', 'tip', 'Microformats', 'h-x-app is the old name of h-app',
+                    'IndieAuth servers read both, but h-app is the current name. You can keep both classes.', '<div class="h-app h-x-app">…</div>', $where);
+            }
+        }
+
+        foreach ($properties as $name => $values) {
+            if (!str_starts_with($name, 'x-') && array_filter($types, fn ($type) => Vocabulary::type($type)) && !Vocabulary::knowsProperty($types, $name)) {
+                $typeList = implode(' ', $types);
+                $this->add("unknown-property-{$typeList}-{$name}", 'tip', 'Microformats', "“{$name}” isn’t a property of {$typeList}",
+                    'Consumers won’t look for it. Check the spelling against the vocabulary, or prefix experiments with x-.', null, $where);
+            }
+
+            $prefix = Vocabulary::prefix($types, $name, $values[0]);
+            foreach ($values as $value) {
+                $text = is_array($value) ? ($value['value'] ?? null) : $value;
+                if (!is_string($text)) {
+                    continue;
+                }
+                if ($prefix === 'dt') {
+                    $this->checkDate($name, $text, $where);
+                }
+                if ($prefix === 'u' && !in_array($name, self::LOOSE_URLS, true) && !(is_array($value) && isset($value['type']))) {
+                    $this->checkUrl($name, $text, $where);
+                }
+            }
+        }
+
+        if (in_array('h-entry', $types, true)) {
+            $this->checkEntry($found);
+        }
+        if (in_array('h-card', $types, true) && trim((string) first_text($item, 'name')) === '') {
+            $this->add('card-no-name', 'warning', 'Microformats', 'h-card without a name',
+                'Every h-card needs a name. Add p-name, or put the name as the only text inside the h-card.', '<a class="h-card p-name u-url" href="https://example.com/">Your Name</a>', $where);
+        }
+        if (in_array('h-event', $types, true)) {
+            if (!isset($properties['start'])) {
+                $this->add('event-no-start', 'error', 'Microformats', 'h-event without dt-start',
+                    'An event needs a start date and time.', '<time class="dt-start" datetime="2026-10-03T19:00:00-03:00">October 3, 7 pm</time>', $where);
+            }
+            if (trim((string) first_text($item, 'name')) === '') {
+                $this->add('event-no-name', 'warning', 'Microformats', 'h-event without a name', 'Give the event a p-name.', '<h2 class="p-name">Homebrew Website Club</h2>', $where);
+            }
+        }
+        if (in_array('h-feed', $types, true) && !array_filter($item['children'] ?? [], fn (array $child) => in_array('h-entry', $child['type'], true))) {
+            $this->add('feed-empty', 'warning', 'Microformats', 'h-feed without h-entry posts',
+                'Readers expect h-entry items inside an h-feed. Check that each post has class="h-entry" and sits inside the feed element.', null, $where);
+        }
+        if (in_array('h-review', $types, true)) {
+            if (!isset($properties['item'])) {
+                $this->add('review-no-item', 'warning', 'Microformats', 'h-review without p-item', 'Say what is being reviewed with p-item, ideally an h-item, h-card, h-product or h-event.', '<a class="p-item h-item" href="https://example.com/thing">The Thing</a>', $where);
+            }
+            if (!isset($properties['rating'])) {
+                $this->add('review-no-rating', 'tip', 'Microformats', 'h-review without p-rating', 'A rating lets readers show stars. Add p-rating, and p-best if the scale isn’t 1 to 5.', '<data class="p-rating" value="4">★★★★☆</data>', $where);
+            }
+        }
+        if (in_array('h-review-aggregate', $types, true)) {
+            if (!isset($properties['average']) && !isset($properties['rating'])) {
+                $this->add('aggregate-no-average', 'warning', 'Microformats', 'h-review-aggregate without p-average', 'An aggregate review needs the average rating.', '<data class="p-average" value="4.2">4.2</data> from <data class="p-count" value="87">87</data> reviews', $where);
+            }
+            if (!isset($properties['item'])) {
+                $this->add('aggregate-no-item', 'warning', 'Microformats', 'h-review-aggregate without p-item', 'Say what the reviews are about with p-item.', '<span class="p-item h-item"><span class="p-name">The Thing</span></span>', $where);
+            }
+        }
+        if (in_array('h-recipe', $types, true)) {
+            if (!isset($properties['ingredient'])) {
+                $this->add('recipe-no-ingredient', 'warning', 'Microformats', 'h-recipe without ingredients', 'Mark each ingredient with p-ingredient.', '<li class="p-ingredient">2 cups of flour</li>', $where);
+            }
+            if (!isset($properties['instructions'])) {
+                $this->add('recipe-no-instructions', 'tip', 'Microformats', 'h-recipe without e-instructions', 'Wrap the method in e-instructions.', '<ol class="e-instructions">…</ol>', $where);
+            }
+        }
+        if (in_array('h-geo', $types, true)) {
+            foreach (['latitude', 'longitude'] as $axis) {
+                $value = first_text($item, $axis);
+                if ($value === null || !is_numeric(trim((string) $value))) {
+                    $this->add("geo-{$axis}", 'error', 'Microformats', "h-geo without a numeric {$axis}", 'Coordinates are decimal degrees, like -23.5505.', '<data class="p-latitude" value="-23.5505"></data><data class="p-longitude" value="-46.6333"></data>', $where);
+                }
+            }
+        }
+        if (in_array('h-product', $types, true) && trim((string) first_text($item, 'name')) === '') {
+            $this->add('product-no-name', 'warning', 'Microformats', 'h-product without a name', 'Add p-name to the product.', '<h2 class="p-name">Blue Mug</h2>', $where);
+        }
+    }
+
+    private function checkEntry(array $found): void
+    {
+        $properties = $found['item']['properties'];
+        $where = $found['path'];
+
+        if ($this->microformats->authorOf($found) === null) {
+            $this->add('entry-no-author', 'warning', 'Microformats', 'h-entry without an author',
+                'Webmention receivers and readers can’t tell who wrote the post. Add a p-author h-card inside the entry, on the parent h-feed, or a rel=author link.',
+                '<a class="p-author h-card" href="https://example.com/">Your Name</a>', $where);
+        }
+        if (!isset($properties['published'])) {
+            $this->add('entry-no-published', 'warning', 'Microformats', 'h-entry without dt-published',
+                'Readers sort and display posts by their publish date.',
+                '<time class="dt-published" datetime="2026-09-27T14:30:00-03:00">September 27, 2026</time>', $where);
+        }
+        if (!isset($properties['url'])) {
+            $this->add('entry-no-url', 'tip', 'Microformats', 'h-entry without u-url',
+                'A u-url permalink lets people reply to, like and share this exact post.',
+                '<a class="u-url" href="https://example.com/2026/09/my-post">Permalink</a>', $where);
+        }
+        if (!isset($properties['content']) && !isset($properties['name']) && !isset($properties['summary'])) {
+            $this->add('entry-no-content', 'warning', 'Microformats', 'h-entry without content',
+                'The post has no e-content, p-name or p-summary, so there’s nothing to show.', '<div class="e-content">…</div>', $where);
+        }
+    }
+
+    private function checkDate(string $name, string $value, string $where): void
+    {
+        if (in_array($name, self::DURATIONS, true)) {
+            if (!Dates::isDuration($value) && !Dates::isIso($value)) {
+                $this->add("bad-duration-{$name}", 'error', 'Microformats', "dt-{$name} isn’t an ISO 8601 duration",
+                    "“{$value}” can’t be read. Durations look like PT1H30M.", '<time class="dt-duration" datetime="PT1H30M">1 hour 30 minutes</time>', $where);
+            }
+
+            return;
+        }
+
+        if (!Dates::isIso($value)) {
+            $this->add("bad-date-{$name}", 'error', 'Microformats', "dt-{$name} isn’t an ISO 8601 date",
+                "“{$value}” can’t be read as a date. Put the machine-readable date in a datetime attribute and keep any text you like inside the element.",
+                "<time class=\"dt-{$name}\" datetime=\"2026-09-27T14:30:00-03:00\">September 27, 2026</time>", $where);
+        } elseif (in_array($name, ['published', 'updated', 'start', 'end'], true) && str_contains($value, ':') && !Dates::hasZone($value)) {
+            $this->add("date-no-zone-{$name}", 'tip', 'Microformats', "dt-{$name} has a time but no timezone",
+                "“{$value}” will be read in whatever timezone the reader is in. Add an offset like -03:00 or Z.", null, $where);
+        }
+    }
+
+    private function checkUrl(string $name, string $value, string $where): void
+    {
+        if (trim($value) === '') {
+            $this->add("empty-url-{$name}", 'warning', 'Microformats', "Empty u-{$name}", 'The property is there but has no URL. Check the href or src.', null, $where);
+        } elseif (preg_match('/^\s*javascript:/i', $value)) {
+            $this->add("js-url-{$name}", 'error', 'Microformats', "u-{$name} points to javascript:", 'Use a real URL.', null, $where);
+        }
+    }
+
+    private function checkIndieWeb(): void
+    {
+        $mf = $this->microformats;
+        $cards = $mf->ofType('h-card');
+
+        if (!$mf->isEmpty() && !$cards) {
+            $this->add('no-card', 'warning', 'IndieWeb', 'No h-card on the page',
+                'An h-card says who is behind the site. IndieWeb tools use it to show your name and photo next to replies and mentions.',
+                $this->cardSnippet());
+        } elseif ($cards && $mf->representativeCard === null && $this->pageUrl !== null) {
+            $this->add('no-representative-card', 'warning', 'IndieWeb', 'None of the h-cards represents this page',
+                'Tools look for a representative h-card: one whose u-url is also a rel=me link, or whose u-url and u-uid both equal the page URL. Mark your own h-card that way.',
+                sprintf('<a class="h-card u-url u-uid" rel="me" href="%s">Your Name</a>', $this->pageUrl));
+        }
+
+        if (!$this->rels->has('me')) {
+            $this->add('no-rel-me', 'tip', 'IndieWeb', 'No rel=me links',
+                'rel=me links connect this site to your other profiles. They power IndieLogin and the verified links on Mastodon.',
+                "<a rel=\"me\" href=\"https://github.com/you\">GitHub</a>\n<a rel=\"me\" href=\"https://mastodon.social/@you\">Mastodon</a>");
+        }
+
+        if (!$this->rels->has('webmention')) {
+            $this->add('no-webmention', 'tip', 'IndieWeb', 'No Webmention endpoint',
+                'Without one, other sites can’t notify you when they reply to or mention your posts. A hosted service like webmention.io works well.',
+                '<link rel="webmention" href="https://webmention.io/example.com/webmention">');
+        }
+
+        $authorization = $this->rels->has('authorization_endpoint');
+        $token = $this->rels->has('token_endpoint');
+        if (!$this->rels->has('indieauth-metadata') && $authorization !== $token) {
+            $missing = $authorization ? 'token_endpoint' : 'authorization_endpoint';
+            $this->add('indieauth-half', 'warning', 'IndieWeb', "IndieAuth is missing {$missing}",
+                'IndieAuth clients need both endpoints, or a single indieauth-metadata link that lists them.',
+                "<link rel=\"{$missing}\" href=\"https://example.com/auth/{$missing}\">");
+        }
+        if (($authorization || $token) && !$this->rels->has('indieauth-metadata')) {
+            $this->add('indieauth-metadata', 'tip', 'IndieWeb', 'No indieauth-metadata link',
+                'The current IndieAuth spec discovers endpoints through a metadata document. Keep the old links and add the new one.',
+                '<link rel="indieauth-metadata" href="https://example.com/.well-known/oauth-authorization-server">');
+        }
+    }
+
+    private function checkMetadata(): void
+    {
+        $meta = $this->metadata;
+
+        if ($meta->title === null || $meta->title === '') {
+            $this->add('no-title', 'error', 'Metadata', 'No <title>', 'Browsers, search engines and link previews all use it.', '<title>My page</title>');
+        } elseif (mb_strlen($meta->title) > 70) {
+            $this->add('long-title', 'tip', 'Metadata', 'Long title', sprintf('The title has %d characters. Search results and previews cut it around 60 to 70.', mb_strlen($meta->title)));
+        }
+        if ($meta->description === null) {
+            $this->add('no-description', 'warning', 'Metadata', 'No meta description', 'Search engines and link previews show it under the title.', '<meta name="description" content="A sentence about this page.">');
+        }
+        if ($meta->lang === null) {
+            $this->add('no-lang', 'warning', 'Metadata', 'No lang on <html>', 'Screen readers and translators need to know the language.', '<html lang="en">');
+        }
+        if ($meta->charset === null && $this->headerCharset === null) {
+            $this->add('no-charset', 'warning', 'Metadata', 'No character encoding', 'Without it, browsers may guess wrong and break accents and symbols.', '<meta charset="utf-8">');
+        }
+        if ($meta->viewport === null) {
+            $this->add('no-viewport', 'warning', 'Metadata', 'No viewport meta tag', 'Phones will render the page zoomed out at desktop width.', '<meta name="viewport" content="width=device-width, initial-scale=1">');
+        }
+
+        if ($meta->openGraph('og:title') === null) {
+            $this->add('no-og-title', 'tip', 'Metadata', 'No og:title', 'Link previews fall back to the page title, which may carry the site name twice.', '<meta property="og:title" content="My page">');
+        }
+        $image = $meta->openGraph('og:image');
+        if ($image === null) {
+            $this->add('no-og-image', 'tip', 'Metadata', 'No og:image', 'Shared links show a bare text card without it. 1200×630 is a safe size.', '<meta property="og:image" content="https://example.com/card.png">');
+        } elseif (!is_absolute_url($image)) {
+            $this->add('relative-og-image', 'warning', 'Metadata', 'og:image is a relative URL', "Open Graph requires an absolute URL, and many previews ignore “{$image}”.", sprintf('<meta property="og:image" content="%s">', \Mf2\resolveUrl($this->pageUrl ?? 'https://example.com/', $image)));
+        }
+        if ($image !== null && $meta->twitter('twitter:card') === null) {
+            $this->add('no-twitter-card', 'tip', 'Metadata', 'No twitter:card', 'Some apps show a small thumbnail unless you ask for the large image card.', '<meta name="twitter:card" content="summary_large_image">');
+        }
+
+        foreach ($meta->fediverseCreators as $creator) {
+            if (!preg_match('/^@?[^@\s]+@[^@\s]+\.[^@\s]+$/', $creator)) {
+                $this->add('bad-fediverse-creator', 'warning', 'Metadata', 'fediverse:creator isn’t a fediverse handle', "“{$creator}” should look like @you@instance.social.", '<meta name="fediverse:creator" content="@you@mastodon.social">');
+            }
+        }
+        $mastodonLike = array_filter($this->rels->urls('me'), fn (string $url) => preg_match('~^https://[^/]+/@[^/]+/?$~', $url));
+        if (!$meta->fediverseCreators && $mastodonLike) {
+            $handle = preg_replace('~^https://([^/]+)/@([^/]+)/?$~', '@$2@$1', reset($mastodonLike));
+            $this->add('no-fediverse-creator', 'tip', 'Metadata', 'No fediverse:creator',
+                'You link to a fediverse profile with rel=me. With this tag, Mastodon shows your name under links to your posts. Your domain also has to be listed under Author attribution in your Mastodon settings.',
+                sprintf('<meta name="fediverse:creator" content="%s">', $handle));
+        }
+
+        if (!array_filter($meta->icons, fn (array $icon) => $icon['kind'] === 'icon')) {
+            $this->add('no-icon', 'tip', 'Metadata', 'No favicon link', 'Browsers fall back to /favicon.ico. An explicit link lets you use SVG or PNG.', '<link rel="icon" href="/favicon.svg" type="image/svg+xml">');
+        }
+        if (!array_filter($meta->icons, fn (array $icon) => $icon['kind'] === 'apple-touch-icon')) {
+            $this->add('no-touch-icon', 'tip', 'Metadata', 'No apple-touch-icon', 'iOS and some Android launchers use it for home screen shortcuts.', '<link rel="apple-touch-icon" href="/apple-touch-icon.png">');
+        }
+
+        if (!$meta->feeds && !$this->microformats->ofType('h-feed') && !$this->microformats->ofType('h-entry')) {
+            $this->add('no-feed', 'tip', 'Metadata', 'No feed', 'No RSS, Atom, JSON Feed or h-feed found. Readers need one to follow the site.', '<link rel="alternate" type="application/rss+xml" title="RSS" href="/feed.xml">');
+        }
+        foreach ($this->feedChecks as $check) {
+            if (!$check['ok']) {
+                $this->add('broken-feed-' . $check['url'], 'warning', 'Metadata', 'A declared feed has a problem', $check['problem'], null, $check['url']);
+            }
+        }
+    }
+
+    private function cardSnippet(): string
+    {
+        return sprintf(
+            "<div class=\"h-card\">\n  <img class=\"u-photo\" src=\"/me.jpg\" alt=\"\">\n  <a class=\"p-name u-url u-uid\" rel=\"me\" href=\"%s\">Your Name</a>\n  <p class=\"p-note\">A line about you.</p>\n</div>",
+            $this->pageUrl ?? 'https://example.com/',
+        );
+    }
+}
@@ -0,0 +1,31 @@+<?php
+
+namespace Indiechecker;
+
+final class Dates
+{
+    private const DATE = '\d{4}-(\d{2}-\d{2}|\d{3})';
+    private const TIME = '\d{2}:\d{2}(:\d{2}(\.\d+)?)?';
+    private const ZONE = '(Z|[+-]\d{2}:?\d{2})';
+
+    public static function isIso(string $value): bool
+    {
+        $date = self::DATE;
+        $time = self::TIME;
+        $zone = self::ZONE;
+
+        return (bool) preg_match("/^{$date}([T ]{$time}{$zone}?)?$/", trim($value))
+            || (bool) preg_match("/^{$time}{$zone}?$/", trim($value))
+            || (bool) preg_match('/^--\d{2}-\d{2}$/', trim($value));
+    }
+
+    public static function isDuration(string $value): bool
+    {
+        return (bool) preg_match('/^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/', trim($value));
+    }
+
+    public static function hasZone(string $value): bool
+    {
+        return (bool) preg_match('/' . self::TIME . self::ZONE . '$/', trim($value));
+    }
+}
@@ -0,0 +1,70 @@+<?php
+
+namespace Indiechecker;
+
+use DOMDocument;
+use DOMElement;
+use DOMXPath;
+
+final class Document
+{
+    public readonly DOMDocument $dom;
+    public readonly DOMXPath $xpath;
+
+    public function __construct(string $html, public readonly ?string $url, ?string $declaredCharset = null)
+    {
+        $charset = strtoupper($declaredCharset ?? self::sniffCharset($html) ?? 'UTF-8');
+        if ($charset !== 'UTF-8' && in_array($charset, array_map('strtoupper', mb_list_encodings()), true)) {
+            $html = mb_convert_encoding($html, 'UTF-8', $charset);
+        }
+
+        $previous = libxml_use_internal_errors(true);
+        $this->dom = new DOMDocument();
+        $this->dom->loadHTML(
+            mb_encode_numericentity($html, [0x80, 0x10FFFF, 0, 0x1FFFFF], 'UTF-8'),
+            LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET,
+        );
+        libxml_clear_errors();
+        libxml_use_internal_errors($previous);
+
+        $this->xpath = new DOMXPath($this->dom);
+    }
+
+    public function first(string $expression): ?DOMElement
+    {
+        $node = $this->xpath->query($expression)->item(0);
+
+        return $node instanceof DOMElement ? $node : null;
+    }
+
+    public function all(string $expression): array
+    {
+        return array_filter(iterator_to_array($this->xpath->query($expression)), fn ($node) => $node instanceof DOMElement);
+    }
+
+    public function resolve(string $href): string
+    {
+        $base = $this->first('//base[@href]')?->getAttribute('href');
+        $base = $base !== null && $this->url !== null ? \Mf2\resolveUrl($this->url, $base) : ($base ?? $this->url);
+
+        return $base === null ? $href : \Mf2\resolveUrl($base, trim($href));
+    }
+
+    public function snippet(DOMElement $element, int $length = 160): string
+    {
+        $markup = $this->dom->saveHTML($element);
+        $opening = strstr($markup, '>', true);
+
+        return shorten(($opening === false ? $markup : $opening . '>'), $length);
+    }
+
+    private static function sniffCharset(string $html): ?string
+    {
+        $head = substr($html, 0, 2048);
+        if (preg_match('/<meta[^>]+charset\s*=\s*["\']?([\w.:-]+)/i', $head, $match)) {
+            return $match[1];
+        }
+
+        return null;
+    }
+}
@@ -0,0 +1,7 @@+<?php
+
+namespace Indiechecker;
+
+final class FetchError extends \RuntimeException
+{
+}
@@ -0,0 +1,185 @@+<?php
+
+namespace Indiechecker;
+
+use function Mf2\resolveUrl;
+
+final class Fetcher
+{
+    public const USER_AGENT = 'indiechecker/1.0 (microformats and IndieWeb checker)';
+    private const ALLOWED_PORTS = [80, 443, 8080, 8443];
+    private const PROBE_TIMEOUT = 8;
+
+    public function __construct(
+        private int $maxBytes = 2_000_000,
+        private int $timeout = 20,
+        private int $maxRedirects = 5,
+    ) {
+    }
+
+    public static function normalize(string $input): string
+    {
+        $url = trim($input);
+        if ($url === '') {
+            throw new FetchError('Enter a URL to check.');
+        }
+        if (!preg_match('~^[a-z][a-z0-9+.-]*://~i', $url)) {
+            $url = 'https://' . $url;
+        }
+
+        $parts = parse_url($url);
+        if ($parts === false || empty($parts['host'])) {
+            throw new FetchError('That doesn’t look like a URL.');
+        }
+        if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
+            throw new FetchError('Only http and https URLs can be checked.');
+        }
+        if (isset($parts['user']) || isset($parts['pass'])) {
+            throw new FetchError('URLs with a username or password can’t be checked.');
+        }
+
+        return $url;
+    }
+
+    public function get(string $url, string $accept = 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.5'): Response
+    {
+        return $this->follow($url, $accept, true);
+    }
+
+    public function probe(string $url, string $accept): Response
+    {
+        return $this->follow($url, $accept, false);
+    }
+
+    private function follow(string $url, string $accept, bool $readBody): Response
+    {
+        $started = hrtime(true);
+        $redirects = [];
+        $current = self::normalize($url);
+
+        for ($hop = 0; ; $hop++) {
+            [$status, $headers, $body] = $this->request($current, $accept, $readBody);
+            $location = $headers['location'][0] ?? null;
+
+            if ($status < 300 || $status >= 400 || $location === null) {
+                return new Response(
+                    $url,
+                    $current,
+                    $status,
+                    $headers,
+                    $body,
+                    $redirects,
+                    intdiv(hrtime(true) - $started, 1_000_000),
+                );
+            }
+
+            if ($hop >= $this->maxRedirects) {
+                throw new FetchError("Gave up after {$this->maxRedirects} redirects.");
+            }
+
+            $redirects[] = ['url' => $current, 'status' => $status];
+            $current = self::normalize(resolveUrl($current, $location));
+        }
+    }
+
+    private function request(string $url, string $accept, bool $readBody): array
+    {
+        $parts = parse_url($url);
+        $host = trim($parts['host'], '[]');
+        $port = $parts['port'] ?? (strtolower($parts['scheme']) === 'https' ? 443 : 80);
+
+        if (!in_array($port, self::ALLOWED_PORTS, true)) {
+            throw new FetchError("Port {$port} is not allowed. Use 80, 443, 8080 or 8443.");
+        }
+
+        $address = $this->publicAddress($host);
+        $headers = [];
+        $body = '';
+        $tooLarge = false;
+        $probed = false;
+
+        $handle = curl_init($url);
+        curl_setopt_array($handle, [
+            CURLOPT_RESOLVE => ["{$host}:{$port}:" . (str_contains($address, ':') ? "[{$address}]" : $address)],
+            CURLOPT_FOLLOWLOCATION => false,
+            CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
+            CURLOPT_CONNECTTIMEOUT => 5,
+            CURLOPT_TIMEOUT => $readBody ? $this->timeout : self::PROBE_TIMEOUT,
+            CURLOPT_USERAGENT => self::USER_AGENT,
+            CURLOPT_HTTPHEADER => ['Accept: ' . $accept],
+            CURLOPT_ENCODING => '',
+            CURLOPT_HEADERFUNCTION => function ($handle, string $line) use (&$headers) {
+                if (str_starts_with($line, 'HTTP/')) {
+                    $headers = [];
+                } elseif (str_contains($line, ':')) {
+                    [$name, $value] = explode(':', $line, 2);
+                    $headers[strtolower(trim($name))][] = trim($value);
+                }
+
+                return strlen($line);
+            },
+            CURLOPT_WRITEFUNCTION => function ($handle, string $chunk) use (&$body, &$tooLarge, &$probed, $readBody) {
+                if (!$readBody) {
+                    $probed = true;
+
+                    return 0;
+                }
+                if (strlen($body) + strlen($chunk) > $this->maxBytes) {
+                    $tooLarge = true;
+
+                    return 0;
+                }
+                $body .= $chunk;
+
+                return strlen($chunk);
+            },
+        ]);
+
+        $finished = curl_exec($handle);
+
+        if ($tooLarge) {
+            throw new FetchError(sprintf('%s is bigger than %s MB, the most this checker reads.', $url, round($this->maxBytes / 1_000_000, 1)));
+        }
+        if ($finished === false && !$probed) {
+            throw new FetchError("Couldn’t fetch {$url}: " . curl_error($handle));
+        }
+
+        return [curl_getinfo($handle, CURLINFO_RESPONSE_CODE), $headers, $body];
+    }
+
+    private function publicAddress(string $host): string
+    {
+        if (filter_var($host, FILTER_VALIDATE_IP)) {
+            $addresses = [$host];
+        } else {
+            $addresses = [];
+            foreach (@dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) {
+                $addresses[] = $record['ip'] ?? $record['ipv6'];
+            }
+            if (!$addresses) {
+                $addresses = gethostbynamel($host) ?: [];
+            }
+        }
+
+        if (!$addresses) {
+            throw new FetchError("Couldn’t find {$host}. Check the spelling of the domain.");
+        }
+
+        foreach ($addresses as $address) {
+            if (!self::isPublic($address)) {
+                throw new FetchError("{$host} points to a private or reserved address, so it can’t be checked from here. Paste the HTML instead.");
+            }
+        }
+
+        usort($addresses, fn (string $a, string $b) => str_contains($a, ':') <=> str_contains($b, ':'));
+
+        return $addresses[0];
+    }
+
+    private static function isPublic(string $address): bool
+    {
+        $flags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE;
+
+        return filter_var($address, FILTER_VALIDATE_IP, $flags) !== false;
+    }
+}
@@ -0,0 +1,194 @@+<?php
+
+namespace Indiechecker;
+
+final class Metadata
+{
+    private const FEED_TYPES = [
+        'application/rss+xml' => 'RSS',
+        'application/atom+xml' => 'Atom',
+        'application/feed+json' => 'JSON Feed',
+        'application/json' => 'JSON Feed',
+        'application/rdf+xml' => 'RDF',
+        'text/xml' => 'XML',
+        'application/xml' => 'XML',
+    ];
+
+    public readonly ?string $title;
+    public readonly ?string $description;
+    public readonly ?string $lang;
+    public readonly ?string $charset;
+    public readonly ?string $viewport;
+    public readonly ?string $canonical;
+    public readonly ?string $generator;
+    public readonly ?string $robots;
+    public readonly array $themeColors;
+    public readonly array $openGraph;
+    public readonly array $twitter;
+    public readonly array $fediverseCreators;
+    public readonly array $feeds;
+    public readonly array $icons;
+    public readonly ?string $manifest;
+
+    public function __construct(private Document $document)
+    {
+        $title = $document->first('//head/title') ?? $document->first('//title');
+        $this->title = $title ? shorten($title->textContent, 500) : null;
+        $this->description = $this->meta('description');
+        $this->lang = $document->first('//html[@lang]')?->getAttribute('lang');
+        $this->charset = $document->first('//meta[@charset]')?->getAttribute('charset')
+            ?? $this->httpEquivCharset();
+        $this->viewport = $this->meta('viewport');
+        $canonical = $document->first('//link[@rel and @href][contains(concat(" ", normalize-space(@rel), " "), " canonical ")]');
+        $this->canonical = $canonical ? $document->resolve($canonical->getAttribute('href')) : null;
+        $this->generator = $this->meta('generator');
+        $this->robots = $this->meta('robots');
+
+        $themeColors = [];
+        foreach ($document->all('//meta[translate(@name, "THEMCOLR", "themcolr")="theme-color"][@content]') as $meta) {
+            $themeColors[] = ['color' => trim($meta->getAttribute('content')), 'media' => $meta->getAttribute('media') ?: null];
+        }
+        $this->themeColors = $themeColors;
+
+        $this->openGraph = $this->prefixed('og:', 'property');
+        $this->twitter = $this->prefixed('twitter:', 'name') + $this->prefixed('twitter:', 'property');
+        $this->fediverseCreators = array_map(
+            fn (\DOMElement $meta) => trim($meta->getAttribute('content')),
+            $document->all('//meta[@name="fediverse:creator"][@content]'),
+        );
+
+        $this->feeds = $this->findFeeds();
+        $this->icons = $this->findIcons();
+        $manifest = $document->first('//link[@href][contains(concat(" ", normalize-space(translate(@rel, "MANIFEST", "manifest")), " "), " manifest ")]');
+        $this->manifest = $manifest ? $document->resolve($manifest->getAttribute('href')) : null;
+    }
+
+    public function openGraph(string $property): ?string
+    {
+        return $this->openGraph[$property][0] ?? null;
+    }
+
+    public function twitter(string $name): ?string
+    {
+        return $this->twitter[$name][0] ?? null;
+    }
+
+    public function preview(): array
+    {
+        $image = $this->openGraph('og:image') ?? $this->openGraph('og:image:url') ?? $this->twitter('twitter:image');
+
+        return [
+            'title' => $this->openGraph('og:title') ?? $this->twitter('twitter:title') ?? $this->title,
+            'description' => $this->openGraph('og:description') ?? $this->twitter('twitter:description') ?? $this->description,
+            'image' => $image !== null ? $this->document->resolve($image) : null,
+            'imageAlt' => $this->openGraph('og:image:alt') ?? $this->twitter('twitter:image:alt'),
+            'site' => $this->openGraph('og:site_name'),
+            'host' => $this->document->url ? parse_url($this->canonical ?? $this->document->url, PHP_URL_HOST) : null,
+        ];
+    }
+
+    public function toArray(): array
+    {
+        return [
+            'title' => $this->title,
+            'description' => $this->description,
+            'lang' => $this->lang,
+            'charset' => $this->charset,
+            'viewport' => $this->viewport,
+            'canonical' => $this->canonical,
+            'generator' => $this->generator,
+            'robots' => $this->robots,
+            'theme-color' => $this->themeColors,
+            'open-graph' => $this->openGraph,
+            'twitter' => $this->twitter,
+            'fediverse:creator' => $this->fediverseCreators,
+            'feeds' => $this->feeds,
+            'icons' => $this->icons,
+            'manifest' => $this->manifest,
+        ];
+    }
+
+    private function meta(string $name): ?string
+    {
+        $meta = $this->document->first(sprintf(
+            '//meta[translate(@name, "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "abcdefghijklmnopqrstuvwxyz")="%s"][@content]',
+            $name,
+        ));
+
+        return $meta ? trim($meta->getAttribute('content')) : null;
+    }
+
+    private function httpEquivCharset(): ?string
+    {
+        $meta = $this->document->first('//meta[translate(@http-equiv, "CONTENT-TYPE", "content-type")="content-type"][@content]');
+        if ($meta && preg_match('/charset\s*=\s*([\w.:-]+)/i', $meta->getAttribute('content'), $match)) {
+            return $match[1];
+        }
+
+        return null;
+    }
+
+    private function prefixed(string $prefix, string $attribute): array
+    {
+        $values = [];
+        foreach ($this->document->all("//meta[starts-with(@{$attribute}, '{$prefix}')][@content]") as $meta) {
+            $values[$meta->getAttribute($attribute)][] = trim($meta->getAttribute('content'));
+        }
+
+        return $values;
+    }
+
+    private function findFeeds(): array
+    {
+        $feeds = [];
+        $links = $this->document->all('//link[@href][contains(concat(" ", normalize-space(translate(@rel, "ALTERNATE", "alternate")), " "), " alternate ")]');
+        foreach ($links as $link) {
+            $type = strtolower(trim($link->getAttribute('type')));
+            if (!isset(self::FEED_TYPES[$type])) {
+                continue;
+            }
+            $feeds[] = [
+                'url' => $this->document->resolve($link->getAttribute('href')),
+                'format' => self::FEED_TYPES[$type],
+                'type' => $type,
+                'title' => $link->getAttribute('title') ?: null,
+            ];
+        }
+
+        foreach ($this->document->all('//link[@href][contains(concat(" ", normalize-space(@rel), " "), " feed ")]') as $link) {
+            $feeds[] = [
+                'url' => $this->document->resolve($link->getAttribute('href')),
+                'format' => 'h-feed (rel=feed)',
+                'type' => 'text/html',
+                'title' => $link->getAttribute('title') ?: null,
+            ];
+        }
+
+        return $feeds;
+    }
+
+    private function findIcons(): array
+    {
+        $icons = [];
+        foreach ($this->document->all('//link[@rel and @href]') as $link) {
+            $rels = class_tokens(strtolower($link->getAttribute('rel')));
+            $kind = match (true) {
+                in_array('apple-touch-icon', $rels, true), in_array('apple-touch-icon-precomposed', $rels, true) => 'apple-touch-icon',
+                in_array('mask-icon', $rels, true) => 'mask-icon',
+                in_array('icon', $rels, true) => 'icon',
+                default => null,
+            };
+            if ($kind === null) {
+                continue;
+            }
+            $icons[] = [
+                'kind' => $kind,
+                'url' => $this->document->resolve($link->getAttribute('href')),
+                'sizes' => $link->getAttribute('sizes') ?: null,
+                'type' => $link->getAttribute('type') ?: null,
+            ];
+        }
+
+        return $icons;
+    }
+}
@@ -0,0 +1,59 @@+<?php
+
+namespace Indiechecker;
+
+use DOMElement;
+use Mf2\Parser;
+
+final class Mf2Parser extends Parser
+{
+    public const CLASSIC_KEY = 'x-indiechecker-classic';
+    private const CLASSIC_ATTRIBUTE = 'data-indiechecker-classic';
+
+    public function __construct(\DOMDocument $document, ?string $url)
+    {
+        parent::__construct($document, $url);
+
+        $this->classicRootMap += [
+            'hnews' => 'h-entry',
+            'hreview-aggregate' => 'h-review-aggregate',
+            'geo' => 'h-geo',
+        ];
+
+        $this->classicPropertyMap['hnews'] = $this->classicPropertyMap['hentry'] + [
+            'source-org' => ['replace' => 'p-source-org h-card', 'context' => 'vcard'],
+            'dateline' => ['replace' => 'p-dateline h-card', 'context' => 'vcard'],
+        ];
+
+        $this->classicPropertyMap['hreview-aggregate'] = [
+            'summary' => ['replace' => 'p-name'],
+            'item' => ['replace' => 'p-item h-item', 'context' => 'item'],
+            'rating' => ['replace' => 'p-rating'],
+            'average' => ['replace' => 'p-average'],
+            'best' => ['replace' => 'p-best'],
+            'worst' => ['replace' => 'p-worst'],
+            'count' => ['replace' => 'p-count'],
+            'votes' => ['replace' => 'p-votes'],
+            'category' => ['replace' => 'p-category'],
+        ];
+
+        foreach ($this->xpath->query('//*[@class]') as $element) {
+            $classes = class_tokens($element->getAttribute('class'));
+            $classic = array_intersect($classes, array_keys($this->classicRootMap));
+            if ($classic && !preg_grep('/^h-[a-z0-9-]*[a-z]$/', $classes)) {
+                $element->setAttribute(self::CLASSIC_ATTRIBUTE, implode(' ', $classic));
+            }
+        }
+    }
+
+    public function parseH(DOMElement $e, $is_backcompat = false, $has_nested_mf = false)
+    {
+        $result = parent::parseH($e, $is_backcompat, $has_nested_mf);
+
+        if ($result && $e->hasAttribute(self::CLASSIC_ATTRIBUTE)) {
+            $result[self::CLASSIC_KEY] = explode(' ', $e->getAttribute(self::CLASSIC_ATTRIBUTE));
+        }
+
+        return $result;
+    }
+}
@@ -0,0 +1,240 @@+<?php
+
+namespace Indiechecker;
+
+final class Microformats
+{
+    private const ROOT = '/^h(-[a-z0-9]+)?(-[a-z]+)+$/';
+    private const PROPERTY = '/^(p|u|dt|e)-([a-z0-9]+-)?[a-z]+(-[a-z]+)*$/';
+    private const MISTAKES = [
+        'hcard' => 'h-card (or the classic vcard)',
+        'h-vcard' => 'h-card',
+        'hcalendar' => 'h-event (or the classic vevent)',
+        'h-vevent' => 'h-event',
+        'h-calendar' => 'h-event',
+        'hatom' => 'h-entry (or the classic hentry)',
+        'h-atom' => 'h-entry',
+        'h-hentry' => 'h-entry',
+        'h-post' => 'h-entry',
+        'h-article' => 'h-entry',
+        'h-person' => 'h-card',
+        'h-address' => 'h-adr',
+        'p-title' => 'p-name (or p-job-title on an h-card)',
+        'e-summary' => 'p-summary',
+        'u-link' => 'u-url',
+        'dt-date' => 'dt-published',
+        'dt-created' => 'dt-published',
+        'dt-modified' => 'dt-updated',
+    ];
+
+    public readonly array $parsed;
+    public readonly array $all;
+    public readonly array $classic;
+    public readonly array $voteLinks;
+    public readonly array $orphans;
+    public readonly array $mistakes;
+    public readonly ?array $representativeCard;
+
+    public function __construct(private Document $document)
+    {
+        $parser = new Mf2Parser($document->dom, $document->url);
+        $this->parsed = $parser->parse();
+
+        $all = [];
+        foreach ($this->parsed['items'] as $index => $item) {
+            $this->flatten($item, [], $item['type'][0] . '[' . ($index + 1) . ']', null, $all);
+        }
+        $this->all = $all;
+
+        [$this->classic, $this->orphans, $this->mistakes] = $this->scanClasses();
+        $this->voteLinks = $this->scanVoteLinks();
+        $this->representativeCard = $this->findRepresentativeCard();
+    }
+
+    public function canonical(): array
+    {
+        return self::strip($this->parsed);
+    }
+
+    public function isEmpty(): bool
+    {
+        return $this->all === [];
+    }
+
+    public function counts(): array
+    {
+        $counts = [];
+        foreach ($this->all as $found) {
+            foreach ($found['item']['type'] as $type) {
+                $counts[$type] = ($counts[$type] ?? 0) + 1;
+            }
+        }
+        arsort($counts);
+
+        return $counts;
+    }
+
+    public function ofType(string $type): array
+    {
+        return array_values(array_filter($this->all, fn (array $found) => in_array($type, $found['item']['type'], true)));
+    }
+
+    public function authorOf(array $found): ?array
+    {
+        if (!empty($found['item']['properties']['author'])) {
+            return ['source' => 'author property', 'author' => $found['item']['properties']['author'][0]];
+        }
+
+        foreach (array_reverse($found['ancestors']) as $ancestor) {
+            if (in_array('h-feed', $ancestor['type'], true) && !empty($ancestor['properties']['author'])) {
+                return ['source' => 'inherited from the parent h-feed', 'author' => $ancestor['properties']['author'][0]];
+            }
+        }
+
+        if (!empty($this->parsed['rels']['author'])) {
+            return ['source' => 'rel=author link', 'author' => $this->parsed['rels']['author'][0]];
+        }
+
+        return null;
+    }
+
+    private function flatten(array $item, array $ancestors, string $path, ?string $property, array &$all): void
+    {
+        $all[] = ['item' => $item, 'ancestors' => $ancestors, 'path' => $path, 'property' => $property];
+        $ancestors[] = $item;
+
+        foreach ($item['properties'] as $name => $values) {
+            foreach ($values as $value) {
+                if (is_array($value) && isset($value['type'])) {
+                    $this->flatten($value, $ancestors, "{$path} › {$name}", $name, $all);
+                }
+            }
+        }
+
+        foreach ($item['children'] ?? [] as $index => $child) {
+            $this->flatten($child, $ancestors, "{$path} › {$child['type'][0]}[" . ($index + 1) . ']', null, $all);
+        }
+    }
+
+    private function scanClasses(): array
+    {
+        $classic = [];
+        $orphans = [];
+        $mistakes = [];
+
+        foreach ($this->document->all('//*[@class]') as $element) {
+            $classes = class_tokens($element->getAttribute('class'));
+            $hasRoot = (bool) preg_grep(self::ROOT, $classes);
+
+            foreach ($classes as $class) {
+                if (isset(Vocabulary::CLASSIC_ROOTS[$class])) {
+                    $classic[$class] ??= ['count' => 0, 'alongsideMf2' => 0, 'example' => $this->document->snippet($element)];
+                    $classic[$class]['count']++;
+                    $classic[$class]['alongsideMf2'] += $hasRoot ? 1 : 0;
+                }
+
+                if (isset(self::MISTAKES[$class])) {
+                    $mistakes[] = ['class' => $class, 'use' => self::MISTAKES[$class], 'snippet' => $this->document->snippet($element)];
+                } elseif (preg_match('/^(h|p|u|dt|e)-/i', $class) && $class !== strtolower($class)) {
+                    $mistakes[] = ['class' => $class, 'use' => strtolower($class) . ' (class names are case-sensitive and microformats are lowercase)', 'snippet' => $this->document->snippet($element)];
+                }
+
+                if (preg_match(self::PROPERTY, $class) && !$this->insideRoot($element)) {
+                    $orphans[] = ['class' => $class, 'snippet' => $this->document->snippet($element)];
+                }
+            }
+        }
+
+        return [$classic, $orphans, $mistakes];
+    }
+
+    private function insideRoot(\DOMElement $element): bool
+    {
+        for ($parent = $element->parentNode; $parent instanceof \DOMElement; $parent = $parent->parentNode) {
+            if (preg_grep(self::ROOT, class_tokens($parent->getAttribute('class')))) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    private function scanVoteLinks(): array
+    {
+        $votes = [];
+        foreach ($this->document->all('//a[@rev and @href]') as $link) {
+            foreach (class_tokens($link->getAttribute('rev')) as $rev) {
+                if (in_array($rev, ['vote-for', 'vote-against', 'vote-abstain'], true)) {
+                    $votes[] = ['vote' => $rev, 'url' => $this->document->resolve($link->getAttribute('href')), 'text' => shorten($link->textContent, 80)];
+                }
+            }
+        }
+
+        return $votes;
+    }
+
+    private function findRepresentativeCard(): ?array
+    {
+        $cards = [];
+        $seen = [];
+        foreach ($this->ofType('h-card') as $found) {
+            $card = $found['item'];
+            unset($card['value']);
+            $key = json_encode([$found['ancestors'], $card], JSON_INVALID_UTF8_SUBSTITUTE);
+            $property = $found['property'] ?? '';
+            $alias = isset($seen[$key]) && !isset($seen[$key][$property]);
+            if (!$alias) {
+                $cards[] = $found['item'];
+            }
+            $seen[$key][$property] = true;
+        }
+
+        $page = $this->document->url;
+        $relMe = array_map(self::comparable(...), $this->parsed['rels']['me'] ?? []);
+
+        foreach ($cards as $card) {
+            if ($page !== null
+                && array_intersect(self::urls($card, 'uid'), [self::comparable($page)])
+                && array_intersect(self::urls($card, 'url'), [self::comparable($page)])) {
+                return ['card' => $card, 'reason' => 'its uid and url both match the page URL'];
+            }
+        }
+
+        foreach ($cards as $card) {
+            if (array_intersect(self::urls($card, 'url'), $relMe)) {
+                return ['card' => $card, 'reason' => 'its url is also a rel=me link'];
+            }
+        }
+
+        if ($page !== null && count($cards) === 1 && in_array(self::comparable($page), self::urls($cards[0], 'url'), true)) {
+            return ['card' => $cards[0], 'reason' => 'it is the only h-card and its url matches the page URL'];
+        }
+
+        return null;
+    }
+
+    private static function urls(array $item, string $property): array
+    {
+        return array_map(
+            fn ($value) => self::comparable(is_array($value) ? ($value['value'] ?? '') : $value),
+            $item['properties'][$property] ?? [],
+        );
+    }
+
+    private static function comparable(string $url): string
+    {
+        return rtrim(preg_replace('~^https?://~i', '', strtolower(trim($url))), '/');
+    }
+
+    private static function strip(array $data): array
+    {
+        unset($data[Mf2Parser::CLASSIC_KEY]);
+        foreach ($data as $key => $value) {
+            if (is_array($value)) {
+                $data[$key] = self::strip($value);
+            }
+        }
+
+        return $data;
+    }
+}
@@ -0,0 +1,104 @@+<?php
+
+namespace Indiechecker;
+
+use function Mf2\resolveUrl;
+
+final class Rels
+{
+    public readonly array $fromHtml;
+    public readonly array $fromHeaders;
+    public readonly array $relUrls;
+
+    public function __construct(array $parsed, ?Response $response)
+    {
+        $this->fromHtml = $parsed['rels'];
+        $this->relUrls = $parsed['rel-urls'];
+        $this->fromHeaders = $response ? self::parseLinkHeaders($response->headerValues('link'), $response->url) : [];
+    }
+
+    public function endpoint(string $rel): ?array
+    {
+        if (isset($this->fromHeaders[$rel][0])) {
+            return ['url' => $this->fromHeaders[$rel][0], 'source' => 'HTTP Link header'];
+        }
+        if (isset($this->fromHtml[$rel][0])) {
+            return ['url' => $this->fromHtml[$rel][0], 'source' => 'HTML'];
+        }
+
+        return null;
+    }
+
+    public function urls(string $rel): array
+    {
+        return array_values(array_unique([...($this->fromHeaders[$rel] ?? []), ...($this->fromHtml[$rel] ?? [])]));
+    }
+
+    public function has(string $rel): bool
+    {
+        return $this->urls($rel) !== [];
+    }
+
+    public function count(): int
+    {
+        return count(array_unique([...array_keys($this->fromHtml), ...array_keys($this->fromHeaders)]));
+    }
+
+    public function grouped(): array
+    {
+        $groups = [];
+        foreach ([...array_keys($this->fromHeaders), ...array_keys($this->fromHtml)] as $rel) {
+            $group = Vocabulary::relGroup($rel);
+            if (isset($groups[$group][$rel])) {
+                continue;
+            }
+
+            $links = [];
+            foreach ($this->fromHeaders[$rel] ?? [] as $url) {
+                $links[] = ['url' => $url, 'source' => 'Link header', 'text' => null];
+            }
+            foreach ($this->fromHtml[$rel] ?? [] as $url) {
+                $links[] = ['url' => $url, 'source' => 'HTML', 'text' => $this->relUrls[$url]['text'] ?? $this->relUrls[$url]['title'] ?? null];
+            }
+
+            $groups[$group][$rel] = ['about' => Vocabulary::relAbout($rel), 'links' => $links];
+        }
+
+        $order = [...array_keys(Vocabulary::REL_GROUPS), 'XFN', 'Other'];
+        uksort($groups, fn (string $a, string $b) => array_search($a, $order, true) <=> array_search($b, $order, true));
+
+        return $groups;
+    }
+
+    public function xfn(): array
+    {
+        $people = [];
+        foreach ($this->relUrls as $url => $info) {
+            $relations = array_values(array_intersect($info['rels'], array_diff(Vocabulary::XFN, ['me'])));
+            if ($relations) {
+                $people[] = ['url' => $url, 'text' => $info['text'] ?? null, 'relations' => $relations];
+            }
+        }
+
+        return $people;
+    }
+
+    private static function parseLinkHeaders(array $headers, string $base): array
+    {
+        $rels = [];
+        foreach ($headers as $header) {
+            preg_match_all('/<([^>]*)>((?:\s*;\s*[^;,]+(?:="[^"]*"|=[^;,]*)?)*)/', $header, $links, PREG_SET_ORDER);
+            foreach ($links as [, $target, $parameters]) {
+                if (!preg_match('/;\s*rel\s*=\s*(?:"([^"]*)"|([^;,\s]+))/i', $parameters, $rel)) {
+                    continue;
+                }
+                $url = resolveUrl($base, trim($target));
+                foreach (class_tokens(strtolower(($rel[1] ?? '') !== '' ? $rel[1] : ($rel[2] ?? ''))) as $name) {
+                    $rels[$name][] = $url;
+                }
+            }
+        }
+
+        return $rels;
+    }
+}
@@ -0,0 +1,193 @@+<?php
+
+namespace Indiechecker;
+
+final class Report
+{
+    private const HTML_TYPES = ['text/html', 'application/xhtml+xml'];
+    private const MAX_FEED_CHECKS = 4;
+    private const MAX_PASTE_BYTES = 2_000_000;
+
+    public readonly Document $document;
+    public readonly Microformats $microformats;
+    public readonly Rels $rels;
+    public readonly Metadata $metadata;
+    public readonly array $feedChecks;
+    public readonly Advice $advice;
+
+    private function __construct(public readonly ?Response $response, string $html, ?string $url, Fetcher $fetcher)
+    {
+        $this->document = new Document($html, $url, $response?->charset());
+        $this->microformats = new Microformats($this->document);
+        $this->rels = new Rels($this->microformats->parsed, $response);
+        $this->metadata = new Metadata($this->document);
+        $this->feedChecks = $this->checkFeeds($fetcher);
+        $this->advice = new Advice($this->microformats, $this->rels, $this->metadata, $this->feedChecks, $url, $response?->charset());
+    }
+
+    public static function fromUrl(string $url, Fetcher $fetcher): self
+    {
+        $response = $fetcher->get($url);
+
+        if ($response->status >= 400) {
+            throw new FetchError("{$response->url} answered with HTTP {$response->status}.");
+        }
+        $type = $response->contentType();
+        if ($type !== '' && !in_array($type, self::HTML_TYPES, true)) {
+            throw new FetchError("{$response->url} is {$type}, not an HTML page.");
+        }
+
+        return new self($response, $response->body, $response->url, $fetcher);
+    }
+
+    public static function fromHtml(string $html, ?string $baseUrl, Fetcher $fetcher): self
+    {
+        if (trim($html) === '') {
+            throw new FetchError('Paste some HTML to check.');
+        }
+        if (strlen($html) > self::MAX_PASTE_BYTES) {
+            throw new FetchError('That HTML is bigger than 2 MB, the most this checker reads.');
+        }
+
+        $baseUrl = trim((string) $baseUrl) === '' ? null : Fetcher::normalize($baseUrl);
+
+        return new self(null, $html, $baseUrl, $fetcher);
+    }
+
+    public function url(): ?string
+    {
+        return $this->document->url;
+    }
+
+    public function indieWeb(): array
+    {
+        $mf = $this->microformats;
+        $card = $mf->representativeCard;
+        $relMe = $this->rels->urls('me');
+        $entries = $mf->ofType('h-entry');
+        $authored = array_filter($entries, fn (array $found) => $mf->authorOf($found) !== null);
+        $indieAuth = $this->rels->endpoint('indieauth-metadata');
+        $authorization = $this->rels->endpoint('authorization_endpoint');
+        $token = $this->rels->endpoint('token_endpoint');
+
+        return [
+            [
+                'label' => 'Representative h-card',
+                'ok' => $card !== null,
+                'detail' => $card ? 'Found: ' . $card['reason'] . '.' : 'No h-card clearly represents this page.',
+            ],
+            [
+                'label' => 'rel=me links',
+                'ok' => $relMe !== [],
+                'detail' => $relMe ? count($relMe) . ' profile link(s).' : 'None found.',
+                'links' => $relMe,
+            ],
+            $this->endpointCheck('Webmention endpoint', 'webmention'),
+            [
+                'label' => 'IndieAuth',
+                'ok' => $indieAuth !== null || ($authorization && $token),
+                'detail' => match (true) {
+                    $indieAuth !== null => 'Metadata endpoint found in the ' . $indieAuth['source'] . '.',
+                    $authorization && $token => 'Authorization and token endpoints found (older discovery).',
+                    $authorization || $token => 'Only one of the two endpoints is there.',
+                    default => 'No IndieAuth endpoints.',
+                },
+                'links' => array_filter([$indieAuth['url'] ?? null, $authorization['url'] ?? null, $token['url'] ?? null]),
+            ],
+            $this->endpointCheck('Micropub endpoint', 'micropub'),
+            $this->endpointCheck('Microsub endpoint', 'microsub'),
+            $this->endpointCheck('WebSub hub', 'hub'),
+            [
+                'label' => 'Posts with an author',
+                'ok' => $entries !== [] && count($authored) === count($entries),
+                'detail' => $entries
+                    ? sprintf('%d of %d h-entry post(s) have an author.', count($authored), count($entries))
+                    : 'No h-entry posts on this page.',
+            ],
+        ];
+    }
+
+    public function toArray(): array
+    {
+        $canonical = $this->microformats->canonical();
+        $canonical['rels'] = $canonical['rels'] ?: new \stdClass();
+        $canonical['rel-urls'] = $canonical['rel-urls'] ?: new \stdClass();
+
+        return [
+            'url' => $this->url(),
+            'fetch' => $this->response ? [
+                'requested' => $this->response->requestedUrl,
+                'final' => $this->response->url,
+                'status' => $this->response->status,
+                'content-type' => $this->response->header('content-type'),
+                'bytes' => strlen($this->response->body),
+                'milliseconds' => $this->response->milliseconds,
+                'redirects' => $this->response->redirects,
+            ] : null,
+            'summary' => [
+                'types' => $this->microformats->counts() ?: new \stdClass(),
+                'classic' => $this->microformats->classic ?: new \stdClass(),
+                'representative-h-card' => $this->microformats->representativeCard['card']['properties']['url'][0] ?? null,
+                'errors' => $this->advice->count('error'),
+                'warnings' => $this->advice->count('warning'),
+                'tips' => $this->advice->count('tip'),
+            ],
+            'mf2' => $canonical,
+            'indieweb' => $this->indieWeb(),
+            'rels' => [
+                'html' => $this->rels->fromHtml ?: new \stdClass(),
+                'link-header' => $this->rels->fromHeaders ?: new \stdClass(),
+                'xfn' => $this->rels->xfn(),
+                'vote-links' => $this->microformats->voteLinks,
+            ],
+            'metadata' => $this->metadata->toArray(),
+            'feed-checks' => $this->feedChecks,
+            'advice' => $this->advice->all(),
+        ];
+    }
+
+    private function endpointCheck(string $label, string $rel): array
+    {
+        $endpoint = $this->rels->endpoint($rel);
+
+        return [
+            'label' => $label,
+            'ok' => $endpoint !== null,
+            'detail' => $endpoint ? 'Found in the ' . $endpoint['source'] . '.' : 'None found.',
+            'links' => $endpoint ? [$endpoint['url']] : [],
+        ];
+    }
+
+    private function checkFeeds(Fetcher $fetcher): array
+    {
+        $checks = [];
+        $feeds = array_filter($this->metadata->feeds, fn (array $feed) => is_absolute_url($feed['url']));
+
+        foreach (array_slice($feeds, 0, self::MAX_FEED_CHECKS) as $feed) {
+            try {
+                $response = $fetcher->probe($feed['url'], 'application/rss+xml, application/atom+xml, application/feed+json, application/xml;q=0.9, text/html;q=0.8, */*;q=0.5');
+            } catch (FetchError $error) {
+                $checks[] = ['url' => $feed['url'], 'ok' => false, 'problem' => $error->getMessage()];
+                continue;
+            }
+
+            $type = $response->contentType();
+            $problem = match (true) {
+                $response->status >= 400 => "It answered with HTTP {$response->status}.",
+                $feed['type'] === 'text/html' && !in_array($type, self::HTML_TYPES, true) => "It’s served as {$type}, not HTML.",
+                $feed['type'] !== 'text/html' && !preg_match('/xml|rss|atom|json/', $type) => "It’s served as “{$type}”, which feed readers may reject.",
+                default => null,
+            };
+
+            $checks[] = [
+                'url' => $feed['url'],
+                'ok' => $problem === null,
+                'problem' => $problem,
+                'status' => $response->status,
+                'content-type' => $type,
+            ];
+        }
+
+        return $checks;
+    }
+}
@@ -0,0 +1,43 @@+<?php
+
+namespace Indiechecker;
+
+final class Response
+{
+    public function __construct(
+        public readonly string $requestedUrl,
+        public readonly string $url,
+        public readonly int $status,
+        public readonly array $headers,
+        public readonly string $body,
+        public readonly array $redirects,
+        public readonly int $milliseconds,
+    ) {
+    }
+
+    public function header(string $name): ?string
+    {
+        $values = $this->headers[strtolower($name)] ?? [];
+
+        return $values ? end($values) : null;
+    }
+
+    public function headerValues(string $name): array
+    {
+        return $this->headers[strtolower($name)] ?? [];
+    }
+
+    public function contentType(): string
+    {
+        return strtolower(trim(explode(';', $this->header('content-type') ?? '')[0]));
+    }
+
+    public function charset(): ?string
+    {
+        if (preg_match('/charset\s*=\s*"?([\w.:-]+)/i', $this->header('content-type') ?? '', $match)) {
+            return $match[1];
+        }
+
+        return null;
+    }
+}
@@ -0,0 +1,336 @@+<?php
+
+namespace Indiechecker;
+
+final class Vocabulary
+{
+    private const WIKI = 'https://microformats.org/wiki/';
+
+    public const TYPES = [
+        'h-card' => [
+            'about' => 'A person, organization or venue.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'honorific-prefix' => 'p', 'given-name' => 'p', 'additional-name' => 'p',
+                'family-name' => 'p', 'sort-string' => 'p', 'honorific-suffix' => 'p', 'nickname' => 'p',
+                'email' => 'u', 'logo' => 'u', 'photo' => 'u', 'url' => 'u', 'uid' => 'u', 'category' => 'p',
+                'adr' => 'p', 'post-office-box' => 'p', 'extended-address' => 'p', 'street-address' => 'p',
+                'locality' => 'p', 'region' => 'p', 'postal-code' => 'p', 'country-name' => 'p', 'label' => 'p',
+                'geo' => 'p', 'latitude' => 'p', 'longitude' => 'p', 'altitude' => 'p', 'tel' => 'p', 'note' => 'p',
+                'bday' => 'dt', 'key' => 'u', 'org' => 'p', 'job-title' => 'p', 'role' => 'p', 'impp' => 'u',
+                'sex' => 'p', 'gender-identity' => 'p', 'anniversary' => 'dt', 'organization-name' => 'p',
+                'organization-unit' => 'p', 'tz' => 'p', 'rev' => 'dt', 'pronoun' => 'p', 'pronouns' => 'p',
+            ],
+        ],
+        'h-entry' => [
+            'about' => 'A post: an article, note, reply, like, photo and so on.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'summary' => 'p', 'content' => 'e', 'published' => 'dt', 'updated' => 'dt',
+                'author' => 'p', 'category' => 'p', 'url' => 'u', 'uid' => 'u', 'location' => 'p',
+                'syndication' => 'u', 'in-reply-to' => 'u', 'rsvp' => 'p', 'like-of' => 'u', 'repost-of' => 'u',
+                'bookmark-of' => 'u', 'quotation-of' => 'u', 'follow-of' => 'u', 'tag-of' => 'u', 'comment' => 'p',
+                'like' => 'p', 'repost' => 'p', 'photo' => 'u', 'video' => 'u', 'audio' => 'u', 'featured' => 'u',
+                'checkin' => 'p', 'listen-of' => 'u', 'watch-of' => 'u', 'read-of' => 'u', 'read-status' => 'p',
+                'post-status' => 'p', 'content-warning' => 'p', 'invitee' => 'p', 'ate' => 'p', 'drank' => 'p',
+                'latitude' => 'p', 'longitude' => 'p', 'altitude' => 'p', 'audience' => 'p', 'visibility' => 'p',
+            ],
+        ],
+        'h-feed' => [
+            'about' => 'A stream of h-entry posts.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'author' => 'p', 'url' => 'u', 'photo' => 'u', 'summary' => 'p', 'uid' => 'u',
+                'featured' => 'u',
+            ],
+        ],
+        'h-event' => [
+            'about' => 'An event, with a start and usually an end.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'summary' => 'p', 'start' => 'dt', 'end' => 'dt', 'duration' => 'dt',
+                'description' => 'p', 'url' => 'u', 'category' => 'p', 'location' => 'p', 'attendee' => 'p',
+                'content' => 'e', 'published' => 'dt', 'updated' => 'dt', 'author' => 'p', 'photo' => 'u',
+                'featured' => 'u', 'uid' => 'u', 'organizer' => 'p',
+            ],
+        ],
+        'h-cite' => [
+            'about' => 'A citation of something else: a quoted post, a paper, a page.',
+            'status' => 'draft',
+            'properties' => [
+                'name' => 'p', 'published' => 'dt', 'author' => 'p', 'url' => 'u', 'uid' => 'u',
+                'publication' => 'p', 'accessed' => 'dt', 'content' => 'p', 'photo' => 'u',
+            ],
+        ],
+        'h-adr' => [
+            'about' => 'A physical address.',
+            'status' => 'stable',
+            'properties' => [
+                'post-office-box' => 'p', 'extended-address' => 'p', 'street-address' => 'p', 'locality' => 'p',
+                'region' => 'p', 'postal-code' => 'p', 'country-name' => 'p', 'label' => 'p', 'geo' => 'p',
+                'latitude' => 'p', 'longitude' => 'p', 'altitude' => 'p', 'name' => 'p',
+            ],
+        ],
+        'h-geo' => [
+            'about' => 'Latitude and longitude.',
+            'status' => 'stable',
+            'properties' => ['latitude' => 'p', 'longitude' => 'p', 'altitude' => 'p', 'name' => 'p'],
+        ],
+        'h-product' => [
+            'about' => 'A product for sale or review.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'photo' => 'u', 'brand' => 'p', 'category' => 'p', 'description' => 'e',
+                'url' => 'u', 'identifier' => 'u', 'review' => 'p', 'price' => 'p',
+            ],
+        ],
+        'h-recipe' => [
+            'about' => 'A recipe: ingredients and instructions.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'ingredient' => 'p', 'yield' => 'p', 'instructions' => 'e', 'duration' => 'dt',
+                'photo' => 'u', 'summary' => 'p', 'author' => 'p', 'published' => 'dt', 'nutrition' => 'p',
+                'category' => 'p', 'url' => 'u',
+            ],
+        ],
+        'h-review' => [
+            'about' => 'A review of a product, place, event or anything else.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'item' => 'p', 'author' => 'p', 'published' => 'dt', 'rating' => 'p', 'best' => 'p',
+                'worst' => 'p', 'content' => 'e', 'category' => 'p', 'url' => 'u',
+            ],
+        ],
+        'h-review-aggregate' => [
+            'about' => 'The combined result of many reviews.',
+            'status' => 'stable',
+            'properties' => [
+                'item' => 'p', 'average' => 'p', 'best' => 'p', 'worst' => 'p', 'count' => 'p', 'votes' => 'p',
+                'name' => 'p', 'rating' => 'p', 'category' => 'p', 'url' => 'u',
+            ],
+        ],
+        'h-resume' => [
+            'about' => 'A résumé or CV.',
+            'status' => 'stable',
+            'properties' => [
+                'name' => 'p', 'summary' => 'p', 'contact' => 'p', 'education' => 'p', 'experience' => 'p',
+                'skill' => 'p', 'affiliation' => 'p', 'url' => 'u', 'photo' => 'u',
+            ],
+        ],
+        'h-item' => [
+            'about' => 'A generic thing, usually the subject of a review.',
+            'status' => 'stable',
+            'properties' => ['name' => 'p', 'url' => 'u', 'photo' => 'u'],
+        ],
+        'h-listing' => [
+            'about' => 'A classified ad or listing.',
+            'status' => 'draft',
+            'properties' => [
+                'action' => 'p', 'name' => 'p', 'lister' => 'p', 'url' => 'u', 'published' => 'dt',
+                'updated' => 'dt', 'item' => 'p', 'content' => 'e', 'price' => 'p', 'location' => 'p',
+            ],
+        ],
+        'h-measure' => [
+            'about' => 'A measurement: a number and a unit.',
+            'status' => 'draft',
+            'properties' => ['num' => 'p', 'unit' => 'p', 'type' => 'p', 'name' => 'p'],
+        ],
+        'h-app' => [
+            'about' => 'An application, used by IndieAuth to describe a client.',
+            'status' => 'draft',
+            'properties' => ['name' => 'p', 'url' => 'u', 'logo' => 'u', 'summary' => 'p', 'author' => 'p', 'photo' => 'u'],
+        ],
+        'h-x-app' => [
+            'about' => 'The older, experimental name of h-app.',
+            'status' => 'experimental',
+            'properties' => ['name' => 'p', 'url' => 'u', 'logo' => 'u', 'summary' => 'p', 'author' => 'p', 'photo' => 'u'],
+        ],
+        'h-breadcrumb' => [
+            'about' => 'One step of a breadcrumb trail.',
+            'status' => 'draft',
+            'properties' => ['name' => 'p', 'url' => 'u'],
+        ],
+        'h-org' => [
+            'about' => 'An organization. Most sites use h-card for this instead.',
+            'status' => 'draft',
+            'properties' => ['name' => 'p', 'url' => 'u', 'photo' => 'u', 'logo' => 'u', 'unit' => 'p'],
+        ],
+        'h-food' => [
+            'about' => 'Something eaten or drunk, used by ate and drank posts.',
+            'status' => 'draft',
+            'properties' => ['name' => 'p', 'url' => 'u', 'photo' => 'u'],
+        ],
+    ];
+
+    public const CLASSIC_ROOTS = [
+        'vcard' => ['name' => 'hCard', 'mf2' => 'h-card', 'page' => 'hcard'],
+        'adr' => ['name' => 'adr', 'mf2' => 'h-adr', 'page' => 'adr'],
+        'geo' => ['name' => 'geo', 'mf2' => 'h-geo', 'page' => 'geo'],
+        'hentry' => ['name' => 'hAtom entry', 'mf2' => 'h-entry', 'page' => 'hatom'],
+        'hfeed' => ['name' => 'hAtom feed', 'mf2' => 'h-feed', 'page' => 'hatom'],
+        'hnews' => ['name' => 'hNews', 'mf2' => 'h-entry', 'page' => 'hnews'],
+        'vevent' => ['name' => 'hCalendar event', 'mf2' => 'h-event', 'page' => 'hcalendar'],
+        'vcalendar' => ['name' => 'hCalendar container', 'mf2' => null, 'page' => 'hcalendar'],
+        'hreview' => ['name' => 'hReview', 'mf2' => 'h-review', 'page' => 'hreview'],
+        'hreview-aggregate' => ['name' => 'hReview-aggregate', 'mf2' => 'h-review-aggregate', 'page' => 'hreview-aggregate'],
+        'hrecipe' => ['name' => 'hRecipe', 'mf2' => 'h-recipe', 'page' => 'hrecipe'],
+        'hproduct' => ['name' => 'hProduct', 'mf2' => 'h-product', 'page' => 'hproduct'],
+        'hresume' => ['name' => 'hResume', 'mf2' => 'h-resume', 'page' => 'hresume'],
+        'hlisting' => ['name' => 'hListing', 'mf2' => 'h-listing', 'page' => 'hlisting'],
+        'hmedia' => ['name' => 'hMedia', 'mf2' => null, 'page' => 'hmedia'],
+        'haudio' => ['name' => 'hAudio', 'mf2' => null, 'page' => 'haudio'],
+        'xoxo' => ['name' => 'XOXO outline', 'mf2' => null, 'page' => 'xoxo'],
+    ];
+
+    public const XFN = [
+        'contact', 'acquaintance', 'friend', 'met', 'co-worker', 'colleague', 'co-resident', 'neighbor',
+        'child', 'parent', 'sibling', 'spouse', 'kin', 'muse', 'crush', 'date', 'sweetheart', 'me',
+    ];
+
+    public const REL_GROUPS = [
+        'IndieWeb endpoints' => [
+            'webmention' => 'Where to send Webmentions',
+            'pingback' => 'Where to send Pingbacks (older than Webmention)',
+            'micropub' => 'Micropub endpoint for posting from apps',
+            'microsub' => 'Microsub endpoint for readers',
+            'indieauth-metadata' => 'IndieAuth server metadata',
+            'authorization_endpoint' => 'IndieAuth authorization endpoint',
+            'token_endpoint' => 'IndieAuth token endpoint',
+            'ticket_endpoint' => 'TicketAuth endpoint',
+            'hub' => 'WebSub hub',
+            'self' => 'WebSub topic URL',
+        ],
+        'Identity' => [
+            'me' => 'Another profile of the same person',
+            'author' => 'The author of the page',
+            'pgpkey' => 'Public PGP key',
+            'openid.server' => 'OpenID 1 server',
+            'openid.delegate' => 'OpenID 1 delegate',
+            'openid2.provider' => 'OpenID 2 provider',
+            'openid2.local_id' => 'OpenID 2 local id',
+        ],
+        'Classic rel microformats' => [
+            'tag' => 'rel-tag: a tag for the page',
+            'license' => 'rel-license: the license of the content',
+            'nofollow' => 'rel-nofollow: don’t endorse the link',
+            'bookmark' => 'rel-bookmark: permalink of a post',
+            'enclosure' => 'rel-enclosure: a downloadable attachment',
+            'directory' => 'rel-directory: a directory that lists the page',
+            'home' => 'rel-home: the home page of the site',
+            'payment' => 'rel-payment: where to pay or tip',
+        ],
+        'Navigation' => [
+            'canonical' => 'The preferred URL of the page',
+            'alternate' => 'Another version: feed, translation, format',
+            'prev' => 'Previous page',
+            'next' => 'Next page',
+            'up' => 'Parent page',
+            'first' => 'First page',
+            'last' => 'Last page',
+            'search' => 'Search description',
+            'shortlink' => 'Short URL',
+            'archives' => 'Archives',
+            'privacy-policy' => 'Privacy policy',
+            'terms-of-service' => 'Terms of service',
+            'help' => 'Help',
+            'external' => 'Link to another site',
+            'noopener' => 'Opens without window.opener',
+            'noreferrer' => 'Opens without a Referer',
+            'ugc' => 'User-generated link',
+            'sponsored' => 'Paid link',
+        ],
+        'Page resources' => [
+            'stylesheet' => 'Stylesheet',
+            'icon' => 'Favicon',
+            'shortcut' => 'Legacy favicon keyword',
+            'apple-touch-icon' => 'Home screen icon for iOS',
+            'apple-touch-icon-precomposed' => 'Older iOS home screen icon',
+            'mask-icon' => 'Safari pinned tab icon',
+            'manifest' => 'Web app manifest',
+            'preload' => 'Preloaded resource',
+            'modulepreload' => 'Preloaded JavaScript module',
+            'prefetch' => 'Prefetched resource',
+            'preconnect' => 'Early connection',
+            'dns-prefetch' => 'Early DNS lookup',
+            'prerender' => 'Prerendered page',
+        ],
+    ];
+
+    public static function type(string $type): ?array
+    {
+        return self::TYPES[$type] ?? null;
+    }
+
+    public static function status(string $type): string
+    {
+        if (isset(self::TYPES[$type])) {
+            return self::TYPES[$type]['status'];
+        }
+
+        return str_starts_with($type, 'h-x-') ? 'experimental' : 'unknown';
+    }
+
+    public static function specUrl(string $type): ?string
+    {
+        return isset(self::TYPES[$type]) ? self::WIKI . $type : null;
+    }
+
+    public static function classicSpecUrl(string $class): string
+    {
+        return self::WIKI . self::CLASSIC_ROOTS[$class]['page'];
+    }
+
+    public static function knowsProperty(array $types, string $property): bool
+    {
+        foreach ($types as $type) {
+            if (isset(self::TYPES[$type]['properties'][$property])) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public static function prefix(array $types, string $property, mixed $value): string
+    {
+        foreach ($types as $type) {
+            if (isset(self::TYPES[$type]['properties'][$property])) {
+                return self::TYPES[$type]['properties'][$property];
+            }
+        }
+
+        if (is_array($value) && isset($value['html'])) {
+            return 'e';
+        }
+        if (is_string($value) && is_absolute_url($value)) {
+            return 'u';
+        }
+        if (is_string($value) && Dates::isIso($value)) {
+            return 'dt';
+        }
+
+        return 'p';
+    }
+
+    public static function relGroup(string $rel): string
+    {
+        foreach (self::REL_GROUPS as $group => $rels) {
+            if (isset($rels[$rel])) {
+                return $group;
+            }
+        }
+
+        return in_array($rel, self::XFN, true) ? 'XFN' : 'Other';
+    }
+
+    public static function relAbout(string $rel): ?string
+    {
+        foreach (self::REL_GROUPS as $rels) {
+            if (isset($rels[$rel])) {
+                return $rels[$rel];
+            }
+        }
+
+        return in_array($rel, self::XFN, true) ? 'XFN relationship' : null;
+    }
+}
@@ -0,0 +1,58 @@+<?php
+
+namespace Indiechecker;
+
+function e(mixed $value): string
+{
+    return htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
+}
+
+function class_tokens(string $class): array
+{
+    return array_values(array_unique(preg_split('/[\t\n\f\r ]+/', $class, -1, PREG_SPLIT_NO_EMPTY)));
+}
+
+function is_absolute_url(string $value): bool
+{
+    return (bool) preg_match('~^https?://[^\s/]+~i', $value);
+}
+
+function first_text(array $item, string $property): ?string
+{
+    $value = $item['properties'][$property][0] ?? null;
+    if (is_array($value)) {
+        $value = $value['value'] ?? null;
+    }
+
+    return is_string($value) ? $value : null;
+}
+
+function view(string $template, array $data = []): string
+{
+    extract($data);
+    ob_start();
+    require dirname(__DIR__) . "/views/{$template}.php";
+
+    return ob_get_clean();
+}
+
+function link_to(string $url, ?string $text = null): string
+{
+    if (!is_absolute_url($url)) {
+        return '<code>' . e($text ?? $url) . '</code>';
+    }
+
+    return sprintf('<a href="%s" rel="nofollow noopener noreferrer">%s</a>', e($url), e($text ?? $url));
+}
+
+function looks_like_image(string $url): bool
+{
+    return (bool) preg_match('~\.(avif|gif|ico|jpe?g|png|svg|webp)([?#].*)?$~i', $url);
+}
+
+function shorten(string $text, int $length = 280): string
+{
+    $text = trim(preg_replace('/\s+/u', ' ', $text));
+
+    return mb_strlen($text) > $length ? rtrim(mb_substr($text, 0, $length - 1)) . '…' : $text;
+}
@@ -0,0 +1,41 @@+<?php
+
+use function Indiechecker\e;
+
+$items = $report->advice->all();
+$labels = ['error' => 'Error', 'warning' => 'Warning', 'tip' => 'Tip'];
+$whereLimit = 8;
+?>
+<section id="advice" class="section" aria-labelledby="advice-title">
+    <h2 id="advice-title">Advice</h2>
+
+    <?php if (!$items): ?>
+        <p class="empty">Nothing to fix. This page is in great shape.</p>
+    <?php else: ?>
+        <p>Errors break parsing, warnings leave something out that tools expect, and tips are nice to have. Each one comes with HTML you can adapt.</p>
+        <?php foreach ($items as $item): ?>
+            <article class="advice advice-<?= e($item['level']) ?>">
+                <header>
+                    <span class="level level-<?= e($item['level']) ?>"><svg class="icon"><use href="#i-<?= e($item['level']) ?>"/></svg><?= e($labels[$item['level']]) ?></span>
+                    <h3><?= e($item['title']) ?> <span class="area"><?= e($item['area']) ?></span></h3>
+                </header>
+                <p><?= e($item['detail']) ?></p>
+
+                <?php if ($item['where']): ?>
+                    <ul class="where">
+                        <?php foreach (array_slice($item['where'], 0, $whereLimit) as $where): ?>
+                            <li><code><?= e($where) ?></code></li>
+                        <?php endforeach ?>
+                        <?php if (count($item['where']) > $whereLimit): ?>
+                            <li class="muted">and <?= count($item['where']) - $whereLimit ?> more</li>
+                        <?php endif ?>
+                    </ul>
+                <?php endif ?>
+
+                <?php if ($item['fix']): ?>
+                    <pre class="code fix"><code><?= e($item['fix']) ?></code></pre>
+                <?php endif ?>
+            </article>
+        <?php endforeach ?>
+    <?php endif ?>
+</section>
@@ -0,0 +1,145 @@+<?php
+
+use function Indiechecker\e;
+use function Indiechecker\first_text;
+use function Indiechecker\is_absolute_url;
+use function Indiechecker\link_to;
+use function Indiechecker\shorten;
+
+$mf = $report->microformats;
+$card = $mf->representativeCard['card'] ?? null;
+$entries = $mf->ofType('h-entry');
+$entryLimit = 15;
+$linkLimit = 5;
+?>
+<section id="indieweb" class="section" aria-labelledby="indieweb-title">
+    <h2 id="indieweb-title">IndieWeb &amp; rels</h2>
+
+    <ul class="checklist">
+        <?php foreach ($report->indieWeb() as $check): ?>
+            <li class="<?= $check['ok'] ? 'is-ok' : 'is-missing' ?>">
+                <span class="mark"><svg class="icon"><use href="#<?= $check['ok'] ? 'i-check' : 'i-missing' ?>"/></svg><span class="visually-hidden"><?= $check['ok'] ? 'Yes' : 'No' ?>:</span></span>
+                <div>
+                    <strong><?= e($check['label']) ?></strong>
+                    <span class="detail"><?= e($check['detail']) ?></span>
+                    <?php if (!empty($check['links'])): ?>
+                        <ul class="links">
+                            <?php foreach (array_slice($check['links'], 0, $linkLimit) as $link): ?><li><?= link_to($link) ?></li><?php endforeach ?>
+                            <?php if (count($check['links']) > $linkLimit): ?>
+                                <li class="muted">and <?= count($check['links']) - $linkLimit ?> more, listed under <a href="#rel-links">rel links</a></li>
+                            <?php endif ?>
+                        </ul>
+                    <?php endif ?>
+                </div>
+            </li>
+        <?php endforeach ?>
+    </ul>
+
+    <?php if ($card): ?>
+        <h3>Representative h-card</h3>
+        <?php $photo = first_text($card, 'photo'); $url = first_text($card, 'url'); $note = first_text($card, 'note'); ?>
+        <div class="person">
+            <?php if ($photo && is_absolute_url($photo)): ?>
+                <img src="<?= e($photo) ?>" alt="" width="64" height="64" loading="lazy" referrerpolicy="no-referrer">
+            <?php endif ?>
+            <div>
+                <p class="person-name"><?= e(first_text($card, 'name') ?? '(no name)') ?></p>
+                <?php if ($url): ?><p><?= link_to($url) ?></p><?php endif ?>
+                <?php if ($note): ?><p class="muted"><?= e(shorten($note, 200)) ?></p><?php endif ?>
+                <p class="muted">Chosen because <?= e($mf->representativeCard['reason']) ?>.</p>
+            </div>
+        </div>
+    <?php endif ?>
+
+    <?php if ($entries): ?>
+        <h3>Posts and their authors</h3>
+        <div class="table-wrap">
+            <table>
+                <thead><tr><th scope="col">Post</th><th scope="col">Published</th><th scope="col">Author</th></tr></thead>
+                <tbody>
+                <?php foreach (array_slice($entries, 0, $entryLimit) as $found):
+                    $entry = $found['item'];
+                    $label = first_text($entry, 'name') ?? first_text($entry, 'summary') ?? first_text($entry, 'content') ?? $found['path'];
+                    $author = $mf->authorOf($found);
+                    $authorName = null;
+                    if ($author) {
+                        $authorName = is_array($author['author'])
+                            ? (first_text($author['author'], 'name') ?? first_text($author['author'], 'url') ?? ($author['author']['value'] ?? null))
+                            : $author['author'];
+                    }
+                ?>
+                    <tr>
+                        <td>
+                            <?php $permalink = first_text($entry, 'url'); ?>
+                            <?= $permalink ? link_to($permalink, shorten($label, 80)) : e(shorten($label, 80)) ?>
+                        </td>
+                        <td class="num"><?= e(first_text($entry, 'published') ?? '—') ?></td>
+                        <td>
+                            <?php if ($author): ?>
+                                <?= e(shorten((string) $authorName, 60)) ?> <span class="muted">(<?= e($author['source']) ?>)</span>
+                            <?php else: ?>
+                                <span class="level level-warning"><svg class="icon"><use href="#i-warning"/></svg>none</span>
+                            <?php endif ?>
+                        </td>
+                    </tr>
+                <?php endforeach ?>
+                </tbody>
+            </table>
+        </div>
+        <?php if (count($entries) > $entryLimit): ?>
+            <p class="muted">Showing <?= $entryLimit ?> of <?= count($entries) ?> posts.</p>
+        <?php endif ?>
+    <?php endif ?>
+
+    <h3 id="rel-links">rel links</h3>
+    <?php if ($report->rels->count() === 0): ?>
+        <p class="empty">No rel links in the HTML or the Link headers.</p>
+    <?php endif ?>
+    <?php foreach ($report->rels->grouped() as $group => $rels): ?>
+        <h4><?= e($group) ?></h4>
+        <div class="table-wrap">
+            <table class="rels">
+                <thead><tr><th scope="col">rel</th><th scope="col">Links</th></tr></thead>
+                <tbody>
+                <?php foreach ($rels as $rel => $info): ?>
+                    <tr>
+                        <td>
+                            <code><?= e($rel) ?></code>
+                            <?php if ($info['about']): ?><span class="about"><?= e($info['about']) ?></span><?php endif ?>
+                        </td>
+                        <td>
+                            <ul class="links">
+                                <?php foreach ($info['links'] as $link): ?>
+                                    <li>
+                                        <?= link_to($link['url'], $link['url'] === '' ? '(this page)' : null) ?>
+                                        <?php if ($link['source'] !== 'HTML'): ?><span class="badge badge-header"><?= e($link['source']) ?></span><?php endif ?>
+                                        <?php if ($link['text'] !== null && trim($link['text']) !== '' && trim($link['text']) !== $link['url']): ?><span class="muted">“<?= e(shorten($link['text'], 60)) ?>”</span><?php endif ?>
+                                    </li>
+                                <?php endforeach ?>
+                            </ul>
+                        </td>
+                    </tr>
+                <?php endforeach ?>
+                </tbody>
+            </table>
+        </div>
+    <?php endforeach ?>
+
+    <?php if ($report->rels->xfn()): ?>
+        <h3>XFN relationships</h3>
+        <ul class="plain">
+            <?php foreach ($report->rels->xfn() as $person): ?>
+                <li><?= link_to($person['url'], $person['text'] ? shorten($person['text'], 60) : null) ?> <span class="muted"><?= e(implode(', ', $person['relations'])) ?></span></li>
+            <?php endforeach ?>
+        </ul>
+    <?php endif ?>
+
+    <?php if ($mf->voteLinks): ?>
+        <h3>VoteLinks</h3>
+        <ul class="plain">
+            <?php foreach ($mf->voteLinks as $vote): ?>
+                <li><code><?= e($vote['vote']) ?></code> <?= link_to($vote['url'], $vote['text'] ?: null) ?></li>
+            <?php endforeach ?>
+        </ul>
+    <?php endif ?>
+</section>
@@ -0,0 +1,46 @@+<?php
+
+use Indiechecker\Vocabulary;
+
+use function Indiechecker\e;
+?>
+<section class="intro">
+    <h2>What’s on your page?</h2>
+    <p class="lede">Give it a URL and indiechecker reads the page the way IndieWeb tools do. It lists every microformat, old and new, every rel link and endpoint, the meta tags behind link previews, and tells you what to fix, with the HTML to fix it.</p>
+
+    <p class="try">Try <a href="?url=https://tantek.com/">tantek.com</a>, <a href="?url=https://aaronparecki.com/">aaronparecki.com</a> or <a href="?url=https://microformats.org/wiki/Main_Page">microformats.org</a>.</p>
+
+    <h3>It reads</h3>
+    <dl class="reads">
+        <div>
+            <dt>microformats2</dt>
+            <dd class="pills">
+                <?php foreach (Vocabulary::TYPES as $type => $info): ?>
+                    <a class="type" href="<?= e(Vocabulary::specUrl($type)) ?>" title="<?= e($info['about']) ?>"><?= e($type) ?></a>
+                <?php endforeach ?>
+                <span class="more">and any h-x- experiment</span>
+            </dd>
+        </div>
+        <div>
+            <dt>Classic microformats</dt>
+            <dd class="pills">
+                <?php foreach (Vocabulary::CLASSIC_ROOTS as $class => $info): ?>
+                    <a class="type type-classic" href="<?= e(Vocabulary::classicSpecUrl($class)) ?>" title="<?= e($info['name']) ?>"><?= e($class) ?></a>
+                <?php endforeach ?>
+                <span class="type type-classic">rel-tag</span>
+                <span class="type type-classic">rel-license</span>
+                <span class="type type-classic">rel-nofollow</span>
+                <span class="type type-classic">XFN</span>
+                <span class="type type-classic">VoteLinks</span>
+            </dd>
+        </div>
+        <div>
+            <dt>IndieWeb</dt>
+            <dd>Representative h-card, post authorship, rel=me, Webmention, Micropub, Microsub, IndieAuth and WebSub, from the HTML and from HTTP Link headers.</dd>
+        </div>
+        <div>
+            <dt>Meta tags</dt>
+            <dd>Title, description, Open Graph, Twitter cards, fediverse:creator, feeds (and whether they load), icons, theme color and the rest of the head.</dd>
+        </div>
+    </dl>
+</section>
@@ -0,0 +1,53 @@+<?php
+
+use Indiechecker\Mf2Parser;
+use Indiechecker\Vocabulary;
+
+use function Indiechecker\e;
+use function Indiechecker\view;
+
+$types = $item['type'];
+?>
+<div class="mf<?= $depth > 0 ? ' mf-nested' : '' ?>">
+    <?php if ($showHead): ?>
+        <p class="mf-head">
+            <?php foreach ($types as $type): $spec = Vocabulary::specUrl($type); ?>
+                <?php if ($spec): ?><a class="type" href="<?= e($spec) ?>"><?= e($type) ?></a><?php else: ?><span class="type"><?= e($type) ?></span><?php endif ?>
+            <?php endforeach ?>
+            <?php foreach ($item[Mf2Parser::CLASSIC_KEY] ?? [] as $class): ?><span class="badge badge-classic">from <?= e($class) ?></span><?php endforeach ?>
+        </p>
+    <?php endif ?>
+
+    <?php if (isset($item['id']) || isset($item['lang'])): ?>
+        <p class="mf-attrs">
+            <?php if (isset($item['id'])): ?><code>id="<?= e($item['id']) ?>"</code><?php endif ?>
+            <?php if (isset($item['lang'])): ?><code>lang="<?= e($item['lang']) ?>"</code><?php endif ?>
+        </p>
+    <?php endif ?>
+
+    <?php if ($item['properties']): ?>
+        <dl class="props">
+            <?php foreach ($item['properties'] as $name => $values): ?>
+                <div class="prop">
+                    <dt><code><span class="prefix"><?= e(Vocabulary::prefix($types, $name, $values[0])) ?>-</span><?= e($name) ?></code></dt>
+                    <dd>
+                        <?php foreach ($values as $value): ?>
+                            <?= view('value', ['value' => $value, 'name' => $name, 'depth' => $depth]) ?>
+                        <?php endforeach ?>
+                    </dd>
+                </div>
+            <?php endforeach ?>
+        </dl>
+    <?php else: ?>
+        <p class="muted">No properties.</p>
+    <?php endif ?>
+
+    <?php if (!empty($item['children'])): ?>
+        <div class="children">
+            <p class="children-label"><?= count($item['children']) ?> child item<?= count($item['children']) > 1 ? 's' : '' ?></p>
+            <?php foreach ($item['children'] as $child): ?>
+                <?= view('item', ['item' => $child, 'depth' => $depth + 1, 'showHead' => true]) ?>
+            <?php endforeach ?>
+        </div>
+    <?php endif ?>
+</div>
@@ -0,0 +1,119 @@+<?php
+
+use function Indiechecker\e;
+use function Indiechecker\is_absolute_url;
+use function Indiechecker\link_to;
+
+$meta = $report->metadata;
+$preview = $meta->preview();
+$checks = array_column($report->feedChecks, null, 'url');
+$head = [
+    'title' => $meta->title,
+    'description' => $meta->description,
+    'lang' => $meta->lang,
+    'charset' => $meta->charset ?? $report->response?->charset(),
+    'viewport' => $meta->viewport,
+    'canonical' => $meta->canonical,
+    'manifest' => $meta->manifest,
+    'generator' => $meta->generator,
+    'robots' => $meta->robots,
+];
+$missing = '<span class="missing"><svg class="icon"><use href="#i-missing"/></svg>not set</span>';
+?>
+<section id="metadata" class="section" aria-labelledby="metadata-title">
+    <h2 id="metadata-title">Metadata</h2>
+
+    <div class="meta-top">
+        <figure class="share">
+            <div class="share-card">
+                <?php if ($preview['image'] && is_absolute_url($preview['image'])): ?>
+                    <img src="<?= e($preview['image']) ?>" alt="<?= e($preview['imageAlt'] ?? '') ?>" loading="lazy" referrerpolicy="no-referrer">
+                <?php else: ?>
+                    <div class="share-noimage">No og:image</div>
+                <?php endif ?>
+                <div class="share-text">
+                    <?php if ($preview['host'] || $preview['site']): ?><p class="share-host"><?= e($preview['site'] ?? $preview['host']) ?></p><?php endif ?>
+                    <p class="share-title"><?= e($preview['title'] ?? 'No title') ?></p>
+                    <?php if ($preview['description']): ?><p class="share-description"><?= e($preview['description']) ?></p><?php endif ?>
+                </div>
+            </div>
+            <figcaption>Roughly how a shared link looks in chat apps and on Mastodon.</figcaption>
+        </figure>
+
+        <dl class="facts-list">
+            <?php foreach ($head as $label => $value): ?>
+                <div>
+                    <dt><?= e($label) ?></dt>
+                    <dd><?php if ($value === null || $value === ''): ?><?= $missing ?><?php elseif (in_array($label, ['canonical', 'manifest'], true)): ?><?= link_to($value) ?><?php else: ?><?= e($value) ?><?php endif ?></dd>
+                </div>
+            <?php endforeach ?>
+            <div>
+                <dt>theme-color</dt>
+                <dd>
+                    <?php if (!$meta->themeColors): ?><?= $missing ?><?php endif ?>
+                    <?php foreach ($meta->themeColors as $theme): ?>
+                        <span class="swatch"><svg width="14" height="14" aria-hidden="true"><rect width="14" height="14" rx="3" fill="<?= e($theme['color']) ?>"/></svg><code><?= e($theme['color']) ?></code><?php if ($theme['media']): ?> <span class="muted"><?= e($theme['media']) ?></span><?php endif ?></span>
+                    <?php endforeach ?>
+                </dd>
+            </div>
+            <div>
+                <dt>fediverse:creator</dt>
+                <dd><?= $meta->fediverseCreators ? e(implode(', ', $meta->fediverseCreators)) : $missing ?></dd>
+            </div>
+        </dl>
+    </div>
+
+    <?php foreach (['Open Graph' => $meta->openGraph, 'Twitter card' => $meta->twitter] as $title => $tags): ?>
+        <h3><?= e($title) ?></h3>
+        <?php if (!$tags): ?>
+            <p class="empty">None.</p>
+        <?php else: ?>
+            <div class="table-wrap">
+                <table>
+                    <tbody>
+                    <?php foreach ($tags as $property => $values): ?>
+                        <?php foreach ($values as $value): ?>
+                            <tr><th scope="row"><code><?= e($property) ?></code></th><td><?= is_absolute_url($value) ? link_to($value) : e($value) ?></td></tr>
+                        <?php endforeach ?>
+                    <?php endforeach ?>
+                    </tbody>
+                </table>
+            </div>
+        <?php endif ?>
+    <?php endforeach ?>
+
+    <h3>Feeds</h3>
+    <?php if (!$meta->feeds): ?>
+        <p class="empty">No feed links in the head.<?php if ($report->microformats->ofType('h-feed')): ?> The page has an h-feed, which microformats readers can follow.<?php endif ?></p>
+    <?php else: ?>
+        <ul class="checklist">
+            <?php foreach ($meta->feeds as $feed): $check = $checks[$feed['url']] ?? null; ?>
+                <li class="<?= $check === null ? 'is-unknown' : ($check['ok'] ? 'is-ok' : 'is-bad') ?>">
+                    <span class="mark"><svg class="icon"><use href="#<?= $check === null ? 'i-missing' : ($check['ok'] ? 'i-check' : 'i-error') ?>"/></svg></span>
+                    <div>
+                        <strong><?= e($feed['format']) ?></strong><?php if ($feed['title']): ?> <span class="muted"><?= e($feed['title']) ?></span><?php endif ?>
+                        <span class="detail"><?= link_to($feed['url']) ?></span>
+                        <span class="detail"><?php if ($check === null): ?>Not checked.<?php elseif ($check['ok']): ?>Loads fine, served as <code><?= e($check['content-type']) ?></code>.<?php else: ?><?= e($check['problem']) ?><?php endif ?></span>
+                    </div>
+                </li>
+            <?php endforeach ?>
+        </ul>
+    <?php endif ?>
+
+    <h3>Icons</h3>
+    <?php if (!$meta->icons): ?>
+        <p class="empty">No icon links. Browsers will try <code>/favicon.ico</code>.</p>
+    <?php else: ?>
+        <ul class="icons">
+            <?php foreach ($meta->icons as $icon): ?>
+                <li>
+                    <span class="icon-frame"><?php if (is_absolute_url($icon['url'])): ?><img src="<?= e($icon['url']) ?>" alt="" loading="lazy" referrerpolicy="no-referrer"><?php endif ?></span>
+                    <span>
+                        <code><?= e($icon['kind']) ?></code><?php if ($icon['sizes']): ?> <span class="muted"><?= e($icon['sizes']) ?></span><?php endif ?><?php if ($icon['type']): ?> <span class="muted"><?= e($icon['type']) ?></span><?php endif ?>
+                        <br><?= link_to($icon['url']) ?>
+                    </span>
+                </li>
+            <?php endforeach ?>
+        </ul>
+    <?php endif ?>
+</section>
@@ -0,0 +1,73 @@+<?php
+
+use Indiechecker\Mf2Parser;
+use Indiechecker\Vocabulary;
+
+use function Indiechecker\e;
+use function Indiechecker\shorten;
+use function Indiechecker\view;
+
+$mf = $report->microformats;
+$items = $mf->parsed['items'];
+$openLimit = 6;
+?>
+<section id="microformats" class="section" aria-labelledby="microformats-title">
+    <h2 id="microformats-title">Microformats</h2>
+
+    <?php if ($mf->isEmpty() && !$mf->classic): ?>
+        <p class="empty">Nothing here yet. The advice below has an h-card you can start with.</p>
+    <?php else: ?>
+        <?php if ($mf->counts()): ?>
+            <ul class="pills" aria-label="Types found">
+                <?php foreach ($mf->counts() as $type => $count): $status = Vocabulary::status($type); $spec = Vocabulary::specUrl($type); ?>
+                    <li>
+                        <?php if ($spec): ?><a class="type" href="<?= e($spec) ?>" title="<?= e(Vocabulary::type($type)['about']) ?>"><?php else: ?><span class="type"><?php endif ?>
+                        <?= e($type) ?> <span class="count"><?= $count ?></span>
+                        <?= $spec ? '</a>' : '</span>' ?>
+                        <?php if ($status !== 'stable'): ?><span class="badge badge-<?= e($status) ?>"><?= e($status) ?></span><?php endif ?>
+                    </li>
+                <?php endforeach ?>
+            </ul>
+        <?php endif ?>
+
+        <?php if ($mf->classic): ?>
+            <h3>Classic microformats</h3>
+            <p>These use the pre-2010 class names. mf2 parsers read most of them through backward compatibility and show them as their mf2 equivalents below, marked “from”.</p>
+            <div class="table-wrap">
+                <table>
+                    <thead><tr><th scope="col">Class</th><th scope="col">Format</th><th scope="col">Elements</th><th scope="col">mf2 equivalent</th></tr></thead>
+                    <tbody>
+                    <?php foreach ($mf->classic as $class => $found): $root = Vocabulary::CLASSIC_ROOTS[$class]; ?>
+                        <tr>
+                            <td><code><?= e($class) ?></code></td>
+                            <td><a href="<?= e(Vocabulary::classicSpecUrl($class)) ?>"><?= e($root['name']) ?></a></td>
+                            <td class="num"><?= $found['count'] ?><?php if ($found['alongsideMf2']): ?> <span class="muted">(<?= $found['alongsideMf2'] ?> also mf2)</span><?php endif ?></td>
+                            <td><?= $root['mf2'] ? '<code>' . e($root['mf2']) . '</code>' : '<span class="muted">none</span>' ?></td>
+                        </tr>
+                    <?php endforeach ?>
+                    </tbody>
+                </table>
+            </div>
+        <?php endif ?>
+
+        <?php if ($items): ?>
+            <h3>What the parser sees</h3>
+            <?php foreach ($items as $index => $item):
+                $name = $item['properties']['name'][0] ?? $item['properties']['url'][0] ?? null;
+                $name = is_array($name) ? ($name['value'] ?? null) : $name;
+            ?>
+                <details class="mf-root"<?= $index < $openLimit ? ' open' : '' ?>>
+                    <summary>
+                        <?php foreach ($item['type'] as $type): ?><span class="type"><?= e($type) ?></span><?php endforeach ?>
+                        <?php foreach ($item[Mf2Parser::CLASSIC_KEY] ?? [] as $class): ?><span class="badge badge-classic">from <?= e($class) ?></span><?php endforeach ?>
+                        <?php if (is_string($name) && trim($name) !== ''): ?><span class="mf-name"><?= e(shorten($name, 90)) ?></span><?php endif ?>
+                    </summary>
+                    <?= view('item', ['item' => $item, 'depth' => 0, 'showHead' => false]) ?>
+                </details>
+            <?php endforeach ?>
+            <?php if (count($items) > $openLimit): ?>
+                <p class="muted"><?= count($items) - $openLimit ?> more top-level item<?= count($items) - $openLimit > 1 ? 's are' : ' is' ?> collapsed.</p>
+            <?php endif ?>
+        <?php endif ?>
+    <?php endif ?>
+</section>
@@ -0,0 +1,25 @@+<?php
+
+use function Indiechecker\e;
+
+$total = count($report->microformats->all);
+$checks = $report->indieWeb();
+$passing = count(array_filter($checks, fn (array $check) => $check['ok']));
+$feeds = count($report->metadata->feeds);
+$errors = $report->advice->count('error');
+$warnings = $report->advice->count('warning');
+$tips = $report->advice->count('tip');
+
+$items = [
+    'microformats' => ['Microformats', $total === 1 ? '1 found' : "{$total} found"],
+    'indieweb' => ['IndieWeb & rels', "{$passing} of " . count($checks) . ' checks pass'],
+    'metadata' => ['Metadata', $feeds === 1 ? 'head tags, 1 feed' : "head tags, {$feeds} feeds"],
+    'advice' => ['Advice', "{$errors} err · {$warnings} warn · {$tips} tips"],
+    'json' => ['Raw JSON', 'canonical mf2 and more'],
+];
+?>
+<nav class="bar" aria-label="Report sections">
+    <?php foreach ($items as $anchor => [$name, $label]): ?>
+        <a href="#<?= e($anchor) ?>"><span class="bar-label"><?= e($label) ?></span><strong><?= e($name) ?></strong></a>
+    <?php endforeach ?>
+</nav>
@@ -0,0 +1,83 @@+<?php
+
+use function Indiechecker\e;
+use function Indiechecker\view;
+
+$checked = $report?->url();
+$host = $checked ? parse_url($checked, PHP_URL_HOST) : null;
+$pasted = $html !== null;
+?>
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title><?= $report ? e(($host ?? 'Pasted HTML') . ' · indiechecker') : 'indiechecker · microformats and IndieWeb checker' ?></title>
+    <meta name="description" content="Paste a URL or some HTML and see every microformat, rel link, IndieWeb endpoint and meta tag on the page, with fixes for what’s missing.">
+    <meta property="og:title" content="indiechecker">
+    <meta property="og:description" content="Every microformat, rel link, IndieWeb endpoint and meta tag on a page, with fixes for what’s missing.">
+    <meta name="theme-color" content="#ffffff">
+    <link rel="icon" href="favicon.svg" type="image/svg+xml">
+    <link rel="stylesheet" href="style.css">
+    <script src="app.js" defer></script>
+</head>
+<body>
+<svg class="sprite" aria-hidden="true" focusable="false">
+    <symbol id="i-check" viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
+    <symbol id="i-missing" viewBox="0 0 16 16"><path d="M4 8h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
+    <symbol id="i-error" viewBox="0 0 16 16"><path d="M5 5l6 6M11 5l-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
+    <symbol id="i-warning" viewBox="0 0 16 16"><path d="M8 4v5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="8" cy="12" r="1.2" fill="currentColor"/></symbol>
+    <symbol id="i-tip" viewBox="0 0 16 16"><circle cx="8" cy="4.5" r="1.2" fill="currentColor"/><path d="M8 7.5V12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
+</svg>
+
+<div class="wrapper">
+    <header class="side h-app">
+        <h1 class="brand"><a class="p-name u-url" href="./">indiechecker</a></h1>
+        <p class="tagline p-summary">Every microformat, rel link and IndieWeb endpoint on a page.</p>
+
+        <form class="check" method="get" action="./">
+            <label class="visually-hidden" for="url">Page URL</label>
+            <input id="url" name="url" type="text" inputmode="url" autocomplete="url" spellcheck="false"
+                   placeholder="https://your.site/" value="<?= e($pasted ? '' : $url) ?>" required>
+            <button class="button" type="submit">Check</button>
+        </form>
+
+        <details class="paste"<?= $pasted ? ' open' : '' ?>>
+            <summary>Or paste some HTML</summary>
+            <form method="post" action="./">
+                <label for="html">HTML</label>
+                <textarea id="html" name="html" rows="8" spellcheck="false" required
+                          placeholder="&lt;article class=&quot;h-entry&quot;&gt;…&lt;/article&gt;"><?= e($html ?? '') ?></textarea>
+                <label for="base">Page URL <span class="optional">(optional, for relative links)</span></label>
+                <input id="base" name="base" type="text" inputmode="url" spellcheck="false" placeholder="https://your.site/post" value="<?= e($base) ?>">
+                <button class="button" type="submit">Check HTML</button>
+            </form>
+        </details>
+
+        <?php if ($report !== null): ?>
+            <?= view('nav', ['report' => $report]) ?>
+        <?php endif ?>
+    </header>
+
+    <main class="content">
+        <?php if ($error !== null): ?>
+            <div class="alert" role="alert">
+                <svg class="icon"><use href="#i-error"/></svg>
+                <p><?= e($error) ?></p>
+            </div>
+        <?php endif ?>
+
+        <?php if ($report !== null): ?>
+            <?= view('report', ['report' => $report, 'pasted' => $pasted]) ?>
+        <?php elseif ($error === null): ?>
+            <?= view('intro') ?>
+        <?php endif ?>
+    </main>
+
+    <footer class="site-footer">
+        <p>Vocabularies from <a href="https://microformats.org/wiki/Main_Page">microformats.org</a>.</p>
+        <p>dev pablo murad 2026</p>
+    </footer>
+</div>
+</body>
+</html>
@@ -0,0 +1,65 @@+<?php
+
+use function Indiechecker\e;
+use function Indiechecker\link_to;
+use function Indiechecker\view;
+
+$response = $report->response;
+$mf = $report->microformats;
+$counts = $mf->counts();
+$total = count($mf->all);
+$classicTotal = array_sum(array_column($mf->classic, 'count'));
+$errors = $report->advice->count('error');
+$warnings = $report->advice->count('warning');
+$tips = $report->advice->count('tip');
+?>
+<section class="summary" aria-labelledby="summary-title">
+    <h2 id="summary-title" class="visually-hidden">Summary</h2>
+    <p class="checked">
+        <?php if ($report->url()): ?>
+            <?= $pasted ? 'Pasted HTML for' : 'Checked' ?> <?= link_to($report->url()) ?>
+        <?php else: ?>
+            Pasted HTML, no page URL
+        <?php endif ?>
+    </p>
+
+    <?php if ($response): ?>
+        <p class="facts">
+            <span>HTTP <?= e($response->status) ?></span>
+            <span><?= e(number_format(strlen($response->body) / 1024, 1)) ?> KB</span>
+            <span><?= e(number_format($response->milliseconds / 1000, 2)) ?> s</span>
+            <?php if ($response->redirects): ?>
+                <span><?= count($response->redirects) ?> redirect<?= count($response->redirects) > 1 ? 's' : '' ?> from <?= link_to($response->requestedUrl) ?></span>
+            <?php endif ?>
+            <a href="?url=<?= e(rawurlencode($report->url())) ?>&amp;format=json">JSON</a>
+        </p>
+    <?php endif ?>
+
+    <p class="tally">
+        <span>
+            <?php if ($total === 0): ?>
+                No microformats found.
+            <?php else: ?>
+                Found <strong><?= $total ?></strong> microformat<?= $total > 1 ? 's' : '' ?> of <?= count($counts) ?> type<?= count($counts) > 1 ? 's' : '' ?><?php if ($classicTotal): ?>, <?= $classicTotal ?> elements with classic class names<?php endif ?>.
+            <?php endif ?>
+        </span>
+        <a class="level level-error<?= $errors === 0 ? ' level-zero' : '' ?>" href="#advice"><svg class="icon"><use href="#i-error"/></svg><?= $errors ?> error<?= $errors === 1 ? '' : 's' ?></a>
+        <a class="level level-warning<?= $warnings === 0 ? ' level-zero' : '' ?>" href="#advice"><svg class="icon"><use href="#i-warning"/></svg><?= $warnings ?> warning<?= $warnings === 1 ? '' : 's' ?></a>
+        <a class="level level-tip<?= $tips === 0 ? ' level-zero' : '' ?>" href="#advice"><svg class="icon"><use href="#i-tip"/></svg><?= $tips ?> tip<?= $tips === 1 ? '' : 's' ?></a>
+    </p>
+</section>
+
+<?= view('microformats', ['report' => $report]) ?>
+<?= view('indieweb', ['report' => $report]) ?>
+<?= view('metadata', ['report' => $report]) ?>
+<?= view('advice', ['report' => $report]) ?>
+
+<section id="json" class="section" aria-labelledby="json-title">
+    <h2 id="json-title">Raw JSON</h2>
+    <p>The canonical mf2 parse is under <code>mf2</code>. Everything else is what this page shows.
+        <?php if ($report->url() && !$pasted): ?>Get it straight from <code>?url=…&amp;format=json</code>.<?php else: ?>POST the HTML with <code>format=json</code> to get it from the API.<?php endif ?></p>
+    <details class="json">
+        <summary>Show JSON</summary>
+        <pre class="code"><code><?= e(json_encode($report->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE)) ?></code></pre>
+    </details>
+</section>
@@ -0,0 +1,46 @@+<?php
+
+use function Indiechecker\e;
+use function Indiechecker\is_absolute_url;
+use function Indiechecker\link_to;
+use function Indiechecker\looks_like_image;
+use function Indiechecker\shorten;
+use function Indiechecker\view;
+
+$imageProperty = in_array($name, ['photo', 'logo', 'featured'], true);
+?>
+<?php if (is_array($value) && isset($value['type'])): ?>
+    <div class="value value-nested">
+        <?php if (isset($value['value']) && is_string($value['value']) && $value['value'] !== ''): ?>
+            <p class="value-text"><?= e(shorten($value['value'], 200)) ?></p>
+        <?php endif ?>
+        <?= view('item', ['item' => $value, 'depth' => $depth + 1, 'showHead' => true]) ?>
+    </div>
+<?php elseif (is_array($value) && isset($value['html'])): ?>
+    <div class="value value-e">
+        <?php $text = shorten((string) $value['value'], 400); ?>
+        <p><?= $text === '' ? '<span class="muted">(empty)</span>' : e($text) ?></p>
+        <?php if (trim($value['html']) !== ''): ?>
+            <details>
+                <summary>HTML</summary>
+                <pre class="code"><code><?= e($value['html']) ?></code></pre>
+            </details>
+        <?php endif ?>
+    </div>
+<?php elseif (is_array($value) && isset($value['value'])): ?>
+    <div class="value value-image">
+        <?php if (is_absolute_url($value['value'])): ?>
+            <img src="<?= e($value['value']) ?>" alt="<?= e($value['alt'] ?? '') ?>" loading="lazy" referrerpolicy="no-referrer">
+        <?php endif ?>
+        <p><?= link_to($value['value']) ?><?php if (isset($value['alt'])): ?><br><span class="muted">alt:</span> <?= $value['alt'] === '' ? '<span class="muted">(empty)</span>' : e($value['alt']) ?><?php endif ?></p>
+    </div>
+<?php elseif (is_string($value) && is_absolute_url($value)): ?>
+    <div class="value<?= $imageProperty || looks_like_image($value) ? ' value-image' : '' ?>">
+        <?php if ($imageProperty || looks_like_image($value)): ?>
+            <img src="<?= e($value) ?>" alt="" loading="lazy" referrerpolicy="no-referrer">
+        <?php endif ?>
+        <p><?= link_to($value) ?></p>
+    </div>
+<?php else: ?>
+    <p class="value"><?= trim((string) $value) === '' ? '<span class="muted">(empty)</span>' : e(shorten((string) $value, 500)) ?></p>
+<?php endif ?>