From 36984bb5748a2aae9e04ea9a8b671cdca2afeb3a Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:28:45 +0200 Subject: [PATCH] web: resolve node.source by node id; render latestPost from node.loaded Move the wired card from the cardType-keyed additionalData stopgap to the node model: stamp a declarative node.source (migrate-on-read from cardType), resolve the graph via @blento/sources.resolveNodes keyed by node id, and hand the component its data as loaded[nodeId] (data.loaded, threaded through ContextProvider). Sourced cards skip the bespoke loadAdditionalData; a CacheService->CacheAdapter bridge preserves KV caching. loaded is runtime-only (never written back). Component falls back to additionalData for entry points that don't resolve the graph. Render gated at 0px / 0 content diffs on flo-bit.dev (a live site with the card); SSR HTML carries the post with additionalData absent, proving the node.loaded path. sources 20/20, web check 0/0. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LatestBlueskyPostCard.svelte | 6 +- apps/web/src/lib/types.ts | 6 ++ apps/web/src/lib/website/data/context.ts | 3 + apps/web/src/lib/website/data/load.ts | 97 +++++++++++++------ .../lib/website/view/ContextProvider.svelte | 3 + .../[[actor=actor]]/(pages)/+page.svelte | 2 + 6 files changed, 88 insertions(+), 29 deletions(-) diff --git a/apps/web/src/lib/cards/social/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte b/apps/web/src/lib/cards/social/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte index 95988b8..7681491 100644 --- a/apps/web/src/lib/cards/social/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte +++ b/apps/web/src/lib/cards/social/LatestBlueskyPostCard/LatestBlueskyPostCard.svelte @@ -4,6 +4,7 @@ import { BlueskyPost } from '$lib/components/bluesky-post'; import { getAdditionalUserData, + getLoadedData, getDidContext, getHandleContext } from '$lib/website/data/context'; @@ -12,8 +13,11 @@ let { item }: { item: Item } = $props(); const data = getAdditionalUserData(); + const loaded = getLoadedData(); + // Prefer this node's resolved source data (loaded[nodeId] via @blento/sources); fall back to the + // legacy cardType-keyed additionalData for entry points that don't resolve the node graph. // svelte-ignore state_referenced_locally - let feed = $state((data[item.cardType] as any)?.feed); + let feed = $state((loaded?.[item.id]?.data as any)?.feed ?? (data[item.cardType] as any)?.feed); let did = getDidContext(); let handle = getHandleContext(); diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index cd20c0d..66b4d24 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -1,6 +1,7 @@ import type { Blob } from '@atcute/lexicons'; import type { AppBskyActorDefs } from '@atcute/bluesky'; import type { Node } from '@blento/schema'; +import type { ResolveResult } from '@blento/sources'; export type Item = { id: string; @@ -106,6 +107,11 @@ export type WebsiteData = { migratedStorage?: boolean; additionalData: Record; + + /** Resolved `node.source` data, keyed by node id (== card id). Runtime-only, never persisted; + * renderers read their node's loaded data here. See `@blento/sources`. */ + loaded?: Record; + updatedAt: number; version?: number; diff --git a/apps/web/src/lib/website/data/context.ts b/apps/web/src/lib/website/data/context.ts index 1dba075..e1dba99 100644 --- a/apps/web/src/lib/website/data/context.ts +++ b/apps/web/src/lib/website/data/context.ts @@ -8,6 +8,9 @@ export const [getIsRealMobile, setIsRealMobile] = createContext<() => boolean>() export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); export const [getAdditionalUserData, setAdditionalUserData] = createContext>(); +/** Resolved `node.source` data keyed by node id (== card id). Runtime-only. See `@blento/sources`. */ +export const [getLoadedData, setLoadedData] = + createContext>(); export const [getIsCoarse, setIsCoarse] = createContext<() => boolean>(); export const [getSelectedCardId, setSelectedCardId] = createContext<() => string | null>(); export const [getSelectCard, setSelectCard] = createContext<(id: string | null) => void>(); diff --git a/apps/web/src/lib/website/data/load.ts b/apps/web/src/lib/website/data/load.ts index 215a5d7..0ddda64 100644 --- a/apps/web/src/lib/website/data/load.ts +++ b/apps/web/src/lib/website/data/load.ts @@ -5,7 +5,13 @@ import type { CacheService } from '$lib/helpers/cache'; import { createEmptyCard } from '$lib/helpers/items'; import type { Item, PronounsRecord, SectionRecord, WebsiteData } from '$lib/types'; import { buildGraph, nodesToItems, recordToNode, type Node, type NodeRecord } from '@blento/schema'; -import { resolve as resolveSource, type Source } from '@blento/sources'; +import { + resolveNodes, + type Source, + type SourceContext, + type CacheAdapter, + type ResolveResult +} from '@blento/sources'; import { error } from '@sveltejs/kit'; import type { ActorIdentifier, Did } from '@atcute/lexicons'; @@ -342,13 +348,24 @@ export async function loadData( const graph = nodesFromRecords.length ? nodesFromRecords : buildGraph(sectionRecords, cards, fullPage, { order: 'input' }); + + // Declarative-source path: stamp/keep node.source, then resolve the graph by node id via + // @blento/sources. Loaded data travels with the node (result.loaded[nodeId]); its cards skip the + // bespoke loadAdditionalData below. Only the source spec is persisted — loaded is runtime-only. + stampNodeSources(graph); + const sourcedIds = new Set(graph.filter((n) => n.source).map((n) => n.id)); + const loadedPromise = loadNodeSources(graph, { did, cache }); + const migrated = nodesToItems(graph); - const additionalData = await loadAdditionalData( - migrated.cards, - { did, handle, cache, platform }, - env - ); + const [additionalData, loaded] = await Promise.all([ + loadAdditionalData( + migrated.cards.filter((c) => !sourcedIds.has(c.id)), + { did, handle, cache, platform }, + env + ), + loadedPromise + ]); const result = checkData({ page: fullPage, @@ -370,6 +387,7 @@ export async function loadData( ? nodesFromRecords : buildGraph(result.sections, result.cards, fullPage, { order: 'input' }); result.migratedStorage = nodesFromRecords.length > 0; + result.loaded = loaded; return result; } @@ -609,13 +627,11 @@ function hashCardData(items: Item[]): string { } /** - * Cards whose additional data loads through the declarative `@blento/sources` resolver instead of a - * bespoke `loadData`. Maps cardType → the `Source` to resolve (`$self` → the page owner did). The - * resolved `data` lands in `additionalData[cardType]`, byte-identical to the card's own loader, so - * the component renders unchanged. This is the end-to-end proof of the source path — extend as more - * cards migrate. + * Migrate-on-read map: cardType → the declarative `Source` a legacy card implies. Stored nodes that + * already carry `node.source` keep theirs; this only synthesizes a source for not-yet-migrated cards + * (same spirit as the rest of buildGraph). `$self` → the page owner did. Extend as cards migrate. */ -const SOURCE_CARD_LOADERS: Record Source> = { +const NODE_SOURCES: Record Source> = { // Mirrors LatestBlueskyPostCard.loadData: getAuthorFeed(filter: posts_no_replies, limit: 2). latestPost: () => ({ $type: 'app.blento.source#atproto', @@ -624,6 +640,46 @@ const SOURCE_CARD_LOADERS: Record Source> = { }) }; +/** Bridge the app's KV-backed CacheService to the package's tiny injectable CacheAdapter. */ +function cacheServiceAdapter(cache: CacheService): CacheAdapter { + return { + get: (key) => cache.get('card-data', key), + set: (key, value, ttl) => cache.put('card-data', key, value, ttl) + }; +} + +/** + * Resolve every node's declarative `source` through `@blento/sources`, keyed by node id. The loaded + * data travels WITH the node (attached to `ResolvedNode.loaded`); we return a node-id map for the + * current Item-based render path. Only the `source` spec is ever persisted — `loaded` is runtime-only. + */ +async function loadNodeSources( + nodes: Node[], + { did, cache }: { did: Did; cache?: CacheService } +): Promise> { + const sourced = nodes.filter((n) => n.source); + if (!sourced.length) return {}; + + const ctx: SourceContext = { self: did }; + const resolved = await resolveNodes(sourced, ctx, { + cache: cache ? cacheServiceAdapter(cache) : undefined + }); + + const map: Record = {}; + for (const node of resolved) map[node.id] = node.loaded ?? null; + return map; +} + +/** Stamp a synthesized source onto nodes that lack one but whose cardType implies a known read. */ +function stampNodeSources(nodes: Node[]): void { + for (const node of nodes) { + if (node.source) continue; + const cardType = (node.content as { cardType?: string })?.cardType; + const build = cardType ? NODE_SOURCES[cardType] : undefined; + if (build) node.source = build(); + } +} + async function loadAdditionalData( cards: Item[], { @@ -643,22 +699,7 @@ async function loadAdditionalData( const items = cards.filter((v) => cardType === v.cardType); try { - const sourceBuilder = SOURCE_CARD_LOADERS[cardType]; - if (sourceBuilder) { - // Declarative source path: resolve the Source and cache exactly like loadData would. - const loader = () => resolveSource(sourceBuilder(), { self: did }).then((r) => r.data); - if (cache && cardDef?.cacheLoadData) { - const opts = typeof cardDef.cacheLoadData === 'object' ? cardDef.cacheLoadData : {}; - const key = `${cardType}:${did}:${hashCardData(items)}`; - additionDataPromises[cardType] = cache.swr('card-data', key, loader, { - ttl: opts.ttl, - staleWindow: opts.staleWindow, - waitUntil: platform?.context?.waitUntil?.bind(platform.context) - }); - } else { - additionDataPromises[cardType] = loader(); - } - } else if (cardDef?.loadDataServer) { + if (cardDef?.loadDataServer) { additionDataPromises[cardType] = cardDef.loadDataServer(items, { did, handle, diff --git a/apps/web/src/lib/website/view/ContextProvider.svelte b/apps/web/src/lib/website/view/ContextProvider.svelte index cdadb51..8761e00 100644 --- a/apps/web/src/lib/website/view/ContextProvider.svelte +++ b/apps/web/src/lib/website/view/ContextProvider.svelte @@ -3,6 +3,7 @@ import type { Snippet } from 'svelte'; import { setAdditionalUserData, + setLoadedData, setCanEdit, setDidContext, setHandleContext @@ -23,6 +24,8 @@ // svelte-ignore state_referenced_locally setAdditionalUserData(data.additionalData); + // svelte-ignore state_referenced_locally + setLoadedData(data.loaded ?? {}); setCanEdit( () => diff --git a/apps/web/src/routes/[[actor=actor]]/(pages)/+page.svelte b/apps/web/src/routes/[[actor=actor]]/(pages)/+page.svelte index 7a315e4..0c43d10 100644 --- a/apps/web/src/routes/[[actor=actor]]/(pages)/+page.svelte +++ b/apps/web/src/routes/[[actor=actor]]/(pages)/+page.svelte @@ -2,6 +2,8 @@ import Website from '$lib/website/view/Website.svelte'; let { data } = $props(); + + $inspect(data, 'data'); -- 2.51.2