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.
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.
pip install "profilelint @ git+https://github.com/Pexafy/.github"No dependencies outside the standard library, Python 3.10 or newer.
profilelint check profile/README.md --org Pexafyprofile/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.jsonlanchors 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.
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.
Three things are measured here, each with the script that produced it.
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.
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.
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.
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.
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
[](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.
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.
MIT. See LICENSE.