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") <small><a
+ href=//www.w3.org/TR/WCAG20-TECHS/$code>read 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> – <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: '© <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> $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("/", " / ")
+ $elif pronouns := card.pop("pronoun", None):
+ $for pronoun in pronouns:
+ $pronoun\
+ $if not loop.last:
+  / \
+ </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]…</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") – $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 —
+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:
+ …
+ </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()