Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: ["20", "22"]
node: ["22"]

steps:
- uses: actions/checkout@v7
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:

- uses: actions/setup-node@v7
with:
node-version: "20"
node-version: "22"
cache: npm

# The tag is the release, and `pend-analytics --version` reads package.json.
Expand Down
7 changes: 3 additions & 4 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# Treat a dependency's `engines` mismatch as an error, not a warning.
#
# Without this, npm prints EBADENGINE and exits 0, so CI happily went green on a
# dependency requiring Node >=22.12 while this package advertised >=20. A
# published CLI cannot claim a runtime it warns on. Failing the install is the
# only version of this check that cannot be overlooked.
# Without this, npm prints EBADENGINE and exits 0. A published CLI cannot
# claim a runtime a dependency refuses. Failing the install is the only
# version of this check that cannot be overlooked.
engine-strict=true
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ npm test
npm run build
```

CI runs the same set on Node 20 and 22, plus a full-history secret scan and an
CI runs the same set on Node 22, plus a full-history secret scan and an
ASCII punctuation check.

## The rules that matter here
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test/ tests

## Getting started

Requires **Node.js 20+**. No browser install unless you opt into observeEvents (see docs).
Requires **Node.js 22.12+**. No browser install unless you opt into observeEvents (see docs).

Requests to websites use a `pend-analytics/VERSION` user agent so a site owner reading
their logs can identify or block us.
Expand Down Expand Up @@ -199,7 +199,7 @@ Prefer small, single-purpose changes. Start with
[Pendulum-url]: https://pendulumdev.co.uk/
[Status-shield]: https://img.shields.io/badge/status-stable-000000?style=for-the-badge
[Status-url]: README.md
[Node-shield]: https://img.shields.io/badge/node-20+-000000?style=for-the-badge&logo=nodedotjs
[Node-shield]: https://img.shields.io/badge/node-22.12+-000000?style=for-the-badge&logo=nodedotjs
[Node-url]: https://nodejs.org/
[GA4-shield]: https://img.shields.io/badge/GA4-000000?style=for-the-badge&logo=googleanalytics
[GA4-url]: https://developers.google.com/analytics
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,5 +60,5 @@ There is no catalog and no checklist.
| Pull | `pend-analytics audit -c analytics.toml` |
| Validate via MCP | `config_validate` |

Requires Node 20+. A Google service account with read access. Playwright only
Requires Node 22.12+. A Google service account with read access. Playwright only
if you opt into observe.
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.9/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.10/schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.{ts,mjs}", "*.json"]
Expand Down
58 changes: 23 additions & 35 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"NOTICE"
],
"engines": {
"node": ">=20"
"node": ">=22.12.0"
},
"scripts": {
"test": "node scripts/run-tests.mjs",
Expand All @@ -62,8 +62,8 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"commander": "^14.0.3",
"google-auth-library": "^10.9.1",
"commander": "^15.0.0",
"google-auth-library": "^11.0.2",
"smol-toml": "^1.7.1",
"zod": "^4.4.3"
},
Expand Down
7 changes: 3 additions & 4 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
* remembers to add never runs, and never fails, so it reads as passing coverage
* that does not exist. Discovery removes the human step.
*
* Zero dependencies and hand-rolled recursion on purpose. Node's own runner
* cannot do this on our supported floor (Node 20): passing a directory finds no
* `.ts` files, and glob arguments only landed in Node 21. Raising the floor to
* suit the test script would be the tail wagging the dog for a published CLI.
* Zero dependencies and hand-rolled recursion on purpose. Node can glob
* `.ts` files now, but a one-file walker keeps discovery in-repo and
* independent of runner flags.
*
* node scripts/run-tests.mjs # everything
* node scripts/run-tests.mjs score # only paths containing "score"
Expand Down
35 changes: 18 additions & 17 deletions src/analytics/rivals.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { CruxOriginBundle, RivalOriginObservation, RivalPageObservation } from "../types.js";
import type {
CruxOriginBundle,
RivalOriginObservation,
RivalPageObservation,
} from "../types.js";
import { fetchCruxOrigin, originFromBaseUrl, pagespeedApiKey } from "./crux.js";
import { fetchText, type FetchLike } from "./tags.js";
import { type FetchLike, fetchText } from "./tags.js";

const SAMPLE_LIMIT = 5;

Expand All @@ -11,7 +15,8 @@ function firstMatch(html: string, re: RegExp): string | undefined {
}

function stripTags(html: string): string {
return html.replace(/<script[\s\S]*?<\/script>/gi, " ")
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
Expand All @@ -20,19 +25,18 @@ function stripTags(html: string): string {

export function parseRivalPage(html: string, url: string): RivalPageObservation {
const title = firstMatch(html, /<title[^>]*>([\s\S]*?)<\/title>/i);
const description = firstMatch(
html,
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
) ?? firstMatch(
html,
/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i,
);
const description =
firstMatch(
html,
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
) ??
firstMatch(html, /<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
const h1 = firstMatch(html, /<h1\b[^>]*>([\s\S]*?)<\/h1>/i);
const schemaTypes = [
...new Set(
[...html.matchAll(/"@type"\s*:\s*"([^"]+)"/g)].map((m) => m[1]).filter(
(t): t is string => Boolean(t),
),
[...html.matchAll(/"@type"\s*:\s*"([^"]+)"/g)]
.map((m) => m[1])
.filter((t): t is string => Boolean(t)),
),
];
const text = stripTags(html);
Expand All @@ -49,10 +53,7 @@ export function parseRivalPage(html: string, url: string): RivalPageObservation
};
}

async function sitemapSample(
origin: string,
fetchImpl: FetchLike,
): Promise<string[]> {
async function sitemapSample(origin: string, fetchImpl: FetchLike): Promise<string[]> {
try {
const xml = await fetchText(`${origin}/sitemap.xml`, fetchImpl);
const locs = [...xml.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)]
Expand Down
5 changes: 1 addition & 4 deletions test/analytics/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,7 @@ rivals = ["https://rival.test", "https://peer.test"]
siteUrl = "sc-domain:example.com"
`);
const cfg = loadConfig(path);
assert.deepEqual(cfg.analytics.rivals, [
"https://rival.test",
"https://peer.test",
]);
assert.deepEqual(cfg.analytics.rivals, ["https://rival.test", "https://peer.test"]);
});

it("rejects enabled analytics without sources", () => {
Expand Down
5 changes: 4 additions & 1 deletion test/analytics/rivals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ describe("parseRivalPage", () => {
});

it("omits fields that were not in the HTML", () => {
const page = parseRivalPage("<html><body>plain</body></html>", "https://rival.test/x");
const page = parseRivalPage(
"<html><body>plain</body></html>",
"https://rival.test/x",
);
assert.equal(page.title, undefined);
assert.equal(page.description, undefined);
assert.equal(page.h1, undefined);
Expand Down