Lists the microformats, rel links and IndieWeb endpoints on a page, and how to fix them
feat: site checks, generator-aware fixes, Markdown export and CI mode
- robots.txt: missing, blocking every crawler, declared sitemaps,
/sitemap.xml fallback. 429 and 5xx count as "couldn't check",
not as missing.
- Domain: plain http, rel=canonical on another host, www and bare
domain both serving the site without a redirect.
- Fixes for a missing h-card, rel=me, feed or Webmention endpoint
say where the code goes in WordPress, Hugo or Eleventy when the
generator meta tag names one.
- format=markdown returns the report as Markdown.
- fail=errors|warnings answers HTTP 412 when there are findings at
that level, for CI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -13,7 +13,9 @@ **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.
+**The site around the page.** robots.txt (missing, or blocking every crawler), the sitemaps it declares or the one at `/sitemap.xml`, whether the page ends up on https, whether rel=canonical points to another host, and whether both `www.` and the bare domain serve the site without one redirecting to the other. These checks only run on a fetched URL, not on pasted HTML.
+
+**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. When the page's `generator` meta tag says WordPress, Hugo or Eleventy, the snippets for a missing h-card, rel=me, feed or Webmention endpoint say where the code goes in that tool, or which plugin does it for you.
## Running it
@@ -45,7 +47,30 @@ -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": "..."}`.
+The canonical mf2 parse is under `mf2`. The rest of the response is what the page shows: `summary`, `indieweb`, `rels`, `metadata`, `feed-checks`, `robots`, `domain` and `advice`. `robots` and `domain` are `null` for pasted HTML. A failed fetch answers 422 with `{"error": "..."}`.
+
+## Markdown
+
+`format=markdown` returns the same report as Markdown, served as `text/markdown`: the fetch facts, the counts, a short summary, then every finding grouped into errors, warnings and tips, with its fix in a code block. It's meant for pasting into an issue or keeping next to the site's source.
+
+```bash
+curl "http://127.0.0.1:8123/?url=https://tantek.com/&format=markdown" > report.md
+```
+
+## In CI
+
+Add `fail=errors` and the response comes back as HTTP 412 when the page has any error. `fail=warnings` does the same for errors or warnings. It works with every format, so `curl -f` is enough to break a build:
+
+```bash
+curl -sf "https://indie.pablomurad.com/?url=https://example.com/&fail=errors" -o /dev/null
+```
+
+For finer rules, read the counts from the JSON:
+
+```bash
+curl -s "https://indie.pablomurad.com/?url=https://example.com/&format=json" \
+ | jq -e '.summary.errors == 0 and .summary.warnings <= 2'
+```
## Fetching
@@ -55,13 +80,20 @@ - 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)
+- robots.txt at 64 KB and 8 seconds, then up to 2 of the sitemaps it declares, or `/sitemap.xml` when it declares none (headers only)
+- one request to the `www.` or bare-domain twin of the host, when there is one (headers only)
+
+That's at most 10 requests for one check.
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
+- `public/index.php` takes the request and answers with a page, JSON or Markdown
- `src/Fetcher.php` does the HTTP requests and the address checks
+- `src/Report.php` runs a check, including the robots.txt, sitemap and domain requests
+- `src/Robots.php` reads robots.txt
+- `src/Markdown.php` writes the Markdown report
- `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
@@ -8,6 +8,29 @@ private const LEVELS = ['error' => 0, 'warning' => 1, 'tip' => 2]; private const DURATIONS = ['duration'];
private const LOOSE_URLS = ['email', 'tel', 'impp', 'key', 'uid'];
+ private const GENERATOR_FIXES = [
+ 'no-card' => [
+ 'wordpress' => "<!-- WordPress: the “IndieWeb” plugin adds an h-card widget and author h-card for you. By hand, in your theme: -->\n%card%",
+ 'hugo' => "<!-- Hugo: put this in layouts/partials/h-card.html and include it from your base template with {{ partial \"h-card.html\" . }} -->\n%card%",
+ 'eleventy' => "<!-- Eleventy: put this in _includes/h-card.njk and include it from your base layout with {% include \"h-card.njk\" %} -->\n%card%",
+ ],
+ 'no-rel-me' => [
+ 'wordpress' => "<!-- WordPress: the “IndieWeb” plugin adds rel=me links from your user profile. By hand, in your theme: -->\n<a rel=\"me\" href=\"https://github.com/you\">GitHub</a>\n<a rel=\"me\" href=\"https://mastodon.social/@you\">Mastodon</a>",
+ 'hugo' => "<!-- Hugo: list your profiles under [params] in hugo.toml, then in a partial: -->\n{{ range .Site.Params.relme }}<a rel=\"me\" href=\"{{ . }}\">{{ . }}</a>{{ end }}",
+ 'eleventy' => "<!-- Eleventy: list your profiles in _data/site.json as \"relme\": [...], then in your layout: -->\n{% for url in site.relme %}<a rel=\"me\" href=\"{{ url }}\">{{ url }}</a>{% endfor %}",
+ ],
+ 'no-feed' => [
+ 'wordpress' => "<!-- WordPress makes /feed/ for you. If the link is missing, your theme removed it; add this to header.php: -->\n<link rel=\"alternate\" type=\"application/rss+xml\" title=\"RSS\" href=\"<?php bloginfo('rss2_url'); ?>\">",
+ 'hugo' => "<!-- Hugo builds /index.xml by default. In layouts/partials/head.html: -->\n{{ with .OutputFormats.Get \"rss\" }}<link rel=\"alternate\" type=\"application/rss+xml\" title=\"{{ $.Site.Title }}\" href=\"{{ .Permalink }}\">{{ end }}",
+ 'eleventy' => "<!-- Eleventy: the official @11ty/eleventy-plugin-rss plugin builds the feed. Then in your base layout: -->\n<link rel=\"alternate\" type=\"application/atom+xml\" title=\"Feed\" href=\"/feed.xml\">",
+ ],
+ 'no-webmention' => [
+ 'wordpress' => "<!-- WordPress: the “Webmention” plugin adds the endpoint and receives mentions on your own site. Or point to a hosted service in header.php: -->\n<link rel=\"webmention\" href=\"https://webmention.io/example.com/webmention\">",
+ 'hugo' => "<!-- Hugo: in layouts/partials/head.html -->\n<link rel=\"webmention\" href=\"https://webmention.io/{{ .Site.BaseURL | replaceRE \"^https?://([^/]+).*\" \"$1\" }}/webmention\">",
+ 'eleventy' => "<!-- Eleventy: in your base layout -->\n<link rel=\"webmention\" href=\"https://webmention.io/example.com/webmention\">",
+ ],
+ ];
+
private array $items = [];
public function __construct(
@@ -15,12 +38,16 @@ private Microformats $microformats, private Rels $rels,
private Metadata $metadata,
private array $feedChecks,
+ private ?array $robots,
+ private ?array $domain,
+ private ?string $generator,
private ?string $pageUrl,
private ?string $headerCharset,
) {
$this->checkMicroformats();
$this->checkIndieWeb();
$this->checkMetadata();
+ $this->checkSite();
}
public function all(): array
@@ -51,7 +78,7 @@ 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());
+ $this->fixFor('no-card', $this->cardSnippet()));
}
foreach ($mf->classic as $class => $found) {
@@ -259,7 +286,7 @@ 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());
+ $this->fixFor('no-card', $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.',
@@ -269,13 +296,13 @@ 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>");
+ $this->fixFor('no-rel-me', "<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">');
+ $this->fixFor('no-webmention', '<link rel="webmention" href="https://webmention.io/example.com/webmention">'));
}
$authorization = $this->rels->has('authorization_endpoint');
@@ -349,13 +376,77 @@ $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">');
+ $this->add('no-feed', 'tip', 'Metadata', 'No feed', 'No RSS, Atom, JSON Feed or h-feed found. Readers need one to follow the site.', $this->fixFor('no-feed', '<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 checkSite(): void
+ {
+ if ($this->robots === null && $this->domain === null) {
+ return;
+ }
+
+ $host = $this->pageUrl !== null ? strtolower((string) parse_url($this->pageUrl, PHP_URL_HOST)) : '';
+ $domain = $this->domain;
+
+ if ($domain !== null) {
+ if (!$domain['https']) {
+ $this->add('not-https', 'warning', 'Site', 'Served over plain http',
+ 'The page’s final address is http, so browsers mark it as not secure and anyone on the network can change it in transit. Webmention and IndieAuth services expect https. Let’s Encrypt certificates are free, and most hosts turn them on with one setting.');
+ }
+ if ($domain['canonical-matches'] === false) {
+ $this->add('canonical-elsewhere', 'warning', 'Site', 'canonical points to a different host',
+ "The canonical URL is on “{$domain['canonical-host']}”, but this page lives on “{$host}”. Search engines will credit the other host, and tools that follow canonical links will leave this one. If this is the real address, point canonical here.",
+ sprintf('<link rel="canonical" href="%s">', $this->pageUrl));
+ }
+ $variant = $domain['variant'];
+ if ($variant !== null && !$variant['redirects-to-canonical'] && $variant['status'] < 400) {
+ $this->add('www-duplicate', 'warning', 'Site', "Both {$variant['host']} and {$host} serve the site",
+ "https://{$variant['host']}/ answered HTTP {$variant['status']} without redirecting to {$host}, so the site exists at two addresses. Links, webmentions and search credit get split between them. Redirect one to the other with a 301.");
+ }
+ }
+
+ $robots = $this->robots;
+ if ($robots === null || $robots['unavailable']) {
+ return;
+ }
+
+ if (!$robots['found']) {
+ $this->add('no-robots', 'tip', 'Site', 'No robots.txt',
+ "The site answers HTTP {$robots['status']} for /robots.txt. It isn’t required, but crawlers look there first for your sitemap and your crawling preferences.",
+ sprintf("User-agent: *\nAllow: /\n\nSitemap: https://%s/sitemap.xml", $host ?: 'example.com'), $robots['url']);
+ }
+ if ($robots['blocks-all']) {
+ $this->add('robots-blocks-all', 'warning', 'Site', 'robots.txt blocks every crawler',
+ '“User-agent: *” with “Disallow: /” tells search engines and feed crawlers to stay away from the whole site. If that isn’t on purpose, remove the rule or narrow it to the paths you want hidden.',
+ null, $robots['url']);
+ }
+ foreach ($robots['sitemaps'] as $check) {
+ if (!$check['ok'] && !$check['unavailable']) {
+ $this->add('broken-sitemap-' . $check['url'], 'warning', 'Site', 'A declared sitemap has a problem', $check['problem'], null, $check['url']);
+ }
+ }
+ $fallback = $robots['fallback-sitemap'];
+ if ($robots['sitemaps'] === [] && $fallback !== null && $fallback['missing']) {
+ $this->add('no-sitemap', 'tip', 'Site', 'No sitemap',
+ 'robots.txt doesn’t declare a sitemap and /sitemap.xml doesn’t answer. A sitemap lets search engines find every page, including the ones nothing links to yet.',
+ sprintf('Sitemap: https://%s/sitemap.xml', $host ?: 'example.com'));
+ }
+ }
+
+ private function fixFor(string $key, ?string $default): ?string
+ {
+ $fix = self::GENERATOR_FIXES[$key][$this->generator] ?? null;
+ if ($fix === null) {
+ return $default;
+ }
+
+ return str_replace('%card%', $this->cardSnippet(), $fix);
}
private function cardSnippet(): string