Skip to main content

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:

  1. User opens a file whose extension is assigned to your viewer/editor
  2. TagSpaces loads your extension's index.html in an iframe
  3. TagSpaces sends a loadFile message with the file content and metadata
  4. Your extension renders the content
  5. For editors: on save, your extension sends a saveFile message with the updated content

Extension types

TypeCan readCan writeExample
Viewerimage-viewer, pdf-viewer
Editormd-editor, text-editor
PerspectiveGrid, Kanban, Gallery

Two approaches to building extensions

TagSpaces extensions come in two flavours depending on their complexity:

ApproachBuild stepExamplesWhen to use
Plain JavaScriptNoneimage-viewer, json-editor, text-viewerWrapping an existing JS library, lightweight viewers
React + bundlernpm run buildmd-editor, media-player, text-editorRich 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:

FieldDescription
extensionIdUnique identifier, matches the folder name
extensionNameHuman-readable name shown in the UI
extensionType"viewer" or "editor"
fileTypesArray of file type associations
fileTypes[].extArray 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

DirectionCommandDescription
App → ExtensionloadDefaultTextContentDelivers file content and metadata
Extension → AppcontentLoadedExtension is ready
Extension → AppsaveFileSends updated content back to be saved
Extension → AppsetSearchQueryRequest 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:

LibraryPurpose
BootstrapLayout and base UI styling
i18nextInternationalization
Mark.jsText search highlighting
DOMPurifySanitize 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 app
  • object-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:

  1. Build your extension: npm run build
  2. In TagSpaces settings → File Types, assign your extension to the target file extension
  3. 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:

  1. Fork the repository
  2. Add your extension in a new subdirectory following the structure for your chosen approach
  3. For plain JS extensions: ensure index.html works directly without any build step
  4. For React extensions: ensure npm run build produces a working dist/index.html
  5. 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:

ExtensionApproachTypeComplexityGood for learning
text-viewerPlain JSViewerMinimalBasic message API wiring, no build step
image-viewerPlain JSViewerMediumVendoring libraries, EXIF handling
json-editorPlain JSEditorMediumPlain JS editor, save flow
text-editorReactEditorMediumReact + Monaco, save flow
media-playerReactViewerMediumReact + MUI + Vidstack
md-editorReactEditorAdvancedReact, rich WYSIWYG, Milkdown