Extension Development Guide
TagSpaces supports three types of modular extensions: viewers (read-only file preview), editors (read/write file editing), and perspectives (custom file browsing views). All shipped extensions are hosted in the tagspaces-extensions monorepo on GitHub.
This guide covers building a viewer or editor extension. For perspective development, refer to the source code of an existing perspective such as grid as perspectives are more tightly coupled to the main application.
How extensions work
Extensions run inside a sandboxed iframe within TagSpaces. Communication between the host application and the extension happens exclusively through the browser's postMessage API. This isolation means extensions cannot access the file system directly — the host reads and delivers file content, and the extension posts changes back.
┌─────────────────────────────────────┐
│ TagSpaces App │
│ │
│ ┌──────────────────────────────┐ │
│ │ Extension (sandboxed │ │
│ │ iframe) │ │
│ │ │ │
│ │ window.addEventListener │ │
│ │ ('message', handler) │ │
│ └──────────┬───────────────────┘ │
│ │ postMessage │
│ ▼ │
│ TagSpaces host │
└─────────────────────────────────────┘
The lifecycle of opening a file:
- User opens a file whose extension is assigned to your viewer/editor
- TagSpaces loads your extension's
index.htmlin an iframe - TagSpaces sends a
loadFilemessage with the file content and metadata - Your extension renders the content
- For editors: on save, your extension sends a
saveFilemessage with the updated content
Extension types
| Type | Can read | Can write | Example |
|---|---|---|---|
| Viewer | ✓ | ✗ | image-viewer, pdf-viewer |
| Editor | ✓ | ✓ | md-editor, text-editor |
| Perspective | — | — | Grid, Kanban, Gallery |
Two approaches to building extensions
TagSpaces extensions come in two flavours depending on their complexity:
| Approach | Build step | Examples | When to use |
|---|---|---|---|
| Plain JavaScript | None | image-viewer, json-editor, text-viewer | Wrapping an existing JS library, lightweight viewers |
| React + bundler | npm run build | md-editor, media-player, text-editor | Rich editors, complex UI, MUI components |
Both approaches use the same index.html entry point and the same postMessage API — the only difference is how the JavaScript is written and whether a build step is involved.
Prerequisites
- Git
- Node.js 18+ (only needed for React extensions)
Setting up the development environment
1. Clone the extensions repository
git clone https://github.com/tagspaces/tagspaces-extensions.git
cd tagspaces-extensions
The repository is a monorepo. Each extension lives in its own subdirectory:
tagspaces-extensions/
├── image-viewer/ ← plain JS
├── json-editor/ ← plain JS
├── text-viewer/ ← plain JS
├── md-editor/ ← React + Vite
├── media-player/ ← React + Vite
├── my-new-extension/ ← your extension goes here
└── ...
2. Create your extension directory
mkdir my-extension
cd my-extension
Extension structure
Plain JavaScript extension
No build step. TagSpaces loads index.html directly from the extension folder:
my-extension/
├── package.json ← manifest (no build scripts needed)
├── index.html ← entry point, loaded directly by TagSpaces
├── extension.js ← your vanilla JS code
├── extension.css ← optional styles
├── libs/ ← vendored third-party libraries
│ └── some-library/
└── locales/
├── en/
│ └── ns.my-extension.json
└── de/
└── ns.my-extension.json
React extension (with build step)
TagSpaces loads the compiled output from the dist/ folder:
my-extension/
├── package.json ← manifest and build config
├── vite.config.js
├── src/
│ ├── App.jsx
│ └── index.jsx
├── dist/ ← compiled output, loaded by TagSpaces
│ └── index.html
└── locales/
└── en/
└── ns.my-extension.json
package.json
The package.json serves as both the npm manifest and the extension manifest. TagSpaces reads the tagspaces section to determine how to register your extension:
{
"name": "my-extension",
"version": "1.0.0",
"description": "A TagSpaces extension for viewing XYZ files",
"license": "MIT",
"tagspaces": {
"extensionId": "my-extension",
"extensionName": "My Extension",
"extensionType": "viewer",
"fileTypes": [
{
"type": "XYZ Viewer",
"ext": ["xyz", "xyzx"],
"color": "#2196f3"
}
]
},
"scripts": {
"build": "...",
"dev": "..."
}
}
Key fields in the tagspaces section:
| Field | Description |
|---|---|
extensionId | Unique identifier, matches the folder name |
extensionName | Human-readable name shown in the UI |
extensionType | "viewer" or "editor" |
fileTypes | Array of file type associations |
fileTypes[].ext | Array of file extensions this extension handles |
The postMessage API
Receiving a file (viewer and editor)
When TagSpaces loads your extension and a file needs to be displayed, it sends a loadDefaultTextContent message. Capture event.origin on the first message so you can use it as the targetOrigin when posting back:
Saving changes (editor only)
When the user saves, post a saveFile message back using the captured trustedOrigin:
function saveFile(updatedContent) {
window.parent.postMessage(
{
command: 'saveFile',
payload: {
fileContent: updatedContent,
},
},
trustedOrigin ?? '*'
);
}
Signaling that the extension is ready
After your extension has initialised and is ready to receive the file, send a contentLoaded message so TagSpaces knows the iframe is ready:
window.parent.postMessage({ command: 'contentLoaded' }, '*');
Summary of available commands
| Direction | Command | Description |
|---|---|---|
| App → Extension | loadDefaultTextContent | Delivers file content and metadata |
| Extension → App | contentLoaded | Extension is ready |
| Extension → App | saveFile | Sends updated content back to be saved |
| Extension → App | setSearchQuery | Request to highlight search terms |
Building a plain JavaScript extension
Plain JS extensions are the simplest approach. The entire extension lives in a single index.html with libraries loaded from a local libs/ folder or a CDN. There is no build step — what you write is what TagSpaces loads.
index.html
The index.html loads your libraries and wires up the postMessage API:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="libs/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="extension.css" />
</head>
<body>
<div id="content"></div>
<script src="libs/i18next/i18next.min.js"></script>
<script src="libs/some-library/some-library.min.js"></script>
<script src="extension.js"></script>
</body>
</html>
extension.js
The main script handles the postMessage lifecycle:
(function () {
'use strict';
var trustedOrigin = null;
// Signal readiness as soon as the script runs
window.parent.postMessage({ command: 'contentLoaded' }, '*');
// Listen for messages from TagSpaces
window.addEventListener('message', function (event) {
if (event.source !== window.parent) return;
trustedOrigin = event.origin; // capture for replies
var data = event.data;
if (data.command === 'loadDefaultTextContent') {
renderContent(data.payload.fileContent, data.payload.editMode);
}
});
function renderContent(content, editMode) {
var container = document.getElementById('content');
// Use your library to render content into container
container.textContent = content;
}
})();
For an editor, add a save handler. A common pattern is wiring it to Ctrl+S:
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
}
});
function saveFile() {
var updatedContent = getEditorContent(); // read from your library
window.parent.postMessage(
{ command: 'saveFile', payload: { fileContent: updatedContent } },
trustedOrigin ?? '*'
);
}
package.json for a plain JS extension
No build or dev scripts are needed — just the manifest:
{
"name": "my-extension",
"version": "1.0.0",
"description": "A TagSpaces extension for viewing XYZ files",
"license": "MIT",
"tagspaces": {
"extensionId": "my-extension",
"extensionName": "My Extension",
"extensionType": "viewer",
"fileTypes": [
{
"type": "XYZ Viewer",
"ext": ["xyz"],
"color": "#2196f3"
}
]
}
}
Common libraries used by plain JS extensions
Most plain JS extensions share a common set of libraries, vendored into the libs/ folder:
| Library | Purpose |
|---|---|
| Bootstrap | Layout and base UI styling |
| i18next | Internationalization |
| Mark.js | Text search highlighting |
| DOMPurify | Sanitize HTML before rendering |
Study image-viewer or json-editor in the monorepo as a complete reference for the plain JS pattern.
Building with React
React extensions are suited for richer UIs. They require a build step but offer the full React/MUI ecosystem. A typical setup uses a bundler like Vite or webpack. Using Vite:
npm create vite@latest . -- --template react
npm install
Update vite.config.js so the build output is a single HTML file that TagSpaces can load:
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
rollupOptions: {
output: {
// Single entry point
inlineDynamicImports: true,
},
},
},
base: './',
});
A minimal React viewer component wiring up the message API:
import { useEffect, useState } from 'react';
export default function App() {
const [content, setContent] = useState(null);
useEffect(() => {
const handler = (event) => {
const { command, payload } = event.data;
if (command === 'loadFile') {
setContent(payload.fileContent);
}
};
window.addEventListener('message', handler);
// Signal readiness
window.parent.postMessage({ command: 'contentLoaded' }, '*');
return () => window.removeEventListener('message', handler);
}, []);
if (!content) return <div>Loading...</div>;
return <pre>{content}</pre>;
}
Security considerations
Extensions run in an iframe, providing a natural isolation boundary.
Extension-author controls
The following practices should be applied in every extension.
Add a CSP meta tag to index.html
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'none'; object-src 'none'">
connect-src 'none'prevents the extension from making any outbound network requests — critical for a privacy-first appobject-src 'none'blocks legacy plugin content
Sanitise any HTML before rendering it
Never assign file content directly to innerHTML. Use DOMPurify (already a standard dependency in plain JS extensions):
// ✗ unsafe
container.innerHTML = fileContent;
// ✓ safe
container.innerHTML = DOMPurify.sanitize(fileContent);
Validate the postMessage sender
Always check that messages originate from the TagSpaces host before processing them:
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return; // ignore unexpected senders
const { command, payload } = event.data;
// ...
});
Avoid dynamic code execution
Do not use eval(), new Function(), or setTimeout/setInterval with string arguments. Use 'use strict' at the top of every script.
Internationalization
Extensions use i18next for translations. Translation files live in a locales/ directory:
my-extension/
└── locales/
├── en/
│ └── ns.my-extension.json
└── de/
└── ns.my-extension.json
Translations are contributed via Transifex.
Testing your extension locally
The easiest way to test is to point TagSpaces at your local build output. In TagSpaces Desktop:
- Build your extension:
npm run build - In TagSpaces settings → File Types, assign your extension to the target file extension
- Set the extension path to point to your local
dist/index.html
For web development, you can serve the dist/ folder with any static server and open index.html directly in a browser, then simulate the loadFile message from the browser console:
// Simulate TagSpaces sending a file to your extension
window.dispatchEvent(new MessageEvent('message', {
data: {
command: 'loadFile',
payload: {
fileContent: 'Hello, world!',
filePath: '/test/hello.xyz',
fileName: 'hello.xyz',
editMode: false,
},
},
}));
Contributing your extension
All official extensions are part of the tagspaces-extensions monorepo. To contribute:
- Fork the repository
- Add your extension in a new subdirectory following the structure for your chosen approach
- For plain JS extensions: ensure
index.htmlworks directly without any build step - For React extensions: ensure
npm run buildproduces a workingdist/index.html - Open a pull request with a description of what file types your extension handles
Reference: existing extensions
Studying existing extensions is the fastest way to understand the patterns. Good starting points:
| Extension | Approach | Type | Complexity | Good for learning |
|---|---|---|---|---|
| text-viewer | Plain JS | Viewer | Minimal | Basic message API wiring, no build step |
| image-viewer | Plain JS | Viewer | Medium | Vendoring libraries, EXIF handling |
| json-editor | Plain JS | Editor | Medium | Plain JS editor, save flow |
| text-editor | React | Editor | Medium | React + Monaco, save flow |
| media-player | React | Viewer | Medium | React + MUI + Vidstack |
| md-editor | React | Editor | Advanced | React, rich WYSIWYG, Milkdown |