# Plugin Grid





Advanced data grid with sorting, filtering, pagination, and row selection capabilities.

## Installation [#installation]

```bash
npm install @object-ui/plugin-grid
```

This package publishes a stylesheet. Import it after the base sheets, or the grid renders unstyled — the themed utilities it uses have no other source in a published app ([#4929](https://github.com/objectstack-ai/objectui/issues/4929)):

```css
/* src/index.css */
@import "tailwindcss";
@import "@object-ui/components/style.css";
@import "@object-ui/fields/style.css";
@import "@object-ui/plugin-grid/style.css";
```

<PluginLoader plugins="['grid']">
  ## Interactive Examples [#interactive-examples]

  Each preview below **is** an `object-grid` node drawn by this plugin — the JSON
  in the Code tab is the whole example, and the rows on screen came out of a
  `find()` call, not out of that JSON. The records are served by the docs site's
  demo data source, because `dataSource` is not a schema key: it is the prop the
  registered renderer pulls off `SchemaRendererProvider` context (see
  [ObjectQL Integration](#objectql-integration) below), so in your own app these
  same nodes read whatever object your data source serves. That fixture answers
  search and sort but not filters, which is the only reason no `filter` is
  authored here.

  ### Columns, sort and search [#columns-sort-and-search]

  <SchemaExample id="plugin-grid/object-grid-columns" />

  ### Selection, row actions and column summaries [#selection-row-actions-and-column-summaries]

  <SchemaExample id="plugin-grid/object-grid-selection-summaries" />

  ## Features [#features]

  * **Sorting** - Multi-column sorting support
  * **Filtering** - Column-level filtering
  * **Pagination** - Built-in pagination controls
  * **Row Selection** - Single and multi-row selection
  * **Custom Cells** - Custom cell renderers
  * **Responsive** - Mobile-friendly layouts

  ## Schema API [#schema-api]

  ### Grid [#grid]

  A grid node is an `ObjectGridSchema`: one required `objectName`, plus keys drawn
  from the list this plugin **declares** as its authoring surface
  (`GRID_QUERY_INPUTS`, `packages/plugin-grid/src/index.tsx`) — the same list that
  feeds the designer panel and the generated `sdui-intrinsics.d.ts`, so what is
  authorable here is what the renderer reads.

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": ["name", "email"],
    "sort": [{ "field": "created", "order": "desc" }],
    "pagination": { "pageSize": 20 }
  }
  ```

  Note the `type`. It is `object-grid` (or `view:grid`), &#x2A;*never `grid`** — bare
  `grid` is the CSS Grid *layout container* from `@object-ui/components`, whose
  `columns` is a column **count**. See [Registration](#registration-is-a-side-effect-of-the-import)
  below for why this plugin deliberately does not claim it.

  | Key                                                                                                                                                                       | Type                       | Notes                                                                                     |
  | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ----------------------------------------------------------------------------------------- |
  | `objectName`                                                                                                                                                              | `string` (**required**)    | The object queried. There is no `object`.                                                 |
  | `columns`                                                                                                                                                                 | `string[] \| ListColumn[]` | Field names or column objects — see below.                                                |
  | `label`                                                                                                                                                                   | `I18nLabel`                | Table caption and export file title.                                                      |
  | `filter`                                                                                                                                                                  | `ViewFilterRule[]`         | Baked into the query, lowered to `$filter`.                                               |
  | `sort`                                                                                                                                                                    | `[{ field, order }]`       | Initial order; a header click replaces it.                                                |
  | `pagination`                                                                                                                                                              | `PaginationConfig`         | `{ pageSize?, pageSizeOptions? }` — **strict**, and its presence is what enables paging.  |
  | `searchableFields`                                                                                                                                                        | `string[]`                 | A non-empty list is what puts the search box in the toolbar.                              |
  | `data`                                                                                                                                                                    | `ViewData`                 | Inline rows that bypass the object query — see [Inline data](#inline-data).               |
  | `selection`                                                                                                                                                               | `SelectionConfig`          | `{ type: 'none' \| 'single' \| 'multiple' }`.                                             |
  | `rowActions` / `bulkActions`                                                                                                                                              | `string[]`                 | **Names** of actions, not inline definitions.                                             |
  | `editable` / `singleClickEdit`                                                                                                                                            | `boolean`                  | Inline editing — see [Inline Editing](#inline-editing).                                   |
  | `navigation`                                                                                                                                                              | `NavigationConfig`         | What a row click does: `{ mode: 'page' \| 'drawer' \| 'modal' \| 'split' \| 'none', … }`. |
  | `operations`                                                                                                                                                              | `object`                   | Toggles the built-in CRUD/export/import affordances, e.g. `{ delete: false }`.            |
  | `rowHeight`, `frozenColumns`, `resizable`, `reorderableColumns`, `showColumnTypeIcons`, `rowColor`, `conditionalFormatting`, `aggregations`, `exportOptions`, `className` |                            | The rest of the declared surface.                                                         |
  | `grouping`                                                                                                                                                                | `GroupingConfig`           | Row grouping — **page-scoped**, see [Grouping is page-scoped](#grouping-is-page-scoped).  |

  **There is no `sortable`, `filterable`, `onRowClick`, `onSelectionChange`,
  `onCellChange`, `onRowSave`, `onBatchSave` or `object` on this schema.** The two
  booleans do not exist at all; the five `on*` names are **component props**
  (`ObjectGridComponentProps`), which no metadata document can carry — see
  [Row callbacks are component props](#row-callbacks-are-component-props).

  ### Column Definition [#column-definition]

  A column is either a **field name** (`"name"`) or a `ListColumn` object.
  `ListColumn` is declared by `@objectstack/spec/ui` (`ListColumnSchema`) and
  re-exported from `@object-ui/types`; it is the type of `ObjectGridSchema`'s
  `columns`, so it is the same column vocabulary the saved-view metadata uses.

  | Key         | Type                                | Meaning                                                                 |
  | ----------- | ----------------------------------- | ----------------------------------------------------------------------- |
  | `field`     | `string` (**required**)             | The field this column reads. There is no `accessorKey`.                 |
  | `label`     | `string \| Record<string, string>`  | Header text, or an inline locale map. There is no `header`.             |
  | `width`     | `number`                            | Column width in pixels.                                                 |
  | `align`     | `'left' \| 'center' \| 'right'`     | Cell alignment.                                                         |
  | `hidden`    | `boolean`                           | Hidden by default, revealable in the column chooser.                    |
  | `sortable`  | `boolean`                           | Allow sorting on this column.                                           |
  | `resizable` | `boolean`                           | Allow dragging this column's width.                                     |
  | `wrap`      | `boolean`                           | Wrap long cell text instead of eliding it.                              |
  | `type`      | `string`                            | Override the rendered cell type instead of inferring it from the field. |
  | `pinned`    | `'left' \| 'right'`                 | Freeze the column to one edge.                                          |
  | `summary`   | `ColumnSummary \| { type, field? }` | Footer aggregation — see [Column Summaries](#column-summaries).         |
  | `prefix`    | `{ field, type? }`                  | Render a second field inline before the value.                          |
  | `link`      | `boolean`                           | Render the value as a link to the record.                               |
  | `action`    | `string`                            | Run a named action when the cell is clicked.                            |

  `ListColumnSchema` is a **strict** Zod object, so an unknown key is *rejected*
  rather than ignored — a column is spelled this one way. `header` and
  `accessorKey` are not softer spellings of `label` and `field`; they fail
  validation.

  ### Column Summaries [#column-summaries]

  A column can declare a footer aggregation with `summary`, either as a shorthand
  string or as an object that aggregates a different field than the one displayed:

  ```json
  {
    "columns": [
      { "field": "name", "summary": "count_filled" },
      { "field": "amount", "type": "currency", "summary": "sum" },
      { "field": "owner", "summary": { "type": "count_unique", "field": "owner_id" } }
    ]
  }
  ```

  The accepted values are `ColumnSummarySchema` from `@objectstack/spec`:

  | `summary`        | Footer shows                     | Reads          |
  | ---------------- | -------------------------------- | -------------- |
  | `none`           | nothing — the column opts out    | —              |
  | `count`          | number of rows                   | every row      |
  | `count_filled`   | rows whose cell is non-empty     | raw values     |
  | `count_empty`    | rows whose cell is empty         | raw values     |
  | `count_unique`   | distinct non-empty values        | raw values     |
  | `percent_filled` | share of rows that are non-empty | raw values     |
  | `percent_empty`  | share of rows that are empty     | raw values     |
  | `sum`            | total                            | numeric values |
  | `avg`            | mean                             | numeric values |
  | `min`            | smallest                         | numeric values |
  | `max`            | largest                          | numeric values |

  A cell counts as empty when it is `null`, `undefined`, `""` or an empty array,
  so an unset multi-select or lookup reads as empty rather than as a filled `[]`.

  The count and percent families read raw cell values, so they work on text,
  select and lookup columns. `sum`/`avg`/`min`/`max` need numeric values (numeric
  strings are parsed) and render nothing when the column has none.

  A `currency` or `percent` column formats its `sum`/`avg`/`min`/`max` in that
  unit. Counts stay plain cardinalities and percentages carry their own `%`, so
  `count_unique` on a currency column reads `Unique: 3`, not `$3.00`.

  The footer row renders only when at least one column resolves to a summary — a
  view whose columns are all `none` (or carry no `summary`) has no footer.

  ## Usage [#usage]

  ### Registration is a side effect of the import [#registration-is-a-side-effect-of-the-import]

  ```ts
  import '@object-ui/plugin-grid';
  ```

  That single import is the whole of registration — there is no components map to
  iterate over. Importing the entry runs the three `ComponentRegistry.register(...)`
  calls in `packages/plugin-grid/src/index.tsx`, which claim exactly these schema
  types:

  | Namespaced key              | Bare-name fallback          | Renderer behind it                                           |
  | --------------------------- | --------------------------- | ------------------------------------------------------------ |
  | `plugin-grid:object-grid`   | `object-grid`               | `ObjectGridRenderer` — the data grid, queried from an object |
  | `view:grid`                 | none — `skipFallback: true` | the same renderer, under the view protocol                   |
  | `plugin-grid:import-wizard` | `import-wizard`             | `ImportWizardRenderer` — spreadsheet / clipboard import      |

  `ComponentRegistry.register` publishes `namespace:type`, and — unless the call
  passes `skipFallback: true` — the bare `type` as a back-compat fallback
  (`packages/core/src/registry/Registry.ts:194`, fallback at `:226`).

  **Bare `grid` is deliberately not this plugin's.** `skipFallback: true` on the
  `view:grid` call keeps the data grid from claiming it, because `grid` belongs to
  the CSS Grid *layout* container in `@object-ui/components`
  (`packages/components/src/renderers/layout/grid.tsx:50`). Reach the data grid as
  `object-grid`, or as `view:grid` when you want the namespaced spelling.

  ### Registering the renderer under your own key [#registering-the-renderer-under-your-own-key]

  To serve the data grid under a key of your own, register the exported renderer —
  that is what a manual registration is here:

  ```ts
  import { ComponentRegistry } from '@object-ui/core';
  import { ObjectGridRenderer } from '@object-ui/plugin-grid';

  ComponentRegistry.register('my-grid', ObjectGridRenderer, {
    namespace: 'my-app',
    label: 'My Grid',
    category: 'plugin',
  });
  ```

  `ObjectGrid`, `ObjectGridRenderer`, `VirtualGrid`, `SplitPaneGrid`,
  `ImportWizard`, `InlineEditing` and `FormulaBar` are on the package's export
  surface, alongside the hooks (`useGroupedData`, `useColumnSummary`,
  `useCellClipboard`, …) and the component prop types
  (`ObjectGridComponentProps`, `VirtualGridProps`, …). There is no aggregate map
  among them, and the *schema* types are not here either — they live in
  `@object-ui/types`, because the schema is the shared authoring contract rather
  than this package's component API.

  ## Examples [#examples]

  ### Basic Grid [#basic-grid]

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": [
      { "field": "name", "label": "Name", "width": 200, "sortable": true },
      { "field": "email", "label": "Email" },
      { "field": "role", "label": "Role" },
      { "field": "status", "label": "Status", "type": "select" }
    ],
    "sort": [{ "field": "name", "order": "asc" }],
    "pagination": { "pageSize": 20 }
  }
  ```

  ### Cell appearance [#cell-appearance]

  A column does not carry a render function. What it *can* say is which **cell
  type** to use and how to decorate the value — the same vocabulary the saved-view
  metadata uses, so a grid authored by hand and one authored in the designer
  render alike.

  ```json
  {
    "type": "object-grid",
    "objectName": "opportunities",
    "columns": [
      { "field": "stage", "type": "select" },
      { "field": "amount", "type": "currency", "align": "right", "summary": "sum" },
      { "field": "name", "link": true, "prefix": { "field": "health", "type": "badge" } },
      { "field": "owner_id", "action": "reassign" }
    ]
  }
  ```

  `link: true` renders the value as a link to the record and `action: "reassign"`
  runs a named action on click — that is the metadata form of the "Actions column"
  a render function used to be written for. A genuinely custom cell **renderer** is
  a component-layer concern: `VirtualGridColumn.cell` on `VirtualGrid`, a React
  prop, not an authoring key.

  ### Inline data [#inline-data]

  A grid normally queries `objectName`. To render fixed rows instead — demos,
  fixtures, tests — give it a `ViewData` with the `value` provider. The rows go
  under `items`.

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": [
      { "field": "name", "label": "Name" },
      { "field": "email", "label": "Email" }
    ],
    "data": {
      "provider": "value",
      "items": [
        { "id": 1, "name": "John Doe", "email": "john@example.com", "status": "Active" },
        { "id": 2, "name": "Jane Smith", "email": "jane@example.com", "status": "Active" }
      ]
    }
  }
  ```

  ### Selectable Grid [#selectable-grid]

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": ["name", "email"],
    "selection": { "type": "multiple" },
    "bulkActions": ["delete", "export"]
  }
  ```

  `selection.type` is the canonical spelling; the boolean `selectable` is a
  deprecated legacy alias, read only when `selection` is absent. Declaring bulk
  actions auto-enables multi-select, so the two keys agree by construction.

  To react to a selection in React, pass the `onRowSelect` **component prop** —
  see [Row callbacks are component props](#row-callbacks-are-component-props).

  ### Grid with Pagination [#grid-with-pagination]

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": ["name", "email"],
    "pagination": { "pageSize": 10, "pageSizeOptions": [10, 20, 50, 100] }
  }
  ```

  `PaginationConfig` is a **strict** object of exactly `pageSize` and
  `pageSizeOptions` — there is no `showSizeChanger`, and none is needed: the pager
  always carries a rows-per-page picker, and `pageSizeOptions` only replaces the
  choices it offers with your own.

  ### Grouping is page-scoped [#grouping-is-page-scoped]

  `grouping` groups **the records the browser has already fetched** — one page —
  not the whole result set. The grid buckets the rows in hand and computes every
  per-group count and aggregation from that same array, so both the set of groups
  and every number in a group header are properties of the page, not of the query.

  Two consequences, and the second is the one to decide against before you ship
  the view:

  * Every group count is a page slice, so it can be lower than the group's real
    size.
  * **A group whose records all fall beyond the page does not appear at all.** On
    a grid read as an org lens ("what is outstanding, per business unit"), a unit
    that is silently absent is a wrong answer that looks like good news.

  Since objectui#7189 the grid says so on screen rather than leaving it silent.
  When the result set is larger than the rows loaded, a short `Partial` marker
  appears beside **every group count*&#x2A; — where the authoritative-looking number
  is — and a line above the group list carries the whole sentence, e.g.
  &#x2A;"Grouped over the first 100 of 186 records…"&#x2A;. When no match total is
  reachable the wording weakens honestly to &#x2A;"Grouped over the 100 records
  loaded. More may match this view…"*. A grouped grid whose result set fits in
  one page shows neither: the marker is conditional, which is what makes it worth
  reading.

  The disclosure is not a fix for the scoping — it makes it visible. If a view
  needs true totals per group today, the working options are to narrow the query
  until the result set fits one page (a `filter` that scopes the lens), to raise
  `pagination.pageSize` past the result set, or to compute the aggregation server
  side and render it from a dataset rather than from grid grouping.

  ### ObjectQL Integration [#objectql-integration]

  The object comes from `objectName`; there is no `object` key. Filtering is the
  metadata `filter` (lowered to `$filter`) and search is `searchableFields`
  (lowered to `$searchFields`).

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": [
      { "field": "name", "label": "Name" },
      { "field": "email", "label": "Email" },
      { "field": "created_at", "label": "Created", "type": "datetime" }
    ],
    "filter": [{ "field": "status", "operator": "equals", "value": "active" }],
    "searchableFields": ["name", "email"],
    "pagination": { "pageSize": 20 }
  }
  ```

  The adapter itself is **not** a schema key: a schema is a serialisable document,
  while a live adapter is an object with methods. The grid reads its adapter from
  React context, which the host installs once above the whole tree with
  `<SchemaRendererProvider dataSource={...} />`.

  ## Features [#features-1]

  ### Sorting [#sorting]

  Columns sort by default. `sortable` is a **per-column** key, used to turn a
  column off; the grid-level `sort` declares the order the grid opens with. There
  is no top-level `sortable` switch.

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "sort": [{ "field": "created", "order": "desc" }],
    "columns": [
      { "field": "name", "label": "Name" },
      { "field": "email", "label": "Email", "sortable": false }
    ]
  }
  ```

  ### Filtering and search [#filtering-and-search]

  There is no per-column filter key and no top-level `filterable` switch. A grid
  narrows its query two ways: a `filter` baked into the metadata, and a toolbar
  search over the fields named in `searchableFields`.

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "filter": [
      { "field": "status", "operator": "equals", "value": "active" },
      { "field": "created", "operator": "after", "value": "2026-01-01" }
    ],
    "searchableFields": ["name", "email"],
    "columns": ["name", "email", "status"]
  }
  ```

  ### Row Actions [#row-actions]

  `rowActions` and `bulkActions` are lists of **action names** — the actions
  themselves live in the object's action set, so the same action behaves
  identically wherever it is offered. They are `string[]`, not inline definitions
  carrying callbacks.

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": ["name", "email"],
    "rowActions": ["view", "edit", "delete"],
    "selection": { "type": "multiple" },
    "bulkActions": ["delete", "export"]
  }
  ```

  ### Row callbacks are component props [#row-callbacks-are-component-props]

  `onRowClick`, `onRowSelect`, `onCellChange`, `onRowSave`, `onBatchSave`,
  `onEdit`, `onDelete`, `onBulkDelete` and `onAddRecord` are React props on
  `ObjectGridComponentProps`. They are functions, so no metadata document can hold
  them, and writing one of them into a schema does nothing at all: the grid builds
  the inner table's handlers itself and never reads any of these nine off the
  schema.

  The one callback the grid does read off the schema is `onNavigate`, declared on
  `ObjectGridSchema` for programmatic callers only. It is a function value too, so
  it is no more authorable than the nine — it is deliberately absent from the
  manifest and the designer panel, and prefer passing it as a prop
  (objectui#5234, maintainer ruling of 2026-08-19).

  ```tsx
  import { ObjectGrid } from '@object-ui/plugin-grid';
  import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';

  export const Grid = (props: ObjectGridComponentProps) => (
    <ObjectGrid
      {...props}
      onRowClick={(record) => console.log('Row clicked:', record)}
      onRowSelect={(rows) => console.log('Selection changed:', rows)}
    />
  );
  ```

  Note `onRowSelect` — the prop that reports a selection change is spelled that
  way; there is no `onSelectionChange` on this component.

  The declarative alternative, which *is* metadata and survives a round trip
  through storage, is `navigation`: its `mode` decides what a row click does
  without any host code.

  ### Inline Editing [#inline-editing]

  Enable inline cell editing for quick data updates:

  ```json
  {
    "type": "object-grid",
    "objectName": "users",
    "columns": [
      { "field": "id", "label": "ID" },
      { "field": "name", "label": "Name" },
      { "field": "email", "label": "Email" },
      { "field": "status", "label": "Status", "type": "select" }
    ],
    "editable": true,
    "singleClickEdit": false
  }
  ```

  `editable` is the only switch: it is a grid-level flag, and edits persist
  through the host's data source (`dataSource.update`) with no callback to wire.

  **Features:**

  * **Double-click to edit**: double-click any editable cell to enter edit mode
    (`singleClickEdit: true` opens it on the first click instead)
  * **Keyboard shortcuts**: press Enter on a focused cell to start editing, Enter
    again to save, Escape to cancel
  * **Per-field read-only**: which cells open is decided by the **field
    definition**, not by a column key — a field marked `readonly`, and
    computed/binary field types (formula, autonumber, file, …), never open an
    editor. There is no `editable` key on `ListColumn`.
  * **Visual feedback**: editable cells show a hover state, and the input is
    focused and selected when editing begins

  To own persistence in a React host, supply `onCellChange` as a **component
  prop** — it is not a schema key.

  ### Batch Editing & Multi-Row Save [#batch-editing--multi-row-save]

  Edit multiple cells across multiple rows and save them individually or all at
  once. The schema half is just `editable` — the save/cancel affordances appear on
  their own once a row has pending changes:

  ```json
  {
    "type": "object-grid",
    "objectName": "products",
    "columns": [
      { "field": "sku", "label": "SKU" },
      { "field": "name", "label": "Name" },
      { "field": "price", "label": "Price", "type": "currency", "align": "right" },
      { "field": "stock", "label": "Stock", "type": "number", "align": "right" }
    ],
    "editable": true
  }
  ```

  Left alone, saving goes through the host's data source. A React host that needs
  to own persistence supplies `onRowSave` / `onBatchSave` as **component props** —
  and because they are props, they take the adapter from the host's own scope
  rather than from anything in the schema:

  ```tsx
  import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';

  type Persistence = Pick<ObjectGridComponentProps, 'onRowSave' | 'onBatchSave'>;

  const persistence = (
    dataSource: NonNullable<ObjectGridComponentProps['dataSource']>,
  ): Persistence => ({
    onRowSave: async (rowIndex, changes, row) => {
      await dataSource.update('products', row.id, changes);
    },
    onBatchSave: async (allChanges) => {
      await Promise.all(
        allChanges.map(({ row, changes }) => dataSource.update('products', row.id, changes)),
      );
    },
  });
  ```

  **Features:**

  * **Pending changes tracking**: edit multiple cells across rows before saving
  * **Visual indicators**: modified rows highlighted in amber, modified cells in bold
  * **Row-level save/cancel**: individual row save and cancel buttons
  * **Batch operations**: Save All and Cancel All buttons for bulk actions
  * **Flexible callbacks** — all three are `ObjectGridComponentProps`, never schema
    keys: `onRowSave` for a single row, `onBatchSave` for many, `onCellChange` for
    each staged cell edit

  ## TypeScript Support [#typescript-support]

  The schema and column types come from `@object-ui/types`; this package exports
  the *component* types. Neither `GridSchema` nor `GridColumn` is on this
  package's export surface, and both names are taken elsewhere by different
  things — `GridSchema` in `@object-ui/types` is the CSS Grid **layout**
  container, and `GridColumn` in `@object-ui/fields` is a column of the
  line-items form widget (keyed `name`). The data-grid pair is
  `ObjectGridSchema` + `ListColumn`.

  ```typescript
  import type { ObjectGridSchema, ListColumn } from '@object-ui/types';
  import type { ObjectGridComponentProps } from '@object-ui/plugin-grid';

  const nameColumn: ListColumn = {
    field: 'name',
    label: 'Full Name',
    sortable: true
  };

  const grid: ObjectGridSchema = {
    type: 'object-grid',
    objectName: 'users',
    columns: [nameColumn],
    pagination: { pageSize: 20 }
  };

  // Row callbacks are COMPONENT props, not schema keys.
  const gridProps: ObjectGridComponentProps = {
    schema: grid,
    onRowClick: (record) => console.log('Row clicked:', record)
  };
  ```

  Annotating the literal is the point: an un-annotated `const schema = { … }`
  type-checks no matter what is written in it, so a snippet that carries no
  annotation cannot tell you whether its keys are real.

  ## License [#license]

  MIT
</PluginLoader>
