diff --git a/.changeset/milab-6648-block-kinds-and-templates.md b/.changeset/milab-6648-block-kinds-and-templates.md new file mode 100644 index 0000000000..969a17571b --- /dev/null +++ b/.changeset/milab-6648-block-kinds-and-templates.md @@ -0,0 +1,93 @@ +--- +"@platforma-sdk/block-kind": minor +"@milaboratories/pl-model-common": minor +"@milaboratories/pl-model-middle-layer": minor +"@milaboratories/pl-middle-layer": minor +"@platforma-sdk/block-tools": minor +"@milaboratories/ts-builder": minor +"@platforma-sdk/model": minor +--- + +Block kinds and project templates. + +A **block kind** is a separately-versioned npm package declaring the typed init-params +contract a block is created from; many block versions implement one kind version. On top +of kinds sits the **template engine**: a project exports to `template-v1` YAML, and a +template — exported or hand-authored — applies into a fresh project. + +**New package `@platforma-sdk/block-kind`.** `defineBlockKind({ name, +version, parseInitializationParams })` returns a frozen `CompiledBlockKind`. Source `name` / +`version` from the kind's own `package.json` so the on-wire `{name}@{version}` cannot +drift from what npm publishes. + +```ts +// /kind/src/index.ts +import { defineBlockKind } from "@platforma-sdk/block-kind"; +import { name, version } from "../package.json" with { type: "json" }; + +export type BlockParams = { numbers?: number[] }; +const Params = z.object({ numbers: z.array(z.number()).optional() }).strict(); + +export const kind = defineBlockKind({ + name, + version, + parseInitializationParams: (value) => Params.parse(value), +}); +``` + +**`@platforma-sdk/model`** — the kind is now part of the model: + +```ts +const dataModel = new DataModelBuilder({ kind }) + .from("v1") + .init(({ params }) => ({ numbers: params?.numbers ?? [] })); + +export const platforma = BlockModelV3.create({ dataModel, kind }) + .templateParams((data) => ({ numbers: data.numbers })) + .args(...) + .done(); +``` + +`init` receives the kind's `params` (optional — a block may be created without a +template) and builds the block's initial storage from them. `templateParams()` is the +inverse: it projects block state back to the kind's params for export. Both are written in +live terms — the SDK marks the column identifiers in what the lambda returned, so nothing about +templates leaks into a block's own code. + +**`@milaboratories/pl-model-common`** — `BlockKindReference` + `formatKindRef`, the +`template-v1` document schema, the kind selector's semver ranges, and the `{ $ref: … }` wrapper +that marks a column identifier inside template params. `wrapTemplateRefs` puts those wrappers +on, in the block's own bundle where the reference system is already known; the template engine +stores what is inside verbatim and redirects the block ids textually, so it holds no model of +that system at all. + +**`@milaboratories/pl-middle-layer`** — `MiddleLayer.exportProjectAsTemplate(id)` and +`MiddleLayer.applyTemplateToProject(id, document)`, backing "Export Project as +Template…" and "Create Project from Template…". The template import path is public: +`parseProjectTemplateV1Yaml`, `validateTemplateV1ForApply`, `resolveTemplateEntries`, plus the +`BlockPackProvider` seam deciding which registries to consult. Entries resolve against the configured registries, ids are mapped to the blocks +they become, and each entry's params are offered to the block's kind for a shape check +before anything is created. + +**`@platforma-sdk/block-tools`** — the `kind` part in `.structure` with its own package +rules and scaffold, a `build-kind-manifest` command, kind-first publication (the kind +content is written to the registry's `kinds/` tree, source-hash guarded and idempotent, +before the facade — gated by a version-match check that hard-fails before any write), +and registry-side kind resolution. + +**`@milaboratories/ts-builder`** — `block-kind` build target (rolldown config + tsconfig). + +**BREAKING:** + +- `BlockModelV3.create(dataModel)` → `BlockModelV3.create({ dataModel, kind })`. A block + cannot omit its kind. +- `new DataModelBuilder()` → `new DataModelBuilder({ kind })`, and `init` takes + `({ params })` rather than no argument. +- `templateParams()` is required — `done()` throws without it. A block whose state + cannot be reduced to params returns `{}` explicitly, rather than exporting an entry + that silently applies as a default-initialized block. +- Every kind must declare `parseInitializationParams`. A kind whose params are genuinely empty + still declares one; it just rejects everything but `{}`. +- Publishing a block whose model was compiled against a kind requires the facade to + declare that kind as a dependency, at a matching version. Blocks declaring no kind + publish exactly as before. diff --git a/.changeset/milab-6648-example-blocks-declare-kind.md b/.changeset/milab-6648-example-blocks-declare-kind.md new file mode 100644 index 0000000000..15b23e8969 --- /dev/null +++ b/.changeset/milab-6648-example-blocks-declare-kind.md @@ -0,0 +1,10 @@ +--- +"@milaboratories/milaboratories.monetization-test": patch +"@milaboratories/milaboratories.pool-explorer": patch +"@milaboratories/milaboratories.ui-examples": patch +--- + +Declare a block kind. Each block gains a `kind/` package holding its init-params +contract, and its model is built with `new DataModelBuilder({ kind })` / +`BlockModelV3.create({ dataModel, kind })` and projects its params back via +`templateParams()`. diff --git a/etc/blocks/blob-url-custom-protocol/.structure b/etc/blocks/blob-url-custom-protocol/.structure index 491d734467..218abba169 100644 --- a/etc/blocks/blob-url-custom-protocol/.structure +++ b/etc/blocks/blob-url-custom-protocol/.structure @@ -1 +1 @@ -{"version":1} \ No newline at end of file +{"version":2} \ No newline at end of file diff --git a/etc/blocks/blob-url-custom-protocol/block/package.json b/etc/blocks/blob-url-custom-protocol/block/package.json index e9321db2f3..d235ea42e3 100644 --- a/etc/blocks/blob-url-custom-protocol/block/package.json +++ b/etc/blocks/blob-url-custom-protocol/block/package.json @@ -25,6 +25,7 @@ }, "dependencies": {}, "devDependencies": { + "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind": "workspace:*", "@milaboratories/milaboratories.test-blob-url-custom-protocol.model": "workspace:*", "@milaboratories/milaboratories.test-blob-url-custom-protocol.ui": "workspace:*", "@milaboratories/milaboratories.test-blob-url-custom-protocol.workflow": "workspace:*", diff --git a/etc/blocks/enter-numbers-v3/model/.oxfmtrc.json b/etc/blocks/blob-url-custom-protocol/kind/.oxfmtrc.json similarity index 100% rename from etc/blocks/enter-numbers-v3/model/.oxfmtrc.json rename to etc/blocks/blob-url-custom-protocol/kind/.oxfmtrc.json diff --git a/etc/blocks/enter-numbers-v3/ui/.oxlintrc.json b/etc/blocks/blob-url-custom-protocol/kind/.oxlintrc.json similarity index 71% rename from etc/blocks/enter-numbers-v3/ui/.oxlintrc.json rename to etc/blocks/blob-url-custom-protocol/kind/.oxlintrc.json index 5cb5522788..b1a139038f 100644 --- a/etc/blocks/enter-numbers-v3/ui/.oxlintrc.json +++ b/etc/blocks/blob-url-custom-protocol/kind/.oxlintrc.json @@ -1,3 +1,3 @@ { - "extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-block-ui.json"] + "extends": ["node_modules/@milaboratories/ts-builder/dist/configs/oxlint-node.json"] } diff --git a/etc/blocks/blob-url-custom-protocol/kind/package.json b/etc/blocks/blob-url-custom-protocol/kind/package.json new file mode 100644 index 0000000000..e94cda9832 --- /dev/null +++ b/etc/blocks/blob-url-custom-protocol/kind/package.json @@ -0,0 +1,43 @@ +{ + "name": "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind", + "version": "1.0.0", + "private": true, + "description": "Block kind for the blob-url-custom-protocol block", + "files": [ + "dist/**/*" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "sources": "./src/index.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "ts-builder build --target block-kind && block-tools build-kind-manifest", + "check": "ts-builder check --target block-kind", + "formatter:check": "ts-builder formatter --check", + "linter:check": "ts-builder linter --check", + "types:check": "ts-builder type-check --target block-kind", + "fmt": "ts-builder format", + "watch": "ts-builder build --target block-kind --watch" + }, + "dependencies": { + "@platforma-sdk/block-kind": "workspace:*" + }, + "devDependencies": { + "@milaboratories/ts-builder": "workspace:*", + "@milaboratories/ts-configs": "workspace:*", + "@platforma-sdk/block-tools": "workspace:*" + }, + "peerDependencies": { + "@types/node": "*", + "typescript": "*" + } +} diff --git a/etc/blocks/blob-url-custom-protocol/kind/src/index.ts b/etc/blocks/blob-url-custom-protocol/kind/src/index.ts new file mode 100644 index 0000000000..d6a0af49fb --- /dev/null +++ b/etc/blocks/blob-url-custom-protocol/kind/src/index.ts @@ -0,0 +1,32 @@ +import { assertParamsObject, defineBlockKind } from "@platforma-sdk/block-kind"; +import { name, version } from "../package.json" with { type: "json" }; + +/** + * Init-params contract for the blob-url-custom-protocol block — deliberately + * empty. The block's whole `BlockData` is two `ImportFileHandle`s, and those are + * desktop-signed, machine- and session-local references produced by a real OS + * file-dialog gesture (see the upload flow). Nothing a creator or a project + * template could serialize ahead of time, so this block takes no init params and + * `init` always returns the unset defaults. + */ +export type BlockParams = Record; + +/** + * The same contract at runtime, for params that arrive from a template file rather + * than from typed code. + * + * An empty contract has nothing to check beyond the envelope: any field a file sets is a + * field this block does not read, so it is dropped rather than refused, and the block + * initializes exactly as it would with no params at all. + */ +function parseInitializationParams(value: unknown): BlockParams { + assertParamsObject(value); + + return {}; +} + +export const kind = defineBlockKind({ + name, + version, + parseInitializationParams, +}); diff --git a/etc/blocks/blob-url-custom-protocol/kind/tsconfig.json b/etc/blocks/blob-url-custom-protocol/kind/tsconfig.json new file mode 100644 index 0000000000..54112078cf --- /dev/null +++ b/etc/blocks/blob-url-custom-protocol/kind/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@milaboratories/ts-configs/block/facade", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "resolveJsonModule": true + }, + "include": ["src/**/*", "package.json"], + "exclude": ["dist", "node_modules"] +} diff --git a/etc/blocks/blob-url-custom-protocol/model/package.json b/etc/blocks/blob-url-custom-protocol/model/package.json index 7c71fb507b..4e52ad12de 100644 --- a/etc/blocks/blob-url-custom-protocol/model/package.json +++ b/etc/blocks/blob-url-custom-protocol/model/package.json @@ -25,6 +25,7 @@ "watch": "ts-builder build --target block-model --watch" }, "dependencies": { + "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind": "workspace:*", "@platforma-sdk/model": "workspace:*", "zod": "catalog:" }, diff --git a/etc/blocks/blob-url-custom-protocol/model/src/index.ts b/etc/blocks/blob-url-custom-protocol/model/src/index.ts index a7aa2c03b4..00767519b5 100644 --- a/etc/blocks/blob-url-custom-protocol/model/src/index.ts +++ b/etc/blocks/blob-url-custom-protocol/model/src/index.ts @@ -1,10 +1,6 @@ import type { ImportFileHandle, InferHrefType, InferOutputsType } from "@platforma-sdk/model"; -import { - BlockModel, - extractArchiveAndGetURL, - getResourceField, - MainOutputs, -} from "@platforma-sdk/model"; +import { BlockModelV3, DataModelBuilder } from "@platforma-sdk/model"; +import { kind } from "@milaboratories/milaboratories.test-blob-url-custom-protocol.kind"; import { z } from "zod"; export const ImportFileHandleSchema = z @@ -14,26 +10,51 @@ export const ImportFileHandleSchema = z ((_a) => true) as (arg: string | undefined) => arg is ImportFileHandle | undefined, ); -export const BlockArgs = z.object({ +export const BlockData = z.object({ inputTgzHandle: ImportFileHandleSchema, inputZipHandle: ImportFileHandleSchema, }); -export type BlockArgs = z.infer; +export type BlockData = z.infer; -export const platforma = BlockModel.create("Heavy") +/** What the workflow consumes — projected from {@link BlockData} by the args lambda. */ +export type BlockArgs = { + inputTgzHandle: ImportFileHandle | undefined; + inputZipHandle: ImportFileHandle | undefined; +}; - .withArgs({ - inputTgzHandle: undefined, - inputZipHandle: undefined, - }) +// This block takes no init params (its kind declares `Record`): +// both fields are desktop-signed `ImportFileHandle`s, which no template can +// pre-wire. So `init` ignores params and returns the unset defaults. +const dataModel = new DataModelBuilder({ kind }) + .from("v1") + .init(() => ({ inputTgzHandle: undefined, inputZipHandle: undefined })); + +export const platforma = BlockModelV3.create({ dataModel, kind }) + + .args((data) => ({ + inputTgzHandle: data.inputTgzHandle, + inputZipHandle: data.inputZipHandle, + })) + + // Nothing to project: the kind takes no params, because both handles are signed, + // session-local references from an OS file-dialog gesture and would not resolve in + // the project a template is applied into. + .templateParams(() => ({})) .output("handleTgz", (ctx) => ctx.outputs?.resolve("handleTgz")?.getImportProgress()) .output("handleZip", (ctx) => ctx.outputs?.resolve("handleZip")?.getImportProgress()) - .output("tgz_content", extractArchiveAndGetURL(getResourceField(MainOutputs, "siteTgz"), "tgz")) + // Both archive outputs use the accessor form. V1 drove `tgz_content` through + // the config-based `extractArchiveAndGetURL(getResourceField(MainOutputs, …))` + // helpers so the block covered both surfaces; those helpers return a + // `TypedConfig`, which only V1's `output()` accepts — `BlockModelV3.output()` + // takes render lambdas only. The config surface is therefore gone here, and + // the two outputs differ solely in the archive format they extract. + .output("tgz_content", (ctx) => ctx.outputs?.resolve("siteTgz")?.extractArchiveAndGetURL("tgz")) .output("zip_content", (ctx) => ctx.outputs?.resolve("siteZip")?.extractArchiveAndGetURL("zip")) + .sections((_ctx) => { return [{ type: "link", href: "/", label: "Main" }]; }) diff --git a/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue b/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue index 0111dd1961..ed90b15af1 100644 --- a/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue +++ b/etc/blocks/blob-url-custom-protocol/ui/src/MainPage.vue @@ -7,8 +7,8 @@ const app = useApp();