Dicey Table

A Worked Example: Scryfall Card Data

Every other page in this track describes a field. This one describes a whole plugin: a real, validated manifest for a real card-data provider, with every block explained and every limitation stated rather than left for you to discover.

This is an example of writing a plugin for a provider. It is not an integration, a partnership or an endorsement. DiceyTable maintains no relationship with any card-data provider. The plugin author owns that relationship — its terms, its attribution, its rate-limit agreement and any credential it needs. The attribution and termsUrl below are the example author's own declarations, exactly as they would be in any plugin you write, and nothing on this page characterises anybody's terms on your behalf. Read the provider's own documentation and write your own.

Scryfall was chosen for a narrow, practical reason: it needs no credential, so you can register this manifest, open the deck builder and see cards without a credential route anywhere in the loop. That makes it a good first example, not a recommended dependency.

Read Plugins and Writing a plugin first. This page assumes their vocabulary and does not repeat their field tables.

Where the files are#

examples/plugins/scryfall-mtg/ in the DiceyTable repository holds the manifest and a short README. In a real plugin that directory is the repository root — a plugin is a public GitHub repository with diceytable.plugin.json at its top level, registered against an immutable commit sha.

It lives there rather than under mods/ because that directory holds git submodules, and rather than in the docs content tree because the example gate there compiles every file it finds as a table script or a mod script, and a JSON manifest has no script surface to compile against.

The manifest is not decorative. apps/web/src/ui/docs/__tests__/pluginExample.test.ts parses it with the real pluginManifestSchema, runs the real validatePluginManifest — the function the publish scanner calls — and the real secret scan, compiles its ui descriptor with the real compilePluginUiToDeckSchema, and checks that the listing below matches the file byte for byte.

The whole manifest#

examples/plugins/scryfall-mtg/diceytable.plugin.json

{
  "schemaVersion": "1.0",
  "id": "example.scryfall-mtg",
  "name": "Scryfall Card Data (example)",
  "version": "1.0.0",
  "breakingVersion": 1,
  "summary": "Worked example: Magic: The Gathering card data, read from the Scryfall API.",
  "description": "A reference plugin published as a worked EXAMPLE, not as a DiceyTable integration. DiceyTable holds no relationship with Scryfall or with any other card-data provider: the author of a plugin owns that relationship, its terms, its attribution and any credential it needs. Read this manifest alongside /docs/plugins/scryfall-example, which walks every block below and states what this shape cannot do yet.",
  "compatibility": { "engine": ">=0.1 <1.0" },
  "capabilities": { "version": "1", "allowed": ["network"] },
  "origins": ["api.scryfall.com"],
  "endpoints": [
    {
      "name": "searchCards",
      "origin": "api.scryfall.com",
      "method": "GET",
      "path": "/cards/search",
      "query": { "format": "json", "order": "name" },
      "params": [
        {
          "name": "q",
          "location": "query",
          "format": "text",
          "maxLength": 200,
          "required": true,
          "description": "A provider search query, e.g. set:neo type:creature."
        },
        {
          "name": "unique",
          "location": "query",
          "format": "enum",
          "options": ["cards", "art", "prints"],
          "default": "cards",
          "required": false,
          "description": "Whether near-duplicate printings collapse to one row."
        },
        {
          "name": "page",
          "location": "query",
          "format": "integer",
          "default": "1",
          "required": false,
          "description": "1-based page number. See the docs page: the platform cannot drive this automatically."
        }
      ],
      "auth": { "kind": "none" },
      "returns": "cards",
      "description": "One page of cards matching a search query."
    },
    {
      "name": "cardRulings",
      "origin": "api.scryfall.com",
      "method": "GET",
      "path": "/cards/{scryfallId}/rulings",
      "params": [
        {
          "name": "scryfallId",
          "location": "path",
          "format": "uuid",
          "required": true,
          "description": "The card id this mapping stores as the card identity."
        }
      ],
      "auth": { "kind": "none" },
      "returns": "raw",
      "description": "Official rulings for one card, looked up by its provider id."
    }
  ],
  "cardMapping": {
    "root": "data",
    "nextPagePath": "has_more",
    "totalPath": "total_cards",
    "identityField": "scryfall_id",
    "fields": {
      "scryfall_id": { "path": "id", "required": true },
      "name": { "path": "name", "transforms": ["trim"], "required": true },
      "art": {
        "path": "image_uris.normal",
        "fallbackPaths": ["card_faces[0].image_uris.normal"]
      },
      "mana_cost": {
        "path": "mana_cost",
        "fallbackPaths": ["card_faces[0].mana_cost"],
        "default": ""
      },
      "mana_value": { "path": "cmc", "transforms": ["number"], "default": 0 },
      "type_line": {
        "path": "type_line",
        "fallbackPaths": ["card_faces[0].type_line"],
        "transforms": ["trim"],
        "default": ""
      },
      "rarity": { "path": "rarity", "transforms": ["lowercase"], "default": "common" },
      "colors": {
        "path": "color_identity",
        "transforms": ["join"],
        "joinSeparator": "",
        "default": "C"
      },
      "set_name": { "path": "set_name", "transforms": ["trim"], "default": "" },
      "collector_number": { "path": "collector_number", "transforms": ["string"], "default": "" }
    }
  },
  "ui": {
    "fields": [
      { "key": "scryfall_id", "label": "Card id", "type": "text" },
      { "key": "name", "label": "Card name", "type": "text" },
      { "key": "art", "label": "Card image", "type": "text" },
      { "key": "mana_cost", "label": "Mana cost", "type": "text" },
      { "key": "mana_value", "label": "Mana value", "type": "number" },
      { "key": "type_line", "label": "Type line", "type": "text" },
      {
        "key": "rarity",
        "label": "Rarity",
        "type": "select",
        "options": ["common", "uncommon", "rare", "mythic", "special", "bonus"]
      },
      { "key": "colors", "label": "Colour identity", "type": "text" },
      { "key": "set_name", "label": "Set", "type": "text" },
      { "key": "collector_number", "label": "Collector number", "type": "text" }
    ],
    "roles": {
      "key": "scryfall_id",
      "title": "name",
      "art": "art",
      "group": "type_line"
    },
    "columns": [
      { "fieldKey": "name", "header": "Card" },
      { "fieldKey": "mana_cost", "header": "Cost", "align": "right" },
      { "fieldKey": "mana_value", "header": "MV", "align": "right" },
      { "fieldKey": "type_line", "header": "Type" },
      { "fieldKey": "rarity", "header": "Rarity" },
      { "fieldKey": "set_name", "header": "Set" }
    ],
    "stats": [
      { "kind": "histogram", "fieldKey": "mana_value", "label": "Mana curve" },
      { "kind": "sum", "fieldKey": "mana_value", "label": "Total mana value" },
      { "kind": "breakdown", "fieldKey": "rarity", "label": "Rarity" },
      { "kind": "breakdown", "fieldKey": "colors", "label": "Colour identity" }
    ],
    "search": {
      "endpoint": "searchCards",
      "controls": [
        { "param": "q", "control": "text", "label": "Search query" },
        { "param": "unique", "control": "enum-chips", "label": "Printings" }
      ]
    }
  },
  "exposedApi": [
    {
      "name": "rulingsForCard",
      "summary": "Official rulings for one card, by the card id this plugin stores.",
      "endpoint": "cardRulings",
      "params": {
        "type": "object",
        "fields": {
          "scryfallId": { "schema": { "type": "string", "maxLength": 36 } }
        }
      },
      "returns": {
        "type": "object",
        "fields": {
          "object": { "schema": { "type": "string", "maxLength": 32 } },
          "has_more": { "schema": { "type": "boolean" } },
          "next_page": { "schema": { "type": "string", "maxLength": 400 }, "optional": true },
          "warnings": {
            "schema": { "type": "array", "items": { "type": "string", "maxLength": 400 }, "maxItems": 16 },
            "optional": true
          },
          "data": {
            "schema": {
              "type": "array",
              "maxItems": 200,
              "items": {
                "type": "object",
                "fields": {
                  "object": { "schema": { "type": "string", "maxLength": 32 } },
                  "oracle_id": { "schema": { "type": "string", "maxLength": 64 } },
                  "source": { "schema": { "type": "string", "maxLength": 32 } },
                  "published_at": { "schema": { "type": "string", "maxLength": 32 } },
                  "comment": { "schema": { "type": "string", "maxLength": 4000 } }
                }
              }
            }
          }
        }
      }
    }
  ],
  "attribution": "Card data from Scryfall (scryfall.com). This example plugin is not affiliated with, endorsed by, or supported by Scryfall or Wizards of the Coast.",
  "termsUrl": "https://scryfall.com/docs/api",
  "rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 1 },
  "license": "MIT",
  "tags": ["cards", "tcg", "mtg", "example"]
}

The rest of this page walks it block by block.

Identity and versions#

"id": "example.scryfall-mtg",
"version": "1.0.0",
"breakingVersion": 1,
"compatibility": { "engine": ">=0.1 <1.0" }

id is reverse-DNS and is the string a game's card source and a mod's manifest both name. It is also the id saved decks key against, so it is not a thing to rename casually.

version is bumped on every publish. breakingVersion is bumped only when the card model changes incompatibly — a different identity field, a different id space. Saved decks key to (pluginId, breakingVersion), so a patch release leaves every deck intact and a breakingVersion bump forks the deck pool. See breakingVersion and migration before you touch it.

compatibility.engine is a range, and the range form the engine actually reads is >=<major>.<minor> and <<major>.<minor>. A caret range parses as a string and constrains nothing, so write the explicit bounds.

Capabilities: network, and nothing else#

"capabilities": { "version": "1", "allowed": ["network"] }

This plugin ships no table half, so there is no tableScript, no sandboxed script and no code of any kind — a network plugin is pure declaration. It also means no combined, so nothing about this plugin can read table state.

That is the shape to aim for. combined exists, is allowed, and is deliberately the hard path: a plugin that can read table state and send data to its own servers is, on the host, a cheating vector. If the split shape can do what you need, take it.

Origins: one hostname#

"origins": ["api.scryfall.com"]

A bare lowercase hostname — no scheme, no port, no path, no wildcard. Every endpoint's origin must be one of these, and the platform composes https://{origin}{path} itself. The plugin has no field in which to express a destination, which is what makes the allowlist hold regardless of what any plugin does.

Endpoints#

Two, and they are deliberately different shapes.

searchCards — query parameters, returns: "cards"#

{
  "name": "searchCards",
  "origin": "api.scryfall.com",
  "method": "GET",
  "path": "/cards/search",
  "query": { "format": "json", "order": "name" },
  "params": [
    { "name": "q", "location": "query", "format": "text", "maxLength": 200, "required": true },
    { "name": "unique", "location": "query", "format": "enum", "options": ["cards", "art", "prints"], "default": "cards", "required": false },
    { "name": "page", "location": "query", "format": "integer", "default": "1", "required": false }
  ],
  "auth": { "kind": "none" },
  "returns": "cards"
}
  • query holds the values the platform sends on every request to this endpoint. They are not caller-supplied and there is no way for a caller to change them.
  • q is text, which is free text capped at maxLength. Free text is only legal in a query string — a query value is percent-encoded and cannot change the host or the path.
  • unique is an enum, so its options are the complete set of values that can ever be sent. Anything else is refused before a request is composed.
  • page is an integer. Its default is validated against the format at publish time, so "default": "one" would not parse.
  • auth: { "kind": "none" } is the whole credential story here. A provider that needed one would declare bearer or a header name and the value would be written server-side, never here — see Credentials.

cardRulings — a path parameter, returns: "raw"#

{
  "name": "cardRulings",
  "origin": "api.scryfall.com",
  "method": "GET",
  "path": "/cards/{scryfallId}/rulings",
  "params": [
    { "name": "scryfallId", "location": "path", "format": "uuid", "required": true }
  ],
  "auth": { "kind": "none" },
  "returns": "raw"
}

Every {placeholder} needs a matching parameter that declares location: "path" and required: true — an absent segment changes which resource is addressed, so an optional one is a publish error. The format must be one that provably cannot contain a path separator: token, uuid, integer or enum. Here it is uuid, which is exactly the shape of the id this plugin's mapping stores as the card identity, so a mod holding a card id already holds a valid argument.

It is returns: "raw" rather than "cards", and the reason is a real constraint rather than a preference — see one root per manifest, below.

The card mapping#

This is the block that turns a provider's JSON into cards. It is a declaration, not code: dotted read paths and a closed list of transforms, with no expression language anywhere.

"cardMapping": {
  "root": "data",
  "nextPagePath": "has_more",
  "totalPath": "total_cards",
  "identityField": "scryfall_id",
  "fields": { }
}

root addresses the array of card records inside the response. The provider wraps its search results as { "object": "list", "total_cards": …, "has_more": …, "data": [ … ] }, so root is "data".

identityField names the field carrying the card's stable id. It must be one of fields and must be required, because a saved deck stores that value and a card with no id cannot be re-resolved later.

Field by field:

Key Read Why it is written that way
scryfall_id id, required The identity. Required, so a record without one is dropped rather than emitted unusable.
name name, trim, required The card's printed name. A double-faced card's name is "Front // Back" at the top level, which is what a decklist row should say.
art image_uris.normal, falling back to card_faces[0].image_uris.normal The fallback is the whole reason fallbackPaths exists: a double-faced card has no top-level image_uris, and without the fallback every one of them would render art-less.
mana_cost mana_cost, falling back to card_faces[0].mana_cost, default "" Same split shape as the art, for the same reason.
mana_value cmc through the number transform, default 0 The provider sends a JSON number; number makes the field's type explicit so a histogram and a sum over it are legal.
type_line type_line, falling back to card_faces[0].type_line, trim Bound to the group role below, so a decklist groups by card type.
rarity rarity through lowercase, default "common" Normalised, because the select field type declares a closed option list and a stray "Rare" would sit outside it.
colors color_identity through join with an empty joinSeparator, default "C" The provider sends an array like ["W","U"]; join flattens it to "WU". A colourless card sends [], which joins to an empty string — and an empty string is treated as no value, so the "C" default lands exactly where it should.
set_name set_name, trim, default "" Plain passthrough.
collector_number collector_number through string, default "" The provider sends this as a string that is usually digits. string makes the field's type unambiguous rather than leaving it to the first record seen.

Two rules worth internalising from that table:

  1. A candidate path wins only if it produces a usable value all the way through its transforms. An empty string, an empty array joined to nothing, or a number transform that cannot parse all fall through to the next fallbackPath, then to default.
  2. A required field may not declare a default. A record that yields nothing for a required field is dropped, which is the honest outcome — the alternative is a card whose id is a placeholder.

nextPagePath is a truth-teller here, not a cursor#

The provider's own next_page is a fully-qualified URL. The platform refuses URL-shaped cursors by construction — handing one to the fetch layer would hand it an attacker-chosen destination and defeat the origin allowlist outright — so next_page is unusable as a paging cursor no matter how it is declared.

What this manifest declares instead is nextPagePath: "has_more", a boolean. That does not enable paging, and it is not pretending to: it makes the prefetch report itself as truncated after the first page instead of stopping silently and implying it saw everything. Declaring nothing at all would be the worse outcome, not the neutral one.

totalPath: "total_cards" is read for display only.

The UI descriptor: what it puts on screen#

ui is how this plugin's cards are presented, declared as data. There is no HTML, no CSS, no URL and no length unit in the grammar — an author says which of their own mapped fields plays which part, and DiceyTable renders it with the same components a game's own card schema renders through.

"ui": {
  "fields": [ ],
  "roles": { "key": "scryfall_id", "title": "name", "art": "art", "group": "type_line" },
  "columns": [ ],
  "stats": [ ],
  "search": { }
}

fields declares the type of each mapped key — text, number, boolean or select. The mapping said how a value is extracted; this says what it is, and the type is what makes a sum over a text field a publish-time error instead of a column of NaN. Every key here must be one the cardMapping produces, or no card would ever carry a value for it.

roles binds four jobs to four fields:

Role Bound to What it does on screen
key scryfall_id The card identity a saved deck stores. Must equal cardMapping.identityField.
title name The tile caption and the decklist row label.
art art The card image. Absent would mean name-only placeholder tiles.
group type_line The heading a decklist is grouped under. Absent would mean a flat list.

columns are the result grid's columns, in order, each naming a field and an optional header and alignment. There is deliberately no width — a column width is a free-form length string, and layout on a public page is the platform's, not a third party's.

stats are the aggregates beside the deck. This example declares all three kinds so you can see what each is for: a histogram over mana_value is the mana curve, a sum over the same field is a single total, and a breakdown over rarity and over colors is a count per distinct value. A histogram or sum over a text field, or a breakdown over a number, fails at publish.

How the compiled result actually reaches the screen#

When the deck builder loads a game whose card source is this plugin, the descriptor arrives from the registry, is re-parsed by the strict UI schema — control characters, bidirectional-formatting characters and zero-width characters in any author string are rejected here, not just at publish — and is then compiled into a real deck schema. That compiled schema replaces the game's fields, roles, columns and stats; everything else the game declared (its formats, partitions, layouts, counters, attachment rules and its own filters) survives untouched, and the merged document is validated again.

Two consequences you should design around:

  • roles.key must match the game's roles.key. If they disagree the source fails with a named error and a banner, rather than rendering a grid of blank cells. A game keying its decks on scryfall_id works with this plugin; a game keying on something else does not.
  • A game filter over a field this plugin does not declare is a visible failure, because the merged schema is re-validated. That is a genuine incompatibility between two documents, and it says so.

The attribution string is displayed above the deck builder and beside the results.

The exposed API#

exposedApi is what a mod may call through api.callPlugin, under the plugin-call capability. This example exposes one function:

{
  "name": "rulingsForCard",
  "summary": "Official rulings for one card, by the card id this plugin stores.",
  "endpoint": "cardRulings",
  "params": { "type": "object", "fields": { "scryfallId": { "schema": { "type": "string", "maxLength": 36 } } } },
  "returns": { }
}

The chain mod call → plugin function → endpoint → origin is followable statically, which is what lets the mod scanner and a human reviewer see where a mod's traffic actually goes. See Calling a plugin from a mod for the caller's side.

Three things the schema does not tell you, all of which this example is built around:

  1. Every declared param name must be a parameter its endpoint declares. The URL composer refuses an undeclared parameter outright, so a mismatch here is a call that always fails with refused — the least debuggable outcome there is. Nothing cross-checks it at publish; the test beside this example does.

  2. The value-schema grammar has no pattern, so uuid is enforced by the endpoint, not by the function. params.scryfallId is declared as a string with a length cap; the actual 8-4-4-4-12 check happens when the URL is composed, and a bad value comes back as refused.

  3. A declared return schema strips what it does not name. Keys the provider sends and the manifest does not declare are dropped before the caller sees them, so you declare what you want rather than enumerating the provider's whole response. Declaring next_page and warnings as optional here is still worth doing — a rulings response rarely carries them, but a caller that wants them needs them named. What stripping does not relax is the fields you did declare: a wrong type or a missing non-optional field is still refused.

    This used to be strict, and a single undeclared key turned a perfectly successful call into unavailable. It was changed after a second provider proved the rule unworkable — see Value schemas for the params-strict / returns-strip split.

Attribution, terms, licence and quotas#

"attribution": "Card data from Scryfall (scryfall.com). …",
"termsUrl": "https://scryfall.com/docs/api",
"license": "MIT",
"rateLimits": { "requestsPerMinute": 60, "requestsPerDay": 20000, "maxConcurrent": 1 }

attribution is displayed wherever this plugin's data appears and termsUrl points at the terms the author read and accepted on their own behalf. Write your own; do not copy these.

license is the licence of the manifest source in your repository. It says nothing about the licence of the data the provider serves, which is the provider's business and yours.

rateLimits is a ceiling you accept, never a budget the platform grants — the fetch layer enforces min(declared, platform ceiling), so a large number buys nothing and a small one is honoured. maxConcurrent: 1 here is a deliberate choice rather than a default: one request at a time is the politest thing a plugin can do to a provider that asks for restraint, and the deck prefetch is sequential anyway. Read your provider's own guidance and declare accordingly; the numbers above are this example author's, not a recommendation. The quota and circuit-breaker rules apply unchanged, including the breaker you share with every other plugin on the same hostname.

Wiring it into a game#

A game reaches this plugin through its own card schema, not through any code:

{
  "version": 1,
  "breakingVersion": 1,
  "name": "Magic: The Gathering",
  "source": {
    "kind": "plugin",
    "pluginId": "example.scryfall-mtg",
    "options": {
      "_endpoint": "searchCards",
      "_searchParam": "q",
      "q": "set:neo",
      "unique": "cards"
    }
  },
  "fields": [{ "key": "scryfall_id", "type": "text" }, { "key": "name", "type": "text" }],
  "roles": { "key": "scryfall_id", "title": "name" }
}

The options bag reserves exactly three keys, all beginning with _, which no declared parameter name can collide with because a parameter name must start with a letter:

Key What it names
_endpoint Which card endpoint to use. Omitted, the platform takes the first one.
_searchParam The declared parameter the deck builder's free-text search box feeds.
_pageParam The declared parameter a paging cursor is fed back into. Useless with this provider — see below.

Everything else in the bag is a declared parameter value, passed through untouched. q is required on this endpoint, so a game that omits it gets a failed source rather than an empty one — and because the prefetch stops after the first page here, q should be scoped narrowly. A query that returns the game's whole card universe is a query that will truncate.

The game's own fields/roles are replaced by the plugin's compiled descriptor at load, but they still have to be declared and their roles.key still has to agree with the plugin's identity field.

This manifest declares no deckImport, and it should not: Scryfall is a card database, not a deck database, so there is no endpoint returning somebody's decklist for an import block to map. A game sourced from this plugin can still offer import from a different provider by naming that plugin in its own cardSchema.json — see Deck import.

What this example cannot do yet#

Four limits, all real, all reached while building this manifest. None of them is a bug in the example.

Plugin-declared search controls do not render#

The manifest declares this, and it validates:

"search": {
  "endpoint": "searchCards",
  "controls": [
    { "param": "q", "control": "text", "label": "Search query" },
    { "param": "unique", "control": "enum-chips", "label": "Printings" }
  ]
}

These render. Each declared control appears in the deck builder's search pane, above the game's own filters and under a platform sentence saying the options come from the card provider and change what is requested.

They do not arrive through the deck-schema compiler, and that distinction is worth keeping straight: a deck schema's filters bind to card field keys and narrow cards that are already loaded, while these bind to declared endpoint parameters and change what gets fetched. Same widgets, different target, so the compiler still ignores search and the controls are resolved separately against the endpoint the request will actually go to.

Three rules apply, and a control that breaks one is dropped rather than rendered broken:

  • its param must be a parameter that endpoint declares;
  • the widget must be able to drive that parameter's format — text for text/token/uuid, enum-chips for enum, boolean for boolean;
  • an enum-chips needs declared options.

range and enum-multi are not available here at all. Each produces more than one value, and a declared parameter takes one — inventing a "3..7" encoding would be inventing your provider's syntax. enum-chips is single-choice on this surface for the same reason; pressing the active chip clears it.

Values are validated against the declared format before being sent, so a half-typed uuid narrows nothing instead of producing a refusal the searcher cannot interpret. A game's _searchParam free-text box still works and is sent alongside them.

So: declare search if you want to record your intent — it is legal and it costs nothing — but do not design a plugin whose usability depends on it. Declare the parameters you want reachable, and tell game authors which one to point _searchParam at.

There is no /search route#

GET /api/plugins/:id/cards serves both the deck prefetch and the deck-builder search. They are the same request shape with different declared parameter values. Do not design around a separate search route on our side; declare one upstream endpoint and let the search box feed a declared parameter. The route list has the detail.

One cardMapping root per manifest, so two response shapes do not fit#

cardMapping is per manifest, not per endpoint, and its root is a single path. This provider — like most — wraps a search result as { "data": [ … ] } but returns a bare card object from its by-id route. root: "data" addresses the first and finds nothing in the second, and root: "" does the reverse.

That is why cardRulings is returns: "raw" and why this manifest has exactly one card endpoint. A plugin whose provider offers only by-id access can declare a by-id endpoint as returns: "cards" with root: "" and it will work — but it will be the only card shape that manifest can serve, and it makes GET /api/plugins/:id/cards?endpoint=…&p.id=… a per-card lookup on your plugin. Prefer a bulk or search endpoint when your provider offers one.

A mod sees at most 32 fields of any one object#

A declared return schema is capped at 32 fields per object. A card object from a provider of this size carries well over 32 top-level keys, so a mod can never be handed a whole one — you would declare the 32 that matter and the rest are stripped.

That cap is a real constraint but not the reason a card lookup is unexposable. Card endpoints are barred from exposedApi outright, because card data reaches a table through the platform's prefetch rather than through mod calls — see above.

Small, stable envelopes are the comfortable case, which is why this example exposes a rulings lookup: three keys at the top level and five per record.

Testing it before you publish#

The pre-publish checklist applies in full. Two additions this example demonstrates:

  1. Validate the manifest in a test, not by eye. validatePluginManifest reports every issue at once with a path per issue, and it is a plain function — a five-line test that feeds it your manifest file catches a typo before a registration does.
  2. Record one real response and assert your read paths against it. The test beside this example keeps a trimmed recording of a search page and asserts that every required mapping path resolves on it, that every optional one either resolves or declares a default, and — the one that actually matters — that the double-faced fallback is load-bearing rather than cargo cult. A recording cannot notice the provider changing, but it catches the far more common failure: a mapping edited without the shape it reads being re-checked.

See also#