@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/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-xG6RNJPx.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-DvRqSoqY.css">
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
- return raw;
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.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
- "@notionhq/custom-blocks": "0.1.0"
28
+ "valibot": "^1.3.1",
29
+ "@notionhq/custom-blocks": "0.1.1"
28
30
  },
29
31
  "devDependencies": {
30
32
  "@tailwindcss/vite": "^4.2.4",