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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
8 changes: 4 additions & 4 deletions content/docs/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ icon: House
---

Axonpack is a set of free, open-source foundation libraries for React Native and Expo apps. Each one
is small, focused and dependency-light — something you drop into an app you already have, rather than
a framework you adopt.
is small, focused and dependency-light. You drop one into an app you already have, rather than
adopting a framework.

Every package is published independently, versioned independently, and documented independently.
There is no `axonpack` meta-package to install, and installing one never brings in another.
Expand All @@ -19,13 +19,13 @@ Everything is MIT licensed and developed in the open at
<PackageCards />

<Callout type="info">
These docs cover what is published, and nothing else — a page describing something you cannot
These docs cover what is published, and nothing else. A page describing something you cannot
install would only make you guess which half of this site is real.
</Callout>

## How these docs are organised

**One folder per package.** The sidebar lists them below this page picking one puts you in that
**One folder per package.** The sidebar lists them below this page, and picking one puts you in that
package's documentation and nothing else. A package's guides, its reference and its screenshots all
live under its own name, so nothing here is shared between two libraries by accident.

Expand Down
37 changes: 35 additions & 2 deletions scripts/fetch-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
//
// No dependencies. Node 20+ has fetch.

import { access, mkdir, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";

const SCOPE = "@axonpack/";

Expand Down Expand Up @@ -162,6 +162,39 @@ async function countDownloads(name, publishedAt) {
return answered ? total : null;
}

// --- repository stars ---------------------------------------------------------------------------
//
// Read at build time like everything else here, so the page ships as static HTML and no visitor
// pays for a request to GitHub. It goes stale between builds, which the nightly deploy keeps short.
//
// Never fatal. GitHub allows 60 unauthenticated calls an hour per IP and Actions runners share
// addresses, so a rate limit here is normal rather than exceptional. A missing count hides the
// number; it must not take the build down, because the build is also the deploy.
const fetchStars = async (repo) => {
try {
const res = await fetch(`https://api.github.com/repos/${repo}`, {
headers: {
Accept: "application/vnd.github+json",
// Actions sets this. It lifts the limit to 1,000 an hour and is not needed locally.
...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}),
},
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return (await res.json()).stargazers_count ?? null;
} catch (error) {
console.warn(` stars unavailable for ${repo}: ${error.message}`);
return null;
}
};

// The repo name lives in content.json already, so the count follows whatever that names rather than
// being pinned again here.
const content = JSON.parse(
await readFile(new URL("../src/content.json", import.meta.url), "utf8"),
);
const repo = { name: content.nav.github.repo, stars: await fetchStars(content.nav.github.repo) };
console.log(` ${repo.name} -> ${repo.stars ?? "?"} stars`);

const packages = [];
for (const name of [...names].sort()) {
const manifest = await json(`https://registry.npmjs.org/${encode(name)}/latest`);
Expand Down Expand Up @@ -207,6 +240,6 @@ for (const name of [...names].sort()) {
await mkdir(new URL("../src/generated/", import.meta.url), { recursive: true });
await writeFile(
new URL("../src/generated/packages.json", import.meta.url),
JSON.stringify({ builtAt: new Date().toISOString(), packages }, null, 2) + "\n",
JSON.stringify({ builtAt: new Date().toISOString(), repo, packages }, null, 2) + "\n",
);
console.log(`wrote src/generated/packages.json (${packages.length} packages)`);
74 changes: 65 additions & 9 deletions scripts/sync-changelog.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* Usage: bun run sync:changelog
*/
import { readFile, writeFile } from 'node:fs/promises';
import { gunzipSync } from 'node:zlib';

/**
* Every package with a changelog page. A package earns an entry here on the day it goes to npm, the
Expand All @@ -32,22 +33,68 @@ const get = async (url, as = 'text') => {
};

/**
* This repository is mounted as a submodule at `docs/` inside the monorepo, so when you are working
* there the package's changelog is on disk one level up. Read that first: it means a version bumped
* locally shows on the site immediately, without waiting for the change to reach `main`.
* Pulls one file out of a gzipped tar. Forty lines against a dependency for a format that is fixed
* 512-byte headers: a name, an octal size, then the data padded to the next block. npm prefixes
* every path in a package tarball with `package/`.
*/
async function readChangelog(name) {
function fileFromTarball(archive, wanted) {
const tar = gunzipSync(archive);
let offset = 0;
while (offset + 512 <= tar.length) {
const header = tar.subarray(offset, offset + 512);
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/s, "");
if (!name) return null; // Two zero blocks end the archive.
const size = parseInt(header.subarray(124, 136).toString("utf8").replace(/\0.*$/s, "").trim(), 8) || 0;
const start = offset + 512;
if (name === wanted) return tar.subarray(start, start + size).toString("utf8");
offset = start + Math.ceil(size / 512) * 512;
}
return null;
}

/**
* The changelog, from the published package itself.
*
* It used to come from `main` of the monorepo over raw.githubusercontent, which tied this build to
* another repository's branch state: a package on npm whose source had not reached `main` yet gave
* a 404 and failed the deploy. The tarball cannot have that problem, because it is the thing that
* was published. It is also what the README always claimed this site does.
*
* The local checkout still wins when there is one, so a version bumped in the monorepo shows here
* before it is published. That does mean a local run can succeed where CI would not, since CI only
* ever checks out this repository.
*/
async function readChangelog(name, registry) {
try {
const local = await readFile(
new URL(`../../packages/${name}/CHANGELOG.md`, import.meta.url),
'utf8',
"utf8",
);
console.log(`${name}: ../packages (local checkout)`);
return local;
} catch {
console.log(`${name}: raw.githubusercontent.com (main)`);
return get(`https://raw.githubusercontent.com/axonpack/axonpack/main/packages/${name}/CHANGELOG.md`);
// No monorepo around this checkout, which is the normal case in CI.
}

const tarball = registry?.versions?.[registry?.["dist-tags"]?.latest]?.dist?.tarball;
if (!tarball) {
console.warn(`${name}: no tarball listed on npm — leaving the committed changelog alone`);
return null;
}

try {
const response = await fetch(tarball);
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
const changelog = fileFromTarball(Buffer.from(await response.arrayBuffer()), "package/CHANGELOG.md");
if (changelog) {
console.log(`${name}: npm tarball`);
return changelog;
}
console.warn(`${name}: the tarball ships no CHANGELOG.md — leaving the committed page alone`);
} catch (error) {
console.warn(`${name}: could not read the tarball (${error.message}) — leaving the committed page alone`);
}
return null;
}

/**
Expand Down Expand Up @@ -95,7 +142,9 @@ function parse(markdown) {
}

async function sync({ name, slug }) {
const [markdown, registry] = await Promise.all([readChangelog(name), readRegistry(name)]);
// The registry doc has to come first: it is where the tarball URL lives.
const registry = await readRegistry(name);
const markdown = await readChangelog(name, registry);

// `null` means we could not reach npm, which is not the same as a version being absent from it —
// saying "not yet published" because the network was down would put a false claim on the page.
Expand All @@ -110,6 +159,14 @@ async function sync({ name, slug }) {
})
: null;

const latest = registry?.['dist-tags']?.latest;

// Nothing to regenerate from. The page on disk is committed, so it stays as it was rather than
// being emptied, and the version still reaches releases.generated.ts if npm answered.
if (markdown === null) {
return { slug, latest, date: latest ? fmtDate(latest) : null };
}

const releases = parse(markdown);

const body = releases
Expand Down Expand Up @@ -148,7 +205,6 @@ async function sync({ name, slug }) {
})
.join('\n\n');

const latest = registry?.['dist-tags']?.latest;
const page = `---
title: Changelog
description: Every published release of ${name}, newest first.
Expand Down
20 changes: 20 additions & 0 deletions src/app/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,23 @@ html > body[data-scroll-locked] {
}
}
}

/* The landing page's header deliberately has no line under it, and the docs pages have no top bar at
all, so fumadocs' own navbar on the blog was the only header on the site drawing one. The colour
goes rather than the border, so nothing shifts by the pixel the border occupies. */
#nd-nav > nav {
border-bottom-color: transparent;
}

/* Every screenshot in the docs is a phone capture, about 2.16 times taller than it is wide, and
fumadocs renders MDX images at the full column width. That made each one roughly 1500px tall, so
a page turned into a scroll. Capping the height rather than the width is what keeps a future
landscape diagram at full size: at column width its height is already under this.
`width: auto` is load-bearing: next/image writes the intrinsic size onto the tag as a width
attribute, which counts as a specified width, so clamping only the height squashed the picture
instead of scaling it. */
.prose img {
width: auto;
max-height: 28rem;
margin-inline: auto;
}
39 changes: 18 additions & 21 deletions src/components/devtools-panel.module.css
Original file line number Diff line number Diff line change
@@ -1,47 +1,44 @@
/* The panel's own motion, kept beside it rather than in the page's stylesheet, so rewriting the
hero cannot take the carousel with it. */

/* Six panes over 24s, dwelling on each then sliding to the next. The track carries a seventh pane
/* Five panes over 20s, dwelling on each then sliding to the next. The track carries a sixth pane
that repeats the first, so the wrap at 100% lands on an identical frame instead of snapping back.
Change the pane count and every stop below has to be recut. */
Change the pane count and every stop below has to be recut: each pane owns 20% of the cycle, of
which it dwells for 16 and slides for 4. */
@keyframes carousel {
0%,
13% {
16% {
transform: translateX(0);
}
16.67%,
29.67% {
20%,
36% {
transform: translateX(-100%);
}
33.33%,
46.33% {
40%,
56% {
transform: translateX(-200%);
}
50%,
63% {
60%,
76% {
transform: translateX(-300%);
}
66.67%,
79.67% {
80%,
96% {
transform: translateX(-400%);
}
83.33%,
96.33% {
transform: translateX(-500%);
}
100% {
transform: translateX(-600%);
transform: translateX(-500%);
}
}

/* Same clock as the track, one delay per tab. */
@keyframes tabOn {
0%,
13% {
16% {
opacity: 1;
}
16.67%,
97% {
20%,
96% {
opacity: 0.35;
}
100% {
Expand All @@ -66,11 +63,11 @@

@media (prefers-reduced-motion: no-preference) {
.track {
animation: carousel 24s cubic-bezier(0.65, 0, 0.35, 1) infinite;
animation: carousel 20s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}

.tab {
animation: tabOn 24s steps(1, end) infinite;
animation: tabOn 20s steps(1, end) infinite;
}

.flow {
Expand Down
Loading
Loading