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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ Thumbs.db
*.log
npm-debug.log*
pnpm-debug.log*

# Local development documents (instructions and roadmap), kept on this machine only
/CLAUDE.md
/PLAN.md
310 changes: 0 additions & 310 deletions CLAUDE.md

This file was deleted.

330 changes: 0 additions & 330 deletions PLAN.md

This file was deleted.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"husky": "^9.1.7",
"lighthouse": "^13.5.0",
"lint-staged": "^17.6.0",
"playwright-layout-guard": "0.2.0",
"playwright-layout-guard": "0.3.0",
"prettier": "^3.9.10",
"prettier-plugin-astro": "^1.1.2",
"prettier-plugin-tailwindcss": "^0.8.1",
Expand Down
99 changes: 5 additions & 94 deletions pnpm-lock.yaml

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

4 changes: 0 additions & 4 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,3 @@ allowBuilds:
# versions, so it does nothing once miniflare asks for a patched version itself: then delete it.
overrides:
'undici@>=7.0.0 <7.30.1': 7.30.1
# My own package (playwright-layout-guard, published by me): the minimum release age of pnpm does
# not apply to it. Remove the entry when the version is old enough or when it is updated.
minimumReleaseAgeExclude:
- playwright-layout-guard@0.2.0
58 changes: 57 additions & 1 deletion scripts/check-links.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';

import { checkLink, checkLinks, extractExternalLinks, fetchPage, summarize } from './check-links';
import {
checkLink,
checkLinks,
extractExternalLinks,
fetchPage,
registryAddress,
summarize,
} from './check-links';

/** A fetch that answers by "METHOD url" with a status, or throws for the value "error". */
function fakeFetch(answers: Record<string, number | 'error'>): {
Expand Down Expand Up @@ -47,6 +54,30 @@ describe('extractExternalLinks', () => {
});
});

describe('registryAddress', () => {
it('turns the page of a package on npmjs.com into its address in the registry', () => {
expect(registryAddress('https://www.npmjs.com/package/playwright-layout-guard')).toBe(
'https://registry.npmjs.org/playwright-layout-guard',
);
expect(registryAddress('https://www.npmjs.com/package/playwright-layout-guard/v/0.3.0')).toBe(
'https://registry.npmjs.org/playwright-layout-guard',
);
});

it('encodes the name of a scoped package, so the slash becomes %2F', () => {
expect(registryAddress('https://www.npmjs.com/package/@playwright/test')).toBe(
'https://registry.npmjs.org/%40playwright%2Ftest',
);
});

it('leaves everything else alone', () => {
expect(registryAddress('https://www.npmjs.com/~leonkrix')).toBeUndefined();
expect(registryAddress('https://www.npmjs.com/package/')).toBeUndefined();
expect(registryAddress('https://github.com/leonkrix')).toBeUndefined();
expect(registryAddress('https://notnpmjs.com/package/x')).toBeUndefined();
});
});

describe('checkLink', () => {
it('passes a link that answers with success', async () => {
const { fetch: fetchImpl } = fakeFetch({ 'HEAD https://github.com/leonkrix': 200 });
Expand All @@ -55,6 +86,31 @@ describe('checkLink', () => {
});
});

it('checks a package of npm in the registry, because npmjs.com refuses automated requests', async () => {
const { fetch: fetchImpl, calls } = fakeFetch({
'HEAD https://registry.npmjs.org/playwright-layout-guard': 200,
});
const result = await checkLink(
'https://www.npmjs.com/package/playwright-layout-guard',
fetchImpl,
0,
);
expect(result).toMatchObject({
url: 'https://www.npmjs.com/package/playwright-layout-guard',
status: 'pass',
});
expect(calls).toEqual(['HEAD https://registry.npmjs.org/playwright-layout-guard']);
});

it('fails a package that does not exist in the registry', async () => {
const { fetch: fetchImpl } = fakeFetch({
'HEAD https://registry.npmjs.org/no-such-package': 404,
});
expect(
await checkLink('https://www.npmjs.com/package/no-such-package', fetchImpl, 0),
).toMatchObject({ status: 'fail' });
});

it('asks again with GET when a server refuses HEAD', async () => {
const { fetch: fetchImpl, calls } = fakeFetch({
'HEAD https://example.org/': 405,
Expand Down
36 changes: 33 additions & 3 deletions scripts/check-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ export function extractExternalLinks(html: string, ownHost: string): string[] {
return [...links].sort();
}

/**
* npmjs.com shows a challenge to automated requests (HTTP 403), also for a package that exists. The
* registry is where the package really lives and answers everybody, so a link to a package page is
* checked there. Anything else on npmjs.com is checked as it is.
*/
export function registryAddress(url: string): string | undefined {
const { hostname, pathname } = new URL(url);
if (hostname !== 'www.npmjs.com' && hostname !== 'npmjs.com') {
return undefined;
}
const parts = pathname.split('/').filter((part) => part !== '');
if (parts[0] !== 'package') {
return undefined;
}
// A scoped package is two parts, @scope/name; the whole name is encoded, so the slash becomes %2F
const name = parts[1]?.startsWith('@') === true ? parts.slice(1, 3).join('/') : parts[1];
return name === undefined || name === ''
? undefined
: `https://registry.npmjs.org/${encodeURIComponent(name)}`;
}

/** The domain itself or one of its subdomains, nothing that only ends with the same letters. */
const isBotWall = (url: string): boolean => {
const { hostname } = new URL(url);
Expand Down Expand Up @@ -106,15 +127,24 @@ export async function checkLink(
retryDelay = 3000,
): Promise<LinkResult> {
let detail = '';
const registry = registryAddress(url);
const target = registry ?? url;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
let status = await request(url, 'HEAD', fetchImpl);
let status = await request(target, 'HEAD', fetchImpl);
// Some servers do not answer HEAD requests
if ([400, 403, 405, 501].includes(status)) {
status = await request(url, 'GET', fetchImpl);
status = await request(target, 'GET', fetchImpl);
}
if (status < 400) {
return { url, status: 'pass', detail: `HTTP ${String(status)}` };
return {
url,
status: 'pass',
detail:
registry === undefined
? `HTTP ${String(status)}`
: `HTTP ${String(status)} in the npm registry (npmjs.com refuses automated requests)`,
};
}
if (isBotWall(url) && [401, 403, 429, 999].includes(status)) {
return {
Expand Down
2 changes: 1 addition & 1 deletion src/games/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ The games of the Playground section.
- `registry.ts` lists every game (slug, name, tag, description, icon, `status`). A game is merged as `hidden` and set to `live` in the pull request that completes it. A hidden game with a `GamePage.astro` still gets its page in the dev server (`pnpm dev`, never in a build), so it can be played while it is built. Only live games get a page, a tile, a navigation entry and a sitemap entry. The home page shows at most four tiles: a game with `home: false` is left out there (`homeGames()`) and comes last on `/games/`, which shows all of them. With no live game the site looks as if the Playground did not exist.
- `shared/` holds what several games use: seeded random numbers (`random.ts`, reproducible rounds and generators) and the tech vocabulary type (`tech-term.ts`).
- `<slug>/` is one game (see below).
- The pages come from `src/pages/games/[...slug].astro` (overview and one page per live game), the home page section from `src/components/games/Playground.astro`, the page frame from `src/layouts/GameLayout.astro` (title, the game, and the panels "How to play", "Keyboard" and "How it is built" as native `<details>`, no script needed). The panel "How it is built" of a game stays short (two or three sentences) and **links to the longer write-up** when there is a note for the game: a file in `src/content/notes/` with `game: <slug>` in its front matter (see CLAUDE.md, "Notes"); nothing to wire by hand.
- The pages come from `src/pages/games/[...slug].astro` (overview and one page per live game), the home page section from `src/components/games/Playground.astro`, the page frame from `src/layouts/GameLayout.astro` (title, the game, and the panels "How to play", "Keyboard" and "How it is built" as native `<details>`, no script needed). The panel "How it is built" of a game stays short (two or three sentences) and **links to the longer write-up** when there is a note for the game: a file in `src/content/notes/` with `game: <slug>` in its front matter (the fields are in `src/lib/notes.ts`); nothing to wire by hand.

## A game folder

Expand Down
4 changes: 2 additions & 2 deletions src/games/backbone/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Backbone

A planning game about networks: you route traffic through a small network, then a stress test judges the plan. Our own game, our own levels, texts and drawings. The concept (the reasoning behind every rule) is in [docs/network-game-concept.md](../../../docs/network-game-concept.md).
A planning game about networks: you route traffic through a small network, then a stress test judges the plan. Our own game, our own levels, texts and drawings.

Status: **built and live in this branch** (set to `live` in the registry; it is merged after Leon's okay). The model, the stress test, the exact solver, the generator, the campaign pool (13 chapters, 78 levels), the interface, the overview page, random levels in five tiers, the note, the browser tests and the Lighthouse budget are done. Open: polish (fonts, animation, real packets instead of dots), see PLAN.md, 4.5.
Status: **live** (set to `live` in the registry). The model, the stress test, the exact solver, the generator, the campaign pool (13 chapters, 78 levels), the interface, the overview page, random levels in five tiers, the note, the browser tests and the Lighthouse budget are done. Open: polish (fonts, animation, real packets instead of dots).

## Rules

Expand Down
4 changes: 2 additions & 2 deletions src/games/backbone/data/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ let loaded: Promise<readonly LevelRecord[]> | undefined;

/**
* The page is drawn first; the level pool (a few hundred kilobytes of JSON) is fetched afterwards,
* so that it never competes with the first paint (see CLAUDE.md, "Fonts and layout shift", and the
* note "One page, four scores"). The wait ends when the page has loaded and its fonts are there,
* so that it never competes with the first paint (see the note "One page, four
* scores"). The wait ends when the page has loaded and its fonts are there,
* plus a moment, or after four seconds at the latest.
*/
function afterPaint(): Promise<void> {
Expand Down
Loading
Loading