Tintero Developers

React, Vue & bundlers

Shipping a framework app as a Tintero plugin. The two Vite configurations that work, why single-file output matters, and how to keep the type definitions honest.

On this page

No plugin needs a build step. But a panel with real state is nicer to write in a framework, and every awkward part of the configuration below comes from one fact: your plugin is pasted in as script text, not served as files.

Why it all has to be one file

Tintero reads your main file and drops the contents into the frame. That frame is locked down with default-src 'none', so nothing inside it can fetch anything: not another chunk, not a stylesheet, not a font.

Which rules out code splitting, dynamic imports of your own chunks, and anything referenced by URL. It all has to end up in one file.

Vite and React, with the CSS in its own file

Produce plugin.js and plugin.css. Tintero loads a stylesheet named after main by itself, so plugin.css arrives with nothing configured on your side.

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    cssCodeSplit: false, // one stylesheet, not one per component
    rollupOptions: {
      output: {
        entryFileNames: 'plugin.js',
        assetFileNames: (asset) => (asset.name === 'style.css' ? 'plugin.css' : asset.name),
      },
    },
  },
});
{
  "scripts": {
    "build": "vite build",
    "package": "npm run build && cp plugin.json icon.svg dist/ && cd dist && zip -r ../plugin.zip plugin.js plugin.css plugin.json icon.svg"
  }
}

Remember: that only happens when main ends in .js. Here, it does.

Vite and React, with the CSS inside the bundle

If you would rather ship exactly one file, let a plugin fold the stylesheet into the bundle. This is what Full Focus Mode does:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';

export default defineConfig({
  plugins: [react(), cssInjectedByJsPlugin()],
  base: './',
  build: {
    outDir: 'dist',
    emptyOutDir: true,
    rollupOptions: {
      output: {
        entryFileNames: 'plugin.js',
        manualChunks: undefined, // no code splitting: nothing can be fetched later
      },
    },
  },
  define: {
    'process.env.NODE_ENV': '"production"',
  },
});

manualChunks: undefined is the line that matters. Without it, Rollup will happily split your vendor code into a second file that can never load.

You can also do the CSS yourself, which is one dependency fewer:

// src/index.jsx
import styles from './style.css?inline';

const sheet = document.createElement('style');
sheet.textContent = styles;
document.head.appendChild(sheet);

Mounting

Tintero gives you a plugin-root element. Mount into it, and create it if it is not there, so the same bundle still runs when you open it straight in a browser while developing:

// src/index.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

class MyPlugin extends TinteroPlugin {
  async onActivate() {
    let root = document.getElementById('plugin-root');

    if (!root) {
      root = document.createElement('div');
      root.id = 'plugin-root';
      document.body.appendChild(root);
    }

    ReactDOM.createRoot(root).render(<App />);
  }
}

registerPlugin(new MyPlugin());

Both styles work: set plugin.onActivate on an instance, or extend TinteroPlugin and override the method. Extending reads better once your plugin has some structure to it.

Getting types

Copy tintero-plugin-sdk.d.ts next to your source. It declares the globals, so there is nothing to import:

// anywhere in your source
const characters = await tintero.project.getCharacters();
//    ^? Character[]

For the manifest, give the object a type and let the compiler check your permissions:

import type { PluginManifest } from './tintero-plugin-sdk';

export const manifest: TinteroSDK.PluginManifest = {
  id: 'com.example.stats',
  name: 'Statistics',
  version: '1.0.0',
  description: 'Charts for your manuscript',
  author: { name: 'You' },
  type: 'app',
  surfaces: ['app'],
  main: 'plugin.js',
  scopes: ['project.read', 'project.read.files', 'convert.format'],
};

A misspelled permission becomes a compile error instead of a SCOPE_DENIED in somebody else’s hands.

Development loop with a bundler

Vite’s dev server is no help here, because your code has to run inside Tintero’s frame to reach the tintero global. Two things that do work:

  1. Build, and let Tintero watch. Point auto-reload at your plugin.zip and run vite build --watch with a packaging step. Slower than hot reload, but it is the real thing.
  2. Fake the global and use the dev server for interface work. Stub tintero as shown in Development workflow, build your components against it with hot reload, and go back to the real app for anything to do with permissions, surfaces or theming.

Most people use the second for layout and the first for everything else.

How big is too big

There is no limit on how big your bundle can be. The cap you may have read about is on the arguments you pass to the API, not on your code. But your bundle gets pasted in as script every time your plugin starts, so a few megabytes of it makes the panel visibly slow to open. Pick small libraries, and look at what you are actually shipping before you publish.