diff --git a/packages/schema/src/migrate.test.ts b/packages/schema/src/migrate.test.ts --- a/packages/schema/src/migrate.test.ts +++ b/packages/schema/src/migrate.test.ts @@ -46,15 +46,19 @@ expect(nodes.find((n) => n.id === 's1')).toMatchObject({ kind: 'container', - type: 'grid', + content: { $type: 'app.blento.defs#container', containerType: 'grid' }, parent: null, page: 'blento.self' }); const a = nodes.find((n) => n.id === 'a')!; - expect(a).toMatchObject({ kind: 'leaf', type: 'link', parent: 's1' }); + expect(a).toMatchObject({ + kind: 'leaf', + content: { $type: 'app.blento.defs#card', cardType: 'link' }, + parent: 's1' + }); expect(a.layout).toMatchObject({ x: 0, y: 2, w: 2, h: 2, mobileW: 4 }); - expect(a.style).toEqual({ color: 'red' }); + expect(a.style).toEqual({ tokens: { color: 'red' } }); // document order: b (y=0) ranks before a (y=2) const b = nodes.find((n) => n.id === 'b')!; @@ -65,7 +69,7 @@ it('synthesizes a default grid container when there are no sections', () => { const nodes = migrateV1([], [card({ id: 'x' })], 'blento.self', { genId: stableIds() }); const container = nodes.find((n) => n.kind === 'container')!; - expect(container.type).toBe('grid'); + expect(container.content.containerType).toBe('grid'); expect(container.id).toBe('gen0'); expect(nodes.find((n) => n.id === 'x')!.parent).toBe('gen0'); }); diff --git a/packages/schema/src/migrate.ts b/packages/schema/src/migrate.ts --- a/packages/schema/src/migrate.ts +++ b/packages/schema/src/migrate.ts @@ -10,7 +10,7 @@ */ import * as TID from '@atcute/tid'; import { generateNKeysBetween } from 'fractional-indexing'; -import type { Node } from './node.js'; +import { CARD_CONTENT, CONTAINER_CONTENT, GRID_CELL_LAYOUT, type Node } from './node.js'; import type { PageRecord, StyleTokens } from './records.js'; export interface V1Card { @@ -110,12 +110,12 @@ sectionList.forEach((s, i) => { nodes.push({ id: s.id, - type: s.sectionType, kind: 'container', parent: null, rank: sectionRanks[i], page, - data: s.sectionData ?? {}, + // Generic container fallback: containerType discriminates; sectionData spread as fields. + content: { ...(s.sectionData ?? {}), $type: CONTAINER_CONTENT, containerType: s.sectionType }, version: 1 }); }); @@ -143,15 +143,18 @@ } const ranks = generateNKeysBetween(null, null, group.length); group.forEach((c, i) => { + const cardData = + c.cardData && typeof c.cardData === 'object' ? (c.cardData as Record) : {}; const node: Node = { id: c.id, - type: c.cardType, kind: 'leaf', parent, rank: ranks[i], page, - data: c.cardData ?? {}, + // Generic card fallback: cardType discriminates; cardData spread as fields. + content: { ...cardData, $type: CARD_CONTENT, cardType: c.cardType }, layout: { + $type: GRID_CELL_LAYOUT, x: c.x, y: c.y, w: c.w, @@ -164,7 +167,7 @@ }, version: 1 }; - if (c.color) node.style = { color: c.color }; + if (c.color) node.style = { tokens: { color: c.color } }; nodes.push(node); }); } @@ -199,42 +202,54 @@ * leaves -> cards (layout -> coords, style.color -> color, parent -> sectionId). */ export function nodesToItems(nodes: Node[]): { sections: LegacySection[]; cards: V1Card[] } { + const num = (v: unknown) => (typeof v === 'number' ? v : 0); + const sections: LegacySection[] = nodes .filter((n) => n.kind === 'container') .sort(byRank) - .map((c, i) => ({ - id: c.id, - sectionType: c.type, - page: c.page, - index: i, - sectionData: (c.data ?? {}) as Record, - version: c.version - })); + .map((c, i) => { + const sectionData = { ...c.content } as Record; + const containerType = sectionData.containerType as string | undefined; + delete sectionData.$type; + delete sectionData.containerType; + return { + id: c.id, + sectionType: containerType ?? 'grid', + page: c.page, + index: i, + sectionData, + version: c.version + }; + }); const cards: V1Card[] = nodes .filter((n) => n.kind === 'leaf') .sort(byRank) .map((n) => { - const l = (n.layout ?? {}) as Record; + const cardData = { ...n.content } as Record; + const cardType = cardData.cardType as string | undefined; + delete cardData.$type; + delete cardData.cardType; + const l = (n.layout ?? {}) as Record; const card: V1Card = { id: n.id, - cardType: n.type, - cardData: n.data, - x: l.x ?? 0, - y: l.y ?? 0, - w: l.w ?? 0, - h: l.h ?? 0, - mobileX: l.mobileX ?? 0, - mobileY: l.mobileY ?? 0, - mobileW: l.mobileW ?? 0, - mobileH: l.mobileH ?? 0, + cardType: cardType ?? 'text', + cardData, + x: num(l.x), + y: num(l.y), + w: num(l.w), + h: num(l.h), + mobileX: num(l.mobileX), + mobileY: num(l.mobileY), + mobileW: num(l.mobileW), + mobileH: num(l.mobileH), sectionId: n.parent ?? undefined, page: n.page, version: n.version }; - if (l.rotation) card.rotation = l.rotation; - const style = n.style as { color?: string } | undefined; - if (style?.color) card.color = style.color; + if (typeof l.rotation === 'number') card.rotation = l.rotation; + const color = n.style?.tokens?.color; + if (color) card.color = color; return card; }); diff --git a/packages/schema/src/node.ts b/packages/schema/src/node.ts --- a/packages/schema/src/node.ts +++ b/packages/schema/src/node.ts @@ -1,62 +1,84 @@ /** - * The Blento v2 node envelope — the frozen contract. + * The Blento node envelope — the stored record shape. * - * New card / layout / theme types MUST NOT change this shape. They only put new shapes into the - * open blobs (`data`, `layout`, `style`, `source`) and register a renderer. See blento-v2-plan.md §2. + * Two decoupled typed axes: `content.$type` is the DATA shape (an open union of `app.blento.defs` + * members — interop), while `style.renderer` is the RENDER type (defaults from `content.$type`, + * overridable). There is deliberately NO top-level `type` string. New card / layout / theme types + * only add members to the open blobs (`content`, `layout`, `style`, `source`) and register a + * renderer/def — the envelope never changes. See ../../../blento-schema-design.md. */ -/** Generic behavior class. Drives editor/runtime treatment, not the specific renderer. */ +/** Structural role. Drives editor/runtime treatment, not the specific renderer. */ export type NodeKind = 'document' | 'container' | 'leaf'; +/** CONTENT — the data. `$type` = the data shape (open union of `app.blento.defs`). */ +export interface Content { + $type: string; + [k: string]: unknown; +} + +/** POSITION within the parent, typed by the parent container's contract (e.g. `#gridCell`). */ +export interface Layout { + $type: string; + [k: string]: unknown; +} + +/** PRESENTATION intent. Never concrete CSS. */ +export interface Style { + /** Renderer id/ref override; the default resolves from `content.$type`. */ + renderer?: string; + /** Semantic design tokens (color roles, etc.). */ + tokens?: Record; + [k: string]: unknown; +} + /** - * A declared read capability. Resolved by trusted first-party loaders; renderers only ever - * receive already-fetched JSON. See plan §6. + * A declared read. Resolved by trusted first-party loaders; renderers only ever receive + * already-fetched JSON. `atproto` is any XRPC query (output typed by the method's lexicon or the + * explicit `outputs`); `ref` shares another node's loaded data. See the design doc §Source. */ export type Source = - | { kind: 'atproto-collection'; actor: string; collection: string; limit?: number; prop?: string } - | { kind: 'http'; url: string } - | { kind: 'subscribe'; channel: string }; // deferred (app-grade realtime) + | { + $type: 'app.blento.source#atproto'; + method: string; + params?: Record; + service?: string; + outputs?: unknown; + } + | { $type: 'app.blento.source#http'; url: string; outputs?: unknown } + | { + $type: 'app.blento.source#custom'; + loader: string; + params?: Record; + outputs?: unknown; + } + | { $type: 'app.blento.source#ref'; node: string }; export interface Node { /** rkey (TID). */ id: string; - /** Semantic interface key (NSID-style): 'grid' | 'link' | 'bluesky' | 'event' | ... */ - type: string; - /** Generic behavior class. */ + /** Structural role. */ kind: NodeKind; - /** Structural edge: containing node id. null = page/document root. */ + /** Containing node id. null = page/document root. */ parent: string | null; /** Order among siblings — a STRING fractional key (never a float; atproto forbids floats). */ rank: string; /** Denormalized partition/query key: the root document id this node belongs to. */ page: string; - /** CONTENT. Pure JSON, never markup. (v1's cardData.) */ - data: unknown; - /** Optional declared read capability. First-class so it is inspectable without running renderers. */ + /** The data. `content.$type` is the shape and the default renderer key. */ + content: Content; + /** Position within the parent. Integers only. */ + layout?: Layout; + /** Appearance intent (renderer override + tokens). */ + style?: Style; + /** Declared read capability — inspectable without running renderers. */ source?: Source; - /** POSITION within the parent, interpreted by the parent container's type. Integers only. */ - layout?: unknown; - /** APPEARANCE intent the active renderer interprets (color role, variant). Never concrete CSS. */ - style?: unknown; version: number; } -/** Layout blob shapes, keyed by the parent container type that interprets them. */ -export interface GridLayout { - x: number; - y: number; - w: number; - h: number; -} - -export interface FreeLayout { - x: number; - y: number; - z?: number; - rotation?: number; -} - -/** Ordered containers (columns/rows/flow/document) carry no layout — order comes from `rank`. */ -export type OrderedLayout = undefined; - export const SCHEMA_VERSION = 1; + +/** Def NSIDs. The migration uses the generic fallbacks; typed defs (#link, #image, …) are additive. */ +export const CARD_CONTENT = 'app.blento.defs#card'; +export const CONTAINER_CONTENT = 'app.blento.defs#container'; +export const GRID_CELL_LAYOUT = 'app.blento.defs#gridCell'; diff --git a/packages/schema/src/serialize.ts b/packages/schema/src/serialize.ts --- a/packages/schema/src/serialize.ts +++ b/packages/schema/src/serialize.ts @@ -5,19 +5,18 @@ * record omits both the rkey (it's the key) and `parent` when at the document root. Undefined blobs * are dropped so records stay minimal. */ -import type { Node, NodeKind, Source } from './node.js'; +import type { Content, Layout, Node, NodeKind, Source, Style } from './node.js'; export interface NodeRecord { $type: 'app.blento.node'; - type: string; kind: NodeKind; parent?: string; rank: string; page: string; - data?: unknown; + content: Content; + layout?: Layout; + style?: Style; source?: Source; - layout?: unknown; - style?: unknown; updatedAt?: string; version: number; } @@ -25,14 +24,13 @@ export function nodeToRecord(node: Node, updatedAt?: string): NodeRecord { const rec: NodeRecord = { $type: 'app.blento.node', - type: node.type, kind: node.kind, rank: node.rank, page: node.page, + content: node.content, version: node.version }; if (node.parent != null) rec.parent = node.parent; - if (node.data !== undefined) rec.data = node.data; if (node.source !== undefined) rec.source = node.source; if (node.layout !== undefined) rec.layout = node.layout; if (node.style !== undefined) rec.style = node.style; @@ -43,12 +41,11 @@ export function recordToNode(rkey: string, rec: NodeRecord): Node { const node: Node = { id: rkey, - type: rec.type, kind: rec.kind, parent: rec.parent ?? null, rank: rec.rank, page: rec.page, - data: rec.data ?? {}, + content: rec.content ?? { $type: 'app.blento.defs#card' }, version: rec.version ?? 1 }; if (rec.source !== undefined) node.source = rec.source; diff --git a/apps/web/lexicons/custom/app/blento/defs.json b/apps/web/lexicons/custom/app/blento/defs.json new file mode 100644 --- /dev/null +++ b/apps/web/lexicons/custom/app/blento/defs.json @@ -0,0 +1,40 @@ +{ + "$type": "com.atproto.lexicon.schema", + "lexicon": 1, + "id": "app.blento.defs", + "description": "Shared content/layout shapes referenced by app.blento.node's open blobs. Typed cards (#link, #image, …) are added additively; #card / #container are the generic fallbacks.", + "defs": { + "card": { + "type": "object", + "description": "Generic leaf content fallback. cardType discriminates; remaining fields are the card's opaque data.", + "required": ["cardType"], + "properties": { + "cardType": { "type": "string" } + } + }, + "container": { + "type": "object", + "description": "Generic container content fallback. containerType discriminates; remaining fields are the container config.", + "required": ["containerType"], + "properties": { + "containerType": { "type": "string" } + } + }, + "gridCell": { + "type": "object", + "description": "Position within a grid container. Integers only.", + "required": ["x", "y", "w", "h"], + "properties": { + "x": { "type": "integer" }, + "y": { "type": "integer" }, + "w": { "type": "integer" }, + "h": { "type": "integer" }, + "mobileX": { "type": "integer" }, + "mobileY": { "type": "integer" }, + "mobileW": { "type": "integer" }, + "mobileH": { "type": "integer" }, + "rotation": { "type": "integer" } + } + } + } +} diff --git a/apps/web/lexicons/custom/app/blento/node.json b/apps/web/lexicons/custom/app/blento/node.json --- a/apps/web/lexicons/custom/app/blento/node.json +++ b/apps/web/lexicons/custom/app/blento/node.json @@ -8,17 +8,16 @@ "key": "tid", "record": { "type": "object", - "required": ["type", "kind", "rank", "page", "version"], + "required": ["kind", "rank", "page", "content", "version"], "properties": { - "type": { "type": "string" }, "kind": { "type": "string", "knownValues": ["document", "container", "leaf"] }, "parent": { "type": "string" }, "rank": { "type": "string" }, "page": { "type": "string" }, - "data": { "type": "unknown" }, - "source": { "type": "unknown" }, + "content": { "type": "unknown" }, "layout": { "type": "unknown" }, "style": { "type": "unknown" }, + "source": { "type": "unknown" }, "updatedAt": { "type": "string", "format": "datetime" }, "version": { "type": "integer" } }