Skip to content

feat(utils): slug the URL path into output filenames - #30

Merged
Hugoer merged 3 commits into
mainfrom
feat/url-slug-filenames
Sep 4, 2026
Merged

feat(utils): slug the URL path into output filenames#30
Hugoer merged 3 commits into
mainfrom
feat/url-slug-filenames

Conversation

@Hugoer

@Hugoer Hugoer commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Closes #12. Implements the spec settled in the issue, as amended in this comment.

buildFilename keyed output names on hostname alone, so every page of one host collided and the _NN tiebreaker carried no page identity. Worse, runBatch records results in completion order, so _NN did not correspond to a URL's position in the list — the number a page received varied between runs of the same command.

Behaviour change

Output filenames gain a [-<slug>] segment derived from the URL path:

https://a.com/                       ->  psi-a.com-2026-09-02-171221-mobile.json          (unchanged)
https://a.com/es/page-one            ->  psi-a.com-es-page-one-2026-09-02-171221-mobile.json
https://a.com/es/zapatos-de-niño     ->  psi-a.com-es-zapatos-de-niño-...-mobile.json
https://a.com/es/page?utm_source=x   ->  psi-a.com-es-page-2026-09-02-171221-mobile.json

A root path adds no segment, so single-page runs and origin-scoped crux / crux-history produce byte-identical filenames to before. sitemap is passed an origin and is unaffected. Applies to lab, psi, crux, crux-history and links, plus the .clean.json, .summary.json and -output-ai.txt names that derive from the same function.

Two decisions amended from the original spec

Both were found by running the specified algorithm rather than reasoning about it.

Unicode is preserved instead of slugging with [a-z0-9]. pathname percent-encodes non-ASCII, so an ASCII-only rule emits the UTF-8 bytes as text: /es/zapatos-de-niño became es-zapatos-de-ni-c3-b1o, and /日本語/ページ became e6-97-a5-e6-9c-ac-... — hex carrying no page identity, which defeats the purpose of the change. The slug now decodes first and keeps \p{L}\p{N}.

The cap is 80 bytes rather than 40 characters, and truncation keeps the tail. A character cap is unsafe once Unicode is allowed: 80 CJK characters is 240 bytes, which exceeds the 255-byte filename limit on its own. Keeping the tail matters because URLs are hierarchical — the shared part is the prefix, so a head-truncating cap collapsed exactly the sibling pages most likely to be audited together (.../zapatillas-running-hombre and .../zapatillas-running-mujer both became es-productos-calzado-deportivo-zapatilla). The 40-char cap was also leaving 126 bytes of measured headroom unused.

A 6-char hash is appended when the slug is truncated, reversing the spec's "no hash suffix" call. The spec's own fallback is _NN, which it concedes is unstable across runs given completion-order batching; a hash of the full path is stable. Short paths — the overwhelming majority — never see one. _NN is retained as the last-resort tiebreaker.

New export

urlSlug(url) from web-perf-cli/utils, so a consumer can predict an output path. Added to the README API table, and type-tests/consumer.ts now imports from that subpath — it previously imported nothing from utils, so those published declarations were unchecked.

Verification

npm run lint, npm test (609 passed, +15), npm run generate-types, npm run check-types — all pass.

Verified against the real CLI, not just unit tests. A three-URL psi run on one host completed out of order (lcp, then /, then cls — the exact instability the issue describes) and produced:

psi-web.dev-articles-lcp-2026-09-04-101417-mobile.json
psi-web.dev-2026-09-04-101418-mobile.json
psi-web.dev-articles-cls-2026-09-04-101552-mobile.json

examples/crux-save.js (origin scope) still writes crux-web.dev-2026-09-04-101714-phone.json, confirming the backward-compatibility claim live.

Known churn

lib/utils.test.js had one assertion passing a URL with a path; the other twelve use bare origins and were unaffected.

buildFilename keyed output names on hostname alone, so every page of one host
collided and the _NN tiebreaker carried no page identity. runBatch records
results in completion order rather than input order, so _NN did not even
correspond to a URL's position in the list — the number a page received varied
between runs of the same command.

urlSlug derives a filename-safe slug from the path. Query strings and fragments
are dropped, so tracking parameters do not produce a different filename for the
same page. A root path yields an empty slug, which leaves single-page runs and
origin-scoped crux/crux-history byte-identical to before.

Two details that are not obvious:

Percent-encoding is decoded and Unicode letters are kept, rather than slugging
with [a-z0-9]. `pathname` percent-encodes non-ASCII, so an ASCII-only rule emits
the UTF-8 bytes as text — /es/zapatos-de-niño becomes es-zapatos-de-ni-c3-b1o,
and a non-Latin path becomes hex carrying no page identity at all.

The cap is 80 bytes, not 80 characters, and truncation keeps the path's tail.
80 CJK characters is 240 bytes, which would exceed the 255-byte filename limit
on its own. Keeping the tail matters because URLs are hierarchical: the shared
part is the prefix, so keeping the head would collapse exactly the deep-category
pages most likely to be audited together. A 6-char hash of the full path is
appended once truncated, which makes the name stable across runs where _NN is
not. _NN is retained as the last-resort tiebreaker.

urlSlug is exported from web-perf-cli/utils so a consumer can predict a path.
consumer.ts imported nothing from web-perf-cli/utils, so the published
declarations for that subpath were unchecked and urlSlug shipped uncovered.
Asserts the return types and that urlSlug is not nullable, so a consumer never
has to narrow it.
The filename formats are written out in both README.md and CLAUDE.md and have
drifted before, so both carry the [-<slug>] segment and the rules behind it:
query and fragment dropped, percent-encoding decoded, Unicode letters kept, an
80-byte cap that keeps the path's tail with a hash.

States plainly that a root path adds no segment, since the practical question
for anyone with an existing results/ directory is whether their filenames
changed — for single-page and origin-scoped runs they did not.

Adds urlSlug to the library API table now that it is exported.
@Hugoer
Hugoer merged commit e5c739b into main Sep 4, 2026
6 checks passed
@Hugoer
Hugoer deleted the feat/url-slug-filenames branch September 4, 2026 10:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-URL runs on one host produce filenames that cannot be traced back to their URL

1 participant