my eye

Bootstrap

Committed 79650f

index 0000000..a5ba6e4
--- /dev/null

+import * as d3 from "d3"
+import * as fs from "fs"
+import * as jsdom from "jsdom"
+
+const CANVAS_SIZE = 300
+const RADIUS_SCALE = 24
+const STROKE = 1
+const axes = ["identity", "authentication", "posts", "syndication", "posting UI",
+              "navigation", "search", "aggregation", "interactivity", "security",
+              "responses"]
+
+const drawLevel = (level) => {
+  const results = scores[level - 1]
+  const radius = RADIUS_SCALE * (level - 1) + (RADIUS_SCALE * 1.5)
+  const diameter = radius * 2
+  const g = svg.append('g')
+    .attr('transform', 'translate(' + CANVAS_SIZE/2 + ',' + CANVAS_SIZE/2 + ')')
+  const colors = ['green', 'orange', 'grey']
+  const palette = []
+  for (let i = 0; i < 10; i++) {
+    palette[i] = colors[results[i][0]]
+  }
+  const color = d3.scaleOrdinal(palette)
+  const pie = d3.pie()
+  const arc = d3.arc()
+    .innerRadius(0)
+    .outerRadius(radius - STROKE)
+  const arcs = g.selectAll('arc')
+    .data(pie([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]))
+    .enter()
+    .append('g')
+    .attr('class', 'arc')
+    .attr('fill', '#222')
+  arcs.append('a')
+    .attr('href', d => { return `/${domain.replace('_', '.')}#${level}-${axes[d.index]}` })
+    .attr('target', '_top')
+    .append('path')
+    .attr('fill', (d, i) => {
+      return color(i)
+    })
+    .attr('d', arc)
+}
+
+const domain = process.argv[2]
+const scores = JSON.parse(fs.readFileSync(`sites/${domain}/details.json`))["scores"];
+const dom = new jsdom.JSDOM('<!DOCTYPE html><body></body>')
+const body = d3.select(dom.window.document.querySelector('body'))
+const svg = body.append('svg')
+  .attr('width', CANVAS_SIZE)
+  .attr('height', CANVAS_SIZE)
+  .attr('xmlns', 'http://www.w3.org/2000/svg')
+drawLevel(5)
+drawLevel(4)
+drawLevel(3)
+drawLevel(2)
+drawLevel(1)
+fs.writeFileSync(`sites/${domain}/scoreboard.svg`, body.html())

index 0000000..c4779dc
--- /dev/null

+"""Analyze personal websites."""
+
+import collections
+import hashlib
+import logging
+import os
+import pathlib
+import subprocess
+import time
+
+import PIL
+import requests
+import web
+import webagt
+import webint_data
+import webint_jobs
+import webint_owner
+import webint_system
+import whois
+from reportlab.graphics import renderPM
+from svglib.svglib import svg2rlg
+from web import tx
+
+from .utils import silos
+
+logging.basicConfig(level=logging.DEBUG, filename="crawl.log", filemode="w", force=True)
+
+app = web.application(
+    __name__,
+    args={
+        "site": r"[a-z\d.-]+\.[a-z]+",
+        "page": r".*",
+    },
+    model={
+        "redirects": {
+            "incoming": "TEXT UNIQUE NOT NULL",
+            "outgoing": "TEXT NOT NULL",
+        },
+        "resources": {
+            "url": "TEXT UNIQUE NOT NULL",
+            "crawled": "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP",
+            "details": "JSON NOT NULL",
+        },
+    },
+    mounts=[webint_data.app, webint_owner.app, webint_jobs.app, webint_system.app],
+)
+sites_path = pathlib.Path("sites")
+sites_path.mkdir(exist_ok=True)
+
+agent = webagt.Agent("IndieWebRocksBot")
+blocklist = ["accounts.google.com"]
+ignored_rels = [
+    "author",
+    "bookmark",
+    "canonical",
+    "category",
+    "contents",
+    "home",
+    "nofollow",
+    "noreferrer",
+    "noopener",
+    "pingback",
+    "profile",
+    "shortcut",
+    "shortlink",
+    "syndication",
+    "tag",
+    "ugc",
+]
+social_network_rels = ["acquaintance", "colleague", "friend", "met"]
+
+
+def refresh_domain(domain):
+    """Fetch `domain` and store site details and related media."""
+    if domain in blocklist or not webagt.uri(domain).suffix:
+        logging.debug(f"skipping {domain}")
+        return
+    # TODO logging.debug("getting previous details..")  # for etag
+    start = time.time()
+    logging.debug("downloading HTML..")
+    try:
+        response = agent.get(domain)
+    except (requests.ConnectionError, requests.Timeout) as err:
+        return {"status": "not responding", "error": str(err)}
+    if domain != response.url.host:
+        try:
+            tx.db.insert("redirects", incoming=domain, outgoing=response.url.host)
+        except tx.db.IntegrityError:
+            tx.db.update(
+                "redirects",
+                outgoing=response.url.host,
+                where="incoming = ?",
+                vals=[domain],
+            )
+        refresh_domain(response.url.host)
+        return
+    domain_details = webagt.uri(domain)
+    try:
+        tx.db.insert(
+            "resources",
+            url=domain,
+            details={
+                "metaverse": hashlib.sha256(domain.encode("utf-8")).hexdigest().upper(),
+                "domain": {
+                    "name": domain,
+                    "suffix": domain_details.suffix,
+                    "hsts": domain_details.in_hsts,
+                },
+            },
+        )
+        web.enqueue(query_whois, domain)
+    except tx.db.IntegrityError:
+        pass
+    site_path = sites_path / domain
+    site_path.mkdir(parents=True, exist_ok=True)
+
+    web.enqueue(run_lighthouse, domain)
+    web.enqueue(run_pa11y, domain)
+
+    update_details = get_updater(domain)
+    update_details(
+        accessed=web.now().to_iso8601_string(),
+        response={
+            "status": response.status,
+            "time": time.time() - start,
+            "headers": dict(response.headers),
+            "length": round(len(response.text) / 1000),
+        },
+    )
+    logging.debug("parsing Microformats..")
+    mf2json = response.mf2json
+    rels = dict(mf2json["rels"])
+
+    if authorization_endpoint := rels.pop("authorization_endpoint", None):
+        indieauth_details = {"authorization_endpoint": authorization_endpoint}
+        if token_endpoint := rels.pop("token_endpoint", None):
+            indieauth_details["token_endpoint"] = token_endpoint
+        update_details(indieauth=indieauth_details)
+    if indieauth_metadata_endpoint := rels.pop("indieauth-metadata", None):
+        web.enqueue(get_indieauth_metadata, domain, indieauth_metadata_endpoint[0])
+
+    if search := rels.pop("search", None):
+        web.enqueue(get_search_description, domain, search[0])
+
+    if manifest := rels.pop("manifest", None):
+        web.enqueue(get_manifest, domain, manifest[0])
+
+    if hub_endpoint := rels.pop("hub", None):
+        web.enqueue(
+            get_websub_hub, domain, hub_endpoint[0], rels.pop("self", [domain])[0]
+        )
+
+    web.enqueue(get_activitypub, domain)
+
+    card = response.card
+    update_details(mf2json=mf2json, card=card, rels=rels)
+    photo_url = rels.pop("apple-touch-icon", None)
+    card_type = None
+    if card:
+        card_type = "person"
+        if card_org := card.get("org"):
+            if card["name"][0] == card_org[0]:
+                card_type = "organization"
+        if emails := card.get("email"):
+            gravatars = {}
+            for email in emails:
+                email = email.removeprefix("mailto:")
+                gravatars[email] = hashlib.md5(
+                    email.strip().lower().encode("utf-8")
+                ).hexdigest()
+            # TODO SET `gravatars`
+        if photo_urls := card.get("photo"):  # TODO move to on-demand like icon?
+            if isinstance(photo_urls[0], dict):
+                photo_url = photo_urls[0]["value"]
+            else:
+                photo_url = photo_urls[0]
+    try:
+        icon_url = rels.pop("icon")[0]
+    except KeyError:
+        icon_url = f"{domain}/favicon.ico"
+    web.enqueue(get_media, domain, photo_url, icon_url)
+
+    scripts = []
+    for script in response.dom.select("script"):
+        script_details = dict(script.element.attrib)
+        script_details["content_length"] = len(script.text)
+        script_details["text"] = script.text
+        scripts.append(script_details)
+    stylesheets = rels.pop("stylesheet", [])
+    for stylesheet in response.dom.select("style"):
+        stylesheets.append(
+            {
+                "content_length": len(stylesheet.text),
+                "text": stylesheet.text,
+            }
+        )
+    whostyle = rels.pop("whostyle", None)
+    try:
+        title = response.dom.select("title")[0].text
+    except IndexError:
+        title = ""
+    update_details(
+        scripts=scripts, stylesheets=stylesheets, whostyle=whostyle, title=title
+    )
+
+    for ignored_rel in ignored_rels:
+        rels.pop(ignored_rel, None)
+    social_network = {}
+    for social_network_rel in social_network_rels:
+        if people_rels := rels.pop(social_network_rel, None):
+            social_network[social_network_rel] = people_rels
+    logging.debug("determining reciprocal rel=me..")
+    reciprocals = set()
+    rel_me_silos = []
+    for silo, silo_details in silos.items():
+        if len(silo_details) == 3:
+            rel_me_silos.append(silo_details[0])
+    rel_mes = rels.pop("me", [])
+    url = webagt.uri(domain)  # TODO XXX
+    for me_url in rel_mes:
+        if not me_url.startswith(("http", "https")):
+            continue
+        me_url = webagt.uri(me_url)
+        logging.debug(f"  rel=me {me_url}")
+        if (me_url.domain, me_url.suffix) == ("twitter", "com"):
+            if "/" in me_url.path:
+                continue
+            twitter_id = me_url.path.split("/")[0]
+            twitter_bearer = os.getenv("TWITTER")
+            twitter_profile = agent.get(
+                f"https://api.twitter.com/2/users"
+                f"/by/username/{twitter_id}?user.fields=url",
+                headers={"Authorization": f"Bearer {twitter_bearer}"},
+            ).json["data"]
+            if twitter_profile_url := twitter_profile.get("url", None):
+                try:
+                    recip_url = agent.get(twitter_profile_url).url
+                except requests.Timeout:
+                    continue
+                if recip_url == url:
+                    reciprocals.add(me_url.minimized)
+        if (me_url.subdomain, me_url.domain, me_url.suffix) == (
+            "en",
+            "wikipedia",
+            "org",
+        ):
+            recip_url = agent.get(me_url).mf2json["items"][0]["properties"]["url"][0]
+            if recip_url == url:
+                reciprocals.add(me_url.minimized)
+        if me_url.host not in rel_me_silos:
+            continue
+        try:
+            reverse_rel_mes = agent.get(me_url).mf2json["rels"]["me"]
+        except KeyError:
+            continue
+        for reverse_rel_me in reverse_rel_mes:
+            if webagt.uri(reverse_rel_me).minimized == url.minimized:
+                reciprocals.add(me_url.minimized)
+    update_details(
+        social_network=social_network, reciprocals=list(reciprocals), rel_me=rel_mes
+    )
+    return
+
+    # XXX feed = page.feed
+    # alt_feed_urls = set()
+    # if not feed["entries"]:
+    #     try:
+    #         alt_feed_urls = set(rels["home"]) & set(rels["alternate"])
+    #     except KeyError:
+    #         pass
+    # alternate_reprs = rels.pop("alternate", [])
+    # alternate_feeds = rels.pop("feed", [])
+    # if not feed["entries"]:
+    #     for alt_feed_url in alternate_reprs + alternate_feeds:
+    #         try:
+    #             feed = agent.get(alt_feed_url).feed
+    #         except ValueError:  # XML feed
+    #             pass
+    #         finally:
+    #             print("using", alt_feed_url)
+    # # rels.pop("alternate", None)
+    # for entry in feed["entries"]:
+    #     try:
+    #         published = entry["published"]
+    #         permalink = entry["url"]
+    #         entry.pop("published-str")
+    #     except KeyError:
+    #         continue
+    #     entry.pop("uid", None)
+    #     # TODO refresh_page(permalink)
+
+    # logging.debug("archiving to WARC..")
+    # warc_file = site_path / "warc_output"
+    # subprocess.run(
+    #     [
+    #         "wget",
+    #         "-EHkpq",
+    #         site,
+    #         f"--warc-file={warc_file}",
+    #         "--no-warc-compression",
+    #         "--delete-after",
+    #     ]
+    # )
+
+    logging.debug("calculating IndieMark score..")
+    scores = [
+        [(3, None)] * 10,
+        [(3, None)] * 10,
+        [(3, None)] * 10,
+        [(3, None)] * 10,
+        [(3, None)] * 10,
+    ]
+
+    # L1 Identity
+    if card:
+        if "icon" in rels:
+            scores[0][0] = (0, "contact info and icon on home page")
+        else:
+            scores[0][0] = (1, "contact info but no icon on home page")
+    else:
+        scores[0][0] = (2, "no contact info on home page")
+
+    # L1 Authentication
+    if rel_mes:
+        scores[0][1] = (
+            1,
+            "<code>rel=me</code>s found but none for GitHub or Twitter",
+        )
+        for rel_me in rel_mes:
+            if rel_me.startswith(("https://github.com", "https://twitter.com/")):
+                scores[0][1] = (
+                    0,
+                    "<code>rel=me</code>s found for GitHub and/or Twitter",
+                )
+                break
+    else:
+        scores[0][1] = (2, "no <code>rel=me</code>s found")
+
+    # L1 Posts
+    if feed["entries"]:
+        if len(feed["entries"]) > 1:
+            scores[0][2] = (0, "more than one post")
+        else:
+            scores[0][2] = (1, "only one post")
+    else:
+        scores[0][2] = (2, "no posts")
+
+    # L1 Search
+    if details["ddg"]:
+        scores[0][6] = (0, "your content was found on DuckDuckgo")
+    else:
+        scores[0][6] = (
+            1,
+            "your content was <strong>not</strong> found on DuckDuckgo",
+        )
+
+    # L1 Interactivity
+    scores[0][8] = (0, "content is accessible (select/copy text/permalinks)")
+
+    # L2 Identity
+    scores[1][0] = (0, "you've linked to silo profiles")
+
+    # L3 'h-card contact info and icon on homepage'
+    # L3 'multiple post types'
+    # L3 'POSSE'
+    # L3 'Posting UI'
+    # L3 'Next/Previus Navigation between posts'
+    # L3 'Search box on your site'
+    # L3 'Embeds/aggregation'
+    # L3 'Web Actions'
+
+    # L4 'Send Webmentions'
+    # L4 'PubSubHubbub support'
+    # L4 'Display Search Results on your site'
+    # L4 'Display Reply Context'
+
+    # L5 'Automatic Webmentions'
+    # L5 'Handle Webmentions'
+    # L5 'Display full content rich reply-contexts'
+    # L5 'Search on your own search backend'
+    # L5 'Multiple Reply Types'
+    # L5 'Display Backfeed of Comments'
+
+    details["scores"] = scores
+    # logging.debug("dumping details..")
+    # details["stored"] = web.now().to_iso8601_string()
+    # web.dump(details, path=site_path / "details.json")
+    logging.debug("generating scoreboard..")
+    subprocess.run(["node", "../index.js", site])
+
+
+def get_updater(url):
+    """Return an update function catered to `domain`."""
+
+    def update_details(**kwargs):
+        """Atomically update the resource's details with `kwargs`."""
+        keys = ", ".join([f"'$.{key}', json(?)" for key in kwargs.keys()])
+        tx.db.update(
+            "resources",
+            what=f"details = json_set(details, {keys})",
+            where="url = ?",
+            vals=[web.dump(v) for v in kwargs.values()] + [url],
+        )
+
+    return update_details
+
+
+def query_whois(domain):
+    """Update the creation date for the domain."""
+    logging.debug("querying WHOIS")
+    domain_created = whois.whois(domain)["creation_date"]
+    if isinstance(domain_created, list):
+        domain_created = domain_created[0]
+    try:
+        domain_created = domain_created.isoformat()
+    except AttributeError:
+        pass
+    get_updater(domain)(**{"domain.created": domain_created})
+
+
+def get_media(domain, photo_url, icon_url):
+    """Download the representative photo for the domain."""
+    site_path = sites_path / domain
+    if photo_url:
+        logging.debug("downloading representative photo..")
+        filename = photo_url.rpartition("/")[2]
+        suffix = filename.rpartition(".")[2]
+        if not suffix:
+            suffix = "jpg"
+        original = site_path / f"photo.{suffix}"
+        webagt.download(photo_url, original)
+        final = site_path / "photo.png"
+        if suffix != "png":
+            if suffix == "svg":
+                drawing = svg2rlg(original)
+                renderPM.drawToFile(drawing, final, fmt="PNG")
+            else:
+                try:
+                    image = PIL.Image.open(original)
+                except PIL.UnidentifiedImageError:
+                    pass
+                else:
+                    image.save(final)
+    logging.debug("downloading iconography..")
+    final = site_path / "icon.png"
+    filename = icon_url.rpartition("/")[2]
+    suffix = filename.rpartition(".")[2]
+    original = site_path / f"icon.{suffix}"
+    try:
+        download = webagt.download(icon_url, original)
+    except web.ConnectionError:
+        pass
+    else:
+        if download.status == 200 and suffix != "png":
+            try:
+                image = PIL.Image.open(original)
+            except PIL.UnidentifiedImageError:
+                pass
+            else:
+                image.save(final)
+
+
+def get_indieauth_metadata(domain, indieauth_metadata_endpoint):
+    """Download IndieAuth metadata for the domain."""
+    logging.debug("downloading IndieAuth metadata..")
+    metadata = agent.get(indieauth_metadata_endpoint).json
+    get_updater(domain)(**{"indieauth": {"metadata": metadata}})
+
+
+def get_search_description(domain, search_url):
+    """Download OpenSearch description document at `search_url`."""
+    logging.debug("downloading OpenSearch description..")
+    search_xml = agent.get(search_url).xml
+    search_url = webagt.uri(search_xml.find("Url", search_xml.nsmap).attrib["template"])
+    search_endpoint = f"//{search_url.host}/{search_url.path}"
+    name = None
+    for name, values in search_url.query.items():
+        if values[0] == "{template}":
+            break
+    get_updater(domain)(**{"search_url": [search_endpoint, name]})
+
+
+def get_manifest(domain, manifest_url):
+    """Download site manifest at `manifest_url`."""
+    logging.debug("downloading site manifest..")
+    # if "patches" in web.get(manifest_url).headers:
+    #     get_updater(domain)(**{"manifest": "hot"})
+    webagt.download(manifest_url, sites_path / domain / "manifest.json")
+
+
+def get_websub_hub(domain, endpoint, self):
+    """Subscribe to site via WebSub `endpoint`."""
+    # TODO subscribe if not already
+    logging.debug("subscribing to WebSub hub..")
+    get_updater(domain)(**{"hub": [endpoint, self]})
+
+
+def run_lighthouse(domain):
+    """Run lighthouse for the domain."""
+    logging.debug("running lighthouse..")
+    subprocess.Popen(
+        [
+            "lighthouse",
+            f"https://{domain}",
+            "--output=json",
+            f"--output-path={sites_path}/{domain}/audits.json",
+            "--only-audits=total-byte-weight",
+            '--chrome-flags="--headless"',
+            "--quiet",
+        ],
+        stdout=subprocess.PIPE,
+    ).stdout.read()
+
+
+def run_pa11y(domain):
+    """Run pa11y for the domain."""
+    site_path = sites_path / domain
+    logging.debug("running pa11y..")
+    web.dump(
+        web.load(
+            subprocess.Popen(
+                [
+                    "pa11y",
+                    domain,
+                    "--reporter",
+                    "json",
+                    "--screen-capture",
+                    site_path / "site.png",
+                ],
+                stdout=subprocess.PIPE,
+            ).stdout.read()
+        ),
+        path=site_path / "a11y.json",
+    )
+
+    found_icon = True  # TODO XXX
+    logging.debug("finding most used color, generating images..")
+    try:
+        screenshot = PIL.Image.open(site_path / "site.png")
+    except FileNotFoundError:
+        pass
+    else:
+        colors = collections.Counter()
+        for x in range(screenshot.width):
+            for y in range(screenshot.height):
+                colors[screenshot.getpixel((x, y))] += 1
+        most_used_color = colors.most_common()[0][0]
+        icon = PIL.Image.new("RGB", (1, 1), color=most_used_color)
+        if not found_icon:
+            icon.save(site_path / "icon.png")
+        if not (site_path / "photo.png").exists():
+            icon.save(site_path / "photo.png")
+
+
+def get_activitypub(domain):
+    webfinger = agent.get(f"https://{domain}/.well-known/webfinger")
+    print(webfinger.json)
+
+
+@app.query
+def get_categories(db):
+    categories = collections.Counter()
+    with db.transaction as cur:
+        for post in cur.cur.execute(
+            "select json_extract(resources.details, '$.category') "
+            "AS categories from resources"
+        ):
+            if not post["categories"]:
+                continue
+            if post_categories := web.load(post["categories"]):
+                for post_category in post_categories:
+                    categories[post_category] += 1
+    return categories
+
+
+@app.query
+def get_resources(db):
+    return db.select(
+        "resources",
+        where="crawled > ?",
+        vals=[web.now().subtract(days=7)],
+        order="crawled DESC",
+    )
+
+
+@app.query
+def get_posts(db):
+    return []
+
+
+@app.query
+def get_people(db):
+    return {
+        url: details["card"]
+        for url, details in tx.db.select(
+            "resources", what="url, details", order="url ASC"
+        )
+    }
+
+
+@app.query
+def get_people_details(db):
+    return tx.db.select("people", order="url ASC")
+
+
+# @app._model.migrate(1)
+# def add_redirects(db):
+#     db.create("redirects", "incoming TEXT UNIQUE NOT NULL, outgoing TEXT NOT NULL")

index 0000000..ebc9deb
--- /dev/null

+"""IndieWeb.Rocks web app."""
+
+import collections
+import hashlib
+
+import easyuri
+import micropub
+import web
+from web import tx
+
+from indieweb_rocks import agent, app, refresh_domain, sites_path
+
+from .utils import silos
+
+
+@app.control("")
+class Landing:
+    """Site landing."""
+
+    def get(self):
+        """Return a search box and short description."""
+        return app.view.landing()
+
+
+@app.control("bot")
+class Bot:
+    """Site bot."""
+
+    def get(self):
+        """Return ."""
+        web.header("Content-Type", "text/html")
+        return "This site uses a bot with <code>User-Agent: IndieWebRocksBot</code>"
+
+
+@app.control("featured")
+class Featured:
+    """Featured sites."""
+
+    def get(self):
+        """Return a list of sites with high test scores."""
+        return app.view.featured()
+
+
+@app.control("silos")
+class Silos:
+    """."""
+
+    def get(self):
+        """."""
+        return app.view.silos()
+
+
+@app.control("silos/url_summaries.json")
+class SiloSummaries:
+    """."""
+
+    def get(self):
+        """."""
+        web.header("Content-Type", "application/json")
+        return silos
+
+
+@app.control("features")
+class Features:
+    """Features of sites."""
+
+    def get(self):
+        """Return a list of features and sites that support them."""
+        return app.view.features()
+
+
+@app.control("screens")
+class Screens:
+    """Site screenshots."""
+
+    def get(self):
+        """Return a list of site screenshots."""
+        urls = [url for url in app.model.get_people()]
+        return app.view.screens(urls)
+
+
+@app.control("the-street")
+class TheStreet:
+    """The Street."""
+
+    def get(self):
+        """Return a list of ."""
+        subdomains = collections.defaultdict(list)
+        for url in app.model.get_people():
+            url = easyuri.parse(url)
+            domain = subdomains[f"{url.domain}.{url.suffix}"]
+            if url.subdomain:
+                domain.append(url.subdomain)
+        domains = sorted(
+            [
+                (hashlib.sha256(d.encode("utf-8")).hexdigest().upper(), d)
+                for d in subdomains
+            ]
+        )
+        return app.view.the_street(domains, subdomains)
+
+
+@app.control("search")
+class Search:
+    """Search the IndieWeb."""
+
+    def get(self):
+        """Return a query's results."""
+        try:
+            query = web.form("q").q
+        except web.BadRequest:
+            raise web.SeeOther("/search")
+        try:
+            url = easyuri.parse(query)
+        except (ValueError, easyuri.SuffixNotFoundError):
+            pass
+        else:
+            if url.suffix:
+                raise web.SeeOther(f"/{url.minimized}")
+        # people = tx.db.select(
+        #     "people", where="url LIKE ? OR name LIKE ?", vals=[f"%{query}%"] * 2
+        # )
+        people = tx.db.select(
+            "resources",
+            what="json_extract(resources.details, '$.card') AS card",
+            where="json_extract(resources.details, '$.card.name') LIKE ?",
+            vals=[f"%{query}%"],
+        )
+        posts = tx.db.select(
+            "resources",
+            # what="json_extract(resources.details, '$.card')",
+            where="json_extract(resources.details, '$.card.name') LIKE ?",
+            vals=[f"%{query}%"],
+        )
+        return app.view.results(query, people, posts)
+
+
+@app.control("posts")
+class Posts:
+    """Show indexed posts."""
+
+    def get(self):
+        """Return a chronological list of posts."""
+        return app.view.posts(app.model.get_posts())
+
+
+@app.control("people")
+class People:
+    """Show indexed people and organizations."""
+
+    def get(self):
+        """Return an alphabetical list of people and organizations."""
+        return app.view.people(app.model.get_people())
+
+
+@app.control("people.txt")
+class PeopleTXT:
+    """Index of people as plaintext."""
+
+    def get(self):
+        """Return a list of indexed sites."""
+        # TODO # accept a
+        # TODO tx.db.select(
+        # TODO     tx.db.subquery(
+        # TODO         "crawls", where="url not like '%/%'", order="crawled desc"
+        # TODO     ),
+        # TODO     group="url",
+        # TODO )
+        return "\n".join([url for url in app.model.get_people()])
+
+
+@app.control("places")
+class Places:
+    """Show indexed places."""
+
+    def get(self):
+        """Return an alphabetical list of places."""
+        return "places"
+
+
+@app.control("events")
+class Events:
+    """Show indexed events."""
+
+    def get(self):
+        """Return a chronological list of events."""
+        return "events"
+
+
+@app.control("recipes")
+class Recipes:
+    """Show indexed recipes."""
+
+    def get(self):
+        """Return a list of recipes."""
+        return ""
+
+
+@app.control("reviews")
+class Reviews:
+    """Show indexed reviews."""
+
+    def get(self):
+        """Return a list of reviews."""
+        return "reviews"
+
+
+@app.control("projects")
+class Projects:
+    """Show indexed projects."""
+
+    def get(self):
+        """Return a list of projects."""
+        return "projects"
+
+
+@app.control("categories")
+class Categories:
+    """Browse by category."""
+
+    def get(self):
+        """Return an alphabetical list of categories."""
+        return app.view.categories(app.model.get_categories())
+
+
+@app.control("micropub")
+class Micropub:
+    """Proxy a Micropub request to the signed in user's endpoint."""
+
+    def post(self):
+        form = web.form()
+        client = micropub.Client(tx.user.session["micropub_endpoint"])
+        permalink = client.create_post(form)
+        raise web.SeeOther(permalink)
+
+
+@app.control("crawler")
+class Crawler:
+    """Crawler."""
+
+    def get(self):
+        """Return a log of crawls and form to post a new one."""
+        return app.view.crawler()  # tx.db.select("crawls"))
+
+    def post(self):
+        urls = web.form("url").url.splitlines()
+        for url in urls:
+            web.enqueue(refresh_domain, url)
+        raise web.Accepted(f"enqueued {len(urls)}")
+
+
+@app.control("crawler/all")
+class RecrawlAll:
+    """Recrawl all people."""
+
+    def post(self):
+        for person in tx.db.select("people"):
+            web.enqueue(refresh_domain, person["url"])
+        raise web.Accepted("enqueued")
+
+
+@app.control("stats")
+class Stats:
+    """Show stats."""
+
+    def get(self):
+        """Return site/IndieWeb statistics."""
+        properties = collections.Counter()
+        for person in app.model.get_people_details():
+            for prop in person["details"]:
+                properties[prop] += 1
+        return app.view.stats(properties)
+
+
+@app.control("sites")
+class Sites:
+    """Index of sites as HTML."""
+
+    def get(self):
+        """Return a list of indexed sites."""
+        # TODO # accept a
+        # TODO tx.db.select(
+        # TODO     tx.db.subquery(
+        # TODO         "crawls", where="url not like '%/%'", order="crawled desc"
+        # TODO     ),
+        # TODO     group="url",
+        # TODO )
+        with tx.db.transaction as cur:
+            urls = cur.cur.execute(
+                " select * from ("
+                + "select * from crawls where url not like '%/%' order by crawled desc"
+                + ") group by url"
+            )
+        return app.view.sites(urls)
+
+
+@app.control("sites.txt")
+class SitesTXT:
+    """Index of sites as plaintext."""
+
+    def get(self):
+        """Return a list of indexed sites."""
+        # TODO # accept a
+        # TODO tx.db.select(
+        # TODO     tx.db.subquery(
+        # TODO         "crawls", where="url not like '%/%'", order="crawled desc"
+        # TODO     ),
+        # TODO     group="url",
+        # TODO )
+        with tx.db.transaction as cur:
+            urls = cur.cur.execute(
+                " select * from ("
+                + "select * from crawls where url not like '%/%' order by crawled desc"
+                + ") group by url"
+            )
+        return "\n".join([url[0] for url in urls])
+
+
+@app.control("commons")
+class Commons:
+    """"""
+
+    def get(self):
+        return app.view.commons()
+
+
+@app.control("robots.txt")
+class RobotsTxt:
+    """"""
+
+    def get(self):
+        return "User-agent: *\nDisallow: /"
+
+
+@app.control("terms")
+class TermsOfService:
+    """Terms of service."""
+
+    def get(self):
+        """Return a terms of service."""
+        return app.view.terms()
+
+
+@app.control("privacy")
+class PrivacyPolicy:
+    """Privacy policy."""
+
+    def get(self):
+        """Return a privacy policy."""
+        subprocess.run(["notify-send", "-u", "critical", "PRIVACY POLICY"])
+        return app.view.privacy()
+
+
+@app.control("map")
+class Map:
+    """Map view."""
+
+    def get(self):
+        """Render a map view."""
+        return app.view.map()
+
+
+@app.control("toolbox")
+class Toolbox:
+    """Tools."""
+
+    def get(self):
+        """Display tools."""
+        return app.view.toolbox()
+
+
+@app.control("toolbox/representative-card")
+class RepresentativeCard:
+    """Representative card tool."""
+
+    def get(self):
+        """Parse representative card."""
+        try:
+            url = web.form("url").url
+        except web.BadRequest:
+            return app.view.toolbox.representative_card()
+        web.header("Content-Type", "application/json")
+        return web.dump(agent.get(url).card.data, indent=2)
+
+
+@app.control("toolbox/representative-feed")
+class RepresentativeFeed:
+    """Representative feed tool."""
+
+    def get(self):
+        """Parse representative feed."""
+        try:
+            url = web.form("url").url
+        except web.BadRequest:
+            return app.view.toolbox.representative_feed()
+        web.header("Content-Type", "application/json")
+        return web.dump(agent.get(url).feed.data, indent=2)
+
+
+@app.control("indieauth")
+class IndieAuth:
+    """IndieAuth support."""
+
+    def get(self):
+        """Return sites with IndieAuth support."""
+        sites = tx.db.select("resources", order="url ASC")
+        # for site in sites:
+        #     details = site["details"]
+        #     if indieauth := details.get("indieauth"):
+        #         domain = details["domain"]["name"]
+        return app.view.indieauth(sites)
+
+
+@app.control("details/{site}(/{page})?")
+class SiteDetails:
+    """A web resource."""
+
+    def get(self, site, page=None):
+        web.header("Content-Type", "application/json")
+        return tx.db.select("resources", where="url = ?", vals=[site])[0]["details"]
+
+
+@app.control("a11y/{site}(/{page})?")
+class Accessibility:
+    """A web resource."""
+
+    def get(self, site, page=None):
+        try:
+            a11y = web.load(path=sites_path / site / "a11y.json")
+        except FileNotFoundError:
+            a11y = None
+        return app.view.a11y(site, a11y)
+
+
+# @app.control("{site}")
+@app.control("{site}(/{page})?")
+class URL:
+    """A web resource."""
+
+    def get(self, site, page=None):
+        """Return a site analysis."""
+        page_url = easyuri.parse(site)
+        if page:
+            page_url = easyuri.parse(f"{site}/{page}")
+        redirect = tx.db.select(
+            "redirects",
+            what="outgoing",
+            where="incoming = ?",
+            vals=[page_url.minimized],
+        )
+        try:
+            raise web.SeeOther(redirect[0]["outgoing"])
+        except IndexError:
+            pass
+        if page:
+            return app.view.page(page_url, {})
+        try:
+            details = tx.db.select("resources", where="url = ?", vals=[site])[0][
+                "details"
+            ]
+            # XXX web.load(path=sites_path / site / "details.json")
+        except IndexError:
+            web.enqueue(refresh_domain, site)
+            return app.view.crawl_enqueued()
+        if site in [s[0] for s in silos.values()]:
+            return app.view.silo(page_url, details)
+        try:
+            audits = web.load(path=sites_path / site / "audits.json")
+        except FileNotFoundError:
+            audits = None
+        try:
+            a11y = web.load(path=sites_path / site / "a11y.json")
+        except FileNotFoundError:
+            a11y = None
+        try:
+            manifest = web.load(path=sites_path / site / "manifest.json")
+        except FileNotFoundError:
+            manifest = None
+        return app.view.site(page_url, details, audits, a11y, manifest)
+
+    def post(self, site):
+        web.enqueue(refresh_domain, site)
+        return app.view.crawl_enqueued()
+        # TODO
+        # if no-flash-header or use form argument:
+        #     raise web.SeeOther(); flash user's session with message to insert as CSS
+        # elif flash-header:
+        #     return just message as JSON
+        raise web.flash("crawl enqueued")
+
+
+# XXX @app.control("sites/{site}/scoreboard.svg")
+# XXX class SiteScoreboard:
+# XXX     """A site's scoreboard."""
+# XXX
+# XXX     def get(self, site):
+# XXX         """Return an SVG document rendering given site's scoreboard."""
+# XXX         web.header("Content-Type", "image/svg+xml")
+# XXX         web.header("X-Accel-Redirect", f"/data/{site}/scoreboard.svg")

index 0000000..34099a3
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.637" cy="28.806" r="28.276"/>
+	<g>
+		<path d="M37.443-3.5c8.988,0,16.57,3.085,22.742,9.257C66.393,11.967,69.5,19.548,69.5,28.5c0,8.991-3.049,16.476-9.145,22.456
+			C53.879,57.319,46.242,60.5,37.443,60.5c-8.649,0-16.153-3.144-22.514-9.43C8.644,44.784,5.5,37.262,5.5,28.5
+			c0-8.761,3.144-16.342,9.429-22.742C21.101-0.415,28.604-3.5,37.443-3.5z M37.557,2.272c-7.276,0-13.428,2.553-18.457,7.657
+			c-5.22,5.334-7.829,11.525-7.829,18.572c0,7.086,2.59,13.22,7.77,18.398c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.123,0,13.334-2.607,18.629-7.828c5.029-4.838,7.543-10.952,7.543-18.343c0-7.276-2.553-13.465-7.656-18.571
+			C50.967,4.824,44.795,2.272,37.557,2.272z M46.129,20.557v13.085h-3.656v15.542h-9.944V33.643h-3.656V20.557
+			c0-0.572,0.2-1.057,0.599-1.457c0.401-0.399,0.887-0.6,1.457-0.6h13.144c0.533,0,1.01,0.2,1.428,0.6
+			C45.918,19.5,46.129,19.986,46.129,20.557z M33.042,12.329c0-3.008,1.485-4.514,4.458-4.514s4.457,1.504,4.457,4.514
+			c0,2.971-1.486,4.457-4.457,4.457S33.042,15.3,33.042,12.329z"/>
+	</g>
+</g>
+</svg>

index 0000000..cb08896
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.785" cy="28.501" r="28.836"/>
+	<path d="M37.441-3.5c8.951,0,16.572,3.125,22.857,9.372c3.008,3.009,5.295,6.448,6.857,10.314
+		c1.561,3.867,2.344,7.971,2.344,12.314c0,4.381-0.773,8.486-2.314,12.313c-1.543,3.828-3.82,7.21-6.828,10.143
+		c-3.123,3.085-6.666,5.448-10.629,7.086c-3.961,1.638-8.057,2.457-12.285,2.457s-8.276-0.808-12.143-2.429
+		c-3.866-1.618-7.333-3.961-10.4-7.027c-3.067-3.066-5.4-6.524-7-10.372S5.5,32.767,5.5,28.5c0-4.229,0.809-8.295,2.428-12.2
+		c1.619-3.905,3.972-7.4,7.057-10.486C21.08-0.394,28.565-3.5,37.441-3.5z M37.557,2.272c-7.314,0-13.467,2.553-18.458,7.657
+		c-2.515,2.553-4.448,5.419-5.8,8.6c-1.354,3.181-2.029,6.505-2.029,9.972c0,3.429,0.675,6.734,2.029,9.913
+		c1.353,3.183,3.285,6.021,5.8,8.516c2.514,2.496,5.351,4.399,8.515,5.715c3.161,1.314,6.476,1.971,9.943,1.971
+		c3.428,0,6.75-0.665,9.973-1.999c3.219-1.335,6.121-3.257,8.713-5.771c4.99-4.876,7.484-10.99,7.484-18.344
+		c0-3.543-0.648-6.895-1.943-10.057c-1.293-3.162-3.18-5.98-5.654-8.458C50.984,4.844,44.795,2.272,37.557,2.272z M37.156,23.187
+		l-4.287,2.229c-0.458-0.951-1.019-1.619-1.685-2c-0.667-0.38-1.286-0.571-1.858-0.571c-2.856,0-4.286,1.885-4.286,5.657
+		c0,1.714,0.362,3.084,1.085,4.113c0.724,1.029,1.791,1.544,3.201,1.544c1.867,0,3.181-0.915,3.944-2.743l3.942,2
+		c-0.838,1.563-2,2.791-3.486,3.686c-1.484,0.896-3.123,1.343-4.914,1.343c-2.857,0-5.163-0.875-6.915-2.629
+		c-1.752-1.752-2.628-4.19-2.628-7.313c0-3.048,0.886-5.466,2.657-7.257c1.771-1.79,4.009-2.686,6.715-2.686
+		C32.604,18.558,35.441,20.101,37.156,23.187z M55.613,23.187l-4.229,2.229c-0.457-0.951-1.02-1.619-1.686-2
+		c-0.668-0.38-1.307-0.571-1.914-0.571c-2.857,0-4.287,1.885-4.287,5.657c0,1.714,0.363,3.084,1.086,4.113
+		c0.723,1.029,1.789,1.544,3.201,1.544c1.865,0,3.18-0.915,3.941-2.743l4,2c-0.875,1.563-2.057,2.791-3.541,3.686
+		c-1.486,0.896-3.105,1.343-4.857,1.343c-2.896,0-5.209-0.875-6.941-2.629c-1.736-1.752-2.602-4.19-2.602-7.313
+		c0-3.048,0.885-5.466,2.658-7.257c1.77-1.79,4.008-2.686,6.713-2.686C51.117,18.558,53.938,20.101,55.613,23.187z"/>
+</g>
+</svg>
diff --git a/indieweb_rocks/static/cc/nc-eu.svg b/indieweb_rocks/static/cc/nc-eu.svg
new file mode 100644
index 0000000..83b2d13
--- /dev/null
+++ b/indieweb_rocks/static/cc/nc-eu.svg
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="36.924" cy="28.403" r="28.895"/>
+	<path d="M60.205,5.779C54.012-0.407,46.428-3.5,37.459-3.5c-8.865,0-16.367,3.091-22.508,9.279C8.649,12.18,5.5,19.752,5.5,28.5
+		c0,8.745,3.149,16.266,9.451,22.558c6.301,6.296,13.802,9.442,22.508,9.442c8.809,0,16.446-3.175,22.907-9.521
+		C66.455,44.952,69.5,37.459,69.5,28.5C69.499,19.539,66.401,11.964,60.205,5.779z M56.199,46.82
+		c-5.286,5.226-11.508,7.837-18.66,7.837c-7.156,0-13.325-2.587-18.504-7.761c-5.179-5.174-7.77-11.306-7.77-18.397
+		c0-3,0.474-5.837,1.41-8.51l8.479,3.754h-0.611v3.803h3.001c0,0.538-0.054,1.073-0.054,1.608v0.912h-2.947v3.803h3.481
+		c0.483,2.84,1.555,5.144,3,6.965c3,3.965,7.822,6.106,13.071,6.106c3.43,0,6.533-1.017,8.357-2.036l-1.287-5.944
+		c-1.125,0.589-3.641,1.391-6.104,1.391c-2.68,0-5.196-0.802-6.911-2.731c-0.803-0.91-1.392-2.144-1.767-3.75h11.646l16.549,7.325
+		C59.433,43.225,57.978,45.102,56.199,46.82z M35.387,30.065l-0.07-0.054l0.12,0.054H35.387z M45.351,27.545h0.479v-3.803h-9.07
+		l-3.685-1.63c0.317-0.713,0.693-1.351,1.131-1.85c1.661-2.039,4.017-2.895,6.589-2.895c2.357,0,4.553,0.696,5.945,1.285l1.5-6.108
+		c-1.93-0.855-4.768-1.605-8.035-1.605c-5.035,0-9.321,2.035-12.375,5.463c-0.678,0.783-1.266,1.662-1.799,2.591l-10.523-4.657
+		c1.02-1.529,2.219-2.997,3.608-4.398c5.021-5.12,11.16-7.681,18.424-7.681c7.26,0,13.429,2.56,18.502,7.681
+		c5.124,5.066,7.687,11.252,7.687,18.562c0,2.407-0.272,4.678-0.812,6.82L45.351,27.545z"/>
+</g>
+</svg>
diff --git a/indieweb_rocks/static/cc/nc-jp.svg b/indieweb_rocks/static/cc/nc-jp.svg
new file mode 100644
index 0000000..e8442c6
--- /dev/null
+++ b/indieweb_rocks/static/cc/nc-jp.svg
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="0 0 64 64" enable-background="new 0 0 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="32.485" cy="31.496" r="28.44"/>
+	<path d="M54.708,9.279C48.512,3.093,40.932,0,31.958,0C23.095,0,15.592,3.093,9.453,9.279C3.15,15.68,0,23.253,0,32
+		c0,8.746,3.15,16.268,9.453,22.561C15.752,60.854,23.255,64,31.958,64c8.812,0,16.449-3.173,22.909-9.52
+		C60.956,48.454,64,40.959,64,32C64,23.04,60.902,15.466,54.708,9.279z M50.701,50.32c-5.287,5.227-11.508,7.839-18.661,7.839
+		c-7.156,0-13.324-2.587-18.503-7.761C8.357,45.227,5.768,39.093,5.768,32c0-2.728,0.391-5.318,1.164-7.777l16.383,7.295H19.71
+		v4.981h7.496l0.733,1.521v2.414H19.71v4.98h8.229v7.234h7.963v-7.234h8.281v-4.604l10.448,4.653
+		C53.561,47.202,52.252,48.822,50.701,50.32z M43.337,40.434h-7.433V38.02l0.375-0.728L43.337,40.434z M44.184,33.809v-2.291h-4.979
+		l8.125-14.969h-8.492l-5.595,12.388l-2.969-1.322l-5.113-11.066H16.67l3.524,6.578L9.628,18.422
+		c1.098-1.741,2.424-3.403,3.988-4.983c5.02-5.12,11.16-7.68,18.424-7.68c7.26,0,13.429,2.56,18.502,7.68
+		C55.668,18.506,58.23,24.692,58.23,32c0,2.707-0.342,5.241-1.021,7.607L44.184,33.809z"/>
+</g>
+</svg>

index 0000000..fcf2f4b
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.47" cy="28.736" r="29.471"/>
+	<g>
+		<path d="M37.442-3.5c8.99,0,16.571,3.085,22.743,9.256C66.393,11.928,69.5,19.509,69.5,28.5c0,8.992-3.048,16.476-9.145,22.458
+			C53.88,57.32,46.241,60.5,37.442,60.5c-8.686,0-16.19-3.162-22.513-9.485C8.644,44.728,5.5,37.225,5.5,28.5
+			c0-8.762,3.144-16.343,9.429-22.743C21.1-0.414,28.604-3.5,37.442-3.5z M12.7,19.872c-0.952,2.628-1.429,5.505-1.429,8.629
+			c0,7.086,2.59,13.22,7.77,18.4c5.219,5.144,11.391,7.715,18.514,7.715c7.201,0,13.409-2.608,18.63-7.829
+			c1.867-1.79,3.332-3.657,4.398-5.602l-12.056-5.371c-0.421,2.02-1.439,3.667-3.057,4.942c-1.622,1.276-3.535,2.011-5.744,2.2
+			v4.915h-3.714v-4.915c-3.543-0.036-6.782-1.312-9.714-3.827l4.4-4.457c2.094,1.942,4.476,2.913,7.143,2.913
+			c1.104,0,2.048-0.246,2.83-0.743c0.78-0.494,1.172-1.312,1.172-2.457c0-0.801-0.287-1.448-0.858-1.943l-3.085-1.315l-3.771-1.715
+			l-5.086-2.229L12.7,19.872z M37.557,2.214c-7.276,0-13.428,2.571-18.457,7.714c-1.258,1.258-2.439,2.686-3.543,4.287L27.786,19.7
+			c0.533-1.676,1.542-3.019,3.029-4.028c1.484-1.009,3.218-1.571,5.2-1.686V9.071h3.715v4.915c2.934,0.153,5.6,1.143,8,2.971
+			l-4.172,4.286c-1.793-1.257-3.619-1.885-5.486-1.885c-0.991,0-1.876,0.191-2.656,0.571c-0.781,0.381-1.172,1.029-1.172,1.943
+			c0,0.267,0.095,0.533,0.285,0.8l4.057,1.83l2.8,1.257l5.144,2.285l16.397,7.314c0.535-2.248,0.801-4.533,0.801-6.857
+			c0-7.353-2.552-13.543-7.656-18.573C51.005,4.785,44.831,2.214,37.557,2.214z"/>
+	</g>
+</g>
+</svg>

index 0000000..fca6bf2
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="11 -7 64 64" enable-background="new 11 -7 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.564" cy="28.288" r="29.013"/>
+	<g>
+		<path d="M37.443-3.5c8.951,0,16.531,3.105,22.742,9.315C66.393,11.987,69.5,19.548,69.5,28.5c0,8.954-3.049,16.457-9.145,22.514
+			C53.918,57.338,46.279,60.5,37.443,60.5c-8.649,0-16.153-3.143-22.514-9.43C8.644,44.786,5.5,37.264,5.5,28.501
+			c0-8.723,3.144-16.285,9.429-22.685C21.138-0.395,28.643-3.5,37.443-3.5z M37.557,2.272c-7.276,0-13.428,2.572-18.457,7.715
+			c-5.22,5.296-7.829,11.467-7.829,18.513c0,7.125,2.59,13.257,7.77,18.4c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.123,0,13.334-2.608,18.629-7.828c5.029-4.876,7.543-10.989,7.543-18.343c0-7.313-2.553-13.485-7.656-18.513
+			C51.004,4.842,44.832,2.272,37.557,2.272z M49.615,20.956v5.486H26.358v-5.486H49.615z M49.615,31.243v5.483H26.358v-5.483H49.615
+			z"/>
+	</g>
+</g>
+</svg>

index 0000000..2f03e0a
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.471" cy="28.424" r="28.553"/>
+	<g>
+		<path d="M37.443-3.5c8.988,0,16.58,3.096,22.77,9.286C66.404,11.976,69.5,19.547,69.5,28.5c0,8.954-3.049,16.437-9.145,22.456
+			C53.918,57.319,46.279,60.5,37.443,60.5c-8.687,0-16.182-3.144-22.486-9.43C8.651,44.784,5.5,37.262,5.5,28.5
+			c0-8.761,3.144-16.342,9.429-22.742C21.101-0.415,28.604-3.5,37.443-3.5z M37.529,2.272c-7.257,0-13.401,2.553-18.428,7.657
+			c-5.22,5.296-7.829,11.486-7.829,18.572s2.59,13.22,7.771,18.398c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.162,0,13.371-2.607,18.629-7.828c5.029-4.877,7.543-10.991,7.543-18.343c0-7.314-2.553-13.504-7.656-18.571
+			C50.967,4.824,44.785,2.272,37.529,2.272z M22.471,37.186V19.472h8.8c4.342,0,6.514,1.999,6.514,6
+			c0,0.686-0.105,1.342-0.314,1.972c-0.209,0.629-0.572,1.256-1.086,1.886c-0.514,0.629-1.285,1.143-2.314,1.543
+			c-1.028,0.399-2.247,0.6-3.656,0.6h-3.486v5.714H22.471z M26.871,22.785v5.372h3.771c0.914,0,1.6-0.258,2.058-0.772
+			c0.458-0.513,0.687-1.152,0.687-1.915c0-1.79-0.953-2.686-2.858-2.686h-3.657V22.785z M38.984,37.186V19.472h6.859
+			c2.818,0,5.027,0.724,6.629,2.171c1.598,1.448,2.398,3.677,2.398,6.686c0,3.01-0.801,5.24-2.398,6.686
+			c-1.602,1.447-3.811,2.171-6.629,2.171H38.984z M43.387,23.186v10.287h2.57c1.562,0,2.695-0.466,3.4-1.401
+			c0.705-0.933,1.057-2.179,1.057-3.742c0-1.562-0.352-2.809-1.057-3.743c-0.705-0.933-1.857-1.399-3.457-1.399L43.387,23.186
+			L43.387,23.186z"/>
+	</g>
+</g>
+</svg>

index 0000000..778c048
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.834" cy="28" r="28.834"/>
+	<g>
+		<path d="M37.443-3.5c8.951,0,16.531,3.105,22.742,9.315C66.393,11.987,69.5,19.548,69.5,28.5c0,8.954-3.049,16.457-9.145,22.514
+			C53.918,57.338,46.279,60.5,37.443,60.5c-8.649,0-16.153-3.143-22.514-9.429C8.644,44.786,5.5,37.264,5.5,28.501
+			c0-8.723,3.144-16.285,9.429-22.685C21.138-0.395,28.643-3.5,37.443-3.5z M37.557,2.272c-7.276,0-13.428,2.572-18.457,7.715
+			c-5.22,5.296-7.829,11.467-7.829,18.513c0,7.125,2.59,13.257,7.77,18.4c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.123,0,13.334-2.609,18.629-7.828c5.029-4.876,7.543-10.99,7.543-18.343c0-7.313-2.553-13.485-7.656-18.513
+			C51.004,4.842,44.832,2.272,37.557,2.272z M58.414,29.072l0.629,0.286v9.028l-0.572,0.284l-7.771,3.315L50.357,42.1l-0.4-0.114
+			l-16.743-6.914l-0.572-0.229l-8.285,3.429l-8.171-3.544V26.5l7.657-3.201l-0.057-0.057v-9.029l8.686-3.828l19.6,8.114v7.943
+			L58.414,29.072z M49.328,39.584v-5.655h-0.057v-0.229l-14.686-6v5.83l14.686,6.058v-0.058L49.328,39.584z M50.299,32.157
+			l5.145-2.114l-4.744-2l-5.029,2.114L50.299,32.157z M57.043,37.072V31.53l-5.715,2.4v5.6L57.043,37.072z"/>
+	</g>
+</g>
+</svg>

index 0000000..8d5ffde
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="36.944" cy="28.631" r="29.105"/>
+	<g>
+		<path d="M37.443-3.5c8.951,0,16.531,3.105,22.742,9.315C66.393,11.987,69.5,19.548,69.5,28.5c0,8.954-3.049,16.457-9.145,22.514
+			C53.918,57.338,46.279,60.5,37.443,60.5c-8.649,0-16.153-3.143-22.514-9.429C8.644,44.786,5.5,37.264,5.5,28.501
+			c0-8.723,3.144-16.285,9.429-22.685C21.138-0.395,28.643-3.5,37.443-3.5z M37.557,2.272c-7.276,0-13.428,2.572-18.457,7.715
+			c-5.22,5.296-7.829,11.467-7.829,18.513c0,7.125,2.59,13.257,7.77,18.4c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.123,0,13.334-2.609,18.629-7.828c5.029-4.876,7.543-10.99,7.543-18.343c0-7.313-2.553-13.485-7.656-18.513
+			C51.004,4.842,44.832,2.272,37.557,2.272z M23.271,23.985c0.609-3.924,2.189-6.962,4.742-9.114
+			c2.552-2.152,5.656-3.228,9.314-3.228c5.027,0,9.029,1.62,12,4.856c2.971,3.238,4.457,7.391,4.457,12.457
+			c0,4.915-1.543,9-4.627,12.256c-3.088,3.256-7.086,4.886-12.002,4.886c-3.619,0-6.743-1.085-9.371-3.257
+			c-2.629-2.172-4.209-5.257-4.743-9.257H31.1c0.19,3.886,2.533,5.829,7.029,5.829c2.246,0,4.057-0.972,5.428-2.914
+			c1.373-1.942,2.059-4.534,2.059-7.771c0-3.391-0.629-5.971-1.885-7.743c-1.258-1.771-3.066-2.657-5.43-2.657
+			c-4.268,0-6.667,1.885-7.2,5.656h2.343l-6.342,6.343l-6.343-6.343L23.271,23.985L23.271,23.985z"/>
+	</g>
+</g>
+</svg>

index 0000000..b125942
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.447" cy="28.448" r="28.042"/>
+	<g>
+		<path d="M37.46-3.5c-8.864,0-16.367,3.093-22.509,9.28C8.65,12.18,5.5,19.753,5.5,28.5c0,8.748,3.15,16.269,9.451,22.56
+			c6.301,6.295,13.804,9.44,22.509,9.44c8.811,0,16.447-3.173,22.909-9.519C66.454,44.953,69.5,37.459,69.5,28.5
+			c0-8.96-3.098-16.533-9.291-22.72C54.014-0.407,46.432-3.5,37.46-3.5z M37.54,2.259c7.263,0,13.43,2.56,18.503,7.681
+			c5.125,5.066,7.688,11.252,7.688,18.56c0,7.359-2.508,13.468-7.528,18.32c-5.287,5.228-11.509,7.841-18.663,7.841
+			c-7.156,0-13.323-2.59-18.502-7.761c-5.181-5.173-7.771-11.309-7.771-18.4c0-7.094,2.616-13.281,7.85-18.56
+			C24.137,4.819,30.277,2.259,37.54,2.259z"/>
+		<path d="M51.362,28.791c-0.607,0-1.155,0.365-1.385,0.925l-0.511,1.228l-1.424-11.979c-0.093-0.764-0.747-1.337-1.52-1.323
+			c-0.767,0.015-1.404,0.61-1.466,1.378l-0.549,6.637l-0.671-8.882c-0.061-0.779-0.71-1.386-1.492-1.386
+			c-0.785-0.002-1.437,0.602-1.498,1.382l-0.451,5.796l-0.766-12.162c-0.05-0.791-0.703-1.407-1.498-1.407
+			c-0.793,0.002-1.447,0.618-1.495,1.41l-0.659,10.8l-0.557-8.552c-0.051-0.791-0.703-1.403-1.494-1.403
+			c-0.792,0-1.446,0.61-1.498,1.401L31.747,23l-0.534-7.349c-0.059-0.78-0.703-1.386-1.484-1.392
+			c-0.783-0.005-1.439,0.591-1.506,1.372l-0.999,11.59l-0.23-1.576c-0.098-0.653-0.606-1.166-1.26-1.265
+			c-0.655-0.099-1.294,0.241-1.577,0.838l-1.698,3.572h-5.667v2.998h6.615c0.564,0,1.083-0.318,1.337-0.82l1.386,9.436
+			c0.111,0.751,0.77,1.303,1.528,1.28c0.761-0.023,1.383-0.614,1.451-1.368l0.484-5.621l0.732,10.107
+			c0.06,0.786,0.714,1.395,1.502,1.391c0.789-0.002,1.44-0.616,1.491-1.4l0.6-9.089l0.616,9.464c0.051,0.789,0.707,1.402,1.499,1.4
+			c0.793,0,1.445-0.618,1.494-1.407l0.634-10.384l0.582,9.262c0.048,0.783,0.699,1.398,1.486,1.404
+			c0.786,0.007,1.444-0.602,1.505-1.384l0.588-7.564l0.632,8.312c0.058,0.782,0.705,1.385,1.486,1.387
+			c0.784,0.004,1.437-0.596,1.5-1.375l0.882-10.705l0.344,2.88c0.079,0.68,0.606,1.214,1.284,1.31
+			c0.678,0.093,1.329-0.28,1.589-0.912l2.324-5.601h6.515v-2.999H51.362z M41.296,29.465h-2.831v2.831
+			c0,0.534-0.432,0.966-0.964,0.966c-0.534,0-0.968-0.432-0.968-0.966v-2.831h-2.83c-0.533,0-0.967-0.432-0.967-0.964
+			c0-0.534,0.434-0.967,0.967-0.967h2.83v-2.832c0-0.534,0.434-0.966,0.968-0.966c0.532,0,0.964,0.432,0.964,0.966v2.832h2.831
+			c0.533,0,0.966,0.432,0.966,0.967C42.262,29.033,41.829,29.465,41.296,29.465z"/>
+	</g>
+</g>
+</svg>

index 0000000..6d5f8fc
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="38.05" cy="28.468" r="29.482"/>
+	<g>
+		<path d="M37.443-3.5c8.988,0,16.58,3.096,22.77,9.286C66.404,11.976,69.5,19.547,69.5,28.5c0,8.954-3.049,16.437-9.145,22.456
+			C53.918,57.319,46.279,60.5,37.443,60.5c-8.687,0-16.182-3.144-22.486-9.43C8.651,44.784,5.5,37.262,5.5,28.5
+			c0-8.761,3.144-16.342,9.429-22.742C21.101-0.415,28.604-3.5,37.443-3.5z M37.529,2.272c-7.257,0-13.401,2.553-18.428,7.657
+			c-5.22,5.296-7.829,11.486-7.829,18.572s2.59,13.22,7.771,18.398c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.162,0,13.371-2.607,18.629-7.828c5.029-4.877,7.543-10.991,7.543-18.343c0-7.314-2.553-13.504-7.656-18.571
+			C50.967,4.824,44.785,2.272,37.529,2.272z M38.014,9.128c0.381-0.038,0.715,0.067,1.002,0.314
+			c0.285,0.249,0.445,0.563,0.484,0.943v0.229l0.857,13.828l0.629-7.657c0-0.381,0.133-0.705,0.398-0.971s0.592-0.401,0.971-0.401
+			c0.381,0,0.705,0.134,0.973,0.401c0.266,0.267,0.4,0.59,0.4,0.971v0.228l0.74,10.286l0.744-8.285
+			c0.037-0.342,0.182-0.629,0.43-0.857c0.246-0.229,0.541-0.342,0.885-0.342s0.648,0.106,0.914,0.314
+			c0.268,0.21,0.42,0.486,0.459,0.829l1.486,12.457l0.686-1.657c0.229-0.572,0.666-0.858,1.312-0.858h7.486v2.744h-6.572
+			l-2.342,5.714c-0.268,0.685-0.764,0.972-1.486,0.856c-0.342-0.038-0.619-0.172-0.828-0.4s-0.334-0.514-0.371-0.857l-0.514-4.114
+			l-0.971,11.942c-0.039,0.341-0.182,0.628-0.43,0.856s-0.543,0.343-0.887,0.343c-0.342,0-0.646-0.114-0.914-0.343
+			c-0.266-0.229-0.418-0.513-0.457-0.856v-0.172l-0.799-9.886l-0.686,9.315c-0.078,0.342-0.24,0.629-0.486,0.857
+			c-0.248,0.229-0.543,0.342-0.887,0.342c-0.342,0-0.648-0.113-0.914-0.342s-0.42-0.515-0.457-0.857V43.87l-0.744-11.143
+			l-0.742,12.229v0.172c0,0.382-0.135,0.713-0.4,0.999s-0.59,0.43-0.971,0.43c-0.383,0-0.705-0.144-0.971-0.43
+			c-0.268-0.286-0.402-0.617-0.402-0.999v-0.115l-0.742-11.313l-0.686,10.914v0.171c-0.077,0.344-0.238,0.63-0.485,0.857
+			c-0.248,0.229-0.543,0.343-0.885,0.343s-0.648-0.114-0.914-0.343c-0.267-0.228-0.419-0.514-0.458-0.857v-0.171h-0.056v-0.17
+			l-0.8-11.428l-0.629,7.313c-0.038,0.344-0.191,0.63-0.457,0.857c-0.267,0.229-0.571,0.344-0.914,0.344
+			c-0.343,0-0.639-0.105-0.887-0.315c-0.248-0.208-0.41-0.485-0.486-0.828l-1.429-9.828l-0.171,0.341
+			c-0.228,0.496-0.648,0.744-1.258,0.744h-6.628V28.9h5.772l1.771-3.6c0.267-0.609,0.732-0.867,1.4-0.772
+			c0.666,0.095,1.057,0.467,1.171,1.114l0.4,2.628l1.085-12.628c0-0.381,0.133-0.705,0.4-0.971s0.59-0.401,0.971-0.401
+			s0.704,0.134,0.971,0.401c0.267,0.267,0.399,0.59,0.399,0.971v0.228l0.629,8.915l0.857-11.942v-0.171
+			c0.037-0.343,0.18-0.629,0.428-0.857c0.247-0.228,0.542-0.342,0.886-0.342s0.648,0.114,0.914,0.342
+			c0.268,0.229,0.418,0.514,0.457,0.857v0.171l0.686,10.4l0.801-12.628v-0.229c0-0.342,0.123-0.629,0.371-0.857
+			C37.375,9.3,37.67,9.167,38.014,9.128z"/>
+	</g>
+</g>
+</svg>

index 0000000..8375c34
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
+<svg version="1.0" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="5.5 -3.5 64 64" enable-background="new 5.5 -3.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="37.946" cy="28.887" r="29.704"/>
+	<g>
+		<path d="M37.443-3.5c8.951,0,16.531,3.105,22.742,9.315C66.393,11.987,69.5,19.548,69.5,28.5c0,8.954-3.049,16.457-9.145,22.514
+			C53.918,57.338,46.279,60.5,37.443,60.5c-8.649,0-16.153-3.143-22.514-9.429C8.644,44.786,5.5,37.264,5.5,28.501
+			c0-8.723,3.144-16.285,9.429-22.685C21.138-0.395,28.643-3.5,37.443-3.5z M37.557,2.272c-7.276,0-13.428,2.572-18.457,7.715
+			c-5.22,5.296-7.829,11.467-7.829,18.513c0,7.125,2.59,13.257,7.77,18.4c5.181,5.182,11.352,7.771,18.514,7.771
+			c7.123,0,13.334-2.609,18.629-7.828c5.029-4.876,7.543-10.99,7.543-18.343c0-7.313-2.553-13.485-7.656-18.513
+			C51.004,4.842,44.832,2.272,37.557,2.272z M50.586,19.357c0.494,0,0.914,0.171,1.256,0.513c0.344,0.343,0.516,0.763,0.516,1.258
+			v23.542c0,0.495-0.172,0.914-0.516,1.256c-0.342,0.343-0.762,0.516-1.256,0.516H33.157c-0.496,0-0.914-0.171-1.258-0.516
+			c-0.344-0.343-0.514-0.761-0.514-1.256v-6.973h-6.971c-0.497,0-0.915-0.17-1.258-0.513c-0.342-0.342-0.514-0.761-0.514-1.258
+			V12.386c0-0.458,0.151-0.848,0.458-1.171c0.303-0.323,0.685-0.523,1.142-0.6h0.171h17.428c0.494,0,0.914,0.171,1.258,0.514
+			c0.342,0.342,0.514,0.763,0.514,1.258v6.972H50.586z M26.128,34.214h5.257V21.128c0-0.457,0.151-0.847,0.458-1.171
+			c0.304-0.322,0.667-0.523,1.085-0.6h0.228h6.972v-5.2h-14V34.214z M48.871,22.842h-14v20.058h14V22.842z"/>
+	</g>
+</g>
+</svg>

index 0000000..100e139
--- /dev/null

+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 13.0.2, SVG Export Plug-In . SVG Version: 6.00 Build 14948)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="64px" height="64px" viewBox="-0.5 0.5 64 64" enable-background="new -0.5 0.5 64 64" xml:space="preserve">
+<g>
+	<circle fill="#FFFFFF" cx="31.325" cy="32.873" r="30.096"/>
+	<path id="text2809_1_" d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42
+		c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157
+		c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z
+		 M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136
+		c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"/>
+	<path id="path2815_1_" d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532
+		C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396
+		c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465
+		c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5
+		c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259
+		c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061
+		c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994
+		c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516
+		c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628
+		C18.092,8.818,24.252,6.259,31.567,6.259z"/>
+</g>
+</svg>

index 0000000..3a0f3ae
Binary files /dev/null and b/indieweb_rocks/static/favicon.ico differ

index 0000000..11fc95e
Binary files /dev/null and b/indieweb_rocks/static/microformats.png differ

index 0000000..f9223cb
--- /dev/null

+:root {
+  background-color: #ddd;
+  color: #222;
+  font-size: 16px;
+  line-height: 1.5;
+  margin: 0; }
+body {
+  margin: 2em auto 0 auto;
+  max-width: 40em; }
+p {
+  hyphens: auto; }
+body.widescreen article {
+  margin: 0;
+  max-width: 100%;
+  padding: 0; }
+article {
+  margin: 0 auto;
+  max-width: 50em;
+  padding: 0 2em; }
+body > nav, article, body > footer {
+  background-color: #ddd;
+  margin: 0 auto; }
+body > nav, body > footer {
+  padding: 0 2em; }
+footer {
+  font-size: .8em;
+  text-align: center; }
+h1 {
+  font-size: 2em;
+  margin-top: .5em; }
+/* form label small {
+  display: block; } */
+input[type=text] {
+  width: 27em; }
+input::placeholder {
+  opacity: .4; }
+/* form input, form textarea {
+  border: .1em inset black;
+  font-family: monospace;
+  font-size: 1.25em;
+  padding: .25em .25em .25em .25em;
+  width: 20em; } */
+/* button {
+  border: .1em outset black;
+  font-family: monospace;
+  font-size: 1.25em; } */
+form#analyzer button {
+  background-color: #ccc;
+  border: .15em outset #999;
+  border-width: 0 .1em .1em .1em;
+  border-radius: 0 0 .5em .5em;
+  color: #333;
+  float: right;
+  font-size: .8em;
+  margin-right: -1em;
+  padding: .35em;
+  text-transform: uppercase; }
+form#analyzer button:hover {
+  background-color: #bbb; }
+form#analyzer button:active {
+  border-style: inset; }
+ul {
+  padding-left: 1.5em; }
+.pass {
+  color: green; }
+.maybe {
+  color: yellow; }
+.fail {
+  color: black; }
+.elsewhere {
+  clear: both; }
+.entry {
+  background-color: #ddd;
+  border-radius: 1em;
+  margin: 0 0 2em 0;
+  padding: 0 1em 1em 1em; }
+#scoreboard {
+  display: block;
+  margin: 0 auto; }
+.NOTE {
+  background-color: yellow;
+  font-size: .7em;
+  line-height: 1.5em; }
+.NOTE:before {
+  content: 'NOTE: '; }
+.TODO {
+  color: purple; }
+code {
+  background-color: #ccc;
+  border-radius: .25em;
+  font-size: .8em;
+  line-height: 1;
+  padding: .05em; }
+#indiemark {
+  margin: 0 auto;
+  max-width: 25em; }
+#indiemark code {
+  background-color: #333; }
+.uninterpreted {
+  background-color: #aee219;
+  border: .25em solid #679a06;
+  border-radius: .5em;
+  font-size: .8em;
+  padding: 1em; }
+.uninterpreted p {
+  font-weight: bold; }
+.cclicense {
+  height: 1.5em;
+  margin-right: .25em; }
+.urlbox {
+  color: #000;
+  text-decoration: none; }
+.urlbox:hover em {
+  text-decoration: underline; }
+.urlbox img {
+  position: relative;
+  top: .1em; }
+.urlbox small {
+  position: relative;
+  top: -.2em; }
+.elsewhere {
+  list-style: none;
+  padding: 0 }
+
+body.notoolbars > nav, body.notoolbars > footer {
+  display: none; }

index 0000000..0e70eb1
--- /dev/null

+import math
+import re
+from collections import defaultdict
+from hashlib import sha256
+from pprint import pformat
+
+import pendulum
+import phonenumbers
+import webagt
+from indieweb_rocks.utils import silos
+from web import now, tx
+from webagt import uri
+
+__all__ = [
+    "re",
+    "get_dt",
+    "tx",
+    "uri",
+    "silos",
+    "pformat",
+    "get_silo",
+    "get_human_size",
+    "now",
+    "sha256",
+    "format_phonenumber",
+    "defaultdict",
+    "pendulum",
+    "math",
+]
+
+
+def format_phonenumber(tel):
+    return phonenumbers.format_number(
+        phonenumbers.parse(tel, "US"), phonenumbers.PhoneNumberFormat.INTERNATIONAL
+    )
+
+
+def get_dt(dt):
+    try:
+        return pendulum.instance(dt)
+    except ValueError:
+        return pendulum.parse(dt)
+
+
+def get_silo(url):
+    for silo, details in silos.items():
+        try:
+            domain, profile_patterns, _ = details
+        except ValueError:
+            domain, profile_patterns = details
+        for profile_pattern in profile_patterns:
+            if match := re.match(
+                f"{domain}/{profile_pattern}", url.removeprefix("www.")
+            ):
+                return silo, webagt.uri(url).host, profile_pattern, match.groups()[0]
+    return None
+
+
+suffixes = ["B", "KB", "MB", "GB", "TB", "PB"]
+
+
+def get_human_size(nbytes):
+    i = 0
+    while nbytes >= 1024 and i < len(suffixes) - 1:
+        nbytes /= 1024.0
+        i += 1
+    f = ("%.2f" % nbytes).rstrip("0").rstrip(".")
+    return "%s %s" % (f, suffixes[i])

index 0000000..abf9fc5
--- /dev/null

+$def with (site, issues)
+$var title: Accessibility issues for $site
+$var body_classes = ["widescreen"]
+
+<p style=text-align:center><a href=https://www.w3.org/WAI/WCAG2AA-Conformance>Web
+Content Accessibility Guidelines (WCAG) 2 Level AA Conformance</a></p>
+
+<div style="display:grid;grid-template-columns:60% 40%;height:40em">
+<div style="border:0;height:40em;overflow-y:auto;width:100%">
+    <img src=/sites/$site/fullpage.png width=100%>
+</div>
+<div style="height:40em;overflow-y:auto;padding:">
+$for issue in issues:
+    $ title = issue.pop("code")
+    $ code = title.split(".")[4]
+    <h5>$title.removeprefix("WCAG2AA.")</h5>
+    <p>$issue.pop("message")&nbsp;<small><a
+    href=//www.w3.org/TR/WCAG20-TECHS/$code>read&nbsp;more</a></small></p>
+    <p><small><code>$issue.pop("selector")</code></p>
+    <p><code>$issue.pop("context")</code></small></p>
+</div>
+</div>

index 0000000..ba5e864
--- /dev/null

+$def with ()
+$var title: About
+
+<footer class=h-app>
+<p>🚧 <a href=/ class="u-url p-name" rel=me>IndieWeb.rocks</a> by
+🦺 <a href=//ragt.ag class="u-author h-card">Angelo Gladding</a>
+<small>(<a href=//ragt.ag/code/indieweb.rocks>view source</a> &ndash; <a href=//ragt.ag/code/indieweb.rocks/issues>report an issue</a>)</small></p>
+</footer>

index 0000000..f8ed5ba
--- /dev/null

+$def with (categories)
+$var title: Categories
+
+<p id=categories><small>\
+$for category, count in categories.most_common():
+    $if count == 1:
+        $continue
+    $if not loop.first:
+        ,
+    <a href=/categories/$category>$category</a> <small>$count</small>\
+</small></p>

index 0000000..615dcc8
--- /dev/null

+$def with ()
+$var title: Explore the Creative Commons
+
+<h1>Explore the Creative Commons</h1>
+
+<input type=range name=freeness min=0 max=5 value=1>

index 0000000..743320f
--- /dev/null

+$var title: Crawl enqueued
+
+<p>Crawl enqueued.</p>

index 0000000..11da8b5
--- /dev/null

+$def with ()
+$var title: Crawler
+
+<h1>Crawler</h1>
+
+<style>
+textarea {
+    height: 30em;
+    width: 20em; }
+</style>
+
+<form method=post>
+<textarea name=url></textarea>
+<button>Crawl</button>
+</form>
+
+$if tx.user.is_owner:
+    <form method=post action=/crawler/all>
+    <button>Recrawl All</button>
+    </form>

index 0000000..b2f3953
--- /dev/null

+$def with (url)
+$var query = url.minimized
+<h1>Domain suffix does not exist</h1>

index 0000000..0202b49
--- /dev/null

+$def with ()
+$var title: Frequenty Asked Questions
+
+<h2>How can I remove myself from the results?</h2>
+<p>Remove the representative h-card from your homepage and click the "Recrawl" button.</p>
+<p><strong>Or,</strong> sign in with your website and click the "Hide" button.</p>

index 0000000..b81ea14
--- /dev/null

+$def with ()
+$var title: Featured Sites
+
+<p>These IndieWeb-friendly sites score high on performance, accessibility,
+best practices, SEO and PWA support.</p>

index 0000000..cc50786
--- /dev/null

+$def with ()
+$var title: Features
+
+<p>Features and the sites that support them.</p>

index 0000000..975fa09
--- /dev/null

+$def with (sites)
+$var title: IndieAuth
+
+<h1>IndieAuth</h1>
+
+<p>IndieAuth is a federated login protocol for Web sign-in, enabling users
+to use their own domain to sign in to other sites and services. <a
+href=//indieweb.org/indieauth>wiki</a> <a href=//indieauth.spec.indieweb.org>spec</a> <a href=/tools/indieauth>implement</a></p>
+
+<h3>Personal websites using IndieAuth</h3>
+<ul>
+$for site in sites:
+    $ details = site["details"]
+    $if indieauth := details.get("indieauth"):
+        $ domain = details["domain"]["name"]
+        <li><a href=/$domain>$domain</a> <pre><small>$pformat(indieauth)</small></pre></li>
+</ul>

index 0000000..322b9b3
--- /dev/null

+$def with ()
+<h1 class=h-app><span p-name>IndieWeb Site Validator</span></h1>
+
+<form id=search action=/search method=get>
+<label style=font-size:.8em><strong>Website Address</strong><br>
+<input name=q type=text placeholder="e.g. example.com"></label>
+<button>Validate</button>
+</form>
+
+<h2>Supported Specifications</h2>
+<h3>Technical Reports (TRs)</h3>
+<ul id=tr style=columns:2>
+<li><a href=https://www.w3.org/TR/webmention>Webmention</a></li>
+<li><a href=https://www.w3.org/TR/micropub>Micropub</a></li>
+<li><a href=https://www.w3.org/TR/websub>WebSub</a></li>
+</ul>
+<h3>Works in Progress</h3>
+<div id=wip style="display:grid;grid-template-columns:50% 50%">
+<ul>
+<li><a href=https://microformats.org/wiki/rel-me>rel=me</a></li>
+<li><a href=https://www.w3.org/TR/indieauth>IndieAuth</a></li>
+<li><a href=https://indieweb.org/IndieAuth_Ticket_Auth>Ticket Auth</a></li>
+<li><a href=https://www.w3.org/TR/post-type-discovery>Post Type Discovery</a></li>
+<li><a href=https://www.w3.org/TR/jf2>JF2</a></li>
+<li><a href=https://indieweb.org/fragmention>Fragmention</a></li>
+<li><a href=https://indieweb.org/Microsub>Microsub</a></li>
+</ul>
+</div>
+
+<style>
+#wip ul {
+    margin-top: 0; }
+</style>

index 0000000..1b2d140
--- /dev/null

+$def with ()
+$var title: Map
+
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css" integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ==" crossorigin=""/>
+<script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js" integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ==" crossorigin=""></script>
+
+<style>
+.leaflet-container {
+	height: 400px;
+	width: 600px;
+	max-width: 100%;
+	max-height: 100%;
+}
+</style>
+
+<div id="map" style="width: 600px; height: 400px;"></div>
+<script>
+
+var map = L.map('map').setView([51.505, -0.09], 13);
+
+var tiles = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
+  maxZoom: 19,
+  attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
+}).addTo(map);
+
+var marker = L.marker([51.5, -0.09]).addTo(map)
+  .bindPopup('<b>Hello world!</b><br />I am a popup.').openPopup();
+
+var circle = L.circle([51.508, -0.11], {
+  color: 'red',
+  fillColor: '#f03',
+  fillOpacity: 0.5,
+  radius: 500
+}).addTo(map).bindPopup('I am a circle.');
+
+var polygon = L.polygon([
+  [51.509, -0.08],
+  [51.503, -0.06],
+  [51.51, -0.047]
+]).addTo(map).bindPopup('I am a polygon.');
+
+
+var popup = L.popup()
+  .setLatLng([51.513, -0.09])
+  .setContent('I am a standalone popup.')
+  .openOn(map);
+
+function onMapClick(e) {
+  popup
+    .setLatLng(e.latlng)
+    .setContent('You clicked the map at ' + e.latlng.toString())
+    .openOn(map);
+}
+
+map.on('click', onMapClick);
+
+</script>

index 0000000..1a49c12
--- /dev/null

+$def with (url, page)
+
+$page

index 0000000..0df89eb
--- /dev/null

+$def with (people)
+$var title: People
+
+<h1>People</h1>
+
+<ul id=people>
+$for url, card in people.items():
+    $if not card:
+        $continue
+    <li style="display:grid;grid-template-columns:2em auto;
+    grid-column-gap:.5em;margin-bottom:.5em"><img
+    style=height:2.25em;width:2.25em src=/sites/$url/photo.png>
+    <div><a href=/$url style=color:black>$card["name"][0]<br><small
+    style=color:green>$url</small></a></div></li>
+</ul>

index 0000000..454feb0
--- /dev/null

+$def with (entries)
+$var title: Entries
+
+$for entry in entries:
+    <div>$entry</div>

index 0000000..0828945
--- /dev/null

+$def with ()
+$var title: Privacy Policy

index 0000000..cd2c35e
--- /dev/null

+$def with (query, people, posts)
+$var title: Results for $query
+
+<ul>
+$for person in people:
+    <li>$dict(person)</li>
+    $# <li><a href=/$person["url"]>$dict(person)</a></li>
+</ul>
+
+<ul>
+$for post in posts:
+    <li><a href=/$post["url"]>$dict(post)</a></li>
+</ul>

index 0000000..7218590
--- /dev/null

+$def with (sites)
+$var title: Site Screenshots
+
+<div style="display:grid;grid-template-columns:25% 25% 25% 25%">
+$for site in sites:
+    <div><a href=//$site><img style="width:100%" src=/sites/$site/screenshot.png></a></div>
+</div>

index 0000000..bf5d5d8
--- /dev/null

+$def with (url, site)
+$var title: The $url.host silo
+
+<style>
+.urlbox {
+  line-height: 1.5; }
+.urlbox a {
+  color: #000;
+  display: block;
+  text-decoration: none; }
+.urlbox a:hover em {
+  text-decoration: underline; }
+.urlbox a img {
+  position: relative;
+  top: .1em; }
+.urlbox a small {
+  position: relative;
+  top: -.2em; }
+</style>
+<p class=urlbox><a href=$url>
+$if site["title"]:
+    <em>$site["title"]</em>
+    <img src=/sites/$url.minimized/icon.png style=height:1em;width:1em>
+$if site["hsts"]:
+    $ style = "color:green"
+$elif url.scheme == "https":
+    $ style = "color:green"
+$else:
+    $ style = "color:red"
+<small style=$style>$url</small>
+<img src=/sites/$(url.minimized)/screenshot.png width=100%>
+</a></p>

index 0000000..bec29e5
--- /dev/null

+$def with ()
+$var title: Silos
+
+<ul id=services>
+$for silo, (domain, *_) in silos.items():
+    <li style=padding:.25em><a href=/$domain><img style=height:1.25em;width:1.25em
+    src=/sites/$domain/icon.png>&nbsp;$silo</a></li>
+</ul>
+
+<p><small><a href=/silos/url_summaries.json><code>url_summaries.json</code></a></small></p>

index 0000000..e5f73b2
--- /dev/null

+$def with (url, details, audits, a11y, manifest)
+$ short_title = str(url).removeprefix("@").removeprefix("https://")
+$var title = short_title
+
+<h3>$short_title</h3>
+
+$ axes = ["identity", "authentication", "posts", "syndication", "posting UI",
+$         "navigation", "search", "aggregation", "interactivity", "security",
+$         "responses"]
+$ statuses = ["pass", "maybe", "fail"]
+
+$def render_uninterpreted(title, object, type):
+    <div class=uninterpreted>
+    <a href=//microformats.org/wiki/$title.rstrip("=")><img src=/static/microformats.png
+    style=float:right;height:2em alt="microformats logo"></a>
+    <p><em>Uninterpreted <code>$title</code> $type</em>:</p>
+    <dl>
+    $for key, values in sorted(object.items()):
+        <dt>$key</dt>
+        $if not isinstance(values, list):
+            $ values = [values]
+        $for value in values:
+            <dd>
+            $if type == "links":
+                $uri(value).minimized
+            $elif type == "properties":
+                $value
+            </dd>
+    </dl>
+    </div>
+
+$if manifest:
+    <style>
+    body {
+      background-color: $manifest.get("background_color", "none"); }
+    </style>
+
+$ all_urls = []
+$ rels = details["mf2json"]["rels"]
+
+<div style="display:grid;grid-gap:1em;grid-template-columns:12em auto">
+
+<div style=font-size:.9em>
+
+$ card = details["card"]
+$ urls = []
+$ web_sign_in = []
+$if card:
+    <div class=h-card>
+    $ name = card.pop("name")[0]
+    $# XXX $var title: $:name
+    $ card.pop("family-name", None)
+    $ card.pop("given-name", None)
+    $ nicknames = card.pop("nickname", [])
+    $ orgs = card.pop("org", None)
+    $if photo := card.pop("photo", None):
+        <img src=/sites/$url.minimized/photo.png style=width:100% alt="$name's profile picture">
+    <h1 style=margin-bottom:0>$name</h1>
+    $if nicknames:
+        <p style=margin-top:0><small>a.k.a. $", ".join(nicknames)</small></p>
+    <a href=$url class=urlbox rel=me>
+    <span><img src=/sites/$(url.minimized)/icon.png style=height:1em;width:1em title="\
+    $if page_title := details.get("title"):
+        $page_title\
+    "> <small
+    $if whois_created := details.get("whois_created", None):
+        title="$whois_created"
+        $ years_held = (pendulum.now() - pendulum.parse(whois_created)).years
+        $if years_held < 1:
+            $ whois_color = "red"
+        $elif years_held < 5:
+            $ whois_color = "orange"
+        $elif years_held < 10:
+            $ whois_color = "yellow"
+        $elif years_held < 15:
+            $ whois_color = "green"
+        $elif years_held < 20:
+            $ whois_color = "blue"
+        $elif years_held < 25:
+            $ whois_color = "purple"
+        style="color:$whois_color"
+    >$details["domain"]["name"]</small></span></a>
+    $if "metaverse" in details:
+        $ hash = details["metaverse"][:5]
+        <small><a href=/the-street#$hash><code>$hash</code></a></small><br>
+    $ pronouns = card.pop("pronouns", [])
+    $ card.pop("pronoun", None)
+    $if orgs and name == orgs[0]:
+        🧑‍🤝‍🧑
+    $elif pronouns:
+        $if "they" in pronouns[0]:
+            🧑
+        $elif "she" in pronouns[0]:
+            👩
+        $elif "he" in pronouns[0]:
+            👨
+    $else:
+        🧑
+    <small>
+    $if pronouns:
+        $:pronouns[0].replace(" ", "").replace("/", "&thinsp;/&thinsp;")
+    $elif pronouns := card.pop("pronoun", None):
+        $for pronoun in pronouns:
+            $pronoun\
+            $if not loop.last:
+                &thinsp;/&thinsp;\
+    </small>
+    $if bday := card.pop("bday", None):
+        $ year, month, day = re.match("(\d\d\d\d|-)-(\d\d?|-)-(\d\d?|-)", bday[0]).groups()
+        $if year != "-":
+            $ year = int(year)
+        $ month = int(month)
+        $ day = int(day)
+        $ months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
+        $ months += ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+        $ n = now()
+        <span title="$months[int(month)-1] $day, $year"
+        $if not (month == n.month and day == n.day):
+            style="opacity:25%"
+        >🎂</span>
+    $if "rel_me" not in details:
+        $ details["rel_me"] = details["rels"].pop("me", [])  # TODO REMOVE
+    $ urls = set(uri(u).minimized for u in card.pop("url", []) + details["rel_me"])
+    $ reciprocals = set(details.pop("reciprocals", []))
+    $ self_rel_me = f"indieweb.rocks/{url.minimized}"
+    $if self_rel_me in reciprocals:
+        $ urls.discard(self_rel_me)
+        $ reciprocals.discard(self_rel_me)
+        🍄
+    $if orgs:
+        <br><small>🧑‍🤝‍🧑
+        $for org in orgs:
+            $if isinstance(org, dict):
+                $ org_props = org.pop("properties")
+                $if "url" in org_props:
+                    <a href=$org_props["url"][0]>\
+                $org_props["name"][0]\
+                $if "url" in org_props:
+                    </a>\
+            $else:
+                $org\
+            $if not loop.last:
+                , 
+        </small>
+    $if roles := card.pop("role", None):
+        <br><small>
+        $for role in roles:
+            <code>$role</code>\
+            $if not loop.last:
+                , 
+        </small>
+    $if note := card.pop("note", None):
+        <p style=font-size:.75em;hyphens:auto>$note[0]</p>
+    $if categories := card.pop("category", None):
+        <p>🏷️ <small>
+        $for category in categories:
+            <code>\
+            $if isinstance(category, dict):
+                $ cat_props = category.pop("properties")
+                $if "url" in cat_props:
+                    <a href=$cat_props["url"][0]>\
+                $cat_props["name"][0]\
+                $if "url" in cat_props:
+                    </a>\
+            $else:
+                $category\
+            </code>\
+            $if not loop.last:
+                , 
+        </small></p>
+    $ street_address = card.pop("street-address", None)
+    $ locality = card.pop("locality", None)
+    $ region = card.pop("region", None)
+    $ postal_code = card.pop("postal-code", None)
+    $ country_name = card.pop("country-name", None)
+    $if street_address:
+        <p>📍
+        $if street_address:
+            $street_address[0]
+        $ area_line = []
+        $if locality:
+            $ area_line.append(locality[0])
+        $if region:
+            $ area_line.append(region[0])
+        $", ".join(area_line)
+        $if postal_code:
+            $postal_code[0]
+        $if country_name:
+            $country_name[0]
+        </p>
+    $ emails = [e.partition(":")[2] for e in card.pop("email", [])]
+    $ tels = []
+    $for tel in card.pop("tel", []):
+        $if ":" in tel:
+            $tels.append(tel.partition(":")[2])
+        $else:
+            $tels.append(tel)
+    $ keys = set(card.pop("key", []) + rels.pop("pgpkey", []))
+    $ all_urls = list(urls)
+    $for _url in sorted(urls):
+        $if _url.startswith("sms:") or _url.startswith("callto:"):
+            $ tel = _url.partition(":")[2]
+            $if tel not in tels:
+                $ tels.append(tel)
+            $urls.remove(_url)
+        $elif _url.startswith("mailto:"):
+            $ email = _url.partition(":")[2]
+            $if email not in emails:
+                $ emails.append(email)
+            $urls.remove(_url)
+    $if emails:
+        <ul class=elsewhere>
+        $for email in emails:
+            <li>📧 <small><a href=mailto:$email>$email</a></small>
+            $if "gravatars" in details:
+                $if gravatar := details["gravatars"].pop(email, None):
+                    <a href=//www.gravatar.com/$gravatar><img style=height:1em
+                    src=//www.gravatar.com/avatar/$(gravatar).jpg></a>
+            </li>
+            $ web_sign_in.append(email)
+        </ul>
+    $if tels:
+        <ul class=elsewhere>
+        $for tel in tels:
+            <li>📱 <small>$format_phonenumber(tel)</small><br>
+            <small><a href=callto:$tel>call</a> <a href=sms:$tel>message</a></small>
+            </li>
+            $ web_sign_in.append(tel)
+        </ul>
+    $if keys:
+        <p>🔐
+        $for key in keys:
+            $key
+            $if not loop.last:
+                ,
+            $ web_sign_in.append(uri(key).minimized)
+        </p>
+
+    $def render_rel_me(silo_name, domain, profile_pattern, user):
+        $ path = re.sub(r"(\(.+\))", user, profile_pattern).replace("\\", "")
+        <a href=/$domain title=$silo_name><img src=/sites/$domain/icon.png
+        style=height:1em></a> <a href=https://$domain/$path>$user</a>
+
+    $ supported_web_signin_silos = ["github.com", "twitter.com"]
+    $if urls:
+        $for _url in sorted(urls):
+            $if _url.startswith(url.minimized):
+                $ urls.remove(_url)
+                $continue
+        <ul class=elsewhere>
+        $for _url in sorted(urls):
+            $if _url in reciprocals:
+                $ urls.remove(_url)
+                <li>
+                $if silo := get_silo(_url):
+                    $:render_rel_me(*silo)
+                $else:
+                    $_url
+                ☑️
+                </li>
+                $if _url.partition("/")[0] in supported_web_signin_silos:
+                    $ web_sign_in.append(_url)
+        $for _url in sorted(urls):
+            $if silo := get_silo(_url):
+                $ urls.remove(_url)
+                <li>$:render_rel_me(*silo)</li>
+        $for _url in sorted(urls):
+            <li><a href=//$_url>$_url</a></li>
+        </ul>
+    $ card.pop("uid", None)  # FIXME: what is it good for?
+    $if card:
+        $:render_uninterpreted("h-card", card, "properties")
+    </div>
+    $if payments := rels.pop("payment", None):
+        <h3>Payment</h3>
+        <ul>
+        $for payment in payments:
+            $ payment_url = uri(payment)
+            <li><img src=/sites/$payment_url.host/icon.png><a href=$payment>$payment_url</a></li>
+        </ul>
+$else:
+    <p><em>No <a href=https://indieweb.org/representative_h-card>representative
+    card</a> available.</em></p>
+
+$# <img src=/sites/$(url.minimized)/screenshot.png width=100%>
+
+$ license = rels.pop("license", None)
+$if license:
+    <p><a href=$license[0]>
+    $if cc := re.match(r"https://creativecommons.org/licenses/([a-z-]+)/(\d.\d)", license[0]):
+        $ license, version = cc.groups()
+        <span title="CC $license.upper() $version">
+        <img class=cclicense src=/static/cc/cc.svg alt="Creative Commons logo">\
+        $for part in license.split("-"):
+            <img class=cclicense src=/static/cc/$(part).svg \
+            alt="Creative Commons $(part) license logo">\
+        </span>
+    $else:
+        Licensed $license[0].
+    </a></p>
+
+$if "search_url" in details:
+    $ search_url, search_query_name = details["search_url"]
+    <form action=$search_url method=get>
+    <input type=text name=$search_query_name>
+    <button>Search</button>
+    </form>
+</div>
+<div>
+$# $ feed = details["feed"]
+$# $if feed["entries"]:
+$#     <div class=h-feed>
+$#     $for entry in feed["entries"]:
+$#         $# <pre>$pformat(entry)</pre>
+$#         $if details["whostyle"]:
+$#             <iframe
+$#             onload="this.style.height=(this.contentWindow.document.body.scrollHeight+25)+'px'"
+$#             style=border:0;width:100% srcdoc='<link rel=stylesheet href=$uri(details["whostyle"][0]).normalized>
+$#             <div class=whostyle-$uri(details["url"]).minimized.replace(".", "-")>
+$#         <div class=entry>
+$#         $ entry_url = entry.pop("url", None)
+$#         $ entry_type = entry.pop("type")
+$#         $ post_type = entry.pop("post-type")
+$#         $if entry_type == "entry":
+$#             $if in_reply_to := entry.pop("in-reply-to", None):
+$#                 $ reply = in_reply_to[0]
+$#                 $ reply_url = reply.get("url", "")
+$#                 <p>↩️
+$#                 $ gh_issue_re = r"https://github.com/(\w+)/([\w-]+)/issues/(\d+)(#([\w-]+))?"
+$#                 $if gh_match := re.match(gh_issue_re, reply_url):
+$#                     $ user, repo, issue, _, comment = gh_match.groups()
+$#                     <img src=/sites/github.com/icon.png style=height:1em alt="GitHub logo">
+$#                     <a href=https://github.com/$user>$user</a> /
+$#                     <a href=https://github.com/$user/$repo>$repo</a> /
+$#                     <a href=https://github.com/$user/$repo/issues>issues</a> /
+$#                     <a href=https://github.com/$user/$repo/issues/$issue>$issue</a> # 
+$#                     <a href=https://github.com/$user/$repo/issues/$issue#$comment>$comment</a>
+$#                 $elif tw_match := re.match(r"https://twitter.com/(\w+)/status/(\d+)", reply_url):
+$#                     $ user, tweet = tw_match.groups()
+$#                     <img src=/sites/twitter.com/icon.png style=height:1em class="Twitter logo">
+$#                     <a href=https://twitter.com/$user>$user</a> /
+$#                     <a href=https://twitter.com/$user/status/$tweet>$tweet</a>
+$#                 $else:
+$#                     <a href=$reply_url>$reply_url</a>
+$#                 </p>
+$#             $if photo := entry.pop("photo", None):
+$#                 <p><img src=$photo style=max-width:100% alt=$photo /></p>
+$#             $if entry_name := entry.pop("name", None):
+$#                 <h3>$entry_name</h3>
+$#             $if summary := entry.pop("summary", None):
+$#                 $if entry_name != summary:
+$#                     <p>$summary</p>
+$#             $if like_of := entry.pop("like-of", None):
+$#                 $ like_url = like_of[0]["url"]
+$#                 <p>♥️ <a href=$like_url>$like_url</a></p>
+$#             $if content := entry.pop("content", None):
+$#                 $if post_type == "article":
+$#                     <p>$entry.pop("content-plain")[:280]&hellip;</p>
+$#                 $else:
+$#                     <p>$:content</p>
+$#                     $ entry.pop("content-plain")
+$#             $if categories := entry.pop("category", None):
+$#                 <p><small>
+$#                 $for category in categories:
+$#                     <code>$category</code>\
+$#                     $if not loop.last:
+$#                         , 
+$#                 </small></p>
+$#         $elif entry_type == "event":
+$#             <p>$entry.pop("name")<br>
+$#             <small>$entry.pop("start")&thinsp;&ndash;&thinsp;$entry.pop("end", None)</small></p>
+$#             $ entry.pop("start-str")
+$#             $ entry.pop("end-str", None)
+$#             <form method=post action=/micropub>
+$#             <input type=hidden name=in-reply-to value="$entry_url">
+$#             <select name=rsvp>
+$#             <option value=yes>Yes</option>
+$#             <option value=no>No</option>
+$#             <option value=maybe>Maybe</option>
+$#             </select>
+$#             <button>RSVP</button>
+$#             </form>
+$#         <p style=text-align:right>\
+$#         $if author := entry.pop("author", None):
+$#             $if author_url := author.pop("url", None):
+$#                 $if uri(author_url).minimized not in all_urls:
+$#                     $author_url
+$#         <small>
+$#         $if location := entry.pop("location", None):
+$#             $if "latitude" in location:
+$#                 <a href=/map?lat=$location['latitude']&lng=$location['longitude']>\
+$#                 $location["latitude"], $location["longitude"]</a>
+$#         $if published := entry.pop("published", None):
+$#             <time value="$published.get('datetime')" datetime="$published" class="dt-published">$published.get('datetime')</time>
+$#             $# $get_dt(published).diff_for_humans()
+$#             $# $if updated := entry.pop("updated", None):
+$#             $#     $if updated != published:
+$#             $#         , <small>updated $get_dt(updated).diff_for_humans()</small>
+$#             $ entry.pop("published-str", None)
+$#             $ entry.pop("updated", None)
+$#             $ entry.pop("updated-str", None)
+$#         <br><a href=$entry_url>$uri(entry_url).path</a>
+$#         $if syndication_urls := entry.pop("syndication", None):
+$#             $for syndication_url in syndication_urls:
+$#                 $if tw_match := re.match(r"https://twitter.com/(\w+)/status/(\d+)", syndication_url):
+$#                     $ user, tweet = tw_match.groups()
+$#                     <a href=https://twitter.com/$user/status/$tweet><img
+$#                     src=/sites/twitter.com/icon.png style=height:1em class="Twitter logo"></a>
+$#                     $# <a href=https://twitter.com/$user>$user</a> /
+$#                     $# <a href=https://twitter.com/$user/status/$tweet>$tweet</a>
+$#                 $else:
+$#                     $syndication_url\
+$#                 $if not loop.last:
+$#                     , 
+$#         </small></p>
+$#         $ entry.pop("uid", None)  # FIXME: what is it good for?
+$#         $if entry:
+$#             $:render_uninterpreted(f"h-{entry_type}", entry, "properties")
+$#         </div>
+$#         $if details["whostyle"]:
+$#             </div>'>
+$#             </iframe>
+$#     $if rel_next := rels.pop("next", None):
+$#         <p>next: <a href=$rel_next[0]>$rel_next[0]</a></p>
+$#     $if rel_prev := rels.pop("prev", None):
+$#         <p>previous: <a href=$rel_prev[0]>$rel_prev[0]</a></p>
+$#     </div>
+$# $else:
+$#     <p><em>No <a href=https://indieweb.org/feed#How_to_Publish>content
+$#     feed</a> available.</em></p>
+</div>
+
+</div>
+
+<hr style="border:.1em solid #333">
+
+<div style="display:grid;grid-template-columns:50% 50%;">
+
+<div>
+
+$ auth_ep = rels.pop("authorization_endpoint", None)
+$ token_ep = rels.pop("token_endpoint", None)
+$ ticket_ep = None
+$ indieauth_metadata = details.pop("indieauth_metadata", None)
+$ openid_delegate = rels.pop("openid.delegate", None)
+$ openid_server = rels.pop("openid.server", None)
+$if indieauth_metadata:
+    $ auth_ep = indieauth_metadata.get("authorization_endpoint", None)
+    $ token_ep = indieauth_metadata.get("token_endpoint", None)
+    $ ticket_ep = indieauth_metadata.get("ticket_endpoint", None)
+
+$if auth_ep:
+    <p class=pass>Supports
+$else:
+    <p class=fail>Does not support
+<a href=/indieauth>IndieAuth</a>\
+$if auth_ep:
+    $if token_ep:
+         with a <a href=//indieauth.spec.indieweb.org/#token-endpoint>token endpoint</a>\
+        $if ticket_ep:
+             and a <a href=//indieweb.org/IndieAuth_Ticket_Auth#Create_the_IndieAuth_ticket>ticket endpoint</a>\
+.
+</p>
+
+$# $if auth_ep and not indieauth_metadata:
+$#     <p class=NOTE><code>rel=authorization_endpoint</code> is deprecated, leave
+$#     it for now but start using <code>rel=indieauth-metadata</code> instead
+$#     <sup><a href=https://indieauth.spec.indieweb.org/\
+$#     #changes-from-26-november-2020-to-this-version-li-1>read more</a></sup></p>
+
+$ authn = [uri(authn).minimized for authn in rels.pop("authn", [])]
+$if web_sign_in:
+    <p class=pass>Supports <a href=https://microformats.org/wiki/web_sign-in>web sign-in</a>.</p>
+    <ul>
+    $for web_sign_in_endpoint in web_sign_in:
+        $if authn and web_sign_in_endpoint not in authn:
+            $continue
+        <li>$web_sign_in_endpoint</li>
+    </ul>
+
+$# $if openid_delegate and openid_server:
+$#     <p class=NOTE>OpenID <strong>was a protocol</strong> for using a web address
+$#     as an identity to sign-in to websites; it is losing support, <strong>is
+$#     effectively dead</strong> (versions 1 & 2 are both deprecated, sites are
+$#     dropping support), and <strong>has been replaced on the IndieWeb with
+$#     web-sign-in and IndieAuth</strong>. <sup><a
+$#     href=https://indieweb.org/OpenID>read more</a></sup></p>
+
+$ webmention_ep = rels.pop("webmention", None)
+$if webmention_ep:
+    <p class=pass>Supports
+$else:
+    <p class=fail>Does not support
+<a href=//www.w3.org/TR/webmention/>Webmention</a> on the homepage.
+</p>
+
+$ micropub_ep = rels.pop("micropub", None)
+$ media_ep = rels.pop("media-endpoint", None)
+$if micropub_ep:
+    <p class=pass>Supports
+$else:
+    <p class=fail>Does not support
+<a href=//micropub.spec.indieweb.org>Micropub</a>\
+$if micropub_ep and media_ep:
+     with a <a href=//micropub.spec.indieweb.org/#media-endpoint>media endpoint</a>\
+.
+</p>
+
+$ microsub_ep = rels.pop("microsub", None)
+$if microsub_ep:
+    <p class=pass>Supports
+$else:
+    <p class=fail>Does not support
+<a href=//indieweb.org/Microsub>Microsub</a>.
+</p>
+
+</div>
+
+$# $def list_reasons(level):
+$#     <ul id=level$level>
+$#     $for n, (status, reason) in enumerate(details["scores"][level-1]):
+$#         $if status != 3:
+$#             <li id=$(level)-$axes[n] class=$statuses[status]>$:(reason.capitalize()).</li>
+$#     </ul>
+$# 
+$# <div id=indiemark>
+$# <object id=scoreboard data=/sites/$(url.minimized)/scoreboard.svg></object>
+$# <div style="background-color:#222;color:#999;font-size:.8em;padding:.5em 1em;">
+$# <h4>Level 1: Use your domain for identity, sign-in, and publishing posts</h4>
+$# $:list_reasons(1)
+$# <h4>Level 2: Improve your personal identity and post multiple types of posts</h4>
+$# $:list_reasons(2)
+$# <h4>Level 3: Post and send replies from your own site</h4>
+$# $:list_reasons(3)
+$# <h4>Level 4: Receive and show comments</h4>
+$# $:list_reasons(4)
+$# <h4>Level 5: Manage comments</h4>
+$# $:list_reasons(5)
+$# </div>
+$# </div>
+
+</div>
+
+$ dependencies = []
+$#details.pop("stylesheets")
+$# $for stylesheet in details.pop("stylesheets"):
+$#     $if not stylesheet.startswith(url.normalized):
+$#         $ dependencies.append(stylesheet)
+$# $for script in details.pop("scripts"):
+$#     $if "src" in script:
+$#         $if not script["src"].startswith(url.normalized):
+$#             $ dependencies.append(script["src"])
+
+$# <h2>Media</h2>
+$#
+$# <h3>Stylesheets</h3>
+$# $if details["stylesheets"]:
+$#     <ol>
+$#     $for stylesheet in details["stylesheets"]:
+$#         <li>$uri(stylesheet).normalized</li>
+$#     </ol>
+$# $else:
+$#     <p><em>No external stylesheets.</em></p>
+$# $# TODO inline stylesheets
+$#
+$# <h3>Scripts</h3>
+$# $ scripts = details.pop("scripts")
+$# $if scripts:
+$#     <!--p class=NOTE>Some users have scripting turned off. See
+$#     <a href=https://indieweb.org/js;dr>js;dr</a>.</p-->
+$#     <ul>
+$#     $for script in scripts:
+$#         <li>
+$#         $if "src" in script:
+$#             $if not script["src"].startswith(url.normalized):
+$#                 $ dependencies.append(script["src"])
+$#             $uri(script["src"]).normalized
+$#         $elif "text" in script:
+$#             $# TODO $if script.get("type", None) == "application/ld+json":
+$#             <details><summary>inline, $len(script["text"]) characters</summary><pre>$script["text"]</pre></details>
+$#         $else:
+$#             Unknown: $script
+$#         </li>
+$#     </ul>
+$# $else:
+$#     <p><em>No scripting.</em></p>
+$#
+$# <h3>Images/Audio/Video</h3>
+$# <p>...</p>
+
+<div style="display:grid;grid-template-columns:25% 25% 25% 25%">
+
+<div>
+<h2>Performance</h2>
+$ response = details["response"]
+<p><small>Initial weight:</small><br>
+<strong>$response["length"] KB</strong><br>
+<small>Response time:</small><br>
+<strong>
+$if response["time"] < 1:
+    $round(response["time"] * 1000) ms
+$else:
+    $round(response["time"], 2) s
+</strong><br>
+$if audits:
+    <small>Total weight:</small><br>
+    <pre>$audits</pre>
+    $# <strong>$get_human_size(audits["audits"]["total-byte-weight"]["numericValue"])</strong>
+</p>
+</div>
+
+<div>
+<h2>Security</h2>
+$if details["domain"]["hsts"]:
+    <p class=pass>This site is secure <strong>and on the HSTS preload list</strong>.</p>
+$elif url.scheme == "https":
+    <p class=pass>This site is secure.</p>
+$else:
+    <p class=fail>This site is not secure.</p>
+</div>
+
+<div>
+<h2>Privacy</h2>
+$ dns_prefetches = rels.pop("dns-prefetch", None)
+$ preconnects = rels.pop("preconnect", None)
+$if dns_prefetches or preconnects:
+    $if dns_prefetches:
+        $ dependencies.extend(dns_prefetches)
+        <h5>DNS Prefetch</h5>
+        <ol>
+        $for dns_prefetch in dns_prefetches:
+            <li>$dns_prefetch</li>
+        </ol>
+    $if preconnects:
+        $ dependencies.extend(preconnects)
+        <h5>Preconnect</h5>
+        <ol>
+        $for preconnect in preconnects:
+            <li>$preconnect</li>
+        </ol>
+
+$if dependencies:
+    <p class=fail>This site has external dependencies.</p>
+    <ul>
+    $for dependency in dependencies:
+        <li>$dependency</li>
+    </ul>
+$else:
+    <p class=pass>This site is truly independent.</p>
+</div>
+
+<div>
+<h2>Accessibility</h2>
+$if a11y:
+    <p class=fail>$len(a11y) accessibility concerns.</p>    
+$else:
+    <p class=pass>There are no accessibility concerns.</p>
+</div>
+
+</div>
+
+</div>
+
+$if rels:
+    $:render_uninterpreted("rel=", rels, "links")
+
+<footer style=font-size:.8em>
+<p><a href=/details/$(url.minimized)>Details (JSON)</a></p>
+<form method=post>
+$# $if headers := details.get("headers", None):
+$#     <p>$details["headers"]</p>
+<button>Recrawl</button>
+</form>
+$if not tx.user.session:
+    <form method=get action=/guests/sign-in>
+    <input type=hidden name=me value=$url.normalized>
+    <p>If you are the owner of this site, you may sign in to administer this page.</p>
+    <button>Sign in as $details["domain"]["name"]</button>
+    </form>
+$# $if tx.user.session and (tx.user.session["uid"][0] == details["url"]):
+$#     <h3>Site Owner Controls</h3>
+$#     <button>Test</button>
+</footer>
+
+<style>
+h2 {
+  border-bottom: .1em solid #333;
+  font-size: .9em; }
+</style>

index 0000000..6737a5e
--- /dev/null

+$def with (url)
+$var query = url
+
+<h1>Domain not responding</h1>
+
+<p>$url not responding.</p>

index 0000000..eb8adcb
--- /dev/null

+$def with (urls)
+$var title: Indexed Sites
+
+<ul>
+$for url in urls:
+    <li><a href=/$url["url"]>$url["url"]</a> <code>\
+    $ size = url["details"]["size"]
+    $if size < 10:
+        $round(size, 1)\
+    $else:
+        $round(size)\
+    <small style=text-transform:uppercase>kb</small></code>
+    $# last crawled $url["crawled"].diff_for_humans()
+    </li>
+</ul>

index 0000000..c0f832d
--- /dev/null

+$def with (properties)
+$var title: Statistics
+
+$properties

index 0000000..d67f30b
--- /dev/null

+$def with (resource)
+<!doctype html>
+<html lang=en-us class=dark>
+<head>
+<meta charset=utf-8>
+<meta name=viewport content="initial-scale=1.0,user-scalable=no,\
+maximum-scale=1,width=device-width">
+<meta name=apple-mobile-web-app-capable content=yes>
+<meta name=apple-mobile-web-app-status-bar-style content=black-translucent>
+<title>
+$if "title" in resource:
+    $resource.title &mdash;
+IndieWeb.rocks</title>
+<link rel=stylesheet href=/static/web.css>
+<link rel=stylesheet href=/static/screen.css>
+<link rel=icon href=/static/favicon.ico>
+$if not isinstance(resource, str) and "head" in resource:
+    $:resource.head()
+</head>
+<body
+$if "body_classes" in resource:
+     class="$' '.join(resource.body_classes)"\
+>
+
+<style>
+body {
+  font-family: sans-serif; }
+</style>
+
+$# <div style=text-align:right>
+$# $if tx.user.session:
+$#     $if tx.user.is_owner:
+$#         <form method=post action=/owner/sign-out>
+$#     $else:
+$#         <form method=post action=/guests/sign-out>
+$#     $if tx.user.session["uid"][0] == "/":
+$#         <a href=/owner>Site Owner</a>
+$#     $else:
+$#         <a href=/$uri(tx.user.session["uid"][0]).minimized>$tx.user.session["name"][0]</a>
+$#     <br><button>Sign Out</button>
+$#     </form>
+$# $else:
+$#     <a href=/guests><button>Sign In</button></a>
+$# </div>
+
+$if "title" in resource:
+    <nav><a href=/ rel=home><img src=/static/favicon.ico> IndieWeb Site Validator</a></nav>
+
+<article>
+$:resource
+</article>
+
+</body>
+</html>

index 0000000..2375208
--- /dev/null

+$def with ()
+$var title: Terms of Service

index 0000000..e4cb2ab
--- /dev/null

+$def with (domains, subdomains)
+$var title: The Street
+$var body_classes = ["widescreen", "notoolbars"]
+
+<script src=https://unpkg.com/statebus@7/statebus.js></script>
+<script src=https://unpkg.com/statebus@7/client-library.js></script>
+<script src=https://unpkg.com/braidify/braidify-client.js></script>
+<script>
+bus(() => {
+  backgroundColor = bus.state['https://ragt.ag/manifest.json'].background_color
+  themeColor = bus.state['https://ragt.ag/manifest.json'].theme_color
+  console.log("Updating background: ", backgroundColor)
+  document.getElementById("21FFF").style.backgroundColor = backgroundColor
+  document.getElementById("21FFF").querySelector(".theme").style.backgroundColor = themeColor
+})
+</script>
+
+<style>
+div#street-top {
+  background-color:
+  border-bottom: 2em solid #333; }
+div#street-bottom {
+  border-bottom: 2em solid #333; }
+div.street {
+  overflow-y: auto;
+  padding-bottom: 2em;
+  white-space: nowrap; }
+div.street div {
+  display: inline-block;
+  margin: 1em 0;
+  padding: 1em;
+  text-align: center;
+  vertical-align: bottom; }
+div.address {
+  background-color: #ccc;
+  width: 20em; }
+div.express-port {
+  background-color: #999;
+  font-variant: small-caps;
+  width: 12em; }
+</style>
+
+<div style=text-align:center;>
+<h1>The Street</h1>
+<p>The known IndieWeb sorted by <code>sha256(domain)</code>. See <a
+href=https://en.wikipedia.org/wiki/Snow_Crash#Metaverse>Snow Crash's Metaverse</a>.</p>
+</div>
+
+$ last_port = 1
+<div class=street>
+$for hash, domain in domains:
+    $ new_port = int(hash[:2], 16) + 1
+    $while new_port > last_port:
+        $ _port_hex = hex(last_port).partition("x")[2]
+        $ port_hex = f"{_port_hex:0>2}".upper()
+        <div id=$port_hex class=express-port>Express Port <code>$port_hex</code></div>
+        $ last_port += 1
+    $ height = 16.5 + int(math.log(len(subdomains[domain])+1) * 3)
+    <div id=$hash[:5] class=address style="height:$(height)em;white-space:normal;">
+    <div style=margin:0;padding:0;max-height:14em;overflow:auto><img
+    src=/sites/$domain/site.png style=width:100%></div>
+    <div class=theme style=margin:0;padding:0>
+    <a href=/$domain>$domain</a><br>
+    $if subdomains[domain]:
+        <small>
+        $for subdomain in subdomains[domain][:20]:
+            <a href=/$subdomain.$domain>$subdomain</a>\
+            $if not loop.last:
+                ,
+        $if len(subdomains[domain]) > 20:
+            &hellip;
+        </small><br>
+    <a href=#$hash[:5]><code style=font-size:.75em>$hash[:5]</code></a>
+    </div>
+    </div>
+</div>
+<div id=street></div>

index 0000000..a949623
--- /dev/null

+$var title: Parse a representative card
+
+<form>
+<input type=text name=url>
+<button>Parse</button>
+</form>

index 0000000..a830a74
--- /dev/null

+$var title: Parse a representative feed
+
+<form>
+<input type=text name=url>
+<button>Parse</button>
+</form>

index 0000000..bfcfcc0
--- /dev/null

+silos = {
+    "IndieWeb.rocks": ("indieweb.rocks", [r"([\w\.]+)"], True),
+    "GitHub": ("github.com", [r"(\w+)"], True),
+    "Keybase": ("keybase.io", [r"(\w+)"], True),
+    "sourcehut": ("sr.ht", [r"~(\w+)"], True),
+    "IndieWeb": ("indieweb.org", [r"User:([\w.]+)"]),
+    "PyPI": ("pypi.org", [r"user/([\w.]+)"]),
+    "Micro.blog": ("micro.blog", [r"(\w+)"]),
+    "Twitter": ("twitter.com", [r"(\w+)"]),
+    "Reddit": ("reddit.com", [r"u/(\w+)", r"user/(\w+)"]),
+    "Facebook": ("facebook.com", [r"(\w+)"]),
+    "Instagram": ("instagram.com", [r"(\w+)"]),
+    "LinkedIn": ("linkedin.com", [r"in/(\w+)"]),
+    "Foursquare": ("foursquare.com", [r"user/(\d+)", r"(\w+)"]),
+    "Last.fm": ("last.fm", [r"user/(\w+)"]),
+    "Flickr": ("flickr.com", [r"people/(\w+)", r"(\w+)"]),
+    "Amazon": ("amazon.com", [r"shop/(\w+)"]),
+    "Dribbble": ("dribbble.com", [r"(\w+)"]),
+    "Gravatar": ("gravatar.com", [r"(\w+)"]),
+    "Pinboard": ("pinboard.in", [r"u:(\w+)"]),
+    "Wordpress": ("profiles.wordpress.org", [r"(\w+)"]),
+    "Gumroad": ("gumroad.com", [r"(\w+)"]),
+    "Ko-fi": ("ko-fi.com", [r"(\w+)"]),
+    "Twitch": ("twitch.tv", [r"(\w+)"]),
+    "Soundcloud": ("soundcloud.com", [r"(\w+)"]),
+    "Asmodee": ("account.asmodee.net", [r"en/profile/(\d+)"]),
+    "Wikipedia (EN) User": ("en.wikipedia.org", [r"wiki/User:(\w+)"]),
+    "Wikipedia (EN) Notable Person": ("en.wikipedia.org", [r"wiki/([\w\(\)_]+)"]),
+    "Cash App": ("cash.me", [r"\$(\w+)"]),
+    "Kit": ("kit.co", [r"(\w+)"]),
+    "PayPal": ("paypal.me", [r"(\w+)"]),
+    "Speaker Deck": ("speakerdeck.com", [r"(\w+)"]),
+    "WeChat": ("u.wechat.com", [r"([\w\W]+)"]),
+    "Venmo": ("venmo.com", [r"(\w+)"]),
+    "Duolingo": ("duolingo.com", [r"profile/(\w+)"]),
+    "SlideShare": ("slideshare.net", [r"(\w+)"]),
+    "W3": ("w3.org", [r"users/(\w+)"]),
+    "YouTube": ("youtube.com", [r"(\w+)"]),
+    "Vimeo": ("vimeo.com", [r"(\w+)"]),
+    "500px": ("500px.com", [r"(\w+)"]),
+    "Findery": ("findery.com", [r"(\w+)"]),
+    "Untappd": ("untappd.com", [r"user/(\w+)"]),
+    "del.icio.us": ("del.icio.us", [r"(\w+)"]),
+    "Pocket": ("getpocket.com", [r"@(\w+)"]),
+    "Huffduffer": ("huffduffer.com", [r"(\w+)"]),
+    "Hypothesis": ("hypothes.is", [r"users/(\w+)"]),
+    "Lobsters": ("lobste.rs", [r"u/(\w+)"]),
+    "Medium": ("medium.com", [r"@(\w+)"]),
+    "Myspace": ("myspace.com", [r"(\d+)"]),
+    "Hacker News": ("news.ycombinator.com", [r"user\?id=(\w+)"]),
+    "Nextdoor": ("nextdoor.com", [r"profile/(\d+)"]),
+    "Spotify": ("open.spotify.com", [r"user/(\w+)"]),
+    "Pinterest": ("pinterest.com", [r"(\w+)"]),
+    "Pnut": ("pnut.io", [r"@(\w+)"]),
+    "Upcoming": ("upcoming.org", [r"@(\w+)"]),
+    "Diggo": ("diigo.com", [r"profile/(\w+)"]),
+    "Goodreads": ("goodreads.com", [r"user/show/(\d+)"]),
+    "Notist": ("noti.st", [r"(\w+)"]),
+    "Kickstarter": ("kickstarter.com", [r"profile/([\w-]+)"]),
+    "CodePen": ("codepen.io", [r"([\w-]+)"]),
+    "Listen Notes": ("listennotes.com", [r"@(\w+)"]),
+    "Meetup": ("meetup.com", [r"members/(\d+)"]),
+    "Patreon": ("patreon.com", [r"(\w+)"]),
+    "Periscope": ("periscope.tv", [r"(\w+)"]),
+    "Quora": ("quora.com", [r"([\W\w]+)"]),
+    "eBird": ("ebird.org", [r"profile/([\W\w]+)"]),
+    "Stack Overflow": ("stackoverflow.com", [r"users/(\d+/\w+)"]),
+    "npm": ("npmjs.com", [r"~(\w+)"]),
+    "Trakt": ("trakt.tv", [r"users/(\w+)"]),
+    "ORCID": ("orcid.org", [r"([\d-]+)"]),
+    "Wishlistr": ("wishlistr.com", [r"(\w+)"]),
+    "GitLab": ("gitlab.com", [r"(\w+)"]),
+    "AngelList": ("angel.co", [r"(\w+)"]),
+    "OpenStreetMap": ("openstreetmap.org", [r"user/(\w+)"]),
+    "Google+": ("plus.google.com", [r"\+(\w+)"]),
+}

index 0000000..31ac4f9
--- /dev/null

+{
+  "name": "indieweb.rocks",
+  "version": "0.0.1",
+  "description": "IndieWeb site validator",
+  "type": "module",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://ragt.ag/code/projects/indieweb.rocks.git"
+  },
+  "author": "Angelo Gladding <angelo@ragt.ag>",
+  "license": "0BSD",
+  "bugs": {
+    "url": "https://ragt.ag/code/projects/indieweb.rocks/issues"
+  },
+  "homepage": "https://ragt.ag/code/projects/indieweb.rocks",
+  "devDependencies": {
+    "@typescript-eslint/eslint-plugin": "^5.17.0",
+    "@typescript-eslint/parser": "^5.17.0",
+    "eslint": "^7.32.0",
+    "eslint-config-standard": "^16.0.3",
+    "eslint-plugin-import": "^2.25.4",
+    "eslint-plugin-node": "^11.1.0",
+    "eslint-plugin-promise": "^5.2.0"
+  },
+  "dependencies": {
+    "d3": "^7.4.1",
+    "jsdom": "^19.0.0",
+    "lighthouse": "^9.6.7",
+    "pa11y": "^6.2.3"
+  }
+}

index 0000000..898cd57
--- /dev/null

+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.poetry]
+name = "indieweb.rocks"
+version = "0.0.1"
+description = "IndieWeb site validator"
+authors = ["Angelo Gladding <angelo@ragt.ag>"]
+license = "0BSD"
+
+[tool.poetry.plugins."websites"]
+indieweb_rocks = "indieweb_rocks.__web__:app"
+
+[[tool.poetry.source]]
+name = "main"
+url = "https://ragt.ag/code/pypi"
+
+[tool.poetry.dependencies]
+python = ">=3.10,<3.11"
+webint = ">=0.0"
+webint-data = ">=0.0"
+webint-owner = ">=0.0"
+webint-system = ">=0.0"
+micropub = ">=0.0"
+typesense = "^0.14.0"
+svglib = "^1.3.0"
+python-whois = "^0.8.0"
+phonenumbers = "^8.12.55"
+
+[tool.poetry.group.dev.dependencies]
+gmpg = {path="../gmpg", develop=true}
+bgq = {path="../bgq", develop=true}
+easyuri = {path="../easyuri", develop=true}
+newmath = {path="../newmath", develop=true}
+sqlyte = {path="../sqlyte", develop=true}
+microformats = {path="../python-microformats", develop=true}
+micropub = {path="../python-micropub", develop=true}
+txtint = {path="../txtint", develop=true}
+webagt = {path="../webagt", develop=true}
+webint = {path="../webint", develop=true}
+webint-data = {path="../webint-data", develop=true}
+webint-owner = {path="../webint-owner", develop=true}
+webint-system = {path="../webint-system", develop=true}

index 0000000..ed8fdde
--- /dev/null

+{
+  "reportGeneralTypeIssues": false
+} 

index 0000000..71e7fce
--- /dev/null

+import pathlib
+
+import sqlyte
+import web
+
+from indieweb_rocks.__web__ import app
+
+db_path = pathlib.Path("site.db")
+db_path_shm = pathlib.Path("site.db-shm")
+db_path_wal = pathlib.Path("site.db-wal")
+
+
+def unlink():
+    db_path.unlink()
+    db_path_shm.unlink()
+    db_path_wal.unlink()
+
+
+def test_index():
+    # web.tx.host.db = sqlyte.db(db_path)
+
+    with pathlib.Path("webcfg.ini").open("w") as fp:
+        fp.write('SECRET = "test"')
+    response = app.get("?secret=test")
+    print(response.body)
+    # assert response.status == "200 OK"
+
+    def get_headings(h):
+        return [_.text for _ in response.dom.select(f"h{h}")]
+
+    # assert get_headings(2) == ["Database", "Migration"]
+    # unlink()