@notionhq/custom-blocks-dev-shell 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/dist/assets/{index-DvRqSoqY.css → index-CzXh8_H1.css} +1 -1
- package/dist/assets/index-a8PSkfam.js +9 -0
- package/dist/index.html +2 -2
- package/dist-cli/data-sources.js +106 -0
- package/dist-cli/main.js +14 -0
- package/dist-cli/materialize.js +104 -0
- package/dist-cli/worker-manifest.js +3 -1
- package/docs/data-sources.md +88 -0
- package/package.json +4 -2
- package/dist/assets/index-xG6RNJPx.js +0 -9
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>Workers local shell</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-a8PSkfam.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CzXh8_H1.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Load a worker's dev-shell data sources from `<dataDir>/*.json` — one source
|
|
3
|
+
* per file, hand-authored. Every file is validated here, before it reaches
|
|
4
|
+
* the shell, so a malformed source fails spin-up naming the file and the
|
|
5
|
+
* problem instead of breaking the UI.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { basename, resolve } from "node:path";
|
|
9
|
+
import * as v from "valibot";
|
|
10
|
+
const DEV_SHELL_DATA_SOURCE_TYPES = [
|
|
11
|
+
"built-in",
|
|
12
|
+
"worker",
|
|
13
|
+
"manual",
|
|
14
|
+
"syncedFromProd",
|
|
15
|
+
];
|
|
16
|
+
// Local copy of the SDK's NOTION_PROPERTY_TYPES: the compiled CLI runs under
|
|
17
|
+
// plain node, which can't import the SDK's published TS source at runtime.
|
|
18
|
+
// The `satisfies` below plus the type-only import keep the two lists locked
|
|
19
|
+
// together — a drift on either side fails typecheck.
|
|
20
|
+
const NOTION_PROPERTY_TYPES = [
|
|
21
|
+
"title",
|
|
22
|
+
"rich_text",
|
|
23
|
+
"number",
|
|
24
|
+
"checkbox",
|
|
25
|
+
"url",
|
|
26
|
+
"email",
|
|
27
|
+
"phone_number",
|
|
28
|
+
"select",
|
|
29
|
+
"multi_select",
|
|
30
|
+
"status",
|
|
31
|
+
"date",
|
|
32
|
+
"people",
|
|
33
|
+
"files",
|
|
34
|
+
"unique_id",
|
|
35
|
+
"relation",
|
|
36
|
+
"place",
|
|
37
|
+
"formula",
|
|
38
|
+
"rollup",
|
|
39
|
+
"button",
|
|
40
|
+
"verification",
|
|
41
|
+
"last_visited_time",
|
|
42
|
+
"location",
|
|
43
|
+
"created_time",
|
|
44
|
+
"last_edited_time",
|
|
45
|
+
"created_by",
|
|
46
|
+
"last_edited_by",
|
|
47
|
+
];
|
|
48
|
+
const _allPropertyTypesListed = true;
|
|
49
|
+
void _allPropertyTypesListed;
|
|
50
|
+
export function readDataSources(dataDir) {
|
|
51
|
+
if (!existsSync(dataDir)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return readdirSync(dataDir)
|
|
55
|
+
.filter(name => name.endsWith(".json"))
|
|
56
|
+
.map(name => readSource(resolve(dataDir, name)))
|
|
57
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
58
|
+
}
|
|
59
|
+
const nonEmptyString = v.pipe(v.string(), v.nonEmpty());
|
|
60
|
+
const sourceFileSchema = v.object({
|
|
61
|
+
type: v.optional(v.literal("worker"), "worker"),
|
|
62
|
+
key: v.optional(nonEmptyString),
|
|
63
|
+
name: v.optional(nonEmptyString),
|
|
64
|
+
icon: v.optional(v.string()),
|
|
65
|
+
schema: v.optional(v.record(v.string(), v.object({
|
|
66
|
+
name: v.optional(v.string()),
|
|
67
|
+
type: v.picklist(NOTION_PROPERTY_TYPES),
|
|
68
|
+
})), {}),
|
|
69
|
+
rows: v.optional(v.array(v.looseObject({ id: nonEmptyString })), []),
|
|
70
|
+
});
|
|
71
|
+
/**
|
|
72
|
+
* Parse and validate one source file. Structural mistakes throw with the file
|
|
73
|
+
* and problem named; omitted `key`/`name` fall back to the filename, and
|
|
74
|
+
* omitted `schema`/`rows` to empty — so the minimal valid file is `{}`.
|
|
75
|
+
*/
|
|
76
|
+
function readSource(file) {
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new Error(`${file}: not valid JSON — ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
if (Array.isArray(parsed)) {
|
|
85
|
+
throw new Error(`${file}: must be a JSON object`);
|
|
86
|
+
}
|
|
87
|
+
const result = v.safeParse(sourceFileSchema, parsed);
|
|
88
|
+
if (!result.success) {
|
|
89
|
+
const issue = result.issues[0];
|
|
90
|
+
const path = v.getDotPath(issue);
|
|
91
|
+
throw new Error(`${file}: ${path ? `${path}: ` : ""}${issue.message}`);
|
|
92
|
+
}
|
|
93
|
+
const { type, icon, schema, rows } = result.output;
|
|
94
|
+
const key = result.output.key ?? basename(file, ".json");
|
|
95
|
+
return {
|
|
96
|
+
type,
|
|
97
|
+
key,
|
|
98
|
+
name: result.output.name ?? key,
|
|
99
|
+
...(icon !== undefined ? { icon } : {}),
|
|
100
|
+
schema: Object.fromEntries(Object.entries(schema).map(([propertyKey, property]) => [
|
|
101
|
+
propertyKey,
|
|
102
|
+
{ name: property.name ?? propertyKey, type: property.type },
|
|
103
|
+
])),
|
|
104
|
+
rows,
|
|
105
|
+
};
|
|
106
|
+
}
|
package/dist-cli/main.js
CHANGED
|
@@ -11,6 +11,8 @@ import { createRequire } from "node:module";
|
|
|
11
11
|
import { basename, dirname, join, resolve } from "node:path";
|
|
12
12
|
import { fileURLToPath } from "node:url";
|
|
13
13
|
import { BLOCK_BASE_PORT, buildBlockRegistry, SHELL_2_PORT, writeBlockViteConfig, } from "./block-server.js";
|
|
14
|
+
import { readDataSources } from "./data-sources.js";
|
|
15
|
+
import { materializeWorkerSchemaDataSources } from "./materialize.js";
|
|
14
16
|
import { serveUi } from "./serve-ui.js";
|
|
15
17
|
import { blockCapabilities, findWorkerDir, generateWorkerManifest, } from "./worker-manifest.js";
|
|
16
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -114,6 +116,17 @@ async function main() {
|
|
|
114
116
|
// Not an error — start the shell anyway; it shows "None" under Blocks.
|
|
115
117
|
console.log(`${label(basename(workerDir))} Worker declares no custom blocks.`);
|
|
116
118
|
}
|
|
119
|
+
// Materialize schema-only files for the worker's declared sources, then
|
|
120
|
+
// load the directory. Files are validated before injection; a malformed
|
|
121
|
+
// file fails spin-up with the problem named.
|
|
122
|
+
materializeWorkerSchemaDataSources(manifest, resolve(workerDir, "src/data"));
|
|
123
|
+
const dataSources = readDataSources(resolve(workerDir, "src/data"));
|
|
124
|
+
console.log(dataSources.length > 0
|
|
125
|
+
? `${label(basename(workerDir))} Data sources from src/data: ${dataSources
|
|
126
|
+
.map(source => source.name)
|
|
127
|
+
.join(", ")}`
|
|
128
|
+
: `${label(basename(workerDir))} No data sources — create src/data/<key>.json files ` +
|
|
129
|
+
`(format: node_modules/@notionhq/custom-blocks-dev-shell/docs/data-sources.md).`);
|
|
117
130
|
if (blocks.length > 0 &&
|
|
118
131
|
cliArgs.shellPort >= cliArgs.blockBasePort &&
|
|
119
132
|
cliArgs.shellPort < cliArgs.blockBasePort + blocks.length) {
|
|
@@ -161,6 +174,7 @@ async function main() {
|
|
|
161
174
|
await serveUi(distDir, cliArgs.shellPort, {
|
|
162
175
|
mode: "worker",
|
|
163
176
|
blocks: registry,
|
|
177
|
+
dataSources,
|
|
164
178
|
});
|
|
165
179
|
}
|
|
166
180
|
catch (error) {
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Materialize a worker's declared data sources as `<dataDir>/worker_<key>.json`
|
|
3
|
+
* files: the schema with no rows, mirroring a managed database before any
|
|
4
|
+
* sync. Rows are added by editing the file; `data-sources.ts` reads and
|
|
5
|
+
* validates whatever ends up in the directory.
|
|
6
|
+
*
|
|
7
|
+
* A block's data source can come from a `worker.database` or from an inline
|
|
8
|
+
* schema declared on the block (`worker.customBlock({ dataSources })`); both
|
|
9
|
+
* are materialized so all sources are treated equivalently — just files in
|
|
10
|
+
* `src/data/`. The `worker_` prefix keeps generated sources from ever colliding
|
|
11
|
+
* with hand-authored files, so both can coexist.
|
|
12
|
+
*
|
|
13
|
+
* Files are written only when absent, so hand-edited or hand-authored sources
|
|
14
|
+
* survive across restarts; delete a file to regenerate it from the schema.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { blockCapabilities } from "./worker-manifest.js";
|
|
19
|
+
// Worker authoring type names → public data-source (NotionPropertyType) names.
|
|
20
|
+
const TYPE_ALIASES = {
|
|
21
|
+
text: "rich_text",
|
|
22
|
+
file: "files",
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Generated files are namespaced so they never shadow authored files. The
|
|
26
|
+
* filename and `key` carry the prefix — keys must stay unique for bindings to
|
|
27
|
+
* be unambiguous — while the display name stays as declared, so the sidebar
|
|
28
|
+
* shows the clean name.
|
|
29
|
+
*/
|
|
30
|
+
const GENERATED_PREFIX = "worker_";
|
|
31
|
+
export function materializeWorkerSchemaDataSources(manifest, dataDir) {
|
|
32
|
+
mkdirSync(dataDir, { recursive: true });
|
|
33
|
+
// One descriptor per data source key: databases first (source of truth for
|
|
34
|
+
// anything they back), then inline block schemas for keys no database
|
|
35
|
+
// provides.
|
|
36
|
+
const descriptors = new Map();
|
|
37
|
+
for (const database of manifest.databases) {
|
|
38
|
+
descriptors.set(database.key, {
|
|
39
|
+
name: database.config.initialTitle ?? database.key,
|
|
40
|
+
properties: (database.config.schema?.properties ?? {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
for (const block of blockCapabilities(manifest)) {
|
|
44
|
+
for (const [key, slot] of Object.entries(block.config.manifest.dataSources)) {
|
|
45
|
+
const properties = (slot.properties ?? {});
|
|
46
|
+
const existing = descriptors.get(key);
|
|
47
|
+
if (existing !== undefined) {
|
|
48
|
+
// Same key from two blocks with different shapes: slot keys are only
|
|
49
|
+
// unique per block, so one file can't serve both. First block wins;
|
|
50
|
+
// say so instead of leaving the loser silently unbindable.
|
|
51
|
+
if (existing.blockKey !== undefined &&
|
|
52
|
+
!samePropertySchemas(existing.properties, properties)) {
|
|
53
|
+
console.warn(`Blocks "${existing.blockKey}" and "${block.key}" both declare data source ` +
|
|
54
|
+
`"${key}" with different schemas; ${GENERATED_PREFIX}${key}.json uses the ` +
|
|
55
|
+
`schema from "${existing.blockKey}". Author a src/data file for "${block.key}" by hand.`);
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
descriptors.set(key, {
|
|
60
|
+
name: slot.name ?? key,
|
|
61
|
+
properties,
|
|
62
|
+
blockKey: block.key,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const [key, descriptor] of descriptors) {
|
|
67
|
+
// The key is the worker's contract and lands verbatim in the file's
|
|
68
|
+
// `key`; the filename is ours, so path-hostile characters (`/`, `..`)
|
|
69
|
+
// are encoded rather than allowed to leave `dataDir` or crash the write.
|
|
70
|
+
const file = resolve(dataDir, `${GENERATED_PREFIX}${fileSafe(key)}.json`);
|
|
71
|
+
if (existsSync(file)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
writeFileSync(file, `${JSON.stringify(buildSource(key, descriptor), null, 2)}\n`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function fileSafe(key) {
|
|
78
|
+
return key.replace(/[^a-zA-Z0-9_-]/g, "-");
|
|
79
|
+
}
|
|
80
|
+
function samePropertySchemas(left, right) {
|
|
81
|
+
const leftKeys = Object.keys(left).sort();
|
|
82
|
+
const rightKeys = Object.keys(right).sort();
|
|
83
|
+
return (leftKeys.length === rightKeys.length &&
|
|
84
|
+
leftKeys.every((key, index) => key === rightKeys[index] &&
|
|
85
|
+
left[key].type === right[key].type &&
|
|
86
|
+
(left[key].name ?? key) === (right[key].name ?? key)));
|
|
87
|
+
}
|
|
88
|
+
function buildSource(key, descriptor) {
|
|
89
|
+
const { name, properties } = descriptor;
|
|
90
|
+
const schema = {};
|
|
91
|
+
for (const [propertyKey, property] of Object.entries(properties)) {
|
|
92
|
+
schema[propertyKey] = {
|
|
93
|
+
name: property.name ?? propertyKey,
|
|
94
|
+
type: TYPE_ALIASES[property.type] ?? property.type,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
type: "worker",
|
|
99
|
+
key: `${GENERATED_PREFIX}${key}`,
|
|
100
|
+
name,
|
|
101
|
+
schema,
|
|
102
|
+
rows: [],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -96,7 +96,9 @@ export async function extractWorkerManifest(workerDir, options = {}) {
|
|
|
96
96
|
// (per the localhost verification doc's `w.manifest || w.capabilities`).
|
|
97
97
|
const raw = worker.manifest ?? worker.capabilities;
|
|
98
98
|
if (isManifestShape(raw)) {
|
|
99
|
-
|
|
99
|
+
// The shape check only guarantees `capabilities`; older manifests omit
|
|
100
|
+
// the other arrays.
|
|
101
|
+
return { ...raw, databases: raw.databases ?? [], pacers: raw.pacers ?? [] };
|
|
100
102
|
}
|
|
101
103
|
if (Array.isArray(raw)) {
|
|
102
104
|
return { databases: [], pacers: [], capabilities: raw };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Dev-shell data sources
|
|
2
|
+
|
|
3
|
+
Data sources come in kinds, marked by their `type`, and all live in the
|
|
4
|
+
worker's `src/data/*.json` — one source per file, read at spin-up. The shell
|
|
5
|
+
supports the following types today:
|
|
6
|
+
|
|
7
|
+
- `worker` — hand-authored JSON files. This is how you give your blocks data,
|
|
8
|
+
and what this document describes.
|
|
9
|
+
- `manual` — sources a user creates themselves in the shell.
|
|
10
|
+
- `built-in` — sources we provide.
|
|
11
|
+
|
|
12
|
+
## File format
|
|
13
|
+
|
|
14
|
+
```json
|
|
15
|
+
{
|
|
16
|
+
"type": "worker",
|
|
17
|
+
"key": "tasks",
|
|
18
|
+
"name": "Tasks",
|
|
19
|
+
"icon": "✅",
|
|
20
|
+
"schema": {
|
|
21
|
+
"title": { "name": "Title", "type": "title" },
|
|
22
|
+
"status": { "name": "Status", "type": "select" },
|
|
23
|
+
"dueDate": { "name": "Due date", "type": "date" },
|
|
24
|
+
"done": { "name": "Done", "type": "checkbox" }
|
|
25
|
+
},
|
|
26
|
+
"rows": [
|
|
27
|
+
{
|
|
28
|
+
"id": "tasks-1",
|
|
29
|
+
"title": "Ship the block",
|
|
30
|
+
"status": "In progress",
|
|
31
|
+
"dueDate": { "type": "date", "start_date": "2026-08-01" },
|
|
32
|
+
"done": false
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `type` — the source's kind; `"worker"`, the only kind these files can
|
|
39
|
+
declare today, is the default.
|
|
40
|
+
- `key` — what blocks bind against; make it match a `dataSources` key from
|
|
41
|
+
`worker.customBlock(...)` so properties auto-match. Defaults to the
|
|
42
|
+
filename.
|
|
43
|
+
- `name` — the label shown in the shell sidebar. Defaults to `key`.
|
|
44
|
+
- `icon` — optional emoji shown alongside the name.
|
|
45
|
+
- `schema` — property key → `{ name, type }`. Use the same property keys and
|
|
46
|
+
types the block declares. Types: `title`, `rich_text`, `number`, `select`,
|
|
47
|
+
`multi_select`, `status`, `date`, `checkbox`, `url`, `email`,
|
|
48
|
+
`phone_number`, `people`, `files`, `relation`.
|
|
49
|
+
- `rows` — each row needs a unique string `id`; property values live under
|
|
50
|
+
their schema keys.
|
|
51
|
+
|
|
52
|
+
Value shapes by property type:
|
|
53
|
+
|
|
54
|
+
| Type | Value |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `title`, `rich_text`, `select`, `status`, `url`, `email`, `phone_number` | string |
|
|
57
|
+
| `multi_select` | array of strings |
|
|
58
|
+
| `number` | number |
|
|
59
|
+
| `checkbox` | boolean |
|
|
60
|
+
| `date` | `{ "type": "date", "start_date": "YYYY-MM-DD" }`; ranges and datetimes use `"daterange"` / `"datetime"` / `"datetimerange"` with `end_date`, `start_time`, `end_time` |
|
|
61
|
+
| `people` | array of `{ "id": "...", "table": "notion_user" }` pointers |
|
|
62
|
+
| `relation` | array of `{ "id": "...", "table": "block" }` pointers |
|
|
63
|
+
| `files` | string (the host serializes file properties as text today) |
|
|
64
|
+
|
|
65
|
+
## Validation
|
|
66
|
+
|
|
67
|
+
Files are validated at spin-up, before they reach the shell UI. A malformed
|
|
68
|
+
file fails the run with the file and problem named, e.g.
|
|
69
|
+
`src/data/tasks.json: rows.0.id: Invalid key: Expected "id" but received undefined`.
|
|
70
|
+
Omitted `key`/`name` fall back to the filename; omitted `schema`/`rows` to
|
|
71
|
+
empty.
|
|
72
|
+
|
|
73
|
+
## Materialization
|
|
74
|
+
|
|
75
|
+
You don't have to write these files from scratch. At spin-up the shell
|
|
76
|
+
materializes a file for every data source the worker declares — both
|
|
77
|
+
`worker.database(...)` entries and inline `worker.customBlock({ dataSources })`
|
|
78
|
+
schemas — that doesn't already have one: `worker_<key>.json`, the declared
|
|
79
|
+
schema with empty `rows`, mirroring a managed database before any sync.
|
|
80
|
+
|
|
81
|
+
- The `worker_` prefix on the filename and `key` namespaces generated files
|
|
82
|
+
away from ones you author yourself, so both coexist; the display `name`
|
|
83
|
+
stays as declared, so the sidebar shows the clean name.
|
|
84
|
+
- Files are only ever written when absent: fill in `rows` freely and your
|
|
85
|
+
edits survive restarts. Delete a generated file to regenerate it from the
|
|
86
|
+
schema.
|
|
87
|
+
|
|
88
|
+
The shell shows exactly the sources in `src/data/`, nothing else.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@notionhq/custom-blocks-dev-shell",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Local preview shell for Notion custom block workers.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -19,12 +19,14 @@
|
|
|
19
19
|
"bin",
|
|
20
20
|
"dist",
|
|
21
21
|
"dist-cli",
|
|
22
|
+
"docs",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
24
25
|
"dependencies": {
|
|
25
26
|
"react": "^19.2.5",
|
|
26
27
|
"react-dom": "^19.2.5",
|
|
27
|
-
"
|
|
28
|
+
"valibot": "^1.3.1",
|
|
29
|
+
"@notionhq/custom-blocks": "0.1.1"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
30
32
|
"@tailwindcss/vite": "^4.2.4",
|