{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://schemas.revenexx.com/theme-extensions.schema.json",
  "title": "revenexx Theme Extension Manifest",
  "description": "The contract between a theme extension and the Platform. A theme extension adds blocks to an existing theme without forking it: it is its own small Nuxt app, deployed as a Site, registered in the same registry as Apps and Themes, and installed per tenant with a pinned version. Each block is rendered on the extension's server as an island; the theme fetches that HTML server to server, sanitises it, and places it where the tenant put the block in the page editor. Interactive parts ship as custom elements in the declared assets. The file lives at the extension's root as `theme-extensions.json`, and the running extension serves it at `GET /theme-extensions.json` together with the `integrity` hashes of its assets.",
  "type": "object",
  "required": [
    "kind",
    "vendor",
    "name",
    "version",
    "title",
    "target",
    "blocks"
  ],
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string",
      "description": "Reference to this JSON Schema for editor validation and autocompletion."
    },
    "kind": {
      "const": "theme-extension",
      "description": "Resource discriminator. For a theme extension this is always 'theme-extension'; it tells the registry that this package extends a theme rather than being an App or a Theme itself."
    },
    "vendor": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
      "minLength": 2,
      "maxLength": 40,
      "description": "Extension publisher. Lowercase letters, digits and single hyphens, 2–40 characters. Part of every block's bundle name and the required prefix of every custom element the extension renders (`<vendor>-*`), so it has to be a valid HTML tag prefix."
    },
    "name": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
      "minLength": 2,
      "maxLength": 40,
      "description": "Unique extension identifier within the vendor. Lowercase letters, digits and single hyphens, 2–40 characters. Used as the registry id and in the asset path `/_ext/<vendor>/<name>/<version>/<file>` under which the theme serves the extension's assets."
    },
    "version": {
      "type": "string",
      "pattern": "^\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$",
      "maxLength": 32,
      "description": "Semantic version (e.g. '0.1.0'), optionally with a pre-release or build identifier. Versions are immutable. An installation pins an exact version; when the running extension reports a different version than the pin, the theme drops its blocks instead of rendering an unreviewed version."
    },
    "title": {
      "type": "string",
      "description": "Human-readable display name shown in Cockpit, Marketplace, and Console."
    },
    "type": {
      "type": "string",
      "enum": [
        "public",
        "private"
      ],
      "description": "Access type, same semantics as a theme's. 'private' (the default when omitted) — only the owner tenant may install; never listed in the Marketplace. 'public' — once the operator publishes it, any tenant whose theme it targets may install it."
    },
    "description": {
      "type": "string",
      "description": "Short description of what the extension adds. Displayed in the Marketplace and Console."
    },
    "icon": {
      "type": "string",
      "description": "Path to the extension icon relative to the extension root. Used in Cockpit and Marketplace."
    },
    "target": {
      "type": "object",
      "description": "The theme this extension extends. The extension can only be installed for a tenant that has this theme installed, and its blocks only appear while that theme is served.",
      "required": [
        "theme",
        "version"
      ],
      "additionalProperties": false,
      "properties": {
        "theme": {
          "type": "string",
          "pattern": "^[^/\\s]+/[a-z][a-z0-9-]*$",
          "description": "The target theme as `<vendor>/<name>`, e.g. 'revenexx/cover-theme'."
        },
        "version": {
          "type": "string",
          "minLength": 1,
          "description": "Semver range of theme versions the extension is built against, e.g. '^0.1'. Installation is refused for a theme version outside the range."
        }
      }
    },
    "blocks": {
      "type": "array",
      "minItems": 1,
      "description": "The blocks this extension adds to the theme's page editor. Each becomes the bundle `ext__<vendor>__<name>__<id>` in the tenant's component catalog.",
      "items": {
        "$ref": "#/$defs/block"
      }
    },
    "assets": {
      "type": "object",
      "description": "Files the theme loads on pages that contain at least one of this extension's blocks, and never in the page editor. The theme serves them from its own origin under `/_ext/<vendor>/<name>/<version>/<file>`, checks them against `integrity`, and caches them as immutable, so the browser never contacts the extension directly.",
      "additionalProperties": false,
      "properties": {
        "stylesheet": {
          "type": "string",
          "pattern": "^/[A-Za-z0-9_./-]+\\.css$",
          "description": "Path of the extension's stylesheet on the extension Site, e.g. '/assets/example.css'. Use the theme's CSS custom properties (e.g. `var(--ui-primary)`) so blocks follow the tenant's brand."
        },
        "javascript": {
          "type": "string",
          "pattern": "^/[A-Za-z0-9_./-]+\\.m?js$",
          "description": "Path of the extension's JavaScript module on the extension Site, e.g. '/assets/example.js'. It may only define custom elements named `<vendor>-*`; the elements upgrade the server-rendered HTML in the browser."
        }
      }
    },
    "integrity": {
      "type": "object",
      "description": "Subresource-integrity hash per asset path. Not written by hand: the running extension adds it when it serves `GET /theme-extensions.json`, computed from the files it actually serves. The theme refuses to serve an asset whose content does not match.",
      "propertyNames": {
        "pattern": "^/[A-Za-z0-9_./-]+$",
        "description": "Asset path as declared in `assets`."
      },
      "additionalProperties": {
        "type": "string",
        "pattern": "^sha384-[A-Za-z0-9+/]+={0,2}$",
        "description": "SHA-384 subresource-integrity value, e.g. 'sha384-…'."
      }
    }
  },
  "$defs": {
    "block": {
      "type": "object",
      "description": "One block the extension adds to the page editor.",
      "required": [
        "id",
        "island",
        "title",
        "enabledOn"
      ],
      "additionalProperties": false,
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$",
          "description": "Block identifier within the extension, lowercase with single underscores, e.g. 'usp_bar'. Double underscores are reserved as the separator in the bundle name `ext__<vendor>__<name>__<id>`."
        },
        "island": {
          "type": "string",
          "pattern": "^[A-Z][A-Za-z0-9]*$",
          "description": "Name of the server island component in the extension's Nuxt app that renders this block, in PascalCase, e.g. 'UspBar'. The theme requests it server to server at `GET /theme-extensions/islands/<island>?props=<json>`, a route every extension serves and that renders through its own Nuxt island handler. Its HTML is sanitised by the theme: no script, style, link or iframe elements, no event-handler attributes, no javascript: or data: URLs."
        },
        "title": {
          "type": "string",
          "description": "Block name shown in the page editor's block list."
        },
        "description": {
          "type": "string",
          "description": "One line shown in the page editor's block list, saying what the block is for."
        },
        "enabledOn": {
          "type": "array",
          "minItems": 1,
          "uniqueItems": true,
          "items": {
            "type": "string",
            "pattern": "^[a-z][a-z0-9_]*$"
          },
          "description": "Where the block may be placed, by page type: e.g. 'page' for content pages, 'region' for header and footer regions, 'product' for product page templates."
        },
        "settings": {
          "type": "object",
          "description": "What an editor can set on the block, shown as options in the page editor's sidebar and passed to the island as props.",
          "propertyNames": {
            "pattern": "^[a-z][a-zA-Z0-9_]*$",
            "description": "Setting key, as the island receives it as a prop."
          },
          "additionalProperties": {
            "$ref": "#/$defs/setting"
          }
        },
        "javascript": {
          "type": "boolean",
          "default": false,
          "description": "Whether the block needs the extension's JavaScript asset. When true, the theme loads `assets.javascript` on pages containing this block."
        }
      }
    },
    "setting": {
      "description": "One block setting. The `type` decides the input in the page editor: text → a text field, number → a number field, radios → a choice between the listed options, checkbox → an on/off switch, color → a colour picker, range → a slider between min and max.",
      "oneOf": [
        {
          "type": "object",
          "description": "A single line of text.",
          "required": [
            "type",
            "label"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "text",
              "description": "Text setting."
            },
            "label": {
              "type": "string",
              "description": "Label shown in the page editor."
            },
            "default": {
              "type": "string",
              "description": "Value used until an editor changes it."
            },
            "placeholder": {
              "type": "string",
              "description": "Hint shown while the field is empty."
            }
          }
        },
        {
          "type": "object",
          "description": "A number.",
          "required": [
            "type",
            "label"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "number",
              "description": "Number setting."
            },
            "label": {
              "type": "string",
              "description": "Label shown in the page editor."
            },
            "default": {
              "type": "number",
              "description": "Value used until an editor changes it."
            },
            "min": {
              "type": "number",
              "description": "Lowest allowed value."
            },
            "max": {
              "type": "number",
              "description": "Highest allowed value."
            }
          }
        },
        {
          "type": "object",
          "description": "One choice out of a fixed list.",
          "required": [
            "type",
            "label",
            "options"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "radios",
              "description": "Choice setting."
            },
            "label": {
              "type": "string",
              "description": "Label shown in the page editor."
            },
            "default": {
              "type": "string",
              "description": "Key of the option used until an editor changes it."
            },
            "options": {
              "type": "object",
              "minProperties": 1,
              "description": "The choices: the value the island receives, mapped to the label the editor sees.",
              "propertyNames": {
                "description": "Option value, as the island receives it."
              },
              "additionalProperties": {
                "type": "string",
                "description": "Option label shown in the page editor."
              }
            }
          }
        },
        {
          "type": "object",
          "description": "An on/off switch.",
          "required": [
            "type",
            "label"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "checkbox",
              "description": "On/off setting."
            },
            "label": {
              "type": "string",
              "description": "Label shown in the page editor."
            },
            "default": {
              "type": "boolean",
              "description": "Value used until an editor changes it."
            }
          }
        },
        {
          "type": "object",
          "description": "A colour.",
          "required": [
            "type",
            "label"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "color",
              "description": "Colour setting."
            },
            "label": {
              "type": "string",
              "description": "Label shown in the page editor."
            },
            "default": {
              "type": "string",
              "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$",
              "description": "Hex colour used until an editor changes it, e.g. '#00875a'."
            }
          }
        },
        {
          "type": "object",
          "description": "A number chosen on a slider.",
          "required": [
            "type",
            "label",
            "min",
            "max"
          ],
          "additionalProperties": false,
          "properties": {
            "type": {
              "const": "range",
              "description": "Slider setting."
            },
            "label": {
              "type": "string",
              "description": "Label shown in the page editor."
            },
            "default": {
              "type": "number",
              "description": "Value used until an editor changes it."
            },
            "min": {
              "type": "number",
              "description": "Lowest value on the slider."
            },
            "max": {
              "type": "number",
              "description": "Highest value on the slider."
            },
            "step": {
              "type": "number",
              "exclusiveMinimum": 0,
              "description": "Distance between two slider positions."
            }
          }
        }
      ]
    }
  }
}
