Releases: nurkamol/seo-audit
Release list
1.38.2 — an extension on this decade's engine
Fixed
-
The Raycast extension was running an engine seven releases old. It
declared^1.31.0, and the score arrived in the engine at 1.34.0 — so the
extension carried score UI the engine it depended on could never populate.
Because the extension shares its library folder with the app and the command
line, that showed up as an inconsistency rather than an absence: in Recent
Reports a run crawled by the app had a score and a run crawled by the
extension did not, in the same list.Now
^1.38.1, which also brings the site-wide cause collapse and the plural
fixes. The lockfile had been held still while the Store submission was in
review; that submission is a pull request in another repository and is not
affected by this. -
The Windows update dialog did not say to close the app first. It said
running the new installer over this one was the whole update, which is true
only once this one is closed: the installer offers to remove the old version
before writing the new one, and an uninstaller cannot delete files that are
open. It stops with "Unable to uninstall!".Reported by somebody updating 1.38.0, who had pressed Download in that
very dialog and so still had the app running when the installer ran. The
dialog had walked them into it. -
The winget job failed a release it had no business failing.
winget-releaseronly ever updates a package: with nothing yet in
microsoft/winget-pkgsit exits 1 with "Package Nurkamol.SeoAudit does not
exist", which turned an otherwise-finished 1.38.1 red for a step that
structurally could not succeed. It now asks that repository whether the
package is there and warns instead — precisely, rather than with
continue-on-error, so a real failure still fails once the package exists.The first version has to be submitted by hand, once.
desktop/README.mdhas
the komac command; a test checks that the section the warning names actually
exists, because a pointer to a missing heading reads as an answer and ends
the search.
Installing on macOS
brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-auditHomebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.
Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened." It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:
shasum -a 256 seo-audit-1.38.2-macos.zip
# 38e1565799a90243f94e61aef573f38b18c604c0ed4b855d5ffd0e9339bed009Then, once it matches, clear the flag — no sudo needed:
xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.
Windows and Linux
The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.
The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC" — More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.
Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:
shasum -a 256 --ignore-missing -c SHA256SUMS.txtChecksums
Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:
shasum -a 256 --ignore-missing -c SHA256SUMS.txt7b02a22642af74cbfee6684323b75f1956461c5dbb534ccbedefdec76e13c2ac SEO.Audit_1.38.2_amd64.AppImage
fdf68e5a65c8211f73bf73967d08613c21dba896855fd902980eff4719fe59e6 SEO.Audit_1.38.2_amd64.deb
106fc06001f780377d6e124646eec993cb0fb91a2aee9dfea757a60594283974 SEO.Audit_1.38.2_x64-setup.exe
38e1565799a90243f94e61aef573f38b18c604c0ed4b855d5ffd0e9339bed009 seo-audit-1.38.2-macos.zip
The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.
1.38.1 — an updater that can actually update
Updating from 1.38.0 or earlier on macOS needs one command
This release fixes an updater that could not install its own fix. On 1.38.0
and earlier, Update offers this version and then does nothing: the banner
reads GitHub's releases whilebrew upgradereads Homebrew's own copy of the
tap, which is refreshed at most once a day, so for a while after any release
the two disagree and Homebrew answers "the latest version is already
installed".Once, in a terminal:
brew update && brew upgrade --cask seo-auditFrom 1.38.1 onward the button does both steps itself, and this is the last
time it is needed.
Fixed
-
Update said a new version had appeared and then did nothing. The banner
reads GitHub's releases;brew upgradereads Homebrew's own clone of the tap,
and that clone is refreshed by an auto-update which runs at most once a day.
So for up to twenty-four hours after a release the two disagree: the window
offers 1.38.0, Homebrew answers "Not upgrading seo-audit, the latest version
is already installed", and the process exits 0 — so nothing reported a
failure and nothing moved.brew updatenow runs first, as a step rather than a hope, and the command
shown on screen says both so that running it by hand does the same thing.
Found by pressing Update on a real 1.36.0 with 1.38.0 published, watching it
do nothing, and going after the reason. -
Linux was told to run a command that could not work. An apt-installed
copy was offeredsudo apt-get install --only-upgrade seo-audit, and no apt
repository anywhere carries this package — the.debis downloaded from a
release and installed by hand. apt would answer that it cannot locate it. The
same failure as above in a different package manager: an instruction that
reads like an answer and is not one. That copy now goes to the release page,
where the next.debactually is.With nothing left that could produce it, the "Show me" path and its dialog are
gone. An option nothing can reach is an option that does not exist. -
The winget path could never have worked, token or no token. Detection ran
winget list --id Nurkamol.SeoAudit --exactand then asked whether the output
containedseo-audit. It never does: winget prints an Id column reading
Nurkamol.SeoAudit, the Name isSEO Audit, and the lowercase hyphenated
spelling appears nowhere. So the check was false for every winget install
there could ever be — and would have stayed false after a manifest shipped,
with the branch dead for a reason nobody was looking at any more. It matches
on the identifier now, case-insensitively, which is what winget is.The identifier is one constant rather than three copies, and the guard that
keeps it in step with the workflow reads that constant and also fails if
anybody writes it out by hand again. -
The bundle declared no publisher, licence or description, all of which a
winget manifest requires and all of which Windows shows in Add/Remove
Programs. Set, so the first submission has something true to carry.
Installing on macOS
brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-auditHomebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.
Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened." It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:
shasum -a 256 seo-audit-1.38.1-macos.zip
# f1ce47dace1edecef143143f27e50f94dcd068b621e86affdc0ed39d4457eeb1Then, once it matches, clear the flag — no sudo needed:
xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.
Windows and Linux
The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.
The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC" — More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.
Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:
shasum -a 256 --ignore-missing -c SHA256SUMS.txtChecksums
Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:
shasum -a 256 --ignore-missing -c SHA256SUMS.txt1328e19d7b4e3de68706ac978376b6b5f02a03182c2434e60a809b2671bdea17 SEO.Audit_1.38.1_amd64.AppImage
c147fe6865200efeff9454df5c792a4bc087f0a201ebc7d807a4309323ada651 SEO.Audit_1.38.1_amd64.deb
30fe3d86dee9b056638fa986387e9a1a0a20224a90764366a79aebde4c67d1af SEO.Audit_1.38.1_x64-setup.exe
f1ce47dace1edecef143143f27e50f94dcd068b621e86affdc0ed39d4457eeb1 seo-audit-1.38.1-macos.zip
The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.
1.38.0 — a Windows report that saves where you tell it
Added
-
The kept-runs list sorts by any of its columns, and remembers how you left
it. There were no column headers at all: the list was a heading per site
with its own small table under it, so the only order was newest-first and the
only grouping was by host. It is one table now — Site, When, Pages, To
change, Score — and sorting by Site is the grouping it used to have, as one
of five orders rather than the only one.Links, not script: an order is a different view of the same list, so it is a
different URL. It can be bookmarked, and it works before any JavaScript has
run. Clicking the column you are already on turns it around; a new column
opens the way that column is most useful, which is largest-first for dates
and numbers and A-to-Z for a name.The filter and the order are kept in a cookie rather than in
localStorage,
for the same reason: a preference that only works once a script has run would
be the one control on the page that does not. It comes back through the same
validators the query string goes through — a cookie is a URL somebody kept,
and it may have been edited since, so it names a column that exists or it is
ignored.The score cell lost its
/100. The column is headed Score; repeating the
denominator on every row is the header said again, once per line. -
The window takes its type and its accent from the machine it is on. The
font stack stopped at"Segoe UI", which is Windows 10's face — Windows 11
uses Segoe UI Variable Text — and offered LinuxRoboto, an Android face few
desktops have, ahead of Cantarell or Noto. And--accentwas a hardcoded
blue while every one of those systems lets a person pick their own; where the
engine drawing this knows the answer,AccentColoris now that person's
choice,AccentColorTextis the colour guaranteed to be readable on it, and
checkboxes and the date field take it too.The chrome only. A report is a document that gets sent to other people and
keeps its own palette, so it looks the same on their machine as it did on
yours.Keyboard focus is now one visible high-contrast ring rather than whatever the
webview drew for each control type, and scrollbars take the page's colours
instead of the light ones a dark window makes obvious.Not attempted: a design per operating system. "Native on Linux" is not one
target — GNOME and KDE disagree — and a webview half-imitating WinUI lands in
the uncanny valley, which reads as less finished rather than more. It would
also split the browser and the app, which is the one thing this project
refuses everywhere else.
Fixed
-
Save as … wrote straight to Downloads without asking, and said nothing
afterwards. Reported by somebody using the Windows build, which is where the
export links have always been plain downloads — the macOS app has its own save
panel and never had this. The file now lands in a temporary directory, a
native dialog asks where it should go, and a line in the bottom-left corner
says where it went. Cancelling deletes the temporary copy, because a "Save
as" that leaves a file behind after being cancelled is the same surprise
wearing a different coat.The obvious implementation cannot be written: a dialog inside the download
request, so the destination is chosen before any bytes move. That handler runs
on the main thread, and the dialog plugin says in as many words that a
blocking dialog there freezes the application. -
"Open link in new window" did nothing. From the same report. A new-window
request is not a navigation, so the handler that sends external links to the
browser never saw it and nothing else was listening. It now goes the same way
every other external link goes — to the browser, which has an address bar. -
The kept runs were drawn with the webview's default table styling —
edge-to-edge columns and no separators, which is what a spreadsheet looks
like and not what that page is.CHROMEhad no table rules at all: they were
in the stylesheetpage()serves, and/reportsis drawn byshell(). The
date now leads the row at full contrast because it is the link you aim at,
the detail sits beside it muted, the score is right-aligned and tabular, and
the filter is a secondary control so the one primary button on the page is
the one that does something irreversible. -
The kept-since filter shipped in 1.37.0 with no styling. Its CSS went into
the stylesheetpage()serves;/reportsis drawn byshell(), which serves
a different one. The markup was right, the class was right, and the rule
simply never applied. That is twice now — the export links did it one release
earlier — so a test now reads CHROME and fails when a class the served pages
use is styled somewhere the served pages do not read.
Installing on macOS
brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-auditHomebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.
Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened." It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:
shasum -a 256 seo-audit-1.38.0-macos.zip
# 0b78d38c6029e58475566a12fd5d9b5ef78db752b0d95c5be07a32def1236c57Then, once it matches, clear the flag — no sudo needed:
xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.
Windows and Linux
The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.
The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC" — More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.
Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:
shasum -a 256 --ignore-missing -c SHA256SUMS.txtChecksums
Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:
shasum -a 256 --ignore-missing -c SHA256SUMS.txtd42cab61b85d00490d8d1b0827528ba0d3b8a53c3a6d5c0698bd6d1d223b05a8 SEO.Audit_1.38.0_amd64.AppImage
d491b1dba03015828095e54bfb186395a5deccddbd2c0a67c0db19cfd57c3176 SEO.Audit_1.38.0_amd64.deb
2c0eb1120bb14a216eba50dbfb37a562f08beeb3ebb5a6a518fdcb48b2e6191f SEO.Audit_1.38.0_x64-setup.exe
0b78d38c6029e58475566a12fd5d9b5ef78db752b0d95c5be07a32def1236c57 seo-audit-1.38.0-macos.zip
The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.
1.37.0 — a report that says a site-wide problem once
Added
-
The Raycast extension can ask Search Console. The engine has had
--search-consolesince 1.21.0 and the extension never exposed it — a
launcher showing every finding except the one number that is not a proxy for
attention. A checkbox switches it on and a field names the property, the same
shape the flag has: bare means the site being crawled, a value names a
property.It does not ask for credentials. Getting them opens a browser and writes a
file, which is a terminal errand rather than a control in a window — the same
answer the macOS app gives. Without them the engine reports
search-console-unconfiguredand names what is missing, so this fails in the
report rather than going quiet.Checked in both directions, because both failures are silent: a preference
the code reads and the manifest never declares is a control nobody can reach,
and one declared but never read is a control that does nothing. -
--reportslists the runs kept on this machine, and--reports 2026-08-01lists what was kept since. The library was reachable from the
macOS window and the served list and from nowhere on the command line, which
is the one place the rest of this tool lives./reportsgains the same filter
as a date control, and both call one function so the browser and the terminal
cannot disagree about what "since" means.That function is not in
src/library.mjs. That module opens files, so it
importsnode:fs, and the Worker showing the same list has no filesystem and
would not have survived the import —src/kept.mjsis the pure half both
sides need.A date it cannot read is refused rather than ignored, because listing every
run when somebody asked for one week looks like an answer. A date that hides
everything says how many it hid, since an empty list and an empty library read
identically and only one of them means "widen the date". -
A check that fires on every page is one thing to change, not one per
section. Grouping by URL prefix is right when a check hits part of a site —
that is the Shopify/products/insight this project's grouping was built on
— and wrong when it hits all of it. gohugo.io produced 200 things to change
and 104 of them were four checks that each applied to all 150 pages;
canonical-missingalone arrived as twenty-six separate pieces of work, one
per prefix, making the reader do the arithmetic that tells them it was
site-wide. The same report is now 100 things to change, and those four read
every page crawled (150), 1,695 links in.Exactly every page, never nearly. On 149 of 150 "every page" is a false
sentence, and a report is only worth reading because its sentences are true.
It costs no extra requests — the grouping was already computed.
Fixed
-
"1 image(s) without width/height" was the most repeated finding title in a
real report and read like a form nobody finished. Fifty of these across seven
files now pick the right word. Zero is plural, which is the case that gets
written wrong by reaching forn > 1. -
"1 links in" — the same defect one line further on, hardcoded rather than
spelled(s), so no search for the first would have found it. It had been
shipping for as long as reach has been reported, and no fixture ever had
exactly one inbound link. Found by running the tool against a real site and
reading the output, which is the only way anything here has ever been found. -
A PDF's section headings could be left alone at the foot of a page, with
the findings they introduce overleaf. A cause block listing every affected
page can be most of a page tall, so a heading placed just before one had
nowhere to go — the packer was greedy and had no keep-with-next rule. Three
of the 53 pages in a gohugo.io export ended that way:Content 79,
Indexability 26,Images 52.A heading now travels with the block it introduces.
PDF.paginatecame out
ofwriteto make it testable without a renderer, because the bug is
arithmetic rather than drawing — including the cases that must not move a
heading, and the one where a page holds nothing but headings and must not be
emitted blank.It does not make the document shorter, and the page count is unchanged at 53.
The white space above a page break comes from cause blocks being atomic and
nearly page-tall; only splitting them would reclaim it, and a finding split
across a page break is worse than a short page.
Installing on macOS
brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-auditHomebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.
Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened." It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:
shasum -a 256 seo-audit-1.37.0-macos.zip
# a350802df8f5f4bdf808909aab22a88d5313d1b3ffd50c543f41e8d8545fc2bbThen, once it matches, clear the flag — no sudo needed:
xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.
Windows and Linux
The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.
The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC" — More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.
Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:
shasum -a 256 --ignore-missing -c SHA256SUMS.txtChecksums
Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:
shasum -a 256 --ignore-missing -c SHA256SUMS.txt8e4e289a09f72481a476afcd194af08395b22966f801510db8a75fd5f70e3fd2 SEO.Audit_1.37.0_amd64.AppImage
649b24e6c2434637061bc6e103f4c85c8f067da3bdad33f94baa29f12cbb1bcd SEO.Audit_1.37.0_amd64.deb
a8b061f31ffd6f39a4492de1ab6d4a0dcbd882491eb44044a50836ab8691a8e4 SEO.Audit_1.37.0_x64-setup.exe
a350802df8f5f4bdf808909aab22a88d5313d1b3ffd50c543f41e8d8545fc2bb seo-audit-1.37.0-macos.zip
The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.
1.36.0 — checksums, and screenshots that are real
Added
-
Every release now says how to install it, on the page where it is
downloaded. The macOS app is ad-hoc signed rather than notarised — that
needs a paid Apple Developer account this project does not have — so macOS
quarantines the downloaded zip and Gatekeeper refuses it with "SEO Audit is
damaged and can't be opened", offering to move it to the Trash. It is not
damaged, and that message reads exactly like malware.Homebrew never had this problem: the cask verifies the checksum and clears the
flag. But the release page and the README's download badge both led somewhere
that explained none of it. Both now do, with the checksum printed first, so
clearing the flag is something done after verifying the file rather than on
the say-so of a README.Windows gets the same treatment for SmartScreen, and the AppImage's
chmod +x
is written down rather than assumed. -
Every file on a release has a published checksum. The macOS zip always had
one, because the Homebrew cask needs it. The.deb, the.AppImageand the
setup.exehad none — so "check it before you wave the warning away" was
advice you could not actually follow on the two platforms whose warning
dialogs are the reason to say it. On Windows it reduced to "run it anyway".A
SHA256SUMS.txtis attached to each release and the same lines are printed
in its notes, so one command covers all four:shasum -a 256 --ignore-missing -c SHA256SUMS.txt
Hashed from what is actually attached rather than from what each runner built,
which also proves the upload arrived intact. One job rather than a step in each
build: two matrix jobs editing the same release notes race, and the loser's
edit is gone with nothing to show it. -
The screenshots are a real crawl of a real site. The app shot was a
25-page run whose numbers had drifted from what the app draws; it is now
astro.build — 60 pages, 232 findings, 37 things to change, scored 87 — and the
one error at the top is a link to a page that does not exist, which is the bug
this tool was written to catch. A compare shot goes with it, docs.astro.build
against astro.build, matched by path because the hosts differ. Settings was
captioned "seven panes" and has had nine since Search Console shipped. -
The site says there are three apps now, and what to do when one will not
open. nurkamol.github.io/seo-audit offered a single "Download for macOS"
button and described the app as macOS-only, which had been true for about a
day. It now has a card per platform, the Homebrew route marked as the easier
one, and a section for the warnings an unsigned build produces on macOS and
Windows.The verify step and the quarantine command are two blocks with two copy
buttons rather than one block with one, because a single paste that runs the
checksum and then immediately clears the flag has verified nothing. -
The desktop workflow can attach bundles to a tag it is given. A release
whose upload failed can be completed by running the workflow manually, rather
than by force-moving a tag that people may already have fetched.
Fixed
-
Three enum variants warned on every build that they were never
constructed. Each is built under a#[cfg]— two on Windows, one on Linux —
while every platform matches on all of them, because matching is not
constructing. Silenced per variant and only where the claim is true, so a
variant that stops being reachable on the platform that owns it still warns. -
The first tagged release built its Windows and Linux bundles and then could
not attach them.gh release uploadcame back with "HTTP 403: Resource not
accessible by integration" — the workflow never asked forcontents: write,
so its token was read-only. Twenty minutes of building, installing and running
the bundles, all of it correct, and then a refusal on the last step.The same commit also matched tags with a bare
v*, which matches the floating
v1tag, so force-pushingv1started a second full build that would have
spent fifteen minutes failing to attach bundles to a release namedv1.Both mistakes were already solved elsewhere in this repository, and
mac-release.ymlcarries a comment describing the second one exactly. So both
are now checked bytest/workflows.test.mjsrather than written down a third
time: a tag trigger may not be a barev*, and a workflow that writes to a
release must ask for permission to. -
Refreshing a release's checksums no longer rebuilds it first. The
checksums job needsneeds: buildon a tag, where the bundles have to be
attached before anything can hash them. On a manual re-run they are already
attached, and it was spending fifteen minutes compiling two Tauri bundles to
hash four files it downloads from the release anyway.needs:cannot be conditional. A skipped job completes instantly, though, so
the build is what became conditional —only_checksumsskips it, and
always()on the checksums job stops a skipped dependency from skipping it
too. It still refuses to run behind a build that actually failed: hashing
half-attached assets publishes a checksum for a release nobody should trust
yet. -
The checksums job had no repository to talk to. It needs a release's
assets rather than the source, so it had noactions/checkout— andgh
works the repository out from the git remote, so it failed with "fatal: not a
git repository", which names a problem that was not the problem.GH_REPO
says it directly. Checked now, like the other two: a job runningghwith
neither a checkout norGH_REPOfails the suite.
Installing on macOS
brew tap nurkamol/seo-audit https://github.com/nurkamol/seo-audit
brew trust nurkamol/seo-audit
brew install --cask seo-auditHomebrew verifies the download against the checksum below and clears the
quarantine flag for you, so the app opens the first time.
Downloading the zip instead? macOS may say "SEO Audit is damaged and
can't be opened." It is not damaged. The app is ad-hoc signed rather than
notarised — notarising needs a paid Apple Developer account this project
does not have — and macOS refuses quarantined apps without a notarisation
ticket. Check what you downloaded first:
shasum -a 256 seo-audit-1.36.0-macos.zip
# 59789d5a83cbfa57021f9daa84999911c1321cefbfae1b5e4422740dfa4dd51cThen, once it matches, clear the flag — no sudo needed:
xattr -dr com.apple.quarantine "/Applications/SEO Audit.app"That is what right-click → Open does in the Finder, without the dialog.
Apple Silicon, macOS Tahoe or newer.
Windows and Linux
The setup.exe, .deb and .AppImage below are attached by a separate
job and may appear a few minutes after this release does.
The Windows installer is unsigned, so SmartScreen shows "Windows protected
your PC" — More info → Run anyway. Same reason as macOS: a code-signing
certificate is a paid, per-year thing this project does not have. The
.AppImage needs chmod +x before it will run.
Check any of them first. A SHA256SUMS.txt covering every file here is
attached alongside them, and the same checksums are listed at the end of
these notes:
shasum -a 256 --ignore-missing -c SHA256SUMS.txtChecksums
Verify a download before you wave a warning away. With SHA256SUMS.txt
saved beside the file, in the same folder:
shasum -a 256 --ignore-missing -c SHA256SUMS.txt67affeeea6eac4e1c1ed3a28a7544286db69f030e410d2591ccdd62adc270fbf SEO.Audit_1.36.0_amd64.AppImage
94ca9978bd8063143dd7228d042348441926ef04f28bbf9e8b8caf7ff3e5a1ca SEO.Audit_1.36.0_amd64.deb
14181cae775e06c6d89a915c6674e694fd06b0f89a1085f86e3ff45cb9f8326e SEO.Audit_1.36.0_x64-setup.exe
59789d5a83cbfa57021f9daa84999911c1321cefbfae1b5e4422740dfa4dd51c seo-audit-1.36.0-macos.zip
The macOS line appears twice in these notes on purpose: once above, computed
when the app was built, and once here, computed from what is actually attached.
They should agree.
1.35.0 — the app arrives on Windows and Linux
Added
-
A desktop app for Windows and Linux.
desktop/is a Tauri shell that
startsbin/seo-audit.mjs --serve 0, reads the port off stdout and points a
webview at it — exactly whatmac/SeoAudit/Engine.swifthas always done. It
draws nothing: the report is the HTML the server already produces, so both
platforms get the same sidebar, score ring and library, from the same folder
on a machine that has both. The Swift app stays the macOS release.It carries the lessons of the window next door. Port zero, because guessing is
how two copies fight over one. Node found by path rather than byPATH,
because a window launched from a dock inherits almost none. And a failure that
opens a window saying why, because an app that opens and does nothing is the
worst version of that.A menu of File, Edit and Help — Edit predefined, because on Linux and Windows
a webview without one is a text field where Ctrl-C does nothing. Every other
item is navigation or a link. External links leave the app: a report is
full of the audited site's own URLs, and following one used to replace the
report with somebody else's website, in a window with no address bar to get
out of.Packaged with a Node inside it: a 23 MB
setup.exe, a 45 MB.deb, a 115 MB
AppImage. Each platform builds on its own runner, then installs what it
built, runs it, and waits for the engine to appear — because building a
bundle only proves it bundles, and four separate bugs survived to that step. -
The desktop shell knows when there is a new version, and offers the one
thing that is safe. The macOS app settled the principle — the thing that
installed it is the thing that replaces it — and it generalises unevenly,
because the platforms differ in what a program may do without a password it
cannot ask for. A winget install runswinget upgradein place and offers to
relaunch; an apt install is shown the command, becauseaptneeds root and a
GUI app that shells out tosudohangs on a prompt nobody can see; an
AppImage, an installer copy or a hand-placed one gets the release page.It does not download a binary and put it in place itself. That needs a
signature to check or a checksum to compare, and an updater that overwrites an
application on the strength of a plain HTTPS response is a supply-chain hole
with a progress bar on it.Once a day, on a thread, after the window is up, with Check for Updates…
in the menu for asking anyway. It uses GitHub's API and falls back to the Atom
feed, because the anonymous quota is sixty an hour per address and being
refused is ordinary — and a version learned from the feed is announced with
"whether it is a full release could not be checked" rather than asserted,
which is the wording the macOS app already uses.Native dialogs, so the shell still owns no interface of its own. And no HTTP
client crate: the Node it already ships hasfetch, and adding a TLS stack so
a 110 MB bundle can make one request a year is the larger thing. -
Every report can be saved, from any browser.
/reports/<id>/export?as=…returns the file with a name that sorts, in all
seven formats. Links rather than a native dialog: a browser downloads them and
the desktop shell inherits the lot, so the feature landed once and every
platform got it. The macOS app has had an Export menu since it shipped and the
browser had none — somebody on Linux could read a report and not save one.The format list moved to
src/exports.mjs. It was written out twice already,
and a third copy would have been where they started disagreeing about whether
"Structured data" is called that. -
A tagged release carries the Windows and Linux bundles too. Pushing a
version tag already ran three workflows; this is the fourth, and it attaches
thesetup.exe, the.deband the AppImage beside the macOS zip. It builds
them the same way a branch push does — including installing what it built and
waiting for the engine — so the file on a release page is one that was proven
to start, not one that was proven to compile.It never creates the release, only waits for it. The macOS job writes the
title from the tag's annotation and the notes from the CHANGELOG section, and
a job racing it to create an empty one would win about half the time and take
both away. Waiting is the whole of the coordination between them.A winget manifest goes with it, which is what makes the Windows half of
the updater reachable at all:winget upgradecan only move a copy winget
installed. Submitting one opens a pull request againstmicrosoft/winget-pkgs
and so needs a token this repository does not have — so the job says out loud
that it was skipped, because a release that quietly did not publish looks
exactly like one that did. -
Two things that were written down twice are now checked. The version
lives in four files, and the shell refuses to start when its version and the
engine's disagree — so a half-finished bump would have produced bundles that
cannot run, discovered after the tag was pushed. And the winget identifier
lives in the workflow that publishes it and the Rust that queries it; winget
answers a name it has never heard of with silence, so that drift would have
shipped as a Windows build that simply never finds an update. Neither had a
symptom worth debugging. Both now fail on the machine of whoever caused them. -
npm run test:allruns three suites, addingcargo testoverdesktop/.
Same rule as the others: a toolchain that is not there, or an engine not yet
staged beside the shell, is reported as not run and never silently skipped. -
--serveis the desktop UI for Linux and Windows. It always was, in the
sense that the macOS window is a thin client over exactly this server — but
it printed a URL and offered a form with two inputs, so nobody read it that
way and nobody could have.It now opens a browser when a person ran the command, and never when
something else did. The same distinction the pipe check already made: the
macOS window spawns this and draws its own report, and would otherwise get a
browser it never asked for on every launch.--no-openis for the person who
wants neither. Three platforms, nine lines, no dependency — and failing to
open one is not a reason to refuse to serve, so the URL is printed either way. -
Every setting the command line takes is in the form. It offered
urland
limitwhile the engine took a dozen parameters, so somebody at a browser
reached a sixth of what somebody at a terminal did — and/runforwarded only
those two, which meant a control somebody set could quietly do nothing.The controls are drawn from
src/options.mjs, the one table that already
knows every flag and whether a window can reach it. Adding a flag with a
fieldnow adds the control; a flag without one is simply not offered, which
is a decision written down beside the flag it is about. PageSpeed and Search
Console are drawn only where the deployment has said those credentials are
the visitor's own to spend, so a public Worker still offers neither. -
A Preview button beside Audit.
/previewhas answered clients since it
shipped and no page ever reached it. A preview nobody can reach is a preview
nobody uses, and the whole point of one is being reachable before the minutes
are spent. -
Finished runs are kept, and the browser can compare two. The macOS window
has kept every run since 1.23.0 and--servekept none, so somebody on Linux
or Windows got one report and lost it the moment they audited something else.
A seven-minute crawl should only ever happen once, and that is not a
macOS-only claim./reportslists them,/reports/<id>reopens one as the same report every
other front end draws, and ticking two runs compares them through the same
diff()the CLI's--baselineuses — including the cross-site path matching,
so a rebuild can be compared with the site it replaces.On macOS it writes the folder the app already uses, so a crawl started in
the window is in the browser's list a second later and the other way round:
nothing is synchronised, exported or copied, because there is one folder.
Elsewhere it is%APPDATA%and$XDG_DATA_HOME, which is where those systems
keep documents an application manages.A deployed Worker has no library at all.
worker/index.mjsmust stay
web-standard — Cloudflare has no filesystem — so the local server hands it a
store and a Worker is handed nothing, which is also the right answer for a
shared host: keeping strangers' crawls is a thing nobody asked for. -
The app updates itself, rather than explaining how to. Pressing Update
opened Terminal and pastedbrew upgrade --cask seo-auditinto it, which is a
tool telling somebody how to update it. It now runs Homebrew in the window,
streams what Homebrew says line by line, and offers Relaunch when it is
done.Homebrew still does the part that matters — it verifies the download against
the checksum the build wrote, and it keeps its own records straight, which is
exactly why this app must not replace its own bundle behind its back. What
changed is where it runs. Nosudo: the cask installs into/Applications
and clears the quarantine flag without one, so nothing can sit waiting for a
password nobody can type. Terminal and Copy are still there for anyone who
would rather watch it happen.Verified by downgrading a real install to 1.33.1 and pressing the button.
Fixed
- The first desktop bundle opened, started nothing, and waited. It staged
binandsrcand notworker, which--serveimports, so it spawned its
engine and waited fifteen seconds for an address that was never coming. The
staging script now **starts what it staged and waits for...
A score, and three files it can write for you
Added
-
A score out of 100, and the checks that passed to earn it. Every report
now opens with a number, a grade and a ring:81/100 (B), followed by the
arithmetic that produced it. A run starts at 100 and pays for what is wrong
with it — an error-level check costs 12 points, a warning 4, spread across
the pages it is on — so what a check costs is exactly what fixing it is
worth, and every piece of work in Start here now carries the points it
returns.This project has refused a score everywhere else and the refusal still
stands for the thing people usually mean by one: nothing here predicts a
ranking, estimates traffic, or grades a site against its competitors. The
weights are not a new judgement either — every check already carries a level,
argued over check by check when it was written, andscripts/check-levels.mjs
reads those levels back out of the source so a check promoted from warning to
error cannot keep its old weight. A test asserts it on the machine of whoever
changes one. -
What passed, and what was never checked. A report that only lists faults
gives no way to tell a check that passed from one that never ran, and a
missing finding reads exactly like a passing one. Both are now named: a
Passing section listing every check the site cleared, in its own words
("Every page has an og:image", notog-image-missing), and a Not checked
section saying why the rest did not apply — no page carries an image, no
redirect map was given, PageSpeed was not asked.A check that could not run is left out of the score entirely rather than
counted as passed. A site with no images has not passed the alt-text check,
and scoring it as though it had would hand out points for doing less. -
Errors and warnings first, notes after them. Findings used to arrive
interleaved, and a note is "worth knowing, may be deliberate" — reading
forty of them mixed into the faults is how a reader loses track of which is
which. The terminal, the Markdown and the HTML now put what is wrong first
and everything only worth knowing under its own heading, which says out loud
that none of it costs the score anything. -
--write-schema <file>— the JSON-LD this site could add:WebSite,
OrganizationandBreadcrumbList. Every schema generator on the internet
asks you to type the answers in; this one refuses to ask, because the moment
it asks it is no longer describing the site — and structured data that
describes a site inaccurately is worse than none. It is a machine-readable
claim the page does not support, which is a manual-action category at Google.So the rule is narrower than the rest of this project's and it is absolute:
every value emitted is a string this crawl read off this site. An
organisation is named only when the site names itself inog:site_name,
because the home page's title with its tagline still attached is a claim
about a company's name.The first live run made the point better than any test: a breadcrumb built
from<title>produced "Assets | Jekyll • Simple, blog-aware, static
sites" as a step. A title is not a breadcrumb name. Now a step is named by
the page's<h1>when it has exactly one, or by what the site's own
navigation calls it when the links agree and the words are not "read more" —
and by nothing else.Jekyll › Docs › Assets. On that site 142 pages were
skipped rather than given a name invented from a slug.The refusal can be the good answer here, so it says which it is: a site that
already declares everything this could write gets told exactly that, with the
counts, rather than the same sentence a site with no evidence gets. -
--write-llms <file>— thellms.txtthis site should have had.
Sibling to--write-sitemap, on the same premise: the crawl has already read
every page's title, description, section and indexing directives, which is
exactly what the file is made of. Every line is a string the site already
serves — a page that gave us no description gets a line without one rather
than a sentence somebody made up about it, and the H1 is the site's own name
for itself rather than a guess.It refuses for the same reasons the sitemap does, and the refusals matter
more than the file: this is a document handed to an assistant as the
authoritative summary of a site, and one built from a third of the site is
worse than none because it looks complete. A truncated crawl writes nothing
and names the run that would work.Reachable everywhere: the flag,
llms-out=1on the Worker, the macOS Export
menu (which says why when the engine refused), the Raycast extension, and
awrite-llmsinput on the Action.--write-sitemapgained the Action input
it should always have had while the wiring was open. -
Search Console now answers where you rank, and what for. The same
connection, asked one more question — no second account, no scrape, no
keyword provider, and no number that anybody here worked out.Every response Google has ever sent carried
position, and it was being
thrown away. It now travels with each finding, so a template on page two can
be ordered ahead of one nobody has been shown, and the scope line says "12
pages, 4,300 impressions, best at position 6.4".search-console-strikingnames the crawled pages at positions 11 to 20 —
page two, where the click-through rate is roughly nothing and the ranking is
already earned — with the query each is closest on, most-shown first. It is
the only list in this tool that is an opportunity rather than a fault, and
moving one of them up two places is usually less work than a new page.A live property found the part no test would have: Google returns HTTP 200
and zero query rows for a site under its anonymity threshold — 99
impressions over 28 days was well under. Silence there reads exactly like
"this site is found for nothing", so the report says which it is. The query
call is best-effort besides: if it fails, the positions still stand. -
Which AI crawlers a site lets in. GPTBot, OAI-SearchBot, ClaudeBot,
Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended, CCBot
and five more, asked ofrobots.txtthrough the samerobotsVerdict()every
other robots question in this project goes through — so a rule this reads and
a rule Google reads are the same rule, longer-Allow-wins included. Nothing
is fetched that was not already being fetched and nothing is estimated: a
site's position on being read by the answer engines is already written down
in a file it already serves.It is a note, and stays one. A publisher who does not want their work in a
training set and says so has done the correct thing correctly, and a check
that cries wolf gets the whole report ignored. What the finding adds is the
distinction everybody gets wrong — an answering crawler fetches a page
because somebody asked a question just now, so blocking it removes the site
from that answer today; a training crawler does not, and blocking it changes
nothing about being cited. And it says whether anybody actually decided: a
block that arrives throughUser-agent: *rather than the agent's own name is
usually a CDN or plugin default nobody has seen.The one thing here that is a fault gets a warning:
ai-crawler-conflict, a
site servingllms.txt— a file whose only purpose is to tell an assistant
what to read — whilerobots.txtturns that assistant away. The invitation
never gets read, and unlike the block itself, nobody chose it.New area, AI & answer engines, which
llms-missingmoves into: it is
addressed to assistants rather than to crawlers and belongs beside the agents
that read it. -
/checks, served beside/options: every scored check with its weight,
its area and what it says when it passes. "What does this thing actually
check" is now a question with a fetchable answer rather than one that needs a
source file read.
Changed
-
The CSV is the whole checklist, not only the faults. A
pointscolumn
carries what each failing check is taking off the score, so a spreadsheet
sorts by what fixing something is worth rather than only by how often it
occurs. Checks that passed arrive as rows at levelpass, and ones that did
not apply asnot-checkedwith the reason indetail.Additive on purpose:
pointsis appended afterdetailrather than put
besideindexablewhere it reads better, so every column keeps the index it
has had since the file shipped. Anything filtering onerror,warnor
infois untouched. -
The portfolio table has a score column, and orders by it where every run
has one. "Which of my twenty sites is worst" is the only question a portfolio
exists to answer, and counting errors weighs a site-wide failure the same as a
warning on one page of four hundred. A run that never answered prints a dash,
never a zero.
Fixed
-
<title>and every heading arrived with their HTML entities undecoded.
attr()has decoded since it was written, so meta descriptions were always
fine — but a title and an<h1>are element text and went through none of
it. SoWidgets & Cowas the title in the terminal, in the CSV, in the
JSON, and in the lengthtitle-longmeasures against what Google shows,
where it was five characters too long. Hexadecimal numeric references were
not handled at all, and’is what a CMS emits for the apostrophe in
Widget's.Found by generating an
llms.txtfor a real site and reading the first line
of it — not by the test suite, which is the usual way round here. -
Every export dropped the score except the two that were written first.
It reached the terminal and the HTML and stopped there: the CSV had no column
for it, the portfolio had no column for it...
1.33.1 — the window reads certificates again
Fixed
-
The macOS app skipped the certificate checks and blamed the hosted
version.worker/index.mjsruns in two places — Cloudflare, which has no
socket to read a certificate over, and--serveunder Node, which does — and
it switched the check off for both. The window talks to--serve, so every
report it produced was missingtls-expiringandtls-expired, above a note
reading "This report was produced by the hosted version". It was not.Two failures in one: a check quietly not run, which reads exactly like a check
that passed, and a note naming a runtime that was not involved.--servenow
declares that it can read certificates and gets the real check; a deployed
Worker leaves it unset and keeps the note it has always deserved.
1.33.0 — Search Console, proven and reachable
Added
-
The app tells you an update exists, and offers to fetch it. It knew
before —checkIfDue()has had a one-a-day guard for versions — but it was
only called from the main view's.task, which runs once when the window
appears. An app left open for a week checked once, in that week's first
minute, and a release cut the next morning went unmentioned until somebody
quit and came back. And when it did know, the only place that said so was a
Settings pane somebody had to think to open.Now an hourly timer and
didBecomeActiveboth funnel through the same
day-old guard — the timer for an app left running, becoming active for the
laptop that was shut overnight, since a timer neither fires while asleep nor
keeps its schedule afterwards.A banner sits above the report rather than in front of it: a new version is
worth mentioning and never worth interrupting a crawl for. Dismissing it
dismisses that version for good, because a bar that returns every launch is a
bar people learn to ignore.Downloading shows a real fraction where GitHub sent a length and an
indeterminate spinner where it did not — a bar that sits at zero and jumps to
full is worse than one that admits it cannot say. It unpacks withditto,
which is what wrote the archive and what keeps the bundle's signature intact.It stops at the drag, deliberately. Replacing a running bundle safely
needs a helper process that outlives the app it is overwriting, which is
Sparkle's whole job; and a Homebrew install has one correct answer that is not
this one, since overwriting the bundle behindbrew's back leaves its records
describing a version that is no longer there. So a cask install gets the
brewcommand run in Terminal where it can be watched, and everything else
gets the file revealed in Finder. -
A Search Console pane in the macOS app, and the whole Help row is now the
click target rather than its chevron.DisclosureGrouponly hit-tests its own
triangle, which left a full-width row that looked clickable everywhere and
answered in one corner — that reads as the app being broken, not as a small
control.The pane takes the property and offers a sign-in, which runs the engine's own
--search-console-loginthrough the bundled Node. The token never passes
through the app and is never displayed: a token on screen is a token in a
screenshot. What comes back is the list of properties the account can read,
each with a button to use it, because a token that can read nothing looks
exactly like one that works until a run says the property was not found.The hosted Worker will not honour
?search-console=unless the runtime
setsALLOW_SEARCH_CONSOLE, and--servesets it only because it binds to
the loopback address. The credentials belong to whoever started the server, so
a deployed Worker accepting a property name would hand a stranger somebody
else's traffic data. Gated, shape-checked, and tested. -
A Help pane in the macOS app. Written as the questions the app actually
raises — why a crawl takes minutes, why the page count differs from the
sitemap's, where the score is, why performance is blank until it is switched
on, what leaves the machine — rather than a tour of the controls. A control
that needs explaining is better renamed; a decision is what needs saying,
and this app makes several that surprise people.Folded away rather than expanded: a wall of prose in a settings window is a
wall nobody reads, and a list of questions is scannable. -
--search-console-login. The three Search Console credentials were
documented for a year and there was never a way to obtain the third, which is
the actual reason--search-consolehad never run against the live API: not
the code, the paperwork in front of it.Loopback OAuth, which is what Google calls the installed-app flow — a desktop
client may redirect to any port on 127.0.0.1 without registering it, so this
listens on an ephemeral one and the browser does the signing in. Read-only
scope. The refresh token is written to~/.config/seo-audit/.envat mode
600and never printed, because a token echoed to a terminal is a token in a
scrollback buffer. It rewrites that one line and leaves the PageSpeed key
alone, which is tested, because clobbering somebody's key to save a token
would be a poor trade.Then it lists the properties the account can read. A token that can read
nothing looks exactly like one that works, right up until an audit says the
property was not found.Not a GitHub Action input, deliberately: it opens a browser, and a flag CI
can accept and never satisfy is worse than no flag.src/options.mjsrecords
the same answer for the macOS window.The parts that can be wrong quietly are separate exported functions with
tests — the authorisation URL, the token exchange, the file rewrite — and the
loopback flow itself is exercised end to end with only Google faked,
including that a reply carrying the wrongstateis refused and writes
nothing.
Fixed
-
Search Console could never read its credentials from the dotfile. It had
its own copy of the loader thatpsi.mjsuses, and its copy built the pattern
withnew RegExpand a template literal, where\\ssurvives as an escaped
backslash rather than as whitespace. The regex compiled to
/^\\s*GSC_CLIENT_ID\\s*=.../m— a literal backslash followed bys— so it
could not match a line of a real.env. It never threw. Only environment
variables ever worked.Nothing caught it because the only tests for that path injected credentials
and used a fake API, which is exactly what "never run against the live API"
hides. There is now one loader inconfig.mjsthat both callers use, and it
is tested against a dotfile with the whitespace a hand-edited file actually
has — including a name that is a prefix of another, which the broken pattern
would also have got wrong.
1.32.0 — five things a real site found
Added
-
twitter-image-broken— a declaredtwitter:imagethat does not load.
The absence of a Twitter card stays unreported and always will: X falls back
to Open Graph correctly, so reporting it would invent a defect. This is the
opposite case, and the roadmap's rejection of the first did not cover it —
nothing falls back to anything when the tag is present and 404s, and the one
platform handed its own tag previews blank.Only when it differs from
og:image, so one picture never produces two
findings, and only on 404/410/no-answer, because a 403 is hotlink protection
working. All four silent cases are tested: same picture, no tag at all, a
403, and a redirect to the real file. -
body-not-html— a URL the server calls HTML that is not HTML. The body
is already read, so it costs no request, and it replaces the checks it
silences rather than joining them. A real site serves an XML document at
/locations.kmlwithContent-Type: text/html; the crawl believed the header
and reported thirteen things — no title, no h1, no viewport, no charset, thin
content, three Open Graph tags — every one of them true about a document that
was never a page, and not one of them the thing to fix. Now it is one finding
saying what is actually wrong, and the reason it matters: Google indexes
whatever comes back undertext/html.It answers on positive evidence only. XHTML opens with an XML prologue and is
HTML; a fragment with no<html>wrapper is HTML; a byte-order mark before
the doctype is HTML; something merely starting with a brace is not JSON. All
four are tested, because guessing wrong here would silence every check on a
real page — a far worse failure than the noise it removes.
Fixed
-
A dead site with an expired certificate is told what is actually wrong.
expired.badssl.comcame back as "The site did not answer a single request …
The TLS connection succeeds but no response arrives … Cloudflare Bot Fight
Mode does exactly this." Both halves were wrong: the TLS connection does not
succeed, and the cause is a certificate that ran out in 2015. The site checks
that would have named it never run, because the crawl gives up first — and a
browser refuses an expired certificate exactly asfetchdoes, so from the
crawl's side "nothing answered" and "the certificate lapsed" are the same
silence.The certificate is now read before bot protection is blamed, over the same
non-validating socket the check already used, and the report says
"The TLS certificate expired 4151 day(s) ago … which is why nothing here
could be fetched." Found by asking whether the Raycast extension really gets
the certificate checks: it does, and this hole was underneath the question.Where the certificate cannot be read at all — the hosted Worker has no socket
— the old wording stands unchanged, minus the sentence claiming the
certificate is fine. A runtime that cannot run a check says so rather than
implying a result. -
One row named one Open Graph tag and counted three. A run against a real
site printedMissing og:description ×6over four pages — three of which were
missing only the description, one missing all three tags — and listed that one
page three times. Every finding under it was true. The sentence above them was
not, and a summary that misreports is the thing grouping exists to prevent.The cause:
og:title,og:descriptionandog:imagewere pushed under one
id, and a group takes its title from the finding it saw first. They are now
og-title-missing,og-description-missingandog-image-missing— three
ids, because they are three different repairs. A missingog:imageis a
picture somebody has to make; a missingog:titleis one line of template.
Splitting them also makes each separately ignorable, so a site that ships no
og:imageon purpose can silence that without silencing the other two.--ignore og-missingkeeps working and still silences all three. Splitting a
check is our decision, not the decision of whoever wrote that config, and an
upgrade that promises to be compatible should not start failing their build.Not generalised, and the reason is worth writing down: four other ids also
carry more than one title —img-alt-long,img-dimensions,heading-skip,
anchor-ambiguous— and all four are correct. They are the same problem with
the page's own numbers in the title. A test asserting one id, one title would
have flagged the four right ones and this was the only wrong one. -
A Raycast preference could do nothing and nobody would know. The
extension's hand-writtenPreferencestype listed three of the thirteen
preferencescrawlOptions()actually reads; the other ten were invisible to
every caller. It is nowExtensionPreferences, which the Raycast build
generates from the manifest, so it cannot disagree with what the manifest
says — and the two components that read preferences had been calling
getPreferenceValues()untyped, which is what the change surfaced
immediately.Arguments.Auditreplaces a second hand-copied shape.Types only cover half of it:
present.mjsis plain ESM sonode --testcan
run it, so a test now checks in both directions that every preference the
manifest declares is read and that nothing reads a preference the manifest
does not declare. A setting that quietly does nothing is the same failure
src/options.mjsexists to prevent for the macOS window.Found by Greptile on the Store submission, which is worth recording: it
called the type duplication a maintainability nit, and the count made it a
defect. -
A release no longer starts by failing. The macOS job builds on a tag and
attaches the app to that tag's release — a release the documented procedure
created afterwards, by hand. So every release began the same way: a red
release not found, agh release create, a re-run. A failure that is part
of the normal procedure is a failure nobody reads, and the next one, which is
real, reads exactly the same.It now creates the release when it is missing, titled from the tag's own
annotation with that version's CHANGELOG section as the notes — both already
written by the time a tag is pushed, so neither becomes a second copy to keep
in step. A tag with no CHANGELOG section gets a warning and generated notes
rather than a lost build, because by then the app is already sitting there
signed.The section is found with
index()rather than a regex, which is the bug that
was there to be written:## [1.31.0]interpolated into a pattern makes
[1.31.0]a character class matching the digits0,1and3. It would
not have errored. It would have published the wrong section, or none.
Changed
npx @nurkamol/seo-auditis the install the site and README lead with.
The repository form still works and is still documented, pinned — but it
clones 16 MB of app sources, screenshots and tests to reach a 115 kB crawler,
measured rather than guessed.npm i -gis written down too, for anyone who
would rather typeseo-audit.