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
1 change: 1 addition & 0 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: node --test scripts/block-metadata.test.mjs
# the documentation and the registry are generated: the types of what renders them depend on them
- run: npm run docs
- name: The registry is as committed
Expand Down
2 changes: 1 addition & 1 deletion registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@
"name": "agent-workspace",
"type": "registry:block",
"title": "Agent workspace",
"description": "A dashboard with sidebar, charts and data table",
"description": "A workspace where an agent works: sessions, the thread, files and background tasks",
"dependencies": [
"lucide-react"
],
Expand Down
55 changes: 55 additions & 0 deletions scripts/block-metadata.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import ts from "typescript"

/** Read each literal block on its own: formatting and neighbouring entries must not change its metadata. */
export function blockMetadata(source) {
const file = ts.createSourceFile("src/blocks/index.ts", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
if (file.parseDiagnostics.length) throw new Error("Invalid TypeScript in src/blocks/index.ts")
const declaration = file.statements
.filter(ts.isVariableStatement)
.flatMap((statement) => [...statement.declarationList.declarations])
.find((item) => ts.isIdentifier(item.name) && item.name.text === "blocks")
if (!declaration?.initializer || !ts.isArrayLiteralExpression(declaration.initializer)) {
throw new Error("Expected a literal blocks array in src/blocks/index.ts")
}

const metadata = new Map()
for (const entry of declaration.initializer.elements) {
if (!ts.isCallExpression(entry) || !ts.isIdentifier(entry.expression) || entry.expression.text !== "block" ||
entry.arguments.length !== 1 || !ts.isObjectLiteralExpression(entry.arguments[0])) {
throw new Error("Expected block({...}) entries in src/blocks/index.ts")
}
const line = file.getLineAndCharacterOfPosition(entry.getStart(file)).line + 1
const keys = ["name", "title", "description"]
const keyOf = (property) => {
if (ts.isSpreadAssignment(property)) return undefined
const name = ts.isComputedPropertyName(property.name) ? property.name.expression : property.name
return ts.isIdentifier(property.name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name) ? name.text : undefined
}
const unrelated = (property) => ts.isSpreadAssignment(property)
? ts.isObjectLiteralExpression(property.expression) && property.expression.properties.every(unrelated)
: keyOf(property) !== undefined && !keys.includes(keyOf(property))
const properties = new Map()
for (const property of entry.arguments[0].properties) {
// Either can replace an earlier literal at runtime, including its name. Do not emit stale metadata.
const key = keyOf(property)
if (key === undefined) {
if (unrelated(property)) continue
throw new Error(`Expected static block metadata properties at line ${line}`)
}
if (!keys.includes(key)) continue
if (properties.has(key)) throw new Error(`Duplicate block metadata property: ${key} at line ${line}`)
properties.set(key, property)
}
const string = (key) => {
const property = properties.get(key)
if (!property || !ts.isPropertyAssignment(property) || !ts.isStringLiteralLike(property.initializer)) {
throw new Error(`Expected a literal ${key} in block metadata at line ${line}`)
}
return property.initializer.text
}
const name = string("name")
if (metadata.has(name)) throw new Error(`Duplicate block metadata: ${name}`)
metadata.set(name, { title: string("title"), description: string("description") })
}
return metadata
}
73 changes: 73 additions & 0 deletions scripts/block-metadata.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import test from "node:test"
import { blockMetadata } from "./block-metadata.mjs"

test("the existing workspace keeps its own multiline description", () => {
const source = readFileSync(new URL("../src/blocks/index.ts", import.meta.url), "utf8")
const metadata = blockMetadata(source)
assert.equal(metadata.get("agent-workspace").description,
"A workspace where an agent works: sessions, the thread, files and background tasks")
assert.equal(metadata.get("dashboard-01").description, "A dashboard with sidebar, charts and data table")
})

test("metadata tolerates whitespace, comments, quotes, escapes and nested unrelated properties", () => {
const source = `export const blocks = [block({
name: 'first', title: /* description: "wrong" */ "First",
description:\n 'A \\'quoted\\' description',
other: { description: "nested", text: "})" },
}), block({name: "second", title: "Second", description: "Own description"})]`
assert.deepEqual([...blockMetadata(source)], [
["first", { title: "First", description: "A 'quoted' description" }],
["second", { title: "Second", description: "Own description" }],
])
})

test("a missing description cannot consume the next entry", () => {
assert.throws(() => blockMetadata(`const blocks = [
block({name: "first", title: "First"}),
block({name: "second", title: "Second", description: "Not first"}),
]`), /literal description/)
})

test("missing arrays, dynamic metadata and duplicate names fail with a useful error", () => {
assert.throws(() => blockMetadata("const other = []"), /literal blocks array/)
assert.throws(() => blockMetadata(`const blocks = [block({name: "x", title: "X", description: other})]`), /literal description/)
assert.throws(() => blockMetadata(`const blocks = [
block({name: "x", title: "X", description: "One"}),
block({name: "x", title: "X", description: "Two"}),
]`), /Duplicate block metadata: x/)
})

test("spreads and computed properties cannot silently replace literal metadata", () => {
for (const override of ["...extra", '[key]: "Other"', '...{ description: "Other" }']) {
assert.throws(() => blockMetadata(`const blocks = [block({
name: "x", title: "X", description: "Original", ${override},
})]`), /static block metadata properties at line 1/)
}
})

test("repeated metadata keys cannot make the registry differ from the runtime block", () => {
for (const key of ["name", "title", "description"]) {
assert.throws(() => blockMetadata(`const blocks = [block({
name: "x", title: "X", description: "Original", "${key}": "Replacement",
})]`), new RegExp(`Duplicate block metadata property: ${key}`))
}
assert.throws(() => blockMetadata(`const blocks = [block({
name: "x", title: "X", description: "Original", ["description"]: "Replacement",
})]`), /Duplicate block metadata property: description at line 1/)
})

test("provably unrelated computed keys and literal spreads remain supported", () => {
assert.deepEqual([...blockMetadata(`const blocks = [block({
name: "x", title: "X", description: "Original",
["other"]: dynamic, ...{ another: dynamic, ...{ final: dynamic } },
})]`)], [["x", { title: "X", description: "Original" }]])
})

test("invalid TypeScript and nonliteral metadata members are rejected", () => {
assert.throws(() => blockMetadata("const blocks = [block({"), /Invalid TypeScript/)
for (const description of ['get description() { return "Other" }', "description"]) {
assert.throws(() => blockMetadata(`const blocks = [block({name: "x", title: "X", ${description}})]`), /literal description/)
}
})
10 changes: 6 additions & 4 deletions scripts/build-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { execFileSync } from "node:child_process"
import { copyFileSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { fileURLToPath } from "node:url"
import { blockMetadata } from "./block-metadata.mjs"

const root = join(dirname(fileURLToPath(import.meta.url)), "..")
const out = join(root, "public/r")
Expand All @@ -46,17 +47,18 @@ function needs(content) {
}

// The blocks: whole screens, one file each. Their titles and descriptions are in src/blocks/index.ts.
const blockIndex = read("src/blocks/index.ts")
const blockIndex = blockMetadata(read("src/blocks/index.ts"))
const blocks = readdirSync(join(root, "src/blocks"))
.filter((file) => file.endsWith(".tsx"))
.map((file) => {
const name = file.replace(/\.tsx$/, "")
const entry = blockIndex.slice(blockIndex.indexOf(`name: "${name}"`))
const entry = blockIndex.get(name)
if (!entry) throw new Error(`Missing block metadata: ${name}`)
return {
name,
type: "registry:block",
title: entry.match(/title: "([^"]*)"/)?.[1],
description: entry.match(/description: "([^"]*)"/)?.[1],
title: entry.title,
description: entry.description,
...needs(read(`src/blocks/${file}`)),
files: [{ path: `src/blocks/${file}`, type: "registry:component" }],
}
Expand Down
Loading