Skip to content

Repository files navigation

Pexafy/.github

This repository holds the Pexafy organisation profile page and profilelint, the linter that checks it. The interesting part is not the linter: it is src/profilelint/data/allowlist.json, a measured record of what GitHub's HTML filter does to every tag and attribute a README might contain, produced by asking GitHub's renderer rather than by copying a list out of a library.

That distinction turned out to matter. Ten tags that the allowlist everyone copies calls safe are deleted by the live pipeline: <abbr>, <cite>, <dfn>, <small>, <time>, <u>, <bdo>, <figure>, <figcaption> and <caption>. Two it does not list survive. <style> is not stripped, it is escaped, so its CSS appears on your organisation's front page wrapped in visible angle brackets. Only four URL schemes survive on a link, and tel: is not one of them; the anchor is deleted with it and the label is left as plain text. None of this is announced, none of it is reported to you, and the profile page is the worst place to find it out, because there is no preview and no pull request that shows you the rendered result.

The measurement

src/profilelint/probe.py builds a document containing every tag worth asking about, twice: once bare and once carrying fifty candidate attributes. It sends that to GitHub's markdown renderer, cuts the response back into one region per question, and records what came back. The output is a dated dataset:

$ profilelint rules --sanitizer | head -4
measured 2026-08-27 against https://api.github.com/markdown (0.23.12)
abbr             unwrapped: deleted, and its text content is left behind in the page
audio            unwrapped: deleted, and its text content is left behind in the page
bdo              unwrapped: deleted, and its text content is left behind in the page

Four outcomes are distinguished, and no published allowlist distinguishes them:

kept       the element survives, with some subset of its attributes
unwrapped  the tag is deleted and its text content stays on the page as prose
escaped    the markup is shown to the reader, angle brackets and all
removed    element and content both disappear

The middle two are the ones that cost time. A tag that is unwrapped leaves something visible you then have to explain; a tag that is escaped shows the reader your source code. Every list that says "not allowed" collapses all three failures into one word.

Attributes are measured per tag, because they behave per tag. aria-label survives on an <img> and is dropped from a <div>. style is dropped everywhere except on an <img>, where it is replaced with a value the renderer computed, so the attribute is still there and your width is not.

The dataset is a snapshot, and snapshots rot — #gh-dark-mode-only used to hide an image in the wrong theme and now does nothing, which is how this repository started. So it ships with an expiry mechanism instead of a disclaimer:

profilelint probe --check     # re-measure, diff against the record, exit 1 on drift
profilelint probe --write     # record a fresh capture

.github/workflows/allowlist-drift.yml runs that weekly and opens an issue with the diff. Attribute lists are compared as sets, so a reordering is not drift; a job that cries wolf gets muted within a month.

The probe cannot measure everything, and says so rather than guessing. The markdown endpoint does not run the filter that anchors headings, and it resolves relative links differently from a repository page. Both limits are recorded as checked facts in the capture under endpoint_limits, so if GitHub ever starts anchoring headings there, the dataset stops claiming a limitation it no longer has.

Install

pip install "profilelint @ git+https://github.com/Pexafy/.github"

No dependencies outside the standard library, Python 3.10 or newer.

Use

profilelint check profile/README.md --org Pexafy
profile/README.md:2:3   warn   PL002  style on <img> is replaced, not kept
        replaced with a style the renderer computes from the width and height on
        the tag; nothing you write in it is used
profile/README.md:3:1   error  PL004  emphasis or code span inside an HTML block is not parsed
        an HTML block runs to the next blank line; put one after the opening tag
        and the markdown between them is parsed normally
profile/README.md:10:10 error  PL006  relative link docs/handbook.md resolves to
        github.com/Pexafy/.github/blob/main/profile/docs/handbook.md

2 error(s), 1 warning(s), 0 note(s)

Each finding says what the reader will see, not only which rule fired. That is the whole reason for the dataset: PL002 can say the style is replaced rather than dropped because somebody measured it.

It exits non-zero when there is an error, which is enough for CI. From Python:

from profilelint import Config, lint_path

for finding in lint_path("profile/README.md", Config(org="Pexafy")):
    print(finding.line, finding.rule, finding.message)

The other commands:

profilelint rules --provenance scar   # only the rules that came from being caught out
profilelint rules --sanitizer         # what the filter does to each tag, and when that was measured
profilelint anchors profile/README.md
profilelint links profile/README.md --cache .links.jsonl

anchors prints the anchor every heading will get, including the numeric suffix on repeats. links checks every external URL and appends each result to the cache as it goes, so a run you interrupt picks up where it stopped.

What it checks

Eighteen rules, and they are not equally interesting. profilelint rules groups them by where they came from, because presenting them as equals hides the ones that cost somebody an afternoon.

Found by pushing a page and watching it come out wrong, nine of the eighteen. PL004 catches markdown inside an HTML block: a centred header written as <div align="center"> followed immediately by **Tagline** renders the asterisks literally, because an HTML block runs to the next blank line. The fix is one blank line, and the failure is invisible in every editor that does not implement CommonMark's HTML block rules, which is most of them. PL009 catches #gh-dark-mode-only, which no longer hides anything, so pages written before the change show both logos, one above the other, to everybody. PL013 catches two headings with the same anchor — the trap is not the -1 suffix but what happens when you delete the first heading, because every link to the others shifts up by one. PL006 and PL007 catch relative links and relative images, which fail differently: the link is simply wrong, and the image works only because the file happens to be committed next to the README.

Falls out of the capture. Four more read the dataset directly. PL001, PL002 and PL005 know that <style> is escaped rather than deleted, that class on an <img> is replaced with GitHub's own, and that irc: takes the whole anchor with it. An attribute the probe never sent is reported as unmeasured and produces no finding, because a guess dressed as a measurement is what gets a linter added to somebody's ignore list. PL017 is in this group because the probe corrected it: it used to say a <details> without a <summary> renders as a bare triangle, and it does not — GitHub supplies a summary reading "Details". The rule still fires, and now says the true thing.

Read off a specification. The remaining five. Four of those are already covered properly by markdownlint, which the table below says out loud.

Numbers

Three things are measured here, each with the script that produced it.

What real profile pages actually do

tools/harvest.py pulled profile/README.md from the .github repository of 142 well known organisations. Forty-two have one, which is the first result: most organisations do not publish a profile page at all. Of those forty-two, twenty-two trip at least one rule.

rule    fires on          occurrences  what it is
PL008   11 pages (26.2%)  11           badge images served through the caching proxy
PL002    8 pages (19.0%)  18           an attribute that is dropped or replaced
PL011    5 pages (11.9%)   7           image with no alt text
PL007    4 pages ( 9.5%)  12           relative image
PL006    2 pages ( 4.8%)   3           relative link, resolving inside .github
PL004    1 page  ( 2.4%)   4           markdown inside an HTML block
PL009    1 page  ( 2.4%)   2           a retired theme fragment, still there

The eleven rules not in that table fired on nothing. That is the honest shape of it: this is a long tail, and a rule that has never fired in forty-two real pages is guarding against something rare rather than something common. corpus/wild/ holds the full per-page breakdown, the commit sha of every page and a digest of its contents. The pages themselves are not committed — they belong to the organisations that wrote them — and python tools/harvest.py --verify re-fetches them and reports which have changed since.

What the other linters already catch

bench/crosscheck.py builds one document per rule, containing that defect and as little else as possible, and runs all three tools over it.

                                          markdownlint 0.38.0   remark-lint 15.0.1
PL005  scheme does not survive on a link   -                     -
PL006  relative link resolves in .github   -                     -
PL007  relative image                      -                     -
PL008  image is proxied and cached         -                     -
PL009  theme fragment no longer switches   -                     -
PL014  unclosed fence eats the page        -                     -
PL015  document too large to render        -                     -
PL016  README is not where GitHub looks    -                     -
PL001  tag does not survive the filter     MD033, blanket only    -
PL002  attribute dropped or replaced       MD033, blanket only    -
PL003  id and name are rewritten           MD033, blanket only    -
PL004  markdown inside an HTML block       MD033, blanket only    -
PL010  picture missing source or fallback  MD033, blanket only    -
PL011  image has no alt text               MD045                  -
PL012  internal anchor matches no heading  MD051                  -
PL013  two headings, one anchor            MD024                  -
PL017  details has no summary              MD033, blanket only    -
PL018  reference link never defined        MD052                 remark-lint

Four rules are genuinely covered elsewhere, and the rule table says so: PL011, PL012, PL013 and PL018 are markdownlint's MD045, MD051, MD024 and MD052. If you already run markdownlint, those four buy you nothing here.

markdownlint's MD033 is not counted as coverage. It fires on any inline HTML at all, which the control documents in the same file demonstrate: a correctly spaced <div align="center">, a <picture> with a theme source and a <kbd> in a sentence all render perfectly and all get reported. That is detection of a tag being present, not of anything being wrong with it.

tests/test_crosscheck.py compares this table against the claims in the code, so a rule presented as unique that another tool has started covering fails the suite rather than staying in the README.

What happens on input a stranger can send you

A profile README is a file anyone can open a pull request against.

document                       this tool   markdownlint 0.38.0   remark-lint 15.0.1
unmatched brackets x50k          0.0287 s              0.6017 s             0.5711 s
nested brackets x20k             0.0089 s             81.7838 s            over 600 s
single line of 1 MB              0.0008 s              0.4844 s             0.4504 s
emphasis runs x40k               0.0054 s              0.3250 s             0.4275 s

Twenty thousand nested brackets is a legal markdown document of forty kilobytes. markdownlint spends eighty-one seconds on it and remark had not finished after ten minutes, at which point the benchmark gave up and recorded that rather than waiting. Both are doing more than this tool does — they build a full syntax tree, and matching delimiters is where that gets expensive — so this is a bound worth knowing rather than a defect in either. It is the reason the scanner here matches brackets in one stack pass and treats any construct spanning more than 4096 characters as text: a linter in CI is a thing anyone can hand a document to.

corpus/verify.py covers the last one: every fixture in corpus/ carries a written list of statements about the HTML GitHub renders it to, each checked against the live renderer and stamped with the date and renderer version. Those statements are the ground truth corpus/expected.json rests on, so the expectations are anchored to something other than the code that satisfies them.

What breaks

A private .github repository renders no profile page and reports no error. The same is true of profile/readme.md in the wrong case on a case-sensitive checkout, and of a README.md at the repository root, which is only the repository's own README. PL016 checks the path on disk, which is the most this tool can do from outside GitHub; it cannot see repository visibility.

The capture is one measurement of one endpoint. It is the same filter the profile page runs, but it is not the whole pipeline, and the two limits above are recorded rather than assumed. If you see the rendered page disagree with profilelint rules --sanitizer, that is a bug worth reporting and the diff from profilelint probe --check is the report.

Link checking fails in interesting ways. Checking a few hundred links means a few hundred requests to a handful of hosts, and doing that naively earns a wall of 429s that then look like broken links. So concurrency is limited per host rather than globally, and requests are GET with a one-byte range rather than HEAD, because a meaningful minority of hosts answer the two verbs differently. Results are appended to the cache one line at a time, so a run killed halfway loses one entry.

The scanner gives up rather than guessing. A line over 100 KB is recorded and skipped, because that is inlined SVG rather than prose. A construct spanning more than 4096 characters is treated as text. Reading from a path stops at 8 MB and marks the document truncated. Each is a deliberate false negative bought for a bound on cost, and each is reported rather than silent.

What did not work

Rendering the document and diffing it against GitHub's own output was the first approach, and it is why the probe looks the way it does. Diffing whole documents does not work: the markdown endpoint resolves relative links differently and does not anchor headings, so the diff filled with differences that were not bugs. Asking one narrow question per region and recording the answer works, and the questions that cannot be asked there are recorded as limits instead of quietly dropped.

The first version of the probe put every candidate attribute on each tag and read both the tag verdict and the attribute verdict off that one element. Then <abbr> came back deleted, which was surprising enough to check by hand — and it is genuinely deleted, but the check was the point. Loading fifty attributes onto an element is itself something a filter can react to, and a probe that cannot tell "this tag is dropped" from "this tag is dropped when you do that to it" is measuring itself. Every tag is now asked twice.

html.parser from the standard library looked like a free HTML tokenizer. It silently repairs malformed input, which hides precisely the malformation the filter reacts to: a tag missing its closing bracket is exactly the case worth reporting, and the parser invents one. A regex for tags fell over on attribute values containing >. The tokenizer in document.py is forty lines and gets both right.

Checking links with HEAD produced a steady trickle of false failures that only ever reproduced on somebody else's machine. Several badge and CDN hosts answer HEAD with 403 or 405 and GET with 200.

The scanner used to treat a link as opaque and skip past its label, so [![build](badge.svg)](ci) scanned as one link and produced no images at all. That shape is every badge in every README, which meant every image rule was quietly exempt from the images profile pages actually contain.

Layout

profile/README.md is the page GitHub publishes at github.com/Pexafy; it is what this repository is for. src/profilelint/ is the linter and src/profilelint/data/allowlist.json is the capture. corpus/ is the fixture set with its verified expectations, corpus/wild/ the harvested one, bench/ the measurements and tools/ the scripts that produce the datasets.

CI runs the linter against profile/README.md on every push, so if that job is red, this page is wrong.

Licence

MIT. See LICENSE.

About

Org profile page, plus a measured record of what GitHub's HTML filter really does

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages