diff --git a/.sampo/changesets/radiant-flamebinder-ukko.md b/.sampo/changesets/radiant-flamebinder-ukko.md new file mode 100644 index 0000000..2c333e5 --- /dev/null +++ b/.sampo/changesets/radiant-flamebinder-ukko.md @@ -0,0 +1,6 @@ +--- +npm/graphgarden-web: minor +cargo/graphgarden-core: minor +--- + +**⚠️ breaking change:** Added `friends` field to the public file. `fetchFriendGraphs` now takes a `friends` parameter, and fetches all declared friends unconditionally. diff --git a/.sampo/changesets/verdant-stormweaver-vainamoinen.md b/.sampo/changesets/verdant-stormweaver-vainamoinen.md new file mode 100644 index 0000000..b8df102 --- /dev/null +++ b/.sampo/changesets/verdant-stormweaver-vainamoinen.md @@ -0,0 +1,5 @@ +--- +cargo/graphgarden-protocol: minor +--- + +**⚠️ breaking change:** Added required `friends` field, an array of declared friend site base URLs. diff --git a/crates/graphgarden-core/src/build.rs b/crates/graphgarden-core/src/build.rs index 0abdd77..941f51f 100644 --- a/crates/graphgarden-core/src/build.rs +++ b/crates/graphgarden-core/src/build.rs @@ -74,6 +74,7 @@ pub fn build(config: &Config) -> Result { description: config.site.description.clone(), language: config.site.language.clone(), }, + friends: config.friends.clone(), nodes, edges, }) @@ -175,6 +176,7 @@ mod tests { assert_eq!(result.version, crate::model::PROTOCOL_VERSION); assert_eq!(result.base_url, "https://alice.dev/"); assert_eq!(result.site.title, "Alice's Garden"); + assert_eq!(result.friends, vec!["https://bob.dev/"]); assert_eq!(result.nodes.len(), 2); assert!( result diff --git a/crates/graphgarden-core/src/model.rs b/crates/graphgarden-core/src/model.rs index e041667..4ac71a5 100644 --- a/crates/graphgarden-core/src/model.rs +++ b/crates/graphgarden-core/src/model.rs @@ -41,6 +41,7 @@ pub struct PublicFile { pub generated_at: String, pub base_url: String, pub site: SiteMetadata, + pub friends: Vec, pub nodes: Vec, pub edges: Vec, } @@ -69,6 +70,7 @@ mod tests { description: Some(String::from("A blog about gardening")), language: Some(String::from("en")), }, + friends: vec![String::from("https://bob.dev/")], nodes: vec![ Node { url: String::from("/"), @@ -148,6 +150,7 @@ mod tests { "description": "A blog about …", "language": "en" }, + "friends": ["https://bob.dev/"], "nodes": [ { "url": "/", "title": "Home" }, { "url": "/about", "title": "About" }, @@ -199,6 +202,7 @@ mod tests { "generated_at": "2026-02-17T12:00:00Z", "base_url": "https://alice.dev/", "site": { "title": "Test" }, + "friends": [], "nodes": "not_an_array", "edges": [] }"#; diff --git a/crates/graphgarden-protocol/README.md b/crates/graphgarden-protocol/README.md index 55d98c0..9adba32 100644 --- a/crates/graphgarden-protocol/README.md +++ b/crates/graphgarden-protocol/README.md @@ -23,6 +23,7 @@ Served at `BASE_URL/.well-known/graphgarden.json`. "description": "A blog about …", // optional "language": "en" // optional, BCP 47 }, + "friends": ["https://bob.dev/"], "nodes": [ { "url": "/", "title": "Home" }, { "url": "/about", "title": "About" }, @@ -40,6 +41,7 @@ Served at `BASE_URL/.well-known/graphgarden.json`. - **`edges[].source`** — relative path (must match a node). - **`edges[].target`** — relative path for `internal`, absolute URL for `friend`. - **`edges[].type`** — `"internal"` (same site) or `"friend"` (site declared in config). Other external links are ignored during crawl. +- **`friends`** — array of declared friend site base URLs. All listed origins are fetched unconditionally by the web component, regardless of whether any edges reference them. ## Caching diff --git a/crates/graphgarden/tests/cli.rs b/crates/graphgarden/tests/cli.rs index 342d745..3059a8d 100644 --- a/crates/graphgarden/tests/cli.rs +++ b/crates/graphgarden/tests/cli.rs @@ -147,6 +147,9 @@ fn build_happy_path() { assert_eq!(value["base_url"], "https://test.dev/"); assert_eq!(value["site"]["title"], "Test Site"); assert!(!value["nodes"].as_array().unwrap().is_empty()); + + let friends = value["friends"].as_array().unwrap(); + assert!(friends.is_empty()); } #[test] @@ -298,6 +301,10 @@ fn build_friend_links() { "external non-friend links should be dropped" ); + let friends = value["friends"].as_array().unwrap(); + assert_eq!(friends.len(), 1); + assert_eq!(friends[0], "https://bob.dev/"); + assert!( edges .iter() @@ -523,6 +530,10 @@ fn build_full_config() { .iter() .any(|e| e["source"] == "/about/" && e["target"] == "/" && e["type"] == "internal") ); + + let friends = value["friends"].as_array().unwrap(); + assert_eq!(friends.len(), 1); + assert_eq!(friends[0], "https://bob.dev/"); } #[test] @@ -627,6 +638,15 @@ fn build_protocol_invariants() { ); assert!(edge_pairs.insert(pair), "duplicate edge found: {:?}", pair); } + + let friends = value["friends"].as_array().unwrap(); + for friend in friends { + let url = friend.as_str().unwrap(); + assert!( + url.starts_with("http://") || url.starts_with("https://"), + "friend URL should be absolute HTTP(S), got: {url}" + ); + } } /// Validates that a string matches the `YYYY-MM-DDTHH:MM:SSZ` pattern. @@ -677,6 +697,9 @@ fn build_empty_site() { assert!(value["generated_at"].as_str().is_some()); assert_eq!(value["base_url"], "https://test.dev/"); assert_eq!(value["site"]["title"], "Test Site"); + + let friends = value["friends"].as_array().unwrap(); + assert!(friends.is_empty()); } #[test] diff --git a/fixtures/bob/graphgarden.json b/fixtures/bob/graphgarden.json index ae1c2c8..3e12dc7 100644 --- a/fixtures/bob/graphgarden.json +++ b/fixtures/bob/graphgarden.json @@ -7,6 +7,7 @@ "description": "Bob's your uncle for all things rabbits in England", "language": "en" }, + "friends": ["https://alice.test/"], "nodes": [ { "url": "/", "title": "Home" }, { "url": "/about/", "title": "About" }, diff --git a/fixtures/tests/pipeline.test.ts b/fixtures/tests/pipeline.test.ts index d6889ab..d20adea 100644 --- a/fixtures/tests/pipeline.test.ts +++ b/fixtures/tests/pipeline.test.ts @@ -137,6 +137,10 @@ describe("build pipeline", () => { } }); + test("friends lists Bob as a friend", () => { + expect(aliceGraph.friends).toEqual(["https://bob.test/"]); + }); + test("Alice's friend edges target URLs that exist in Bob's graph", () => { const bobGraph: GraphGardenFile = JSON.parse(readFileSync(BOB_JSON_PATH, "utf-8")); @@ -239,12 +243,13 @@ describe("bob mock server", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://local.test/", site: { title: "Local" }, + friends: [`${bobUrl}/`], nodes: [{ url: "/", title: "Home" }], edges: [{ source: "/", target: `${bobUrl}/`, type: "friend" }], }; const graph = buildGraph(localFile, DEFAULT_CONFIG); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, localFile.friends); for (const node of bobGraph.nodes) { const absoluteUrl = new URL(node.url, bobGraph.base_url).href; diff --git a/packages/graphgarden-web/src/index.test.ts b/packages/graphgarden-web/src/index.test.ts index 13efd15..afb1c71 100644 --- a/packages/graphgarden-web/src/index.test.ts +++ b/packages/graphgarden-web/src/index.test.ts @@ -18,6 +18,7 @@ function validFile(): Record { generated_at: "2025-01-01T00:00:00Z", base_url: "https://example.com", site: { title: "Test Site" }, + friends: ["https://friend.com"], nodes: [{ url: "/page", title: "Page" }], edges: [{ source: "/page", target: "https://friend.com", type: "friend" }], }; @@ -122,6 +123,24 @@ describe("isGraphGardenFile", () => { file.site = { title: "Test", language: true }; expect(isGraphGardenFile(file)).toBe(false); }); + + test("missing friends returns false", () => { + const file = validFile(); + delete file.friends; + expect(isGraphGardenFile(file)).toBe(false); + }); + + test("friends with non-string entry returns false", () => { + const file = validFile(); + file.friends = ["https://valid.com", 42]; + expect(isGraphGardenFile(file)).toBe(false); + }); + + test("empty friends array returns true", () => { + const file = validFile(); + file.friends = []; + expect(isGraphGardenFile(file)).toBe(true); + }); }); describe("buildGraph", () => { @@ -164,6 +183,7 @@ describe("buildGraph", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://example.com", site: { title: "Test" }, + friends: [], nodes: [ { url: "/a", title: "A" }, { url: "/b", title: "B" }, @@ -187,6 +207,7 @@ describe("buildGraph", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://empty.com", site: { title: "Empty" }, + friends: [], nodes: [], edges: [], }; @@ -231,6 +252,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://local.test/", site: { title: "Local" }, + friends: [friendTarget], nodes: [{ url: "/", title: "Home" }], edges: [{ source: "/", target: friendTarget, type: "friend" }], }; @@ -242,6 +264,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://friend.test/", site: { title: "Friend Site" }, + friends: [], nodes: [ { url: "/", title: "Friend Home" }, { url: "/blog/", title: "Friend Blog" }, @@ -262,10 +285,11 @@ describe("fetchFriendGraphs", () => { } test("merges friend nodes with absolute URL keys", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); stubFetchWith({ json: () => Promise.resolve(friendFile()) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(graph.hasNode("https://friend.test/")).toBe(true); expect(graph.getNodeAttribute("https://friend.test/", "title")).toBe("Friend Home"); @@ -274,10 +298,11 @@ describe("fetchFriendGraphs", () => { }); test("merges friend edges with resolved URLs", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); stubFetchWith({ json: () => Promise.resolve(friendFile()) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(graph.hasDirectedEdge("https://friend.test/", "https://friend.test/blog/")).toBe(true); }); @@ -288,6 +313,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://local.test/", site: { title: "Local" }, + friends: ["https://friend.test/"], nodes: [ { url: "/", title: "Home" }, { url: "/about/", title: "About" }, @@ -300,79 +326,86 @@ describe("fetchFriendGraphs", () => { const graph = buildGraph(file, DEFAULT_CONFIG); stubFetchWith({ json: () => Promise.resolve(friendFile()) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledWith("https://friend.test/.well-known/graphgarden.json"); }); test("handles fetch rejection gracefully", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); const initialOrder = graph.order; const initialSize = graph.size; vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("Network error"))); vi.spyOn(console, "warn").mockImplementation(() => {}); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(graph.order).toBe(initialOrder); expect(graph.size).toBe(initialSize); }); test("handles non-OK response gracefully", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); const initialOrder = graph.order; stubFetchWith({ ok: false, status: 404, statusText: "Not Found" } as Partial); vi.spyOn(console, "warn").mockImplementation(() => {}); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(graph.order).toBe(initialOrder); }); test("handles invalid response shape gracefully", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); const initialOrder = graph.order; stubFetchWith({ json: () => Promise.resolve({ invalid: true }) }); vi.spyOn(console, "warn").mockImplementation(() => {}); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(graph.order).toBe(initialOrder); }); test("resolves relative paths against friend base_url", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const localFile = localFileWithFriend(); + const graph = buildGraph(localFile, DEFAULT_CONFIG); const file: GraphGardenFile = { version: "0.1.0", generated_at: "2025-01-01T00:00:00Z", base_url: "https://friend.test/", site: { title: "Friend" }, + friends: [], nodes: [{ url: "/deep/path/", title: "Deep Page" }], edges: [], }; stubFetchWith({ json: () => Promise.resolve(file) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, localFile.friends); expect(graph.hasNode("https://friend.test/deep/path/")).toBe(true); expect(graph.getNodeAttribute("https://friend.test/deep/path/", "title")).toBe("Deep Page"); }); test("returns the mutated graph", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); stubFetchWith({ json: () => Promise.resolve(friendFile()) }); - const result = await fetchFriendGraphs(graph, DEFAULT_CONFIG); + const result = await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); expect(result).toBe(graph); }); test("friend-of-friend nodes get correct size and color", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const localFile = localFileWithFriend(); + const graph = buildGraph(localFile, DEFAULT_CONFIG); // Friend file with a friend edge to an unknown third-party URL const file: GraphGardenFile = { @@ -380,12 +413,13 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://friend.test/", site: { title: "Friend" }, + friends: [], nodes: [{ url: "/", title: "Friend Home" }], edges: [{ source: "/", target: "https://charlie.test/", type: "friend" }], }; stubFetchWith({ json: () => Promise.resolve(file) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, localFile.friends); // Charlie was implicitly created by the friend edge expect(graph.hasNode("https://charlie.test/")).toBe(true); @@ -396,10 +430,11 @@ describe("fetchFriendGraphs", () => { }); test("friend internal edges get friendEdgeColor, not localEdgeColor", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const file = localFileWithFriend(); + const graph = buildGraph(file, DEFAULT_CONFIG); stubFetchWith({ json: () => Promise.resolve(friendFile()) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); const friendEdge = graph.directedEdge("https://friend.test/", "https://friend.test/blog/"); expect(friendEdge).toBeDefined(); @@ -413,6 +448,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://local.test/", site: { title: "Local" }, + friends: [], nodes: [ { url: "/", title: "Home" }, { url: "/about/", title: "About" }, @@ -425,13 +461,14 @@ describe("fetchFriendGraphs", () => { const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, []); expect(mockFetch).not.toHaveBeenCalled(); }); test("deduplicates nodes when friend edges point back to local site", async () => { - const graph = buildGraph(localFileWithFriend(), DEFAULT_CONFIG); + const localFile = localFileWithFriend(); + const graph = buildGraph(localFile, DEFAULT_CONFIG); // Friend file with an edge pointing back to the local site const file: GraphGardenFile = { @@ -439,12 +476,13 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://friend.test/", site: { title: "Friend" }, + friends: [], nodes: [{ url: "/", title: "Friend Home" }], edges: [{ source: "/", target: "https://local.test/", type: "friend" }], }; stubFetchWith({ json: () => Promise.resolve(file) }); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, localFile.friends); // Local "/" was resolved to "https://local.test/" by buildGraph; // the friend edge target is the same URL, so no duplicate is created. @@ -459,6 +497,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://local.test/", site: { title: "Local" }, + friends: ["https://bad.test/", "https://good.test/"], nodes: [{ url: "/", title: "Home" }], edges: [ { source: "/", target: "https://bad.test/", type: "friend" }, @@ -472,6 +511,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "", site: { title: "Bad" }, + friends: [], nodes: [{ url: "/page", title: "Page" }], edges: [], }; @@ -480,6 +520,7 @@ describe("fetchFriendGraphs", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://good.test/", site: { title: "Good" }, + friends: [], nodes: [{ url: "/", title: "Good Home" }], edges: [], }; @@ -501,12 +542,33 @@ describe("fetchFriendGraphs", () => { ); vi.spyOn(console, "warn").mockImplementation(() => {}); - await fetchFriendGraphs(graph, DEFAULT_CONFIG); + await fetchFriendGraphs(graph, DEFAULT_CONFIG, localFile.friends); expect(graph.hasNode("https://good.test/")).toBe(true); expect(graph.getNodeAttribute("https://good.test/", "title")).toBe("Good Home"); expect(console.warn).toHaveBeenCalled(); }); + + test("fetches declared friend with no edges pointing to it", async () => { + const file: GraphGardenFile = { + version: "0.1.0", + generated_at: "2025-01-01T00:00:00Z", + base_url: "https://local.test/", + site: { title: "Local" }, + friends: ["https://friend.test/"], + nodes: [{ url: "/", title: "Home" }], + edges: [], + }; + const graph = buildGraph(file, DEFAULT_CONFIG); + stubFetchWith({ json: () => Promise.resolve(friendFile()) }); + + await fetchFriendGraphs(graph, DEFAULT_CONFIG, file.friends); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith("https://friend.test/.well-known/graphgarden.json"); + expect(graph.hasNode("https://friend.test/")).toBe(true); + expect(graph.getNodeAttribute("https://friend.test/", "title")).toBe("Friend Home"); + }); }); describe("assignLayout", () => { @@ -516,6 +578,7 @@ describe("assignLayout", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://example.com", site: { title: "Test" }, + friends: [], nodes: [ { url: "/a", title: "A" }, { url: "/b", title: "B" }, @@ -549,6 +612,7 @@ describe("assignLayout", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://example.com", site: { title: "Test" }, + friends: [], nodes: [{ url: "/", title: "Home" }], edges: [], }; @@ -566,6 +630,7 @@ describe("assignLayout", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://example.com", site: { title: "Test" }, + friends: [], nodes: [ { url: "/a", title: "A" }, { url: "/b", title: "B" }, @@ -718,6 +783,7 @@ describe("customization", () => { generated_at: "2025-01-01T00:00:00Z", base_url: "https://example.com", site: { title: "Test" }, + friends: [], nodes: [ { url: "/a", title: "A" }, { url: "/b", title: "B" }, diff --git a/packages/graphgarden-web/src/index.ts b/packages/graphgarden-web/src/index.ts index dc49647..27d4577 100644 --- a/packages/graphgarden-web/src/index.ts +++ b/packages/graphgarden-web/src/index.ts @@ -52,6 +52,7 @@ export interface GraphGardenFile { generated_at: string; base_url: string; site: GraphGardenSite; + friends: string[]; nodes: GraphGardenNode[]; edges: GraphGardenEdge[]; } @@ -91,6 +92,9 @@ export function isGraphGardenFile(value: unknown): value is GraphGardenFile { if (site.description !== undefined && typeof site.description !== "string") return false; if (site.language !== undefined && typeof site.language !== "string") return false; + if (!Array.isArray(obj.friends) || !obj.friends.every((f: unknown) => typeof f === "string")) + return false; + if (!Array.isArray(obj.nodes) || !obj.nodes.every(isNode)) return false; if (!Array.isArray(obj.edges) || !obj.edges.every(isEdge)) return false; @@ -160,17 +164,19 @@ export function assignLayout(graph: Graph, iterations: number): void { } /** Fetch friend sites' graphs and merge their nodes and edges into `graph`. */ -export async function fetchFriendGraphs(graph: Graph, config: GraphGardenConfig): Promise { +export async function fetchFriendGraphs( + graph: Graph, + config: GraphGardenConfig, + friends: string[], +): Promise { const origins = new Set(); - graph.forEachEdge((_edge, attributes, _source, target) => { - if (attributes.type === "friend") { - try { - origins.add(new URL(target).origin); - } catch { - console.warn(`fetchFriendGraphs: invalid friend target URL: ${target}`); - } + for (const friend of friends) { + try { + origins.add(new URL(friend).origin); + } catch { + console.warn(`fetchFriendGraphs: invalid friend URL: ${friend}`); } - }); + } const results = await Promise.allSettled( [...origins].map(async (origin) => { @@ -283,7 +289,7 @@ export class GraphGarden extends HTMLElement { const config = this.resolveConfig(); this.graph = buildGraph(data, config); - await fetchFriendGraphs(this.graph, config); + await fetchFriendGraphs(this.graph, config, data.friends); assignLayout(this.graph, config.iterations); this.initRenderer(config); } catch (error) {