@vormio/plugin-sdk 0.0.0-stage → 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vormio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,160 @@
1
- # Temporary Holding Version
1
+ # @vormio/plugin-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The manifest schema, runtime helpers and packaging command for writing Vormio plugins. The package is versioned with Vormio-Core (currently 0.0.3) and published to npm under the MIT license: `npm install @vormio/plugin-sdk@0.0.3`.
4
+
5
+ Plugin contract version: `apiVersion: 1`. During development (0.0.x) there is no cross-version compatibility promise: the host loads only plugins with the same `apiVersion` as its own.
6
+
7
+ ## What a plugin can do
8
+
9
+ A plugin provides three capabilities for the formats it declares. They are independent: declaring one does not grant the others.
10
+
11
+ | Extension point | Runs in | Result |
12
+ | --------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
13
+ | Thumbnail | A hidden sandboxed page; Canvas and WebGL work | One PNG whose longest edge is at most the host's `maxEdge` (currently 320); the host then caps it at 512×512 and 4 MiB |
14
+ | Viewer | A sandboxed page over Vormio's viewing area | Shows the file read-only; the user can interact with it |
15
+ | Analyzer | A hidden sandboxed page | A composition report: the file's internal subresources, the files it references and diagnostics; see [Analyzers](#analyzers) |
16
+
17
+ A plugin runs only after it is enabled in a library. Each plugin has its own memory partition:
18
+
19
+ - No access to the network, `file:` or other plugins.
20
+ - No Node and no `eval`.
21
+ - WebAssembly and Workers are available.
22
+ - It can read only the file the host binds to it, plus companion files in the same linked folder whose extensions are listed in `companionFormats`.
23
+ - It cannot modify any file.
24
+
25
+ ## Package layout
26
+
27
+ A plugin is a folder: a `manifest.json` at the root plus the built static files.
28
+
29
+ ```json
30
+ {
31
+ "apiVersion": 1,
32
+ "id": "acme.model-viewer",
33
+ "version": "0.1.0",
34
+ "name": "Model Viewer",
35
+ "description": "Example.",
36
+ "contributions": {
37
+ "thumbnails": [
38
+ {
39
+ "id": "model",
40
+ "entry": "thumbnail.html",
41
+ "formats": ["gltf", "glb"],
42
+ "companionFormats": ["bin", "png", "jpg"]
43
+ }
44
+ ],
45
+ "viewers": [
46
+ {
47
+ "id": "model",
48
+ "entry": "viewer.html",
49
+ "formats": ["gltf", "glb"],
50
+ "companionFormats": ["bin", "png", "jpg"]
51
+ }
52
+ ],
53
+ "analyzers": [{ "id": "model", "entry": "analyzer.html", "formats": ["gltf", "glb"] }]
54
+ }
55
+ }
56
+ ```
57
+
58
+ - `thumbnails`, `viewers` and `analyzers` are each optional, but there must be at least one extension point. Each kind allows up to 8, with unique IDs within the kind.
59
+ - `id` is `publisher.name`, using only lowercase letters, digits and hyphens. The `vormio` publisher is reserved for the host.
60
+ - `formats` and `companionFormats` are lowercase extensions without the dot. Wildcards are not allowed.
61
+ - `entry` is an HTML file in the package. Pages run under a strict Content Security Policy: scripts must be separate files, not inline.
62
+ - Unknown fields are rejected.
63
+
64
+ **Allowed files**: `html js mjs css json wasm png jpg jpeg webp svg woff2 txt md`. Links, `..` paths, Windows reserved names and names that differ only by case are not allowed.
65
+
66
+ **Limits**: at most 1,024 files, 16 MiB per file and 64 MiB in total.
67
+
68
+ **Permission fingerprint**: the grant a library records covers each extension point's ID (analyzers included), `formats` and `companionFormats`. An upgrade that changes only code, name or version keeps the grant; widening what the plugin reads or adding an extension point requires the user to enable it again.
69
+
70
+ ## Runtime API
71
+
72
+ ```ts
73
+ import { analyzer, readCompanion, readInput, thumbnail, viewer } from '@vormio/plugin-sdk'
74
+
75
+ // Viewer: called first and on every file change. Returning means ready; throwing reports a failure.
76
+ viewer(async (input, signal) => {
77
+ const bytes = await readInput(input)
78
+ const texture = await readCompanion(input, 'textures/base.png')
79
+ if (texture.status !== 'ok') console.warn(texture.status)
80
+ // …render; signal aborts when the viewer moves to the next file.
81
+ })
82
+
83
+ // Thumbnail: return a Blob, HTMLCanvasElement or OffscreenCanvas.
84
+ thumbnail(async (input, { maxEdge }) => {
85
+ const canvas = new OffscreenCanvas(maxEdge, maxEdge)
86
+ // …draw
87
+ return canvas
88
+ })
89
+
90
+ // Analyzer: return the file's composition report; see the next section.
91
+ analyzer(async (input) => {
92
+ const gltf = JSON.parse(new TextDecoder().decode(await readInput(input)))
93
+ // …
94
+ return { subresources: [], references: [], diagnostics: [] }
95
+ })
96
+ ```
97
+
98
+ - `input.url` is the main file's address. Companion files resolve by relative address, just as on disk: for `textures/a.png` in a glTF, `fetch(new URL('textures/a.png', input.url))` works, and loaders can use these addresses directly. The addresses support Range requests.
99
+ - Read results:
100
+
101
+ | Status | Meaning | HTTP |
102
+ | --------------- | ----------------------------------------------------------------------------------------------------------- | ----- |
103
+ | `ok` | Read succeeded | 200 |
104
+ | `out-of-bounds` | Invalid path | 400 |
105
+ | `unauthorized` | The extension is not in `companionFormats` | 403 |
106
+ | `over-limit` | More than 256 companion requests in one binding | 429 |
107
+ | `missing` | The file is not in the linked folder, or it was skipped by the scan, removed from the library or is missing | 404 |
108
+ | `changed` | The file changed on disk and no longer matches the library's record | 409 |
109
+ | `unavailable` | Any other reason, such as an offline linked folder or an expired binding | Other |
110
+
111
+ - Pages that do not use these helpers can call `window.vormio` directly; see the `PluginBridge` type in `@vormio/plugin-sdk/bridge`. A thumbnail handler returns the PNG as an `ArrayBuffer`, and an analyzer handler (`onAnalyzeRequest`) returns the composition report.
112
+ - While a plugin view has focus, unmodified Esc, ← and → are handled by the host (back to the grid, previous, next); the plugin does not receive these keys.
113
+
114
+ ## Analyzers
115
+
116
+ Once a plugin is enabled in a library, the host analyzes its declared formats in the background. Matching files join the queue when linking and scanning find new files or versions, when the plugin is enabled, and when the plugin's code changes. The file whose details the user is viewing goes first, and the user can also re-analyze manually. At most 2 analyses run at once, each with a 30-second timeout. A thrown error, a timeout or an invalid report counts as a failed analysis and shows the reason; the same plugin code is not retried on the same file version.
117
+
118
+ A report describes one version of the analyzed file:
119
+
120
+ ```ts
121
+ interface CompositionReport {
122
+ /** Locatable objects inside the file, at most 10,000. */
123
+ subresources: {
124
+ key: string // Unique within this file version, chosen by the plugin, e.g. "materials/0"
125
+ kind: string // Display category, e.g. "Material"; the host uses it only for grouping
126
+ name: string // Display name; may be an empty string
127
+ parent?: string // Key of the parent subresource, for display hierarchy
128
+ }[]
129
+ /** References, at most 50,000. */
130
+ references: {
131
+ from?: string // Key of the referencing subresource; omitted means the file itself
132
+ uri?: string // Points to another file: the raw address written in the file
133
+ key?: string // Points to a subresource in this file; give exactly one of uri and key
134
+ role: string // Display role, e.g. "Base color texture"
135
+ evidence?: string // Location in the file, e.g. "/images/2/uri"
136
+ }[]
137
+ /** Problems found during analysis, at most 500. */
138
+ diagnostics: { severity: 'error' | 'warning' | 'info'; message: string; key?: string }[]
139
+ /** Only part of the analysis completed. */
140
+ partial?: boolean
141
+ }
142
+ ```
143
+
144
+ - The host validates reports with `compositionReportSchema` (`parseReport` also checks the size); plugins can check themselves against the same schema before returning. The report serialized as JSON is at most 4 MiB (counted in UTF-8 bytes). Names and keys are at most 200 characters, kinds and roles 80, evidence 300, diagnostics 500 and addresses 2,048. Names, kinds, roles and evidence must not contain control characters.
145
+ - The report is invalid if it has duplicate keys, a `parent`, `from` or `key` that points to a missing subresource, a cyclic hierarchy, or a reference with both or neither of `uri` and `key`.
146
+ - Write `uri` exactly as it appears in the file; do not resolve it yourself. The host decodes percent-encoding once, ignores everything after `?` and `#`, treats `\` as `/`, resolves it relative to the analyzed file and classifies it: a file in the linked folder, out of bounds (`..` leaving the linked folder), embedded (`data:`), external (absolute paths, drive letters, other schemes) or invalid (undecodable, containing control characters, or pointing to a folder). Whether the target exists is decided by the host at query time.
147
+ - For embedded data (`data:`), report only the header up to the comma, e.g. `data:image/png;base64`. The host classifies it as embedded by prefix and does not store the content; a full `data:` address often exceeds 2,048 characters and would invalidate the whole report.
148
+ - Reporting a reference does not require reading the target file, so you do not need `companionFormats` just to report references. Declare them only when the analysis actually reads companion files.
149
+
150
+ ## Packaging and development
151
+
152
+ The package declares `"sideEffects": false`: when a plugin page uses only the runtime helpers, bundlers (esbuild, Vite, Rollup, webpack) drop the manifest schema and its zod dependency, leaving about 1 KB of runtime code for a minimal page. Node-side packaging and validation are imported from `@vormio/plugin-sdk/node`.
153
+
154
+ ```bash
155
+ npx vormio-plugin pack <build-output-folder> <output-file.vormioplugin>
156
+ ```
157
+
158
+ The command validates the manifest and every file, then writes a zip-format `.vormioplugin`. It never overwrites an existing output file.
159
+
160
+ During development, open the **插件** (Plugins) page in Vormio, choose **添加开发目录…** (Add development folder…) and point it at the build output folder. The host watches the folder: every change is validated again, and a valid build reloads the plugin. If validation fails, the last valid version stays loaded and the error is shown on the plugin card. Vormio never runs build commands.
@@ -0,0 +1,76 @@
1
+ /**
2
+ * What the host binds a plugin page to: one observed version of one file. `url` serves its bytes
3
+ * (with byte ranges); companion files resolve relative to it, as they do on disk.
4
+ */
5
+ export interface PluginInput {
6
+ url: string;
7
+ name: string;
8
+ extension: string;
9
+ size: number;
10
+ }
11
+ export type CompanionStatus = 'out-of-bounds' | 'unauthorized' | 'over-limit' | 'missing' | 'changed' | 'unavailable';
12
+ export type CompanionResult = {
13
+ status: 'ok';
14
+ url: string;
15
+ bytes: ArrayBuffer;
16
+ } | {
17
+ status: CompanionStatus;
18
+ url: string;
19
+ };
20
+ /** An object inside the analyzed file that can be located, such as a mesh or a material. */
21
+ export interface ReportSubresource {
22
+ /** Unique and stable within this version of the file; chosen by the plugin. */
23
+ key: string;
24
+ /** Display label used for grouping, such as "Material"; the host does not interpret it. */
25
+ kind: string;
26
+ name: string;
27
+ /** Key of the subresource this one belongs to, for display as a hierarchy. */
28
+ parent?: string;
29
+ }
30
+ /** A reference to another file (`uri`) or to a subresource of this file (`key`): exactly one. */
31
+ export interface ReportReference {
32
+ /** Key of the subresource that refers; omitted when the file itself refers. */
33
+ from?: string;
34
+ /** The address as written in the file, relative to the analyzed file. */
35
+ uri?: string;
36
+ /** A subresource of the analyzed file. */
37
+ key?: string;
38
+ /** Display label, such as "Base color texture". */
39
+ role: string;
40
+ /** Where in the file the reference is, such as "/images/2/uri". */
41
+ evidence?: string;
42
+ }
43
+ export interface ReportDiagnostic {
44
+ severity: 'error' | 'warning' | 'info';
45
+ message: string;
46
+ /** The subresource the diagnostic is about. */
47
+ key?: string;
48
+ }
49
+ /** What an analyzer found in one observed version of one file. */
50
+ export interface CompositionReport {
51
+ subresources: ReportSubresource[];
52
+ references: ReportReference[];
53
+ diagnostics: ReportDiagnostic[];
54
+ /** The analyzer could only finish part of the file. */
55
+ partial?: boolean;
56
+ }
57
+ /** The frozen object the host's plugin preload exposes as `window.vormio`. */
58
+ export interface PluginBridge {
59
+ /** The viewer's current input. */
60
+ getInput(): Promise<PluginInput>;
61
+ /** Called when the viewer moves to another file; returns the function that unsubscribes. */
62
+ onInput(listener: (input: PluginInput) => void): () => void;
63
+ /** The viewer shows `input`; the host removes its loading state. */
64
+ ready(input: PluginInput): void;
65
+ /** The viewer cannot show `input`; the host shows `message` as plain text. */
66
+ fail(input: PluginInput, message: string): void;
67
+ /**
68
+ * Thumbnail pages: handles each request from the host and returns PNG bytes no larger than
69
+ * `maxEdge` on either side. (Blobs cannot cross the bridge; `thumbnail()` converts them.)
70
+ */
71
+ onThumbnailRequest(handler: (input: PluginInput, size: {
72
+ maxEdge: number;
73
+ }) => Promise<ArrayBuffer>): void;
74
+ /** Analyzer pages: handles each request from the host and returns the composition report. */
75
+ onAnalyzeRequest(handler: (input: PluginInput) => Promise<CompositionReport>): void;
76
+ }
package/dist/bridge.js ADDED
@@ -0,0 +1,2 @@
1
+ // Types shared by plugin pages and the host's plugin preload. No DOM types, so Node code can use them.
2
+ export {};
@@ -0,0 +1,5 @@
1
+ export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, type Contribution, type ContributionKind, type ManifestResult, type PluginManifest, } from './manifest.ts';
2
+ export { checkPackageFiles, checkPackagePath, packageExtensions, packageLimits, type PackageFile, } from './package-rules.ts';
3
+ export { compositionReportSchema, parseReport, reportLimits, type ReportResult } from './report.ts';
4
+ export type { ReportDiagnostic, ReportReference, ReportSubresource } from './bridge.ts';
5
+ export { analyzer, bridge, readCompanion, readInput, thumbnail, viewer, type CompanionResult, type CompanionStatus, type CompositionReport, type PluginBridge, type PluginInput, } from './runtime.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, } from "./manifest.js";
2
+ export { checkPackageFiles, checkPackagePath, packageExtensions, packageLimits, } from "./package-rules.js";
3
+ export { compositionReportSchema, parseReport, reportLimits } from "./report.js";
4
+ export { analyzer, bridge, readCompanion, readInput, thumbnail, viewer, } from "./runtime.js";
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod';
2
+ /** The plugin contract version this SDK describes. Hosts load only plugins with this version. */
3
+ export declare const pluginApiVersion = 1;
4
+ declare const contributionSchema: z.ZodObject<{
5
+ id: z.ZodString;
6
+ entry: z.ZodString;
7
+ formats: z.ZodArray<z.ZodString>;
8
+ companionFormats: z.ZodDefault<z.ZodArray<z.ZodString>>;
9
+ }, z.core.$strict>;
10
+ export type Contribution = z.output<typeof contributionSchema>;
11
+ export declare const manifestSchema: z.ZodObject<{
12
+ apiVersion: z.ZodLiteral<1>;
13
+ id: z.ZodString;
14
+ version: z.ZodString;
15
+ name: z.ZodString;
16
+ description: z.ZodDefault<z.ZodString>;
17
+ contributions: z.ZodObject<{
18
+ thumbnails: z.ZodDefault<z.ZodArray<z.ZodObject<{
19
+ id: z.ZodString;
20
+ entry: z.ZodString;
21
+ formats: z.ZodArray<z.ZodString>;
22
+ companionFormats: z.ZodDefault<z.ZodArray<z.ZodString>>;
23
+ }, z.core.$strict>>>;
24
+ viewers: z.ZodDefault<z.ZodArray<z.ZodObject<{
25
+ id: z.ZodString;
26
+ entry: z.ZodString;
27
+ formats: z.ZodArray<z.ZodString>;
28
+ companionFormats: z.ZodDefault<z.ZodArray<z.ZodString>>;
29
+ }, z.core.$strict>>>;
30
+ analyzers: z.ZodDefault<z.ZodArray<z.ZodObject<{
31
+ id: z.ZodString;
32
+ entry: z.ZodString;
33
+ formats: z.ZodArray<z.ZodString>;
34
+ companionFormats: z.ZodDefault<z.ZodArray<z.ZodString>>;
35
+ }, z.core.$strict>>>;
36
+ }, z.core.$strict>;
37
+ }, z.core.$strict>;
38
+ export type PluginManifest = z.output<typeof manifestSchema>;
39
+ export type ContributionKind = 'thumbnail' | 'viewer' | 'analyzer';
40
+ /** Every contribution of a manifest with its kind. */
41
+ export declare function contributionsOf(manifest: PluginManifest): {
42
+ kind: ContributionKind;
43
+ contribution: Contribution;
44
+ }[];
45
+ export type ManifestResult = {
46
+ ok: true;
47
+ manifest: PluginManifest;
48
+ } | {
49
+ ok: false;
50
+ problems: string[];
51
+ };
52
+ /** Validates parsed `manifest.json` content and reports every problem with its field path. */
53
+ export declare function parseManifest(value: unknown): ManifestResult;
54
+ /** Entry files the manifest names but the package does not contain. */
55
+ export declare function missingEntries(manifest: PluginManifest, paths: ReadonlySet<string>): string[];
56
+ /**
57
+ * What a plugin may do, in a canonical form: every contribution's kind, ID, formats and companion
58
+ * formats, sorted. Hosts hash it into the permission fingerprint that a library's grant records;
59
+ * names, descriptions, versions and code are not part of it.
60
+ */
61
+ export declare function permissionSummary(manifest: PluginManifest): string;
62
+ export {};
@@ -0,0 +1,100 @@
1
+ import { z } from 'zod';
2
+ import { checkPackagePath } from "./package-rules.js";
3
+ /** The plugin contract version this SDK describes. Hosts load only plugins with this version. */
4
+ export const pluginApiVersion = 1;
5
+ const unique = (values) => new Set(values).size === values.length;
6
+ const extension = z.string().regex(/^[a-z0-9]{1,16}$/, 'Use a lower-case extension without a dot');
7
+ const extensions = (min) => z.array(extension).min(min).max(64).refine(unique, 'Extensions must not repeat');
8
+ const contributionSchema = z.strictObject({
9
+ /** Unique within its kind; lower case, digits and hyphens. */
10
+ id: z.string().regex(/^[a-z][a-z0-9-]{0,31}$/),
11
+ /** An HTML file in the package that the host loads in a sandboxed page. */
12
+ entry: z.string().refine((path) => checkPackagePath(path) === null && path.endsWith('.html'), {
13
+ message: 'The entry must be an .html file inside the package',
14
+ }),
15
+ /** Extensions of the main files this contribution handles. */
16
+ formats: extensions(1),
17
+ /** Extensions of other files it may read next to the main file. */
18
+ companionFormats: extensions(0).default([]),
19
+ });
20
+ const contributionList = z
21
+ .array(contributionSchema)
22
+ .max(8)
23
+ .default([])
24
+ .refine((items) => unique(items.map((item) => item.id)), 'Contribution IDs must not repeat');
25
+ export const manifestSchema = z.strictObject({
26
+ apiVersion: z.literal(pluginApiVersion),
27
+ /** `publisher.name`. The `vormio` publisher is reserved for the host. */
28
+ id: z
29
+ .string()
30
+ .regex(/^[a-z0-9][a-z0-9-]{0,39}\.[a-z0-9][a-z0-9-]{0,39}$/, 'Use publisher.name')
31
+ .refine((id) => !id.startsWith('vormio.'), 'The vormio publisher is reserved'),
32
+ version: z
33
+ .string()
34
+ .regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/, 'Use a semantic version'),
35
+ name: z.string().trim().min(1).max(80),
36
+ description: z.string().max(400).default(''),
37
+ contributions: z
38
+ .strictObject({
39
+ thumbnails: contributionList,
40
+ viewers: contributionList,
41
+ analyzers: contributionList,
42
+ })
43
+ .refine((contributions) => contributions.thumbnails.length +
44
+ contributions.viewers.length +
45
+ contributions.analyzers.length >
46
+ 0, 'Declare at least one contribution'),
47
+ });
48
+ /** Every contribution of a manifest with its kind. */
49
+ export function contributionsOf(manifest) {
50
+ return [
51
+ ...manifest.contributions.thumbnails.map((contribution) => ({
52
+ kind: 'thumbnail',
53
+ contribution,
54
+ })),
55
+ ...manifest.contributions.viewers.map((contribution) => ({
56
+ kind: 'viewer',
57
+ contribution,
58
+ })),
59
+ ...manifest.contributions.analyzers.map((contribution) => ({
60
+ kind: 'analyzer',
61
+ contribution,
62
+ })),
63
+ ];
64
+ }
65
+ /** Validates parsed `manifest.json` content and reports every problem with its field path. */
66
+ export function parseManifest(value) {
67
+ const result = manifestSchema.safeParse(value);
68
+ if (result.success)
69
+ return { ok: true, manifest: result.data };
70
+ return {
71
+ ok: false,
72
+ problems: result.error.issues.map((issue) => `${issue.path.length ? issue.path.join('.') : 'manifest'}: ${issue.message}`),
73
+ };
74
+ }
75
+ /** Entry files the manifest names but the package does not contain. */
76
+ export function missingEntries(manifest, paths) {
77
+ return contributionsOf(manifest)
78
+ .map(({ contribution }) => contribution.entry)
79
+ .filter((entry) => !paths.has(entry));
80
+ }
81
+ /**
82
+ * What a plugin may do, in a canonical form: every contribution's kind, ID, formats and companion
83
+ * formats, sorted. Hosts hash it into the permission fingerprint that a library's grant records;
84
+ * names, descriptions, versions and code are not part of it.
85
+ */
86
+ export function permissionSummary(manifest) {
87
+ const entries = contributionsOf(manifest)
88
+ .map(({ kind, contribution }) => ({
89
+ kind,
90
+ id: contribution.id,
91
+ formats: [...contribution.formats].sort(),
92
+ companionFormats: [...contribution.companionFormats].sort(),
93
+ }))
94
+ .sort((a, b) => `${a.kind}:${a.id}`.localeCompare(`${b.kind}:${b.id}`));
95
+ return JSON.stringify({
96
+ apiVersion: manifest.apiVersion,
97
+ id: manifest.id,
98
+ contributions: entries,
99
+ });
100
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { checkPackage, PackageError, readPackageDirectory } from "./package.js";
6
+ import { writeZip, ZipError } from "./zip.js";
7
+ const usage = 'Usage: vormio-plugin pack <plugin directory> <output.vormioplugin>';
8
+ /** `vormio-plugin pack`: validates a built plugin directory and writes a `.vormioplugin` file. */
9
+ async function main(args) {
10
+ const [command, source, output] = args;
11
+ if (command !== 'pack' || !source || !output) {
12
+ console.error(usage);
13
+ return 2;
14
+ }
15
+ if (!output.endsWith('.vormioplugin')) {
16
+ console.error('The output file must end with .vormioplugin');
17
+ return 2;
18
+ }
19
+ const target = resolve(output);
20
+ if (existsSync(target)) {
21
+ console.error(`${target} already exists; choose another name or remove it first`);
22
+ return 1;
23
+ }
24
+ try {
25
+ const contents = await readPackageDirectory(resolve(source));
26
+ const checked = checkPackage(contents);
27
+ const entries = checked.files.map((file) => ({
28
+ path: file.path,
29
+ data: contents.get(file.path),
30
+ }));
31
+ await writeFile(target, writeZip(entries), { flag: 'wx' });
32
+ console.log(`${checked.manifest.id} ${checked.manifest.version}: ${entries.length} files → ${target}`);
33
+ console.log(`digest ${checked.digest}`);
34
+ return 0;
35
+ }
36
+ catch (error) {
37
+ if (error instanceof PackageError || error instanceof ZipError) {
38
+ console.error(`The plugin is not valid:\n${error.message}`);
39
+ return 1;
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+ process.exitCode = await main(process.argv.slice(2));
@@ -0,0 +1,6 @@
1
+ export { checkPackage, PackageError, readPackageArchive, readPackageDirectory, type CheckedPackage, } from './package.ts';
2
+ export { readZip, writeZip, ZipError, type ZipEntry } from './zip.ts';
3
+ export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, type Contribution, type ContributionKind, type PluginManifest, } from '../manifest.ts';
4
+ export { checkPackageFiles, checkPackagePath, packageLimits } from '../package-rules.ts';
5
+ export { compositionReportSchema, parseReport, reportLimits } from '../report.ts';
6
+ export type { CompositionReport } from '../bridge.ts';
@@ -0,0 +1,6 @@
1
+ export { checkPackage, PackageError, readPackageArchive, readPackageDirectory, } from "./package.js";
2
+ export { readZip, writeZip, ZipError } from "./zip.js";
3
+ // The manifest and package rules again, so Node code needs no DOM types from the runtime entry.
4
+ export { contributionsOf, manifestSchema, missingEntries, parseManifest, permissionSummary, pluginApiVersion, } from "../manifest.js";
5
+ export { checkPackageFiles, checkPackagePath, packageLimits } from "../package-rules.js";
6
+ export { compositionReportSchema, parseReport, reportLimits } from "../report.js";
@@ -0,0 +1,25 @@
1
+ import { type PluginManifest } from '../manifest.ts';
2
+ import { type PackageFile } from '../package-rules.ts';
3
+ export declare class PackageError extends Error {
4
+ readonly problems: readonly string[];
5
+ constructor(problems: readonly string[]);
6
+ }
7
+ /** A validated plugin: its manifest, file list and content digest. */
8
+ export interface CheckedPackage {
9
+ manifest: PluginManifest;
10
+ files: PackageFile[];
11
+ /** SHA-256 over every file's path and content, in path order. */
12
+ digest: string;
13
+ }
14
+ /** Validates file contents given as path → bytes (from a zip or a directory). */
15
+ export declare function checkPackage(contents: ReadonlyMap<string, Buffer>): CheckedPackage;
16
+ /**
17
+ * Reads a plugin directory. Links and junctions are refused rather than followed, and reading stops
18
+ * as soon as the package limits are exceeded.
19
+ */
20
+ export declare function readPackageDirectory(directory: string): Promise<Map<string, Buffer>>;
21
+ /** Reads and validates a `.vormioplugin` file. */
22
+ export declare function readPackageArchive(zip: Buffer): {
23
+ checked: CheckedPackage;
24
+ contents: Map<string, Buffer>;
25
+ };
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readdir, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { missingEntries, parseManifest } from "../manifest.js";
5
+ import { checkPackageFiles, packageLimits } from "../package-rules.js";
6
+ import { readZip } from "./zip.js";
7
+ export class PackageError extends Error {
8
+ problems;
9
+ constructor(problems) {
10
+ super(problems.join('\n'));
11
+ this.problems = problems;
12
+ this.name = 'PackageError';
13
+ }
14
+ }
15
+ /** Validates file contents given as path → bytes (from a zip or a directory). */
16
+ export function checkPackage(contents) {
17
+ const files = [...contents].map(([path, data]) => ({ path, size: data.length }));
18
+ const problems = checkPackageFiles(files);
19
+ const manifestBytes = contents.get('manifest.json');
20
+ if (problems.length || !manifestBytes)
21
+ throw new PackageError(problems);
22
+ let json;
23
+ try {
24
+ json = JSON.parse(manifestBytes.toString('utf8'));
25
+ }
26
+ catch {
27
+ throw new PackageError(['manifest.json: not valid JSON']);
28
+ }
29
+ const parsed = parseManifest(json);
30
+ if (!parsed.ok)
31
+ throw new PackageError(parsed.problems);
32
+ const missing = missingEntries(parsed.manifest, new Set(contents.keys()));
33
+ if (missing.length)
34
+ throw new PackageError(missing.map((entry) => `${entry}: entry not found`));
35
+ const digest = createHash('sha256');
36
+ for (const path of [...contents.keys()].sort()) {
37
+ digest.update(path).update('\0');
38
+ digest.update(createHash('sha256').update(contents.get(path)).digest()).update('\0');
39
+ }
40
+ return {
41
+ manifest: parsed.manifest,
42
+ files: files.sort((a, b) => (a.path < b.path ? -1 : 1)),
43
+ digest: digest.digest('hex'),
44
+ };
45
+ }
46
+ /**
47
+ * Reads a plugin directory. Links and junctions are refused rather than followed, and reading stops
48
+ * as soon as the package limits are exceeded.
49
+ */
50
+ export async function readPackageDirectory(directory) {
51
+ const contents = new Map();
52
+ let total = 0;
53
+ const visit = async (relative) => {
54
+ for (const name of (await readdir(join(directory, relative))).sort()) {
55
+ const path = relative ? `${relative}/${name}` : name;
56
+ const info = await lstat(join(directory, path));
57
+ if (info.isSymbolicLink())
58
+ throw new PackageError([`${path}: links are not allowed`]);
59
+ if (info.isDirectory()) {
60
+ await visit(path);
61
+ continue;
62
+ }
63
+ if (!info.isFile())
64
+ throw new PackageError([`${path}: not a regular file`]);
65
+ if (contents.size >= packageLimits.maxFiles)
66
+ throw new PackageError([`more than ${packageLimits.maxFiles} files`]);
67
+ if (info.size > packageLimits.maxFileBytes)
68
+ throw new PackageError([`${path}: file too large`]);
69
+ total += info.size;
70
+ if (total > packageLimits.maxTotalBytes)
71
+ throw new PackageError(['package too large']);
72
+ contents.set(path, await readFile(join(directory, path)));
73
+ }
74
+ };
75
+ await visit('');
76
+ return contents;
77
+ }
78
+ /** Reads and validates a `.vormioplugin` file. */
79
+ export function readPackageArchive(zip) {
80
+ const contents = new Map(readZip(zip).map((entry) => [entry.path, entry.data]));
81
+ return { checked: checkPackage(contents), contents };
82
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * A minimal zip reader and writer for `.vormioplugin` files: stored and deflated entries only, no
3
+ * encryption, no zip64, no spanning. Anything else is rejected rather than guessed at.
4
+ */
5
+ export interface ZipEntry {
6
+ path: string;
7
+ data: Buffer;
8
+ }
9
+ export declare class ZipError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ export declare function writeZip(entries: readonly ZipEntry[]): Buffer;
13
+ /** Reads every file entry, checking sizes against the package limits and each CRC. */
14
+ export declare function readZip(zip: Buffer): ZipEntry[];
@@ -0,0 +1,132 @@
1
+ import { crc32, deflateRawSync, inflateRawSync } from 'node:zlib';
2
+ import { packageLimits } from "../package-rules.js";
3
+ export class ZipError extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = 'ZipError';
7
+ }
8
+ }
9
+ const signatures = { local: 0x04034b50, central: 0x02014b50, end: 0x06054b50 };
10
+ const utf8Flag = 0x0800;
11
+ const symlinkMode = 0o120000;
12
+ export function writeZip(entries) {
13
+ const locals = [];
14
+ const centrals = [];
15
+ let offset = 0;
16
+ for (const entry of entries) {
17
+ const name = Buffer.from(entry.path, 'utf8');
18
+ const compressed = deflateRawSync(entry.data, { level: 9 });
19
+ const stored = compressed.length >= entry.data.length;
20
+ const body = stored ? entry.data : compressed;
21
+ const checksum = crc32(entry.data);
22
+ const local = Buffer.alloc(30);
23
+ local.writeUInt32LE(signatures.local, 0);
24
+ local.writeUInt16LE(20, 4);
25
+ local.writeUInt16LE(utf8Flag, 6);
26
+ local.writeUInt16LE(stored ? 0 : 8, 8);
27
+ local.writeUInt32LE(checksum, 14);
28
+ local.writeUInt32LE(body.length, 18);
29
+ local.writeUInt32LE(entry.data.length, 22);
30
+ local.writeUInt16LE(name.length, 26);
31
+ locals.push(local, name, body);
32
+ const central = Buffer.alloc(46);
33
+ central.writeUInt32LE(signatures.central, 0);
34
+ central.writeUInt16LE(20, 4);
35
+ central.writeUInt16LE(20, 6);
36
+ central.writeUInt16LE(utf8Flag, 8);
37
+ central.writeUInt16LE(stored ? 0 : 8, 10);
38
+ central.writeUInt32LE(checksum, 16);
39
+ central.writeUInt32LE(body.length, 20);
40
+ central.writeUInt32LE(entry.data.length, 24);
41
+ central.writeUInt16LE(name.length, 28);
42
+ central.writeUInt32LE(offset, 42);
43
+ centrals.push(central, name);
44
+ offset += local.length + name.length + body.length;
45
+ }
46
+ const directory = Buffer.concat(centrals);
47
+ const end = Buffer.alloc(22);
48
+ end.writeUInt32LE(signatures.end, 0);
49
+ end.writeUInt16LE(entries.length, 8);
50
+ end.writeUInt16LE(entries.length, 10);
51
+ end.writeUInt32LE(directory.length, 12);
52
+ end.writeUInt32LE(offset, 16);
53
+ return Buffer.concat([...locals, directory, end]);
54
+ }
55
+ /** Reads every file entry, checking sizes against the package limits and each CRC. */
56
+ export function readZip(zip) {
57
+ const endOffset = findEnd(zip);
58
+ const count = zip.readUInt16LE(endOffset + 10);
59
+ const directorySize = zip.readUInt32LE(endOffset + 12);
60
+ const directoryOffset = zip.readUInt32LE(endOffset + 16);
61
+ if (zip.readUInt16LE(endOffset + 4) !== 0 || zip.readUInt16LE(endOffset + 6) !== 0)
62
+ throw new ZipError('Multi-part archives are not supported');
63
+ if (directoryOffset + directorySize > endOffset)
64
+ throw new ZipError('Damaged central directory');
65
+ if (count > packageLimits.maxFiles * 2)
66
+ throw new ZipError('Too many entries');
67
+ const entries = [];
68
+ let total = 0;
69
+ let position = directoryOffset;
70
+ for (let index = 0; index < count; index++) {
71
+ if (position + 46 > endOffset || zip.readUInt32LE(position) !== signatures.central)
72
+ throw new ZipError('Damaged central directory');
73
+ const flags = zip.readUInt16LE(position + 8);
74
+ const method = zip.readUInt16LE(position + 10);
75
+ const checksum = zip.readUInt32LE(position + 16);
76
+ const compressedSize = zip.readUInt32LE(position + 20);
77
+ const size = zip.readUInt32LE(position + 24);
78
+ const nameLength = zip.readUInt16LE(position + 28);
79
+ const extraLength = zip.readUInt16LE(position + 30);
80
+ const commentLength = zip.readUInt16LE(position + 32);
81
+ const madeBy = zip.readUInt16LE(position + 4) >> 8;
82
+ const attributes = zip.readUInt32LE(position + 38);
83
+ const localOffset = zip.readUInt32LE(position + 42);
84
+ const nameBytes = zip.subarray(position + 46, position + 46 + nameLength);
85
+ const path = flags & utf8Flag ? nameBytes.toString('utf8') : nameBytes.toString('latin1');
86
+ position += 46 + nameLength + extraLength + commentLength;
87
+ if (flags & 0x1)
88
+ throw new ZipError(`${path}: encrypted entries are not supported`);
89
+ if (size === 0xffffffff || compressedSize === 0xffffffff || localOffset === 0xffffffff)
90
+ throw new ZipError(`${path}: zip64 is not supported`);
91
+ // Unix-made archives keep the file mode in the upper attribute bits.
92
+ if (madeBy === 3 && ((attributes >>> 16) & 0o170000) === symlinkMode)
93
+ throw new ZipError(`${path}: symbolic links are not allowed`);
94
+ if (path.endsWith('/'))
95
+ continue;
96
+ if (size > packageLimits.maxFileBytes)
97
+ throw new ZipError(`${path}: file too large`);
98
+ total += size;
99
+ if (total > packageLimits.maxTotalBytes)
100
+ throw new ZipError('Package too large');
101
+ if (method !== 0 && method !== 8)
102
+ throw new ZipError(`${path}: unsupported compression`);
103
+ if (localOffset + 30 > zip.length || zip.readUInt32LE(localOffset) !== signatures.local)
104
+ throw new ZipError(`${path}: damaged entry`);
105
+ // A local name that differs from the central one would let tools disagree on the file name.
106
+ const localNameLength = zip.readUInt16LE(localOffset + 26);
107
+ if (!zip.subarray(localOffset + 30, localOffset + 30 + localNameLength).equals(nameBytes))
108
+ throw new ZipError(`${path}: inconsistent entry name`);
109
+ const dataStart = localOffset + 30 + localNameLength + zip.readUInt16LE(localOffset + 28);
110
+ if (dataStart + compressedSize > directoryOffset)
111
+ throw new ZipError(`${path}: damaged entry`);
112
+ const body = zip.subarray(dataStart, dataStart + compressedSize);
113
+ let data;
114
+ try {
115
+ data = method === 0 ? Buffer.from(body) : inflateRawSync(body, { maxOutputLength: size });
116
+ }
117
+ catch {
118
+ throw new ZipError(`${path}: damaged data`);
119
+ }
120
+ if (data.length !== size || crc32(data) !== checksum)
121
+ throw new ZipError(`${path}: damaged data`);
122
+ entries.push({ path, data });
123
+ }
124
+ return entries;
125
+ }
126
+ function findEnd(zip) {
127
+ const earliest = Math.max(0, zip.length - 22 - 0xffff);
128
+ for (let offset = zip.length - 22; offset >= earliest; offset--)
129
+ if (zip.readUInt32LE(offset) === signatures.end)
130
+ return offset;
131
+ throw new ZipError('Not a zip file');
132
+ }
@@ -0,0 +1,20 @@
1
+ /** Limits for a plugin package or development directory. */
2
+ export declare const packageLimits: {
3
+ readonly maxFiles: 1024;
4
+ readonly maxFileBytes: number;
5
+ readonly maxTotalBytes: number;
6
+ readonly maxPathLength: 240;
7
+ };
8
+ /** Static file types a package may contain. Executables and archives are never accepted. */
9
+ export declare const packageExtensions: ReadonlySet<string>;
10
+ /**
11
+ * Checks a path inside a package: forward slashes, NFC, no empty, `.` or `..` segments, no
12
+ * characters or names Windows cannot store, and an allowed extension. Returns the problem, or null.
13
+ */
14
+ export declare function checkPackagePath(path: string): string | null;
15
+ export interface PackageFile {
16
+ path: string;
17
+ size: number;
18
+ }
19
+ /** Checks a package's file list as a whole; returns every problem found. */
20
+ export declare function checkPackageFiles(files: readonly PackageFile[]): string[];
@@ -0,0 +1,78 @@
1
+ /** Limits for a plugin package or development directory. */
2
+ export const packageLimits = {
3
+ maxFiles: 1024,
4
+ maxFileBytes: 16 * 1024 * 1024,
5
+ maxTotalBytes: 64 * 1024 * 1024,
6
+ maxPathLength: 240,
7
+ };
8
+ /** Static file types a package may contain. Executables and archives are never accepted. */
9
+ export const packageExtensions = new Set([
10
+ 'html',
11
+ 'js',
12
+ 'mjs',
13
+ 'css',
14
+ 'json',
15
+ 'wasm',
16
+ 'png',
17
+ 'jpg',
18
+ 'jpeg',
19
+ 'webp',
20
+ 'svg',
21
+ 'woff2',
22
+ 'txt',
23
+ 'md',
24
+ ]);
25
+ const reservedNames = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
26
+ // eslint-disable-next-line no-control-regex
27
+ const forbiddenCharacters = /[\u0000-\u001f\u007f\\:*?"<>|]/;
28
+ /**
29
+ * Checks a path inside a package: forward slashes, NFC, no empty, `.` or `..` segments, no
30
+ * characters or names Windows cannot store, and an allowed extension. Returns the problem, or null.
31
+ */
32
+ export function checkPackagePath(path) {
33
+ if (path.length === 0 || path.length > packageLimits.maxPathLength)
34
+ return 'invalid length';
35
+ if (path !== path.normalize('NFC'))
36
+ return 'not NFC';
37
+ if (forbiddenCharacters.test(path))
38
+ return 'forbidden character';
39
+ for (const segment of path.split('/')) {
40
+ if (segment === '' || segment === '.' || segment === '..')
41
+ return 'invalid segment';
42
+ if (/[. ]$/.test(segment))
43
+ return 'segment ends with a dot or space';
44
+ if (reservedNames.test(segment.split('.')[0]))
45
+ return 'reserved name';
46
+ }
47
+ const name = path.slice(path.lastIndexOf('/') + 1);
48
+ const dot = name.lastIndexOf('.');
49
+ const extension = dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
50
+ if (!packageExtensions.has(extension))
51
+ return `file type .${extension || '(none)'} not allowed`;
52
+ return null;
53
+ }
54
+ /** Checks a package's file list as a whole; returns every problem found. */
55
+ export function checkPackageFiles(files) {
56
+ const problems = [];
57
+ if (files.length > packageLimits.maxFiles)
58
+ problems.push(`more than ${packageLimits.maxFiles} files`);
59
+ let total = 0;
60
+ const seen = new Set();
61
+ for (const file of files) {
62
+ const problem = checkPackagePath(file.path);
63
+ if (problem)
64
+ problems.push(`${file.path}: ${problem}`);
65
+ if (file.size > packageLimits.maxFileBytes)
66
+ problems.push(`${file.path}: file too large`);
67
+ total += file.size;
68
+ const folded = file.path.toLowerCase();
69
+ if (seen.has(folded))
70
+ problems.push(`${file.path}: duplicate name ignoring case`);
71
+ seen.add(folded);
72
+ }
73
+ if (total > packageLimits.maxTotalBytes)
74
+ problems.push('package too large');
75
+ if (!files.some((file) => file.path === 'manifest.json'))
76
+ problems.push('manifest.json missing');
77
+ return problems;
78
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ import type { CompositionReport } from './bridge.ts';
3
+ /** Limits of a composition report; larger reports are rejected as a failed analysis. */
4
+ export declare const reportLimits: {
5
+ readonly subresources: 10000;
6
+ readonly references: 50000;
7
+ readonly diagnostics: 500;
8
+ /** Serialized size: UTF-8 bytes of the report as JSON. */
9
+ readonly bytes: number;
10
+ };
11
+ export declare const compositionReportSchema: z.ZodType<CompositionReport>;
12
+ export type ReportResult = {
13
+ ok: true;
14
+ report: CompositionReport;
15
+ } | {
16
+ ok: false;
17
+ problems: string[];
18
+ };
19
+ /** Validates a report as received from an analyzer, including its serialized size. */
20
+ export declare function parseReport(value: unknown): ReportResult;
package/dist/report.js ADDED
@@ -0,0 +1,113 @@
1
+ import { z } from 'zod';
2
+ /** Limits of a composition report; larger reports are rejected as a failed analysis. */
3
+ export const reportLimits = {
4
+ subresources: 10_000,
5
+ references: 50_000,
6
+ diagnostics: 500,
7
+ /** Serialized size: UTF-8 bytes of the report as JSON. */
8
+ bytes: 4 * 1024 * 1024,
9
+ };
10
+ const controlCharacters = /[\u0000-\u001f\u007f]/; // eslint-disable-line no-control-regex
11
+ /** Display text: bounded and free of control characters. */
12
+ const plain = (max) => z
13
+ .string()
14
+ .max(max)
15
+ .refine((text) => !controlCharacters.test(text), 'Control characters are not allowed');
16
+ const key = z.string().min(1).max(200);
17
+ const subresourceSchema = z.strictObject({
18
+ key,
19
+ kind: plain(80).min(1),
20
+ name: plain(200),
21
+ parent: key.optional(),
22
+ });
23
+ const referenceSchema = z
24
+ .strictObject({
25
+ from: key.optional(),
26
+ uri: z.string().min(1).max(2048).optional(),
27
+ key: key.optional(),
28
+ role: plain(80).min(1),
29
+ evidence: plain(300).optional(),
30
+ })
31
+ .refine((reference) => (reference.uri === undefined) !== (reference.key === undefined), {
32
+ message: 'Give exactly one of uri and key',
33
+ });
34
+ const diagnosticSchema = z.strictObject({
35
+ severity: z.enum(['error', 'warning', 'info']),
36
+ message: z.string().min(1).max(500),
37
+ key: key.optional(),
38
+ });
39
+ /**
40
+ * Checks a report as a whole: unique keys, references and parents that name existing keys, and no
41
+ * cycles in the hierarchy.
42
+ */
43
+ function consistent(report, context) {
44
+ const keys = new Set();
45
+ for (const subresource of report.subresources) {
46
+ if (keys.has(subresource.key))
47
+ context.addIssue({ code: 'custom', message: `Duplicate key ${subresource.key}` });
48
+ keys.add(subresource.key);
49
+ }
50
+ const known = (value, where) => {
51
+ if (value !== undefined && !keys.has(value))
52
+ context.addIssue({ code: 'custom', message: `${where} names unknown key ${value}` });
53
+ };
54
+ for (const subresource of report.subresources)
55
+ known(subresource.parent, 'A parent');
56
+ for (const reference of report.references) {
57
+ known(reference.from, 'A reference');
58
+ known(reference.key, 'A reference target');
59
+ }
60
+ for (const diagnostic of report.diagnostics)
61
+ known(diagnostic.key, 'A diagnostic');
62
+ const parents = new Map(report.subresources.map((each) => [each.key, each.parent]));
63
+ // Keys whose chain of parents is known to end at a root, so each key is walked only once.
64
+ const settled = new Set();
65
+ for (const start of parents.keys()) {
66
+ const path = new Set();
67
+ let current = start;
68
+ while (current !== undefined && !settled.has(current)) {
69
+ if (path.has(current)) {
70
+ context.addIssue({ code: 'custom', message: `The hierarchy has a cycle at ${current}` });
71
+ return;
72
+ }
73
+ path.add(current);
74
+ current = parents.get(current);
75
+ }
76
+ for (const key of path)
77
+ settled.add(key);
78
+ }
79
+ }
80
+ export const compositionReportSchema = z
81
+ .strictObject({
82
+ subresources: z.array(subresourceSchema).max(reportLimits.subresources),
83
+ references: z.array(referenceSchema).max(reportLimits.references),
84
+ diagnostics: z.array(diagnosticSchema).max(reportLimits.diagnostics),
85
+ partial: z.boolean().optional(),
86
+ })
87
+ .superRefine(consistent);
88
+ /** Validates a report as received from an analyzer, including its serialized size. */
89
+ export function parseReport(value) {
90
+ let json;
91
+ try {
92
+ json = JSON.stringify(value);
93
+ }
94
+ catch {
95
+ return { ok: false, problems: ['The report cannot be serialized'] };
96
+ }
97
+ // Measured in UTF-8 bytes. UTF-8 never takes fewer bytes than there are UTF-16 code units, so a
98
+ // string already too long is refused without encoding it.
99
+ const tooLarge = json !== undefined &&
100
+ (json.length > reportLimits.bytes ||
101
+ new TextEncoder().encode(json).byteLength > reportLimits.bytes);
102
+ if (tooLarge)
103
+ return { ok: false, problems: ['The report is larger than 4 MiB'] };
104
+ const result = compositionReportSchema.safeParse(value);
105
+ if (result.success)
106
+ return { ok: true, report: result.data };
107
+ return {
108
+ ok: false,
109
+ problems: result.error.issues
110
+ .slice(0, 20)
111
+ .map((issue) => `${issue.path.length ? issue.path.join('.') : 'report'}: ${issue.message}`),
112
+ };
113
+ }
@@ -0,0 +1,26 @@
1
+ import type { CompanionResult, CompanionStatus, CompositionReport, PluginBridge, PluginInput } from './bridge.ts';
2
+ export type { CompanionResult, CompanionStatus, CompositionReport, PluginBridge, PluginInput };
3
+ /** The host bridge; throws outside a Vormio plugin page. */
4
+ export declare function bridge(): PluginBridge;
5
+ /**
6
+ * Reads a file next to the input, relative to the input's folder (`../` may move up within the
7
+ * linked folder). The host allows only extensions the contribution declares.
8
+ */
9
+ export declare function readCompanion(input: PluginInput, relativePath: string): Promise<CompanionResult>;
10
+ /** Reads the input's own bytes. */
11
+ export declare function readInput(input: PluginInput): Promise<ArrayBuffer>;
12
+ /**
13
+ * Runs a viewer: `show` is called for the first input and again whenever the host moves to another
14
+ * file, with the previous call's signal aborted. Resolving reports ready; throwing reports the
15
+ * error message.
16
+ */
17
+ export declare function viewer(show: (input: PluginInput, signal: AbortSignal) => Promise<void>): void;
18
+ /** Runs a thumbnail provider; a canvas is converted to PNG. */
19
+ export declare function thumbnail(render: (input: PluginInput, size: {
20
+ maxEdge: number;
21
+ }) => Promise<Blob | HTMLCanvasElement | OffscreenCanvas>): void;
22
+ /**
23
+ * Runs an analyzer: `analyze` is called once per request and returns the composition report of
24
+ * the input. Throwing reports the analysis as failed with the error message.
25
+ */
26
+ export declare function analyzer(analyze: (input: PluginInput) => Promise<CompositionReport>): void;
@@ -0,0 +1,84 @@
1
+ /** The host bridge; throws outside a Vormio plugin page. */
2
+ export function bridge() {
3
+ const host = globalThis.vormio;
4
+ if (!host)
5
+ throw new Error('This page is not running inside Vormio');
6
+ return host;
7
+ }
8
+ const statuses = {
9
+ 400: 'out-of-bounds',
10
+ 403: 'unauthorized',
11
+ 429: 'over-limit',
12
+ 404: 'missing',
13
+ 409: 'changed',
14
+ };
15
+ /**
16
+ * Reads a file next to the input, relative to the input's folder (`../` may move up within the
17
+ * linked folder). The host allows only extensions the contribution declares.
18
+ */
19
+ export async function readCompanion(input, relativePath) {
20
+ const url = new URL(relativePath, input.url).href;
21
+ let response;
22
+ try {
23
+ response = await fetch(url);
24
+ }
25
+ catch {
26
+ return { status: 'unavailable', url };
27
+ }
28
+ if (response.ok)
29
+ return { status: 'ok', url, bytes: await response.arrayBuffer() };
30
+ return { status: statuses[response.status] ?? 'unavailable', url };
31
+ }
32
+ /** Reads the input's own bytes. */
33
+ export async function readInput(input) {
34
+ const response = await fetch(input.url);
35
+ if (!response.ok)
36
+ throw new Error(`The file could not be read (${response.status})`);
37
+ return response.arrayBuffer();
38
+ }
39
+ /**
40
+ * Runs a viewer: `show` is called for the first input and again whenever the host moves to another
41
+ * file, with the previous call's signal aborted. Resolving reports ready; throwing reports the
42
+ * error message.
43
+ */
44
+ export function viewer(show) {
45
+ const host = bridge();
46
+ let current;
47
+ const run = async (input) => {
48
+ current?.abort();
49
+ const controller = new AbortController();
50
+ current = controller;
51
+ try {
52
+ await show(input, controller.signal);
53
+ if (!controller.signal.aborted)
54
+ host.ready(input);
55
+ }
56
+ catch (error) {
57
+ if (!controller.signal.aborted)
58
+ host.fail(input, error instanceof Error ? error.message : String(error));
59
+ }
60
+ };
61
+ host.onInput((input) => void run(input));
62
+ void host.getInput().then(run);
63
+ }
64
+ /** Runs a thumbnail provider; a canvas is converted to PNG. */
65
+ export function thumbnail(render) {
66
+ bridge().onThumbnailRequest(async (input, size) => {
67
+ const result = await render(input, size);
68
+ let blob;
69
+ if (result instanceof Blob)
70
+ blob = result;
71
+ else if ('convertToBlob' in result)
72
+ blob = await result.convertToBlob({ type: 'image/png' });
73
+ else
74
+ blob = await new Promise((resolve, reject) => result.toBlob((png) => (png ? resolve(png) : reject(new Error('The canvas is empty'))), 'image/png'));
75
+ return blob.arrayBuffer();
76
+ });
77
+ }
78
+ /**
79
+ * Runs an analyzer: `analyze` is called once per request and returns the composition report of
80
+ * the input. Throwing reports the analysis as failed with the error message.
81
+ */
82
+ export function analyzer(analyze) {
83
+ bridge().onAnalyzeRequest(analyze);
84
+ }
package/package.json CHANGED
@@ -1,6 +1,44 @@
1
1
  {
2
2
  "name": "@vormio/plugin-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.3",
4
+ "description": "Manifest schema, runtime helpers and packaging for Vormio plugins.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "vormio",
8
+ "plugin",
9
+ "sdk"
10
+ ],
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./node": {
19
+ "types": "./dist/node/index.d.ts",
20
+ "default": "./dist/node/index.js"
21
+ },
22
+ "./bridge": {
23
+ "types": "./dist/bridge.d.ts",
24
+ "default": "./dist/bridge.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=24.21.0"
34
+ },
35
+ "dependencies": {
36
+ "zod": "^4.6.6"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "bin": {
42
+ "vormio-plugin": "./dist/node/cli.js"
43
+ }
6
44
  }