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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ Defense in depth against the npm-worm class (Shai-Hulud, mini-Shai-Hulud, the Ma
`bun audit` is the source of truth for dependency advisories. State as of 2026-05-04:

- **postcss `<8.5.10`** (GHSA-qx2v-qp2m-jg93, moderate XSS in CSS stringify). Multiple transitive resolutions — `next@16.2.4` pins `postcss@8.4.31` exactly, and `@tailwindcss/postcss@4.2.3` brings in `postcss@^8.5.6`. Resolved via `overrides.postcss = "8.5.10"` in `package.json`, which dedupes all transitives to the patched version. Drop the override after `next` and `@tailwindcss/postcss` ship releases that pull their transitives to ≥ 8.5.10.
- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by three independent paths (shiki/rehype-pretty-code, react-markdown, velite/@mdx-js/mdx) — all parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1.
- **mdast-util-to-hast `<13.2.1`** (GHSA-4fh9-h7wg-q85m, moderate XSS via unsanitized class attribute). Pulled in by two independent paths (shiki/rehype-pretty-code, velite/@mdx-js/mdx) — both parents accept `^13.0.0`, so the lockfile resolved to 13.2.0 (pre-fix). Resolved via `overrides.mdast-util-to-hast = "^13.2.1"`. Drop the override after parents ship releases that pull a patched version directly; verify with `bun pm ls --all | grep mdast-util-to-hast` showing only ≥ 13.2.1.
- **uuid `<14.0.0`** (GHSA-w5hq-g745-h8pq, moderate missing buffer bounds in v3/v5/v6 when `buf` provided). **Upstream-blocked.** Two parent paths: `resend@6.12.2 → svix@1.90.0 → uuid@^10.0.0` and `@lhci/cli@0.15.1 → uuid@8.3.2`. Neither parent admits a 14.x override without risking CJS imports. Exposure is theoretical on both: `/api/subscribe` uses Resend's send-email endpoint (not svix's webhook-signing path), `@lhci/cli` is dev-only and runs in CI on its own controlled inputs, and the vulnerable code (v3/v5/v6 with explicit `buf`) isn't called by either. Remove the `--ignore` when both parents ship releases bumping uuid to `^14.0.0`.
- **tmp `<=0.2.3`** (GHSA-52f5-9888-hmc6, low symbolic-link path traversal in `dir` param). **Upstream-blocked.** Pulled exclusively by `@lhci/cli@0.15.1` (dev-only, runs in CI on controlled inputs). The symlink-traversal scenario doesn't apply. Remove the `--ignore` when `@lhci/cli` ships a release with patched transitives.

Expand Down
14 changes: 0 additions & 14 deletions bun.lock

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

4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
"test:e2e:install": "playwright install --with-deps chromium webkit"
},
"dependencies": {
"@mdx-js/react": "^3.1.1",
"@next/mdx": "^16.2.4",
"@tailwindcss/typography": "^0.5.19",
"@vercel/analytics": "^2.0.1",
"feed": "^5.2.1",
Expand All @@ -33,10 +31,8 @@
"ogl": "^1.0.11",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"rehype-pretty-code": "^0.14.3",
"resend": "^6.12.2",
"use-scramble": "^2.2.15",
"velite": "^0.3.1"
},
"devDependencies": {
Expand Down
67 changes: 55 additions & 12 deletions src/components/link/index.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,71 @@
"use client";

import NextLink from "next/link";
import { useScramble } from "use-scramble";
import { useCallback, useEffect, useRef } from "react";

interface LinkProps extends React.ComponentProps<typeof NextLink> {
children: string;
}

const SCRAMBLE_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
const SCRAMBLE_WINDOW = 4;
const FRAME_MS = 30;

export const Link = ({ href, children, ...props }: LinkProps) => {
const { ref, replay } = useScramble({
text: children,
speed: 0.5,
tick: 1,
step: 1,
scramble: 4,
seed: 0,
playOnMount: false
});
const nodeRef = useRef<HTMLAnchorElement | null>(null);
const frameRef = useRef<number | null>(null);

const setNode = useCallback((node: HTMLAnchorElement | null) => {
nodeRef.current = node;
}, []);

useEffect(
() => () => {
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
},
[]
);

const replay = useCallback(() => {
const el = nodeRef.current;
if (!el) return;
if (frameRef.current !== null) return;
const target = children;
let revealed = 0;
let lastTime = 0;
const tick = (now: number) => {
if (now - lastTime >= FRAME_MS) {
lastTime = now;
if (revealed >= target.length) {
el.textContent = target;
frameRef.current = null;
return;
}
const head = target.slice(0, revealed);
let tail = "";
const tailLen = Math.min(SCRAMBLE_WINDOW, target.length - revealed);
for (let i = 0; i < tailLen; i++) {
const ch = target.charAt(revealed + i);
tail += /\s/.test(ch)
? ch
: SCRAMBLE_CHARS.charAt(
(Math.random() * SCRAMBLE_CHARS.length) | 0
);
}
el.textContent = head + tail;
revealed += 1;
}
frameRef.current = requestAnimationFrame(tick);
};
frameRef.current = requestAnimationFrame(tick);
}, [children]);

return (
<NextLink
href={href}
ref={ref}
onMouseOver={replay}
ref={setNode}
onMouseEnter={replay}
onTouchStart={replay}
{...props}
>
Expand Down
30 changes: 30 additions & 0 deletions tests/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,36 @@ test.describe("blog post", () => {
});
});

test.describe("footer", () => {
test("custom Link scramble settles back to original on hover", async ({
page
}) => {
await page.goto("/");
const link = page.locator('a[href="https://lfprojects.org"]').first();
await expect(link).toBeVisible();
const original = (await link.textContent()) ?? "";

// Trigger a real pointer enter — the scramble fires on hover.
await link.hover();

// Sample intermediate frames: the in-flight animation must produce at
// least one text snapshot that differs from the static text. Without
// the in-flight guard in the Link component, the scramble would loop
// forever because mutating textContent re-fires mouseover.
const observed = new Set<string>();
for (let i = 0; i < 30; i++) {
observed.add((await link.textContent()) ?? "");
await page.waitForTimeout(20);
}

// Wait for the animation to settle, then assert it returned to the
// original string (regression check for the textContent-refire loop).
await page.waitForTimeout(800);
expect(await link.textContent()).toBe(original);
expect([...observed].some((s) => s !== original)).toBe(true);
});
});

test.describe("mobile layout", () => {
test.use({ viewport: { width: 375, height: 812 } });

Expand Down
Loading