feat: add canvas marketplace distribution and sharing (#3603)

This commit is contained in:
Carlos Florêncio
2026-09-14 09:31:20 +01:00
committed by GitHub
parent 5c6434d7d8
commit ba960f9732
121 changed files with 10732 additions and 121 deletions
+26 -11
View File
@@ -1,20 +1,19 @@
# Kandev plugin registry
This directory is the **official Kandev marketplace source** — the curated,
PR-edited list of plugins that show up when a user opens **Settings > Plugins >
PR-edited list of plugins and canvases that show up when a user opens **Settings > Plugins >
Browse** inside Kandev.
It is deliberately minimal. It records *which repositories* are in the official
catalog; it does **not** store plugin metadata (display name, description,
author, version, tarball URL, checksum, stars). All of that is read from each
plugin's own GitHub release when the catalog index is built, so the listing can
never drift from what actually ships.
catalog and registry-owned preview images. Release metadata (display name,
description, author, version, tarball URL, package digest, stars) is read from
the package and GitHub release when the catalog index is built.
## Files
| File | Purpose |
| --- | --- |
| `plugins.yaml` | The curated pointer list — one entry per plugin repo. Human-edited via PR. |
| `plugins.yaml` | The curated pointer list — one entry per plugin or canvas repo. Human-edited via PR. |
| `schema.json` | JSON Schema (draft 2020-12) that `plugins.yaml` MUST validate against. Enforced in CI. |
| `build-index.mjs` | Build script (zero-dependency Node) that resolves each entry to a full catalog record and emits `index.json`. |
| `build-index.test.mjs` | `node --test` coverage for the build script's parser and enrichment. |
@@ -23,9 +22,9 @@ The generated `index.json` is published to GitHub Pages by the
[`plugin-registry-index`](../.github/workflows/plugin-registry-index.yml)
workflow and served as the fetch contract Kandev consumes.
## One repository per plugin
## One repository per plugin or canvas
Each plugin lives in its **own** public GitHub repository, named
Each plugin or canvas lives in its **own** public GitHub repository, named
`kdlbs/kandev-plugin-<name>` for first-party plugins (third parties may use any
`owner/name`). Every plugin repo publishes its package as a GitHub **Release**
asset in the standard Kandev package format:
@@ -42,7 +41,7 @@ The catalog ranks plugins by **GitHub stars** (with a "recently updated"
alternative from each repo's last release). Kandev collects **no download or
usage telemetry** — there is no "most installed" metric, by design.
## Submitting a plugin to the official catalog
## Submitting a plugin or canvas to the official catalog
1. **Publish your plugin.** Push it to a public GitHub repository and cut a
GitHub **Release** whose assets include `<id>-<version>.tar.gz`. A separate
@@ -51,7 +50,7 @@ usage telemetry** — there is no "most installed" metric, by design.
integrity gate.
2. **Fork this repo** (`kdlbs/kandev`).
3. **Add one entry** to `plugin-registry/plugins.yaml` pointing at your public
repo:
repo. Native plugins keep the short form:
```yaml
- id: my-plugin # MUST equal the `id` in your plugin manifest
@@ -59,6 +58,20 @@ usage telemetry** — there is no "most installed" metric, by design.
categories: [productivity]
```
A portable canvas declares its kind and at least one ordered preview. The
first image is the catalog cover. Preview URLs are direct HTTPS image URLs
and are not package files:
```yaml
- id: sprint-board
repo: your-org/sprint-board
kind: canvas
categories: [productivity]
previews:
- url: https://cdn.example.com/sprint-board-cover.webp
alt: Sprint board overview
```
The `id` **MUST match the `id` in your plugin manifest** and be unique across
the file. `repo` is `owner/name`. `featured` is a maintainer-only pin and
should be left out of submissions.
@@ -71,7 +84,9 @@ usage telemetry** — there is no "most installed" metric, by design.
`schema.json` pointer-list contract.
5. **A maintainer reviews and merges** — maintainer approval is what gates the
official catalog. Once merged, the index-build workflow picks up your entry
and your plugin appears in the in-app catalog on the next build.
and your plugin or canvas appears in the in-app catalog on the next build.
The canvas inspector validates the exact release asset, identity, source mode,
permissions, and archive digest without executing package code.
## Teams and corporates: host your own source instead
+203 -18
View File
@@ -14,16 +14,22 @@
// focused parser reads it without pulling a YAML library into CI.
//
// Robustness: one bad entry (missing release, deleted asset, API hiccup) never
// fails the whole build — its error is logged to stderr and it is skipped. A
// repo whose star lookup fails is emitted with `stars: null`, never `0`, so a
// transient outage can't corrupt the catalog's ranking.
// fails a scheduled build — its error is logged to stderr and it is skipped. A
// pull request fails when it introduces an invalid canvas entry, so registry
// validation cannot silently omit a reviewed canvas. A repo whose star lookup
// fails is emitted with `stars: null`, never `0`, so a transient outage can't
// corrupt the catalog's ranking.
//
// Auth: GitHub API calls use GITHUB_TOKEN when present (in CI, secrets.GITHUB_TOKEN).
// That works for the public repos here; at larger scale a PAT with `public_repo`
// scope set as GITHUB_TOKEN gives higher, more predictable rate limits.
import fs from "node:fs/promises";
import { createHash } from "node:crypto";
import { execFile as execFileCallback } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { fileURLToPath } from "node:url";
const GITHUB_API = "https://api.github.com";
@@ -40,6 +46,9 @@ const SOURCE_URL = "";
const HERE = path.dirname(fileURLToPath(import.meta.url));
const PLUGINS_YAML = path.join(HERE, "plugins.yaml");
const OUTPUT_JSON = path.join(HERE, "index.json");
const execFile = promisify(execFileCallback);
const MAX_CANVAS_PACKAGE_BYTES = 10 * 1024 * 1024;
const CANVAS_INSPECTOR_TIMEOUT_MS = 30_000;
// --- Minimal plugins.yaml parser --------------------------------------------
@@ -56,6 +65,9 @@ export function parsePluginsYaml(text) {
const specs = [];
let current = null;
let inPlugins = false;
let inPreviews = false;
let currentPreview = null;
let previewItemIndent = 0;
for (const rawLine of text.split("\n")) {
const line = stripComment(rawLine);
@@ -66,14 +78,42 @@ export function parsePluginsYaml(text) {
continue;
}
const item = line.match(/^\s*-\s*(.*)$/);
const item = line.match(/^(\s*)-\s*(.*)$/);
if (item) {
if (inPreviews && current && item[1].length > 2) {
currentPreview = {};
current.previews ??= [];
current.previews.push(currentPreview);
previewItemIndent = item[1].length;
assignField(currentPreview, item[2]);
continue;
}
inPreviews = false;
currentPreview = null;
previewItemIndent = 0;
current = {};
specs.push(current);
assignField(current, item[1]);
assignField(current, item[2]);
continue;
}
if (current && /^\s+\S/.test(line)) assignField(current, line.trim());
if (current && /^\s+\S/.test(line)) {
const value = line.trim();
if (value === "previews:") {
current.previews = [];
inPreviews = true;
currentPreview = null;
previewItemIndent = 0;
continue;
}
if (inPreviews && currentPreview && line.search(/\S/) > previewItemIndent) {
assignField(currentPreview, value);
continue;
}
inPreviews = false;
currentPreview = null;
previewItemIndent = 0;
assignField(current, value);
}
}
return specs;
}
@@ -213,6 +253,37 @@ function pickPackageAsset(assets, pluginId, version) {
return { asset: exact || tarballs[0] };
}
function pickCanvasPackageAsset(assets, pluginId, version) {
const exactName = `${pluginId}-${version}.tar.gz`;
const exact = assets.find((asset) => asset.name === exactName);
return exact ? { asset: exact } : { error: `release has no exact ${exactName} asset` };
}
function validatePreviews(previews, required) {
if (required && (!Array.isArray(previews) || previews.length === 0)) {
return "canvas entries require at least one preview image";
}
if (previews === undefined) return undefined;
if (!Array.isArray(previews) || previews.length > 8) return "preview image count must be between 1 and 8";
for (const preview of previews) {
if (!preview || typeof preview.url !== "string" || typeof preview.alt !== "string") {
return "preview images require url and alt";
}
const alt = preview.alt.trim();
let parsed;
try {
parsed = new URL(preview.url);
} catch {
return "preview URLs must be absolute HTTPS URLs";
}
if (parsed.protocol !== "https:" || !parsed.hostname || parsed.username || parsed.password || parsed.hash || preview.url.length > 2048) {
return "preview URLs must be HTTPS without credentials or fragments";
}
if (alt.length < 1 || alt.length > 300) return "preview alt text must be 1 to 300 characters";
}
return undefined;
}
/** "agent-stats" -> "Agent Stats", the fallback when no manifest name is known. */
function humanize(pluginId) {
return pluginId
@@ -230,6 +301,10 @@ export async function buildEntry(spec) {
const pluginId = spec.id;
const repo = spec.repo;
if (!pluginId || !repo) return { error: `entry missing id/repo: ${JSON.stringify(spec)}` };
const kind = spec.kind || "plugin";
const previewError = validatePreviews(spec.previews, kind === "canvas");
if (previewError) return { error: `${pluginId}: ${previewError}` };
if (kind !== "plugin" && kind !== "canvas") return { error: `${pluginId}: unsupported kind ${kind}` };
let release;
try {
@@ -240,34 +315,138 @@ export async function buildEntry(spec) {
const tag = release.tag_name || "";
const version = normalizeVersion(tag);
const { asset, error: assetError } = pickPackageAsset(release.assets || [], pluginId, version);
const picker = kind === "canvas" ? pickCanvasPackageAsset : pickPackageAsset;
const { asset, error: assetError } = picker(release.assets || [], pluginId, version);
if (assetError) return { error: `${pluginId}: ${assetError}` };
const manifest = tag ? await fetchManifest(repo, tag) : {};
const iconUrl = manifest.icon && tag ? rawUrl(repo, tag, manifest.icon) : null;
const meta = await fetchRepoMeta(repo, pluginId);
let packageSHA256 = null;
let inspectedDescriptor = null;
if (kind === "canvas") {
const inspected = await inspectCanvasAsset(asset.browser_download_url, pluginId);
if (inspected.error) return { error: `${pluginId}: ${inspected.error}` };
if (inspected.descriptor.id !== pluginId || inspected.descriptor.version !== version || inspected.descriptor.kind !== "canvas") {
return { error: `${pluginId}: inspected package identity does not match the registry entry` };
}
inspectedDescriptor = inspected.descriptor;
packageSHA256 = inspected.digest;
}
const canonicalRepoURL = `https://github.com/${repo}`;
if (kind === "canvas" && inspectedDescriptor.repo_url) {
if (canonicalizeRepoURL(inspectedDescriptor.repo_url) !== canonicalizeRepoURL(canonicalRepoURL)) {
return { error: `${pluginId}: inspected package repository does not match the registry entry` };
}
}
const presentation = kind === "canvas" ? { ...manifest, ...inspectedDescriptor } : manifest;
const record = {
id: pluginId,
// Presentation prefers the plugin's manifest, then id-derived / release /
// plugins.yaml fallbacks, so the contract shape is stable even on a miss.
name: manifest.display_name || humanize(pluginId),
description: manifest.description || release.name || "",
author: manifest.author || meta.author,
kind,
// Canvas presentation comes from the inspected archive. Plugins retain
// the existing manifest-first projection and fallback behavior.
name: presentation.display_name || humanize(pluginId),
description: presentation.description || release.name || "",
author: presentation.author || meta.author,
categories: manifest.categories || spec.categories || [],
icon_url: iconUrl,
repo_url: `https://github.com/${repo}`,
repo_url: presentation.repo_url || canonicalRepoURL,
version: version || null,
min_kandev_version: manifest.min_kandev_version ?? null,
min_kandev_version: presentation.min_kandev_version ?? null,
...(kind === "canvas" ? { license: inspectedDescriptor.license } : {}),
package_url: asset.browser_download_url,
// Advisory provenance digest; null is a valid, documented value.
package_sha256: null,
package_sha256: packageSHA256,
...(kind === "canvas"
? {
permissions: {
reads: inspectedDescriptor.api_read || [],
writes: inspectedDescriptor.api_write || [],
events: inspectedDescriptor.events || [],
shared_state: Boolean(inspectedDescriptor.state),
external_origins: inspectedDescriptor.network_origins || [],
},
}
: {}),
...(spec.previews ? { previews: spec.previews } : {}),
stars: meta.stars,
updated_at: meta.updatedAt || release.published_at || null,
};
return { record };
}
async function inspectCanvasAsset(assetURL, pluginId) {
const inspector = process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
if (!inspector) return { error: "canvas package inspector is not configured" };
let response;
try {
response = await fetchWithTimeout(assetURL, { headers: { Accept: "application/gzip", "User-Agent": USER_AGENT } });
} catch (error) {
return { error: `canvas package download failed (${error.message})` };
}
if (!response.ok) return { error: `canvas package download returned ${response.status}` };
let body;
try {
body = await readBoundedResponse(response, MAX_CANVAS_PACKAGE_BYTES);
} catch {
return { error: "canvas package exceeds the package size limit" };
}
const digest = createHash("sha256").update(body).digest("hex");
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-canvas-registry-"));
const packagePath = path.join(directory, `${pluginId}.tar.gz`);
try {
await fs.writeFile(packagePath, body, { mode: 0o600 });
const result = await execFile(inspector, ["--file", packagePath], {
maxBuffer: 1024 * 1024,
timeout: CANVAS_INSPECTOR_TIMEOUT_MS,
});
let descriptor;
try {
descriptor = JSON.parse(result.stdout);
} catch {
return { error: "canvas package inspector returned invalid JSON" };
}
return { descriptor, digest };
} catch {
return { error: "canvas package inspection failed" };
} finally {
await fs.rm(directory, { recursive: true, force: true });
}
}
async function readBoundedResponse(response, limit) {
if (!response.body?.getReader) throw new Error("response body stream unavailable");
const reader = response.body.getReader();
const chunks = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) return Buffer.concat(chunks);
const chunk = Buffer.from(value);
total += chunk.length;
if (total > limit) {
await reader.cancel();
throw new Error("response exceeds limit");
}
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
}
function canonicalizeRepoURL(value) {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.username || parsed.password || parsed.search || parsed.hash) return "";
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
} catch {
return "";
}
}
/** Repo metadata → stars (null on failure, never 0), last-push time, owner. */
async function fetchRepoMeta(repo, pluginId) {
try {
@@ -288,10 +467,12 @@ async function fetchRepoMeta(repo, pluginId) {
export async function buildIndex(specs) {
const records = [];
const errors = [];
const canvasErrors = [];
for (const spec of specs) {
const { record, error } = await buildEntry(spec);
if (error) {
errors.push(error);
if ((spec.kind || "plugin") === "canvas") canvasErrors.push(error);
console.error(`skip: ${error}`);
continue;
}
@@ -303,7 +484,7 @@ export async function buildIndex(specs) {
source: { name: SOURCE_NAME, url: SOURCE_URL },
plugins: records,
};
return { document, errors };
return { document, errors, canvasErrors };
}
async function main() {
@@ -312,7 +493,7 @@ async function main() {
// An empty list is expected at launch (no plugin repos yet) — it produces a
// valid, empty index.json and is NOT an error. Only a non-empty list that
// resolves to zero entries (below) indicates a real failure.
const { document, errors } = await buildIndex(specs);
const { document, errors, canvasErrors } = await buildIndex(specs);
await fs.writeFile(OUTPUT_JSON, `${JSON.stringify(document, null, 2)}\n`, "utf8");
console.error(
@@ -325,6 +506,10 @@ async function main() {
console.error("error: no entries could be built; refusing to publish empty index");
process.exitCode = 1;
}
if (process.env.GITHUB_EVENT_NAME === "pull_request" && canvasErrors.length > 0) {
console.error("error: pull-request validation found invalid canvas entries");
process.exitCode = 1;
}
}
// Run only when invoked directly (not when imported by a test).
+125
View File
@@ -1,5 +1,9 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import test, { afterEach } from "node:test";
import os from "node:os";
import path from "node:path";
import {
buildEntry,
buildIndex,
@@ -30,6 +34,16 @@ const jsonResponse = (body, ok = body !== null) => ({
text: async () => JSON.stringify(body ?? {}),
});
const textResponse = (text) => ({ ok: text !== "", status: text ? 200 : 404, text: async () => text });
const binaryResponse = (body) => ({
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(body);
controller.close();
},
}),
});
test("parsePluginsYaml reads the constrained pointer list", () => {
const specs = parsePluginsYaml(
@@ -49,6 +63,28 @@ test("parsePluginsYaml reads the constrained pointer list", () => {
assert.deepEqual(specs[1].categories, ["analytics", "ops"]);
});
test("parsePluginsYaml reads ordered canvas previews", () => {
const specs = parsePluginsYaml(
[
"plugins:",
" - id: board",
" repo: acme/board",
" kind: canvas",
" previews:",
" - url: https://cdn.example/cover.webp",
" alt: Board cover",
" - url: https://cdn.example/detail.webp",
" alt: Board detail",
" featured: true",
].join("\n"),
);
assert.deepEqual(specs[0].previews, [
{ url: "https://cdn.example/cover.webp", alt: "Board cover" },
{ url: "https://cdn.example/detail.webp", alt: "Board detail" },
]);
assert.equal(specs[0].featured, true);
});
test("parseManifestFields extracts presentation keys and ignores the rest", () => {
const fields = parseManifestFields(
[
@@ -124,6 +160,94 @@ test("buildEntry keeps stars null (never 0) when repo metadata lookup fails", as
assert.equal(record.icon_url, null); // no manifest icon
});
test("buildEntry uses inspected canvas presentation metadata and preserves preview order", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-registry-test-"));
const inspector = path.join(directory, "inspector.mjs");
await fs.writeFile(
inspector,
`#!/usr/bin/env node\nprocess.stdout.write(JSON.stringify({id:"board",version:"1.0.0",kind:"canvas",display_name:"Inspected Board",description:"From archive",author:"archive-author",min_kandev_version:"2.0.0",repo_url:"https://github.com/acme/board",license:"MIT"}));\n`,
{ mode: 0o755 },
);
const previousInspector = process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = inspector;
const packageBytes = new Uint8Array([1, 2, 3, 4]);
globalThis.fetch = async (url) => {
const value = String(url);
if (value.includes("/releases/latest")) {
return jsonResponse({
tag_name: "v1.0.0",
assets: [{ name: "board-1.0.0.tar.gz", browser_download_url: "https://dl.example/board.tar.gz" }],
});
}
if (value.includes("/manifest.yaml")) return textResponse("display_name: Board\ndescription: A board\n");
if (value.includes("/repos/")) return jsonResponse({ stargazers_count: 2, owner: { login: "acme" } });
if (value === "https://dl.example/board.tar.gz") return binaryResponse(packageBytes);
throw new Error(`unexpected fetch: ${value}`);
};
try {
const result = await buildEntry({
id: "board",
repo: "acme/board",
kind: "canvas",
previews: [{ url: "https://cdn.example/cover.webp", alt: "Board cover" }],
});
assert.equal(result.error, undefined);
assert.equal(result.record.kind, "canvas");
assert.equal(result.record.name, "Inspected Board");
assert.equal(result.record.description, "From archive");
assert.equal(result.record.author, "archive-author");
assert.equal(result.record.min_kandev_version, "2.0.0");
assert.deepEqual(result.record.previews, [{ url: "https://cdn.example/cover.webp", alt: "Board cover" }]);
assert.equal(result.record.package_sha256, createHash("sha256").update(packageBytes).digest("hex"));
} finally {
if (previousInspector === undefined) delete process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
else process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = previousInspector;
await fs.rm(directory, { recursive: true, force: true });
}
});
test("buildEntry bounds streamed canvas package responses", async () => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "kandev-registry-stream-test-"));
const inspector = path.join(directory, "inspector.mjs");
await fs.writeFile(inspector, `process.stdout.write(JSON.stringify({id:"board",version:"1.0.0",kind:"canvas"}));`, { mode: 0o755 });
const previousInspector = process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = inspector;
globalThis.fetch = async (url) => {
const value = String(url);
if (value.includes("/releases/latest")) return jsonResponse({ tag_name: "v1.0.0", assets: [{ name: "board-1.0.0.tar.gz", browser_download_url: "https://dl.example/board.tar.gz" }] });
if (value.includes("/manifest.yaml")) return textResponse("");
if (value.includes("/repos/")) return jsonResponse({ stargazers_count: 1, owner: { login: "acme" } });
if (value === "https://dl.example/board.tar.gz") {
return {
ok: true,
status: 200,
body: new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(5 * 1024 * 1024));
controller.enqueue(new Uint8Array(5 * 1024 * 1024 + 1));
controller.close();
},
}),
arrayBuffer: async () => new ArrayBuffer(0),
};
}
throw new Error(`unexpected fetch: ${value}`);
};
try {
const result = await buildEntry({ id: "board", repo: "acme/board", kind: "canvas", previews: [{ url: "https://cdn.example/cover.webp", alt: "Board" }] });
assert.match(result.error, /exceeds the package size limit/);
} finally {
if (previousInspector === undefined) delete process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR;
else process.env.KANDEV_CANVAS_PACKAGE_INSPECTOR = previousInspector;
await fs.rm(directory, { recursive: true, force: true });
}
});
test("buildEntry rejects a canvas without a preview before fetching a release", async () => {
const result = await buildEntry({ id: "board", repo: "acme/board", kind: "canvas" });
assert.match(result.error, /at least one preview/);
});
test("empty plugins.yaml parses to no specs and builds a valid empty index", async () => {
assert.deepEqual(parsePluginsYaml("plugins: []"), []);
const { document, errors } = await buildIndex([]);
@@ -155,5 +279,6 @@ test("buildIndex skips bad entries but still builds the good ones", async () =>
assert.equal(document.plugins.length, 1);
assert.equal(document.plugins[0].id, "a");
assert.equal(errors.length, 1);
assert.equal((await buildIndex([{ id: "bad-canvas", repo: "o/bad-canvas", kind: "canvas" }])).canvasErrors.length, 1);
assert.equal(document.schema_version, 1);
});
+51 -2
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/kdlbs/kandev/blob/main/plugin-registry/schema.json",
"title": "Kandev plugin registry list",
"description": "Schema for plugin-registry/plugins.yaml — the curated, PR-edited pointer list of plugin repositories in the official Kandev marketplace source. Validated in CI on every PR. Descriptive metadata (name, description, author, version, package URL) is NOT stored here; it is read from each plugin's own GitHub release at index-build time so it can never drift from what actually ships.",
"title": "Kandev marketplace registry list",
"description": "Schema for plugin-registry/plugins.yaml, the curated pointer list of plugin and canvas repositories. Release metadata is read from the package at index-build time; preview images are registry-owned presentation metadata.",
"type": "object",
"additionalProperties": false,
"required": ["plugins"],
@@ -36,7 +36,56 @@
"items": {
"type": "string"
}
},
"kind": {
"type": "string",
"enum": ["plugin", "canvas"],
"default": "plugin",
"description": "Omitted kind means plugin. Canvas entries are validated as portable canvas packages."
},
"previews": {
"type": "array",
"maxItems": 8,
"items": {
"$ref": "#/$defs/preview"
},
"description": "Ordered listing images. Optional for plugins and required for canvas entries."
}
},
"allOf": [
{
"if": {
"required": ["kind"],
"properties": {"kind": {"const": "canvas"}}
},
"then": {
"required": ["previews"],
"properties": {"previews": {"minItems": 1}}
}
}
]
}
}
},
"$defs": {
"preview": {
"type": "object",
"additionalProperties": false,
"required": ["url", "alt"],
"properties": {
"url": {
"type": "string",
"format": "uri",
"pattern": "^https://",
"maxLength": 2048,
"description": "Direct HTTPS listing image URL without credentials or a fragment."
},
"alt": {
"type": "string",
"minLength": 1,
"maxLength": 300,
"pattern": "\\S",
"description": "Accessible description of the listing image."
}
}
}