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 .changeset/library-constellation-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
---

docs: add an interactive constellation map to the library index

Generate a deterministic library catalog from Solidity sources and CLI presets,
link documented facets in new tabs, and provide a responsive 3D star map with an
automatic accessible 2D fallback. Rebuild documentation when CLI or Solidity
sources change.
59 changes: 59 additions & 0 deletions .github/scripts/netlify-ignore.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';

const script = fileURLToPath(new URL('../../website/netlify-ignore.sh', import.meta.url)).replaceAll('\\', '/');
const shell = process.env.NETLIFY_IGNORE_TEST_SHELL || 'bash';

test('Netlify rebuilds for docs inputs and skips unrelated changes', async (t) => {
const root = await mkdtemp(path.join(os.tmpdir(), 'compose-netlify-ignore-'));
t.after(() => rm(root, { recursive: true, force: true }));
const git = (...args) => execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', windowsHide: true }).trim();
await mkdir(path.join(root, 'website'), { recursive: true });
await writeFile(path.join(root, 'website/index.mdx'), '# Library\n');
git('init', '--quiet');
function commit() {
git('add', '.');
git('-c', 'user.name=Docs Test', '-c', 'user.email=docs-test@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--quiet', '-m', 'Test fixture');
return git('rev-parse', 'HEAD');
}
let previous = commit();
function check(cached, current) {
const result = spawnSync(shell, [script], {
cwd: path.join(root, 'website'),
env: { ...process.env, CACHED_COMMIT_REF: cached, COMMIT_REF: current },
encoding: 'utf8',
windowsHide: true,
});
if (result.error) throw result.error;
assert.equal(result.stderr, '');
return result.status;
}
for (const [file, expected] of [
['website/index.mdx', 1],
['cli/bases/example.json', 1],
['cli/src/example.ts', 1],
['src/ExampleFacet.sol', 1],
['src/token/example/ExampleFacet.sol', 1],
['src/README.md', 0],
['README.md', 0],
]) {
await t.test(file, async () => {
await mkdir(path.dirname(path.join(root, file)), { recursive: true });
await writeFile(path.join(root, file), `Changed ${file}\n`);
const current = commit();
assert.equal(check(previous, current), expected);
previous = current;
});
}
await t.test('missing or untrustworthy baseline always builds', () => {
assert.equal(check('', previous), 1);
assert.equal(check(previous, ''), 1);
assert.equal(check(previous, previous), 1);
assert.equal(check('nonexistent-ref', previous), 1);
});
});
10 changes: 10 additions & 0 deletions .github/workflows/docs-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ on:
pull_request:
paths:
- 'website/**'
- 'cli/**'
- 'src/**/*.sol'
- '.github/workflows/docs-build.yml'
- '.github/scripts/netlify-ignore.test.mjs'
push:
branches: [main]
paths:
- 'website/**'
- 'cli/**'
- 'src/**/*.sol'
- '.github/workflows/docs-build.yml'
- '.github/scripts/netlify-ignore.test.mjs'

jobs:
lint-and-build:
Expand All @@ -25,6 +32,9 @@ jobs:
node-version: '20'
cache: 'npm'

- name: Check docs rebuild rules
run: node --test .github/scripts/netlify-ignore.test.mjs

- name: Install dependencies
run: npm ci

Expand Down
47 changes: 46 additions & 1 deletion website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,49 @@ This command generates static content into the `build` directory and can be serv

```bash
npm run generate-docs
```
```

## Library Catalog Snapshot

`src/data/libraryCatalog.json` is generated from this checkout's `../src` Solidity
files and `../cli/bases` presets. Website components can import the JSON without
GitHub access or runtime requests. Both `npm start` and `npm run build` regenerate
it before Docusaurus runs. After editing sources or presets during development,
run `npm run catalog:generate` again.

```bash
npm run catalog:generate
npm run catalog:check
npm run catalog:test
```

The checked-in snapshot provides a reviewable baseline. Builds regenerate it
from the checkout, so source and preset PRs do not need a bot commit to refresh
the deployed graph. Refresh the baseline when changing the generator or preparing
a catalog update. The check command detects a stale baseline. Output is
deterministic and normalizes CRLF line endings, with a content hash instead of a
build timestamp or changing HEAD.

`files` inventories all Solidity source paths, with filename-based kinds,
category/family grouping and preset memberships. Grouping describes directory
membership, not Solidity dependencies. `presets` preserves each CLI definition
and its source file, plus resolved references with required/optional roles and
exact metadata field paths. CLI-local templates (such as Counter) remain
`external` references and do not become library facets. Missing package source
references fail generation.

The Library index imports the snapshot through `src/components/LibraryGraph`.
It uses Docusaurus document IDs and their published routes to link available
facet pages, including custom slugs, in new tabs. Facets without a matching doc
remain inspectable. CLI preset and search filters change emphasis without
removing or repositioning nodes. The 3D view loads Three.js only in the browser,
with an automatic keyboard-operable SVG fallback when WebGL is unavailable and
a facet list for direct navigation.

Run `npm run graph:test` to check route matching, preset membership and stable
layout. Grouping edges represent family membership, not Solidity dependencies.

The map uses Babylon's constellation arrangement: feature families occupy
different depths, with small glowing facet stars and an oblique orbit camera.
Portrait screens rotate the same sky into a taller composition. The SVG view
uses the same camera projection, so switching renderers preserves the map.
3 changes: 3 additions & 0 deletions website/docs/library/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import DocCard, { DocCardGrid } from '@site/src/components/docs/DocCard';
import DocSubtitle from '@site/src/components/docs/DocSubtitle';
import Icon from '@site/src/components/ui/Icon';
import Callout from '@site/src/components/ui/Callout';
import LibraryGraph from '@site/src/components/LibraryGraph';

# Compose Smart Contracts Library

<DocSubtitle>
Contract-level docs for each **Compose** module and facet. What to call on-chain, how state is organized, and how upgrades work.
</DocSubtitle>

<LibraryGraph />

<DocCardGrid columns={2}>
<DocCard
title="Diamond Core"
Expand Down
7 changes: 4 additions & 3 deletions website/netlify-ignore.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/bin/bash
# Netlify ignore script - Skip builds if website/ directory hasn't changed
# Skip builds only when website inputs have not changed.
# Based on official Netlify documentation: https://docs.netlify.com/configure-builds/ignore-builds/
# Exit code: 0 = skip build, 1 = proceed with build

Expand All @@ -14,8 +14,9 @@ if [ -z "$CACHED_COMMIT_REF" ] || [ -z "$COMMIT_REF" ] || [ "$CACHED_COMMIT_REF"
exit 1
fi

# Skip only when Netlify provides a valid baseline and website content is unchanged.
if git diff --quiet "$CACHED_COMMIT_REF" "$COMMIT_REF" -- website/ 2>/dev/null; then
# Library catalog generation reads Solidity sources and CLI presets at build time.
if git diff --quiet "$CACHED_COMMIT_REF" "$COMMIT_REF" -- \
website/ cli/ ':(top,glob)src/**/*.sol' 2>/dev/null; then
exit 0
fi

Expand Down
4 changes: 2 additions & 2 deletions website/netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
# Publish directory (relative to base directory)
publish = "build"

# Skip Netlify builds if there are no changes in the website/ directory
# Rebuild for website, CLI, or Solidity source changes
# Based on official Netlify documentation: https://docs.netlify.com/configure-builds/ignore-builds/
# Works for both regular PRs and forked PRs
ignore = "./netlify-ignore.sh"
Expand All @@ -29,4 +29,4 @@
to = "https://us.i.posthog.com/:splat"
host = "us.i.posthog.com"
status = 200
force = true
force = true
6 changes: 6 additions & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"catalog:generate": "node scripts/generate-library-catalog.mjs",
"catalog:check": "node scripts/generate-library-catalog.mjs --check",
"catalog:test": "node --test scripts/generate-library-catalog.test.mjs",
"graph:test": "node --test scripts/library-graph.test.mjs",
"prestart": "npm run catalog:generate",
"start": "docusaurus start",
"prebuild": "npm run catalog:generate",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
Expand Down
126 changes: 126 additions & 0 deletions website/scripts/generate-library-catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { createHash } from 'node:crypto';
import { readdir, readFile, mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const defaultRoot = fileURLToPath(new URL('../../', import.meta.url));
const packagePrefix = '@perfect-abstractions/compose/';
const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;

async function filesUnder(root, directory, extension) {
const entries = await readdir(path.join(root, directory), { withFileTypes: true });
const files = [];
for (const entry of entries) {
const relative = `${directory}/${entry.name}`;
if (entry.isDirectory()) files.push(...await filesUnder(root, relative, extension));
else if (entry.isFile() && entry.name.endsWith(extension)) files.push(relative);
}
return files.sort(compare);
}

/** Build a local source inventory with CLI preset membership as a separate overlay. */
export async function buildLibraryCatalog(root = defaultRoot) {
const sourcePaths = await filesUnder(root, 'src', '.sol');
const presetPaths = await filesUnder(root, 'cli/bases', '.json');
const inputs = new Map();
for (const relative of [...sourcePaths, ...presetPaths]) {
inputs.set(relative, (await readFile(path.join(root, relative), 'utf8')).replace(/\r\n/g, '\n'));
}
const files = sourcePaths.map((relative) => {
const name = path.posix.basename(relative, '.sol');
const parts = relative.split('/');
const kind = parts[1] === 'interfaces' ? 'interface'
: name.endsWith('Facet') ? 'facet'
: name.endsWith('Mod') ? 'module'
: 'other';
return {
path: relative,
name,
kind,
category: parts[1],
family: parts.length > 3 ? parts[2] : null,
sha256: createHash('sha256').update(inputs.get(relative)).digest('hex'),
presets: [],
};
});
const byPath = new Map(files.map((file) => [file.path, file]));
const presets = [];
const ids = new Set();

for (const sourcePath of presetPaths) {
const definitions = JSON.parse(inputs.get(sourcePath));
if (!definitions || Array.isArray(definitions) || typeof definitions !== 'object') {
throw new Error(`Expected preset definitions in ${sourcePath}`);
}
for (const id of Object.keys(definitions).sort(compare)) {
if (ids.has(id)) throw new Error(`Duplicate preset ID: ${id}`);
ids.add(id);
const definition = definitions[id];
if (!definition || typeof definition.label !== 'string') {
throw new Error(`Missing preset label: ${sourcePath}#${id}`);
}
const references = [];
// Preserve exact field paths, including mod and erc165 references, not just facets.
function visit(value, fields = []) {
if (typeof value === 'string' && value.endsWith('.sol')) {
const field = fields.join('.');
const libraryReference = value.startsWith(packagePrefix);
const resolvedPath = libraryReference ? `src/${value.slice(packagePrefix.length)}` : null;
const source = resolvedPath ? byPath.get(resolvedPath) : undefined;
const status = source ? 'resolved' : libraryReference ? 'missing' : 'external';
const role = ['required', 'optional'].includes(fields[0]) ? fields[0] : null;
references.push({ field, declaredPath: value, path: source?.path ?? null, role, status });
if (source) source.presets.push({ id, field, role });
} else if (Array.isArray(value)) {
value.forEach((child, index) => visit(child, [...fields, String(index)]));
} else if (value && typeof value === 'object') {
for (const key of Object.keys(value).sort(compare)) visit(value[key], [...fields, key]);
}
}
visit(definition);
presets.push({ id, sourcePath, definition, references });
}
}
const hash = createHash('sha256');
for (const [relative, contents] of inputs) {
hash.update(JSON.stringify([relative, contents]));
}
return {
schemaVersion: 1,
source: { directories: ['src', 'cli/bases'], sha256: hash.digest('hex') },
files,
presets: presets.sort((a, b) => compare(a.id, b.id)),
};
}

export async function generateLibraryCatalog({ root = defaultRoot, check = false } = {}) {
const catalog = await buildLibraryCatalog(root);
const missing = catalog.presets.flatMap((preset) => preset.references
.filter((reference) => reference.status === 'missing')
.map((reference) => `${preset.id}:${reference.field} -> ${reference.declaredPath}`));
if (missing.length) throw new Error(`Unresolved Compose references:\n${missing.join('\n')}`);
const output = path.join(root, 'website/src/data/libraryCatalog.json');
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
let current;
try { current = await readFile(output, 'utf8'); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
if (check) {
if (current?.replace(/\r\n/g, '\n') !== serialized) {
throw new Error('Library catalog is stale. Run npm run catalog:generate in website.');
}
} else if (current?.replace(/\r\n/g, '\n') !== serialized) {
await mkdir(path.dirname(output), { recursive: true });
await writeFile(output, serialized);
}
return catalog;
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const catalog = await generateLibraryCatalog({ check: process.argv.includes('--check') });
console.log(`Library catalog: ${catalog.files.filter((file) => file.kind === 'facet').length} facets, ${catalog.presets.length} presets`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
Loading
Loading