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
65 changes: 65 additions & 0 deletions custom/surface-sdk/src/block-contract-drift.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'

/**
* The SDK declares the block contract a second time — it is a standalone package and
* cannot import from the runtime it describes. That duplication is a real risk: a field
* added on one side and forgotten on the other produces a plugin that compiles against
* a promise the runtime does not keep, and nothing says so until it silently does
* nothing at runtime.
*
* So the field names are compared. Not the types — that would need a compiler on both
* sides — but names catch the failure that actually happens: someone extends one file.
*
* If the SDK ever ships from its own repository this test loses its anchor. Then the
* answer is to generate one side from the other, not to delete the check.
*/
const RUNTIME_CONTRACT = resolve(
process.cwd(),
'../../src/Surface.Rendering/Resources/app/surface/src/blocks/block-contract.ts',
)

const SDK_CONTRACT = resolve(process.cwd(), 'src/index.ts')

function fieldsOf(source: string, interfaceName: string): string[] {
// Generisch oder nicht: BlockControl<T = unknown> und BlockControl sind dieselbe
// Deklaration, und ein Muster, das nur eine Form findet, würde bei einem hinzugefügten
// Typparameter fälschlich Drift melden.
const declaration = new RegExp(`export interface ${interfaceName}(<[^>]*>)? \\{`)
const start = source.search(declaration)
if (start < 0) {
throw new Error(`Interface ${interfaceName} nicht gefunden.`)
}

const body = source.slice(start, source.indexOf('\n}', start))
return [...body.matchAll(/^\s{2}(\w+)\??:/gm)].map((match) => match[1]!).sort()
}

describe('block contract: SDK ↔ runtime', () => {
const runtime = readFileSync(RUNTIME_CONTRACT, 'utf8')
const sdk = readFileSync(SDK_CONTRACT, 'utf8')

it.each(['BlockDefinition', 'BlockControl', 'BlockCategory'])(
'%s declares the same fields on both sides',
(name) => {
expect(fieldsOf(sdk, name)).toEqual(fieldsOf(runtime, name))
},
)

it('the appearance types are the same closed set on both sides', () => {
// Die geschlossene Menge ist die Guardrail. Liefe sie auseinander, könnte ein
// Plugin gegen einen Typ bauen, den die Runtime als reserviert ablehnt.
const extract = (source: string) =>
source
.slice(source.indexOf('AppearanceControlType ='))
.split('\n')[0]!
.replace('AppearanceControlType =', '')
.split('|')
.map((part) => part.trim())
.filter(Boolean)
.sort()

expect(extract(sdk)).toEqual(extract(runtime))
})
})
79 changes: 76 additions & 3 deletions custom/surface-sdk/src/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,29 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { defineComponent } from 'vue'
import { registerSurfaceView, type SurfaceRegistry } from './index'
import {
registerBlock,
registerBlockCategory,
registerControlType,
registerSurfaceView,
type BlockRegistry,
type SurfaceRegistry,
} from './index'

const stub = defineComponent({ render: () => null })

/**
* Nur so viel Registry, wie die geprüfte Funktion anfasst. Über `unknown` gecastet statt
* das Interface zu erfüllen: ein vollständiges Mock würde bei jeder Vertragserweiterung
* mitwachsen müssen, ohne dafür etwas zu prüfen.
*/
type PartialRegistry = Partial<Omit<SurfaceRegistry, 'blocks'>> & {
blocks?: Partial<BlockRegistry>
}

function fakeRegistry(overrides: PartialRegistry) {
window.calloraSurface = { views: [], ...overrides } as unknown as SurfaceRegistry
}

afterEach(() => {
delete window.calloraSurface
vi.restoreAllMocks()
Expand All @@ -12,7 +32,7 @@ afterEach(() => {
describe('registerSurfaceView', () => {
it('forwards the view to the runtime registry when it is present', () => {
const registerView = vi.fn()
window.calloraSurface = { views: [], registerView } as SurfaceRegistry
fakeRegistry({ registerView })

registerSurfaceView({ id: 'voip.calls', component: stub })

Expand All @@ -21,7 +41,7 @@ describe('registerSurfaceView', () => {

it('forwards a surface allowlist as part of the public view contract', () => {
const registerView = vi.fn()
window.calloraSurface = { views: [], registerView } as SurfaceRegistry
fakeRegistry({ registerView })

registerSurfaceView({
id: 'videoconference.room',
Expand All @@ -44,3 +64,56 @@ describe('registerSurfaceView', () => {
expect(warn.mock.calls[0]?.[0]).toContain('voip.calls')
})
})

describe('blocks', () => {
it('forwards a block to the runtime block registry', () => {
const register = vi.fn()
fakeRegistry({ blocks: { registerBlock: register } })

registerBlock({
id: 'communication.call-list',
label: 'Anrufliste',
category: 'telephony',
requires: ['communication.active-call/v1'],
component: stub,
controls: {
title: { type: 'text', label: 'Überschrift' },
accent: { type: 'colorToken', label: 'Akzent' },
},
})

expect(register).toHaveBeenCalledOnce()
expect(register.mock.calls[0]?.[0]).toMatchObject({ id: 'communication.call-list' })
})

it('forwards a category', () => {
const register = vi.fn()
fakeRegistry({ blocks: { registerBlockCategory: register } })

registerBlockCategory({ id: 'telephony', label: 'Telefonie', icon: 'phone' })

expect(register).toHaveBeenCalledWith({ id: 'telephony', label: 'Telefonie', icon: 'phone' })
})

it('forwards a contributed control type', () => {
const register = vi.fn()
fakeRegistry({ blocks: { registerControlType: register } })

registerControlType('communication.phoneNumber')

expect(register).toHaveBeenCalledWith('communication.phoneNumber')
})

it.each([
['registerBlock', () => registerBlock({ id: 'x', label: 'X', category: 'c', component: stub })],
['registerBlockCategory', () => registerBlockCategory({ id: 'c', label: 'C' })],
['registerControlType', () => registerControlType('t')],
])('%s is a no-op with a warning when the runtime is absent', (_name, call) => {
// Ein Plugin darf die Schale, in der es zu Gast ist, nie brechen — auch nicht,
// wenn es vor der Runtime lädt.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

expect(call).not.toThrow()
expect(warn).toHaveBeenCalledOnce()
})
})
172 changes: 172 additions & 0 deletions custom/surface-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,134 @@ export interface SurfaceContextChannel {
diagnostics(): readonly SurfaceContextKeyDiagnostics[]
}

// ─────────────────────────────────────────────────────────────────────────────
// Blocks
//
// A block is a view with editor metadata — the same component, one identity. Its id
// IS the view id IS the island attribute, so a block placed in the editor and a view
// rendered server-side are the same thing rather than two registries to keep in step.
// ─────────────────────────────────────────────────────────────────────────────

/**
* What a control is bound to. `source: 'context'` is the one the comparable tools do
* not have: the value comes from a versioned context key and updates when that context
* changes — an incoming call, a selected lead — with no realtime code. Framer, Webflow
* and Shopware resolve their bindings once and freeze the result.
*
* `scope` is normally omitted; the resolver decides whether a key comes from this
* surface or another one the visitor has open.
*/
export type Binding<T> =
| { source: 'static'; value: T }
| { source: 'context'; key: string; scope?: 'local' | 'shared'; path?: string }
| { source: 'inherit' }
| { source: 'default' }

/**
* Control types that shape APPEARANCE. Closed: each picks a --cal-* role or step, never
* a free value. Contributing one is refused — a free colour picker would undo the
* guardrail that lets the editor promise a composed page still looks like the product.
*/
export type AppearanceControlType = 'colorToken' | 'spacingToken' | 'typeToken' | 'variant'

export type KnownControlType =
| 'text'
| 'richText'
| 'number'
| 'toggle'
| 'select'
| 'list'
| 'group'
| 'media'
| 'link'
| 'date'
| 'context'
| 'query'
| AppearanceControlType
/** Takes other blocks — nesting without a separate slots field. */
| 'slot'

/**
* Open on purpose: a plugin can contribute a phone-number picker or an agent selector.
* The `string & {}` keeps autocompletion for the known types, which a plain `string`
* would collapse.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
export type ControlType = KnownControlType | (string & {})

export interface ControlOption {
value: string
label: string
}

/** A control as the editor renders it. The settings panel is generated from this. */
export interface BlockControl<T = unknown> {
type: ControlType
label: string
description?: string
default?: T
required?: boolean
/** Panel grouping — controls sharing a group render together. */
group?: string
min?: number
max?: number
options?: readonly ControlOption[]
/**
* When this control is shown. Stated positively, unlike Framer's `hidden`: a missing
* predicate then means "always", which is the safer default to forget.
*/
visibleWhen?: (values: Readonly<Record<string, unknown>>) => boolean
/** Keeps the value out of the delivered markup — an api key, an internal id. */
confidential?: boolean
}

export type BlockSurface = 'surface' | 'admin'

/** A block: what the editor offers and what the runtime renders. */
export interface BlockDefinition {
/** Stable id — also the view id and the island attribute. */
id: string
label: string
description?: string
/** A free string. Register it with registerBlockCategory to give it a name and icon. */
category: string
/** Absent means every surface. */
surfaces?: readonly BlockSurface[]
/** Versioned context keys this block reads. */
requires?: readonly string[]
/** Versioned context keys this block publishes. */
provides?: readonly string[]
controls?: Readonly<Record<string, BlockControl>>
component: Component
/** What the picker shows before placement; without it the real component renders. */
preview?: Component
order?: number
icon?: string
}

export interface BlockCategory {
id: string
label: string
icon?: string
order?: number
}

/** Blocks and their categories, on the runtime registry. */
export interface BlockRegistry {
readonly blocks: BlockDefinition[]
readonly categories: BlockCategory[]
readonly controlTypes: string[]
registerBlock(block: BlockDefinition): void
registerBlockCategory(category: BlockCategory): void
registerControlType(type: string): void
}

/** The runtime registry plugins dock into (window.calloraSurface). */
export interface SurfaceRegistry {
readonly views: SurfaceView[]
registerView(view: SurfaceView): void
readonly contextChannel: SurfaceContextChannel
readonly blocks: BlockRegistry
}

declare global {
Expand Down Expand Up @@ -145,6 +268,55 @@ export function registerSurfaceView(view: SurfaceView): void {
registry.registerView(view)
}

/**
* Registers a block. It becomes renderable immediately — the registry registers the
* view alongside it — and appears in the editor's picker once that exists.
*
* Register the category too, unless it is one the host already defines: a block whose
* category nobody registered still works, but shows up unnamed.
*/
export function registerBlock(block: BlockDefinition): void {
const registry = window.calloraSurface
if (!registry) {
console.warn(
`[callora-surface-sdk] surface runtime not initialised; block "${block.id}" was not registered.`,
)
return
}

registry.blocks.registerBlock(block)
}

/** Registers a category for the editor's block picker. Any id is allowed. */
export function registerBlockCategory(category: BlockCategory): void {
const registry = window.calloraSurface
if (!registry) {
console.warn(
`[callora-surface-sdk] surface runtime not initialised; category "${category.id}" was not registered.`,
)
return
}

registry.blocks.registerBlockCategory(category)
}

/**
* Registers a control type this plugin implements — a phone-number picker, an agent
* selector. Appearance types (colorToken, spacingToken, typeToken, variant) are
* reserved and refused: they pick from --cal-* and nothing else.
*/
export function registerControlType(type: string): void {
const registry = window.calloraSurface
if (!registry) {
console.warn(
`[callora-surface-sdk] surface runtime not initialised; control type "${type}" was not registered.`,
)
return
}

registry.blocks.registerControlType(type)
}

/**
* The surface's context channel, or undefined when the runtime has not initialised.
* Returned rather than thrown for the same reason a missing registry warns instead of
Expand Down
Loading
Loading