# Item types Register a first-class item kind, its own file extension, route, icon, and editor, and Undra treats it like a built-in note or canvas: it persists the file, you own the body and the renderer. This is the deepest contribution an extension can make. One `registerItemType` call, plus a presentation and a renderer, gives you a new kind of document that lives in the explorer, opens in a tab, and shows up in the right-click **New** menu. This page covers every field, the two registration modes, and exactly how persistence works. If you have not read [the extension overview](/docs/extensions/) yet, start there: the format (`manifest` + `activate(ctx)`), the no-`import` rule, and the `ctx` API shape are assumed here. ## The three calls {#three-calls} A working item type is three registrations inside `activate(ctx)`: 1. `ctx.registry.registerItemType(manifest.id, def)`, claim the type (identity, file, route, template). 2. `ctx.registerItemTabPresentations([...])`, give it an icon and a title in tabs and the dock. 3. `ctx.registerItemTabRenderers([...])`, supply the React component that renders the editor. The type `id` (a single lowercase token, no dots) is the thread that ties all three together. The same `id` you pass to `registerItemType` is the `id` you use in the presentation and renderer entries. :::warn The type id is not the manifest id `manifest.id` is dot-namespaced (`community.example.book`). The **type** `id` is one lowercase token with no dots (`book`). A dot in the type id is one of the three most common reasons an extension fails to load. ::: ## ItemTypeContribution field by field {#fields} This is the object you pass as the second argument to `ctx.registry.registerItemType(manifest.id, def)`. ```javascript ctx.registry.registerItemType(manifest.id, { id: 'book', // the TYPE: one lowercase token, no dots label: 'Book', // singular human label pluralLabel: 'Books', // optional; defaults to `${label}s` dockIconClassSuffix: 'book', // optional; presentation/dock suffix fileExtension: '.ubook', // full mode: leading-dot, lowercase routePrefix: '/books', // full mode: route under which items open emptyBodyTemplateKind: 'json', // full mode: 'json' or 'markdown' }) ``` | Field | Required | What it does | |-------|----------|--------------| | `id` | yes | The type identity. **One lowercase token, no dots.** Reused verbatim in the presentation and renderer entries, and is the `type` you pass to `ctx.workspace.create`. | | `label` | yes | Singular human-readable name (`Book`). Shown in menus and surfaces. | | `pluralLabel` | no | Plural for list and "New X" surfaces. **Defaults to `` `${label}s` ``**, so `Book` gives `Books` for free. Set it only when the default is wrong (`Person` to `People`, not `Persons`). | | `dockIconClassSuffix` | no | Presentation suffix used by the explorer and dock. Match it to the same suffix in your tab presentation so the icon is consistent everywhere. By convention this is just the type id. | | `fileExtension` | full mode | The file extension Undra owns for this type. **Leading dot, lowercase** (`.ubook`). Convention is `.u` + the type id. | | `routePrefix` | full mode | The route prefix under which items of this type open (`/books`). Convention is `/` + the plural. | | `emptyBodyTemplateKind` | full mode | The body the kernel writes to a brand-new file: `'json'` writes `{}`, `'markdown'` writes an empty string. Pick `'json'` for structured types, `'markdown'` for prose. | | `editor` | no | **Reserved.** A future kernel-routing hook. Omit it. You supply the editor through `registerItemTabRenderers`, not here. | ## Full first-class mode vs metadata-only {#modes} The same call has two modes, decided entirely by whether you set the three "file lifecycle" fields. ### Full first-class mode Set **all three** of `fileExtension`, `routePrefix`, and `emptyBodyTemplateKind`. You are declaring a type that behaves like a built-in. The split of responsibility: - **The host owns the file lifecycle.** When the user picks **New to Book**, the kernel creates a real `.ubook` file, seeds it with the empty template (`{}` for `json`), opens it under `/books`, persists it, and reloads it on restart. You do not touch files. - **You own the body and the renderer.** The body is an opaque string whose meaning is yours. You read it with `ctx.workspace.getDocument`, write it with `ctx.workspace.update`, and render it with the component you registered. This is a custom-editor model: the host handles the document, you handle its contents and its UI. This is what almost every item-type extension wants. The Book example below is full mode. ### Metadata-only mode Omit all three of `fileExtension`, `routePrefix`, and `emptyBodyTemplateKind`. You register only identity (`id`, `label`, `dockIconClassSuffix`). There is no host-managed file, no route, no "New" entry that creates a document. Use this when you only need the type to exist for labelling and presentation, not as a createable, persisted document. :::note It is all-or-nothing The three full-mode fields go together. Set them all for a real document type, or set none of them for metadata-only. A partial set (for example `fileExtension` without `routePrefix`) is not a supported configuration. ::: ## Presentations {#presentations} `ctx.registerItemTabPresentations([...])` gives the type its visual identity in tabs, the explorer, and the dock. Each entry: ```javascript ctx.registerItemTabPresentations([ { id: 'book', // MUST match the item type id title: 'Book', // label shown on the tab icon: Icon, // a component: (props) => element dockIconClassSuffix: 'book', // match the registerItemType suffix }, ]) ``` | Field | What it does | |-------|--------------| | `id` | The item type id. This is how the presentation is bound to your type. | | `title` | The title shown on the tab for this type. | | `icon` | A component called with a props object. Read `props.size` (a pixel number) and render an element. Build it with `ctx.runtime.createElement`. | | `dockIconClassSuffix` | Optional. The dock/explorer icon suffix; keep it equal to the suffix on the type itself. | The `icon` is a plain function component. A minimal one: ```javascript function Icon(p) { return h('span', { style: { fontSize: (p && p.size ? p.size : 14) + 'px' } }, '📘') } ``` ## The renderer contract {#renderer} `ctx.registerItemTabRenderers([...])` supplies the actual editor. Each entry is `{ id, render }`, where `render(p)` is called for **every** tab Undra opens, not just yours. The guard is mandatory: return your component only when the tab is an item of your type, and `null` otherwise. ```javascript ctx.registerItemTabRenderers([ { id: 'book', render: function (p) { return (p.tab && p.tab.kind === 'item' && p.tab.itemType === 'book') ? h(Editor, { itemId: p.tab.itemId }) : null }, }, ]) ``` What `render` receives in `p`: - `p.tab.kind`, the tab kind. Check it equals `'item'`. - `p.tab.itemType`, the registered type id. Check it equals your `id`. - `p.tab.itemId`, the id of the open item. Pass this to your component so it can load and save the body. :::warn Always guard, always return null `render` runs for tabs that are not yours (notes, canvases, other extensions' types). If you do not check `p.tab.itemType` and return `null` for non-matches, you will try to render your editor over unrelated tabs. The `(p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE) ? ... : null` pattern is the contract, not a suggestion. ::: ## Persistence: you own the body string {#persistence} In full mode the host creates and tracks the file, but the **body is an opaque string you control**. The contract is two methods on `ctx.workspace`: - `ctx.workspace.getDocument(itemId)` resolves to `{ id, title, content }`. `content` is the raw body string. - `ctx.workspace.update(itemId, patch)` writes it back. `patch` is `{ title?, content? }`; pass `content` to save the body, `title` to rename the item. There is no schema and no validation. If you stored JSON, you parse it; if you stored markdown, you read it as text. For a structured type the standard pattern is: keep a JSON string on disk, parse on load into a defaulted object, stringify on every save. ```javascript // LOAD: parse defensively, always fall back to a default shape. const doc = await ctx.workspace.getDocument(itemId) const data = { author: '', rating: 0, notes: '' } try { Object.assign(data, JSON.parse(doc.content || '{}')) } catch (e) { /* keep defaults */ } // SAVE: stringify the whole object back into content. ctx.workspace.update(itemId, { content: JSON.stringify(data) }) ``` Three things to get right: - **Tolerate an empty body.** A freshly created `json` item arrives as `{}` (the empty template). Parsing it gives an empty object, so your defaults fill in the missing fields. Always merge onto a default shape rather than trusting the parsed result. - **Wrap the parse in try/catch.** The body is a string you (or a past version of your code) wrote. Never assume it parses. - **Save the whole object.** `content` is the entire body. `update` replaces it; there is no field-level patch on the body. Read-modify-write the full object each time. :::note getDocument is the body, not just metadata `getDocument` returns the item's title plus its raw body `content`. That is everything your editor needs. You do not read files yourself and you do not reach into renderer internals; the `ctx.workspace` contract is the whole surface. ::: ## Complete runnable example {#example} A first-class **Book** type: author, rating, and notes, persisted as JSON, with its own editor. Save it at `/.undra/extensions/book/extension.js`, restart Undra, then right-click a folder to get **New to Book**. ```javascript file="extension.js" export const manifest = { id: 'community.example.book', version: '0.1.0', displayName: 'Book', publisher: 'example', capabilities: ['itemTypes.registry'], } export function activate(ctx) { const { createElement: h, useState, useEffect } = ctx.runtime const TYPE = 'book' // 1. Claim the type. All three file-lifecycle fields set = full first-class. ctx.registry.registerItemType(manifest.id, { id: TYPE, label: 'Book', pluralLabel: 'Books', dockIconClassSuffix: TYPE, fileExtension: '.u' + TYPE, // '.ubook' routePrefix: '/' + TYPE + 's', // '/books' emptyBodyTemplateKind: 'json', // new files start as '{}' }) // 2. Icon + title for tabs, explorer, and the dock. function Icon(p) { return h('span', { style: { fontSize: (p && p.size ? p.size : 14) + 'px' } }, '📘') } ctx.registerItemTabPresentations([ { id: TYPE, title: 'Book', icon: Icon, dockIconClassSuffix: TYPE }, ]) // 3. The editor component. Owns load (parse) and save (stringify) of the body. function Editor(props) { const itemId = props.itemId const [data, setData] = useState(null) useEffect(function () { let cancelled = false ctx.workspace.getDocument(itemId).then(function (doc) { if (cancelled) return var v = { author: '', rating: 0, notes: '' } try { Object.assign(v, JSON.parse(doc.content || '{}')) } catch (e) {} setData(v) }).catch(function () { if (!cancelled) setData({ author: '', rating: 0, notes: '' }) }) return function () { cancelled = true } }, [itemId]) if (!data) { return h('div', { style: { padding: '24px', color: 'var(--u-text2,#8c969f)' } }, 'Loading...') } // Read-modify-write the whole object, then persist the full body string. var save = function (next) { setData(next) ctx.workspace.update(itemId, { content: JSON.stringify(next) }) } var field = { width: '100%', boxSizing: 'border-box', padding: '9px 12px', borderRadius: '9px', font: 'inherit', fontSize: '13.5px', border: '1px solid var(--u-border0,rgba(255,255,255,0.12))', background: 'var(--u-bgPanel,#16191c)', color: 'var(--u-text0,#f2f4f5)', outline: 'none', marginBottom: '10px', } return h('div', { style: { height: '100%', overflow: 'auto', padding: '32px', background: 'var(--u-bgEditor,var(--u-bg0,#111315))', color: 'var(--u-text0,#f2f4f5)', }, }, h('div', { style: { maxWidth: '520px' } }, h('div', { style: { fontSize: '18px', fontWeight: 650, marginBottom: '14px' } }, '📘 Book'), h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Author'), h('input', { value: data.author, onChange: function (e) { save(Object.assign({}, data, { author: e.target.value })) }, style: field, }), h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Rating (0-5)'), h('input', { type: 'number', min: 0, max: 5, value: data.rating, onChange: function (e) { save(Object.assign({}, data, { rating: Number(e.target.value) })) }, style: field, }), h('label', { style: { fontSize: '12px', color: 'var(--u-text2,#8c969f)' } }, 'Notes'), h('textarea', { value: data.notes, rows: 8, onChange: function (e) { save(Object.assign({}, data, { notes: e.target.value })) }, style: Object.assign({}, field, { resize: 'vertical' }), }), ), ) } // 4. Bind the renderer to the type. Guard: only render OUR item tabs. ctx.registerItemTabRenderers([ { id: TYPE, render: function (p) { return (p.tab && p.tab.kind === 'item' && p.tab.itemType === TYPE) ? h(Editor, { itemId: p.tab.itemId }) : null }, }, ]) } ``` The item's **title** is the book's name (the host owns it; rename it through `ctx.workspace.update(itemId, { title })` if you want an in-editor rename). The **body** (author, rating, notes) is the JSON string you persist to the `.ubook` file. Restart Undra and the data reloads intact. :::tip Verify the round-trip After editing a Book, restart Undra and reopen it. If your fields are gone, you almost certainly saved partially or forgot to merge onto defaults on load. The `getDocument` to parse to default-merge to `update` to stringify loop is what makes the round-trip survive a restart. ::: ## It appears in the New menu {#new-menu} Once a full-mode type is registered, Undra wires it into the explorer automatically. **Right-click a folder to get New to (your label)** and the kernel creates a new file with your extension, seeds it with the empty-body template, opens it under your route prefix, and hands it to your renderer. You wrote a type, an icon, and an editor; the create, open, persist, and reload path is the host's. That is the whole payoff of full first-class mode. ## Where to go next - [Canvas widgets](/docs/extensions/canvas-widgets/), embed an interactive widget on the canvas with the same `ctx.runtime` React. - [Commands & AI context](/docs/extensions/commands-and-context/), add palette commands and feed your item's data into AI prompts. - [Packaging & distribution](/docs/extensions/packaging/), bundle a multi-file TypeScript item type into one `extension.js` and ship it.