Vite & Rollup Plugins
twee-ts ships with build tool plugins for Vite and Rollup. Both take the same options and give them the same meaning.
Vite Plugin
// vite.config.ts
import { tweeTsPlugin } from '@rohal12/twee-ts/vite';
export default {
plugins: [
tweeTsPlugin({
sources: ['src/story'],
format: 'sugarcube-2',
}),
],
};Options
interface TweeTsVitePluginOptions {
/** Source directories/files to compile, relative to the working directory. */
sources: readonly string[];
/** Story format ID. */
format?: string;
/** Output file name, relative to the output folder. Default: 'index.html'. */
outputFilename?: string;
/** Additional compile options (tagAliases, trim, modules, exclude, …). */
compileOptions?: PluginCompileOptions;
/** A JS or TS file bundled into the story as Story JavaScript (its CSS as Story Stylesheet). Vite 8+. */
entry?: string;
}The options are checked when the plugin is created. An unknown option (a misspelt outputFileName, say), a value of the wrong type (sources must be an array of non-empty strings; format, outputFilename and entry non-empty strings; each of compileOptions what compile() takes, as Option checks describes: compileOptions.exclude, say, an array of non-empty strings and { base, glob } objects, which loadConfigFile() returns for a config in a folder whose name holds glob characters), compileOptions.sources or compileOptions.formatId (set sources and format instead), an outputFilename that is not a plain relative path, and a Vite older than 8 (the plugin is not created) throw a TweeTsError (INVALID_OPTIONS) that names the option, or twee-ts and the Vite version.
outputFilename is a path inside the output folder: names separated by /, none of them empty, . or .., and no character a file system or a URL reads differently (\ : * ? " < > |, #, %, control characters), no name ending in . or a space, and no name Windows reserves (con, nul, com1, …).
Vite versions
The plugin works with Vite 8 and every later release (the vite peer dependency is >=8). Under an older Vite (an install that overrides the peer dependency, say), tweeTsPlugin() throws a TweeTsError (INVALID_OPTIONS) that names twee-ts and the Vite version, instead of the failure that Vite's own handling of the config would give. CI runs the plugin tests on the latest Vite 8 and type-checks a config against the packed plugins.
Dev server
- Serving: the compiled story is served at the base URL, with Vite's client added, at the path a static host serves the build's file at:
outputFilenamebelow the base, and forindex.html(ordir/index.html) also its folder. A query string is ignored, a percent-encoded path is decoded, and every spelling of the path names it:.and..segments (also percent-encoded) are resolved below the base, empty segments (//index.html) are dropped and a backslash is a slash, as Vite's file serving reads them. OnlyGETandHEADrequests get the story. It is served after Vite's own request checks, so Vite's host check, CORS, proxy and base handling apply to it as to any page, withserver.headersandCache-Control: no-cache, and before Vite serves files: as a build writes the story and the entry's files over apublic/file of the same name, a file inpublic/(or the root) at their path never stands in for them in dev either; every other public file is served as usual. - Rebuilds: a change to a source (Twee,
.jsand.cssfiles insidesources), the head file, a module, or any file the entry was bundled from recompiles the story and reloads the pages that show it. Saves within 50 ms are compiled once. Under abasewith a space or a non-ASCII character (/my game/,/café/), Vite encodes the base and its client cannot match a reload limited to the story's pages, so every open page reloads instead. - Missed changes: before the story is served, the plugin compares the sources, the head file, the modules and the entry's files with what the last compile read, and compiles again if any changed. The files of the sources are those its last walk of them found while no folder it listed, no source path and no build output changed since, so a request with nothing changed only looks at each folder and file once and lists none. So the story served is current even when the file watcher misses a change (after a folder is deleted and created again in quick succession, as
git rebasedoes) or is off (server.watch: null); an open page then reloads only when you reload it. - Excluded files:
compileOptions.excludeleaves files out of the story (see Excluding files). A change to an excluded file recompiles nothing, unless the entry uses the file. - Error overlay: compile and bundling errors appear in Vite's overlay with file and line, also on a page that connects after the error, and the last good story keeps being served, with the files it refers to (workers and other files the entry emits), until the next successful compile. Before the first successful compile, a page with Vite's client is served so the overlay has somewhere to appear.
- Several servers: each dev server stops its own rebuilds when it closes, so a server restarted with
server.restart()(or thershortcut), or abuild()run with the same plugin instance, leaves the running server recompiling.
Build
- Output:
vite buildemits the story asoutputFilename, in the client environment only. An SSR build and other environments (createBuilder().buildApp()) get no story. Errors fail the build; warnings go through Vite's logger. - Inputs: when the config names no input of its own, the plugin gives Vite a stand-in input, so the build needs no
index.htmland anindex.htmlin the project root does not replace the story. An input of your own (build.rolldownOptions.input/build.rollupOptions.input, Vite 8's top-levelinput,build.lib) is built as usual, next to the story. - Name clashes: the build fails, naming the file, when another input or plugin (another twee-ts instance, say) already writes a file named
outputFilename, or a file the entry emits. In development, a story route claimed by another story or an emitted entry asset returns a collision error. A story request refreshes all instances before checking, so adding or removing a conflicting entry import is detected even withserver.watch: null. - Several instances: each plugin instance writes its own story, with its own entry. One instance can take part in builds that run at once, as each build hook reads its own environment's configuration.
- Build watch:
vite build --watchbuilds the story again when a source, the head file, a module or a file the entry was bundled from changes, and when a file is added to or deleted from a source folder. A folder that holds a fileexcludeleaves out, or a build output, is not watched as a whole, so editing those starts no build; a file added straight to such a folder is read with the next build something else starts. The watcher of Vite 8.0 and 8.1 reports no change inside a watched folder, so there a file added to a source folder is read with the next build too. An input reached through a link is watched by its real path and also where it was authored, every hop of it: the link you named, each link a link leads through (story.tw->current.tw->release-3.tw, or a folder link that leads through another), and the links among the folders above. So pointing a source, module or head file link (or any of those hops, a link above a source folder, or a link found in a source folder or any hop of its chain) at another target starts a build, and edits to the new target follow. A hop that leads to a folder holding a build output, and a link directly in the root of the file system, are left out. On macOS the bundler's watcher (rolldown'srolldown-notify) registers a link by the real path of its target and drops an event at the link's own path, so replacing a link raises no event there, and a retargeted link is read with the next build something else starts. Only a registered folder reports the replacement, and it reports what the build writes there too, so the plugin cannot register the folder that holds both the link and the build output. The entry is watched the same way, whether the build bundles it inside itself or separately (a bundler input of its own): its files and the links its imports go through are registered (see Following links). A separate entry's are registered also when its bundle fails, so correcting a broken module rebuilds; an import that does not exist yet is watched through the nearest folder that exists, unless that folder holds a build output, and then creating the file is read with the next build something else starts. Vite's resolver remembers what an import resolved to from one build of the watch to the next, also after a link on the way is pointed elsewhere; for an entry bundled inside the build, the plugin then gives the import the file the link leads to now, as a new build would. - Output inside
sources: nothing a build writes is read as a source, so the output can sit inside a source folder; a later build, the dev server and their watchers leave it out. That covers the story HTML of every output (outputFilenameinbuild.outDir, or in eachoutput.dir), the chunks and assets each bundle writes, and the copies of thepublic/files Vite puts in the output folder, including files reached through directory symlinks or junctions. Within one plugin instance, earlier public copies remain excluded after a public file is removed or a directory link is retargeted. Paths are compared by their real path, so a link to the output folder doesn't let it back in. The story file itself is checked as the command line checks its output file: an existing file at its path that the sources would load (a Twine HTML file, a Twee file) and that twee-ts did not build, in a source folder, is your own, and the build fails withOUTPUT_IS_INPUTrather than overwrite it; an earlier build of the story is written over. Chunks and assets are the bundler's to write and are replaced without a check, since an earlier build's chunk cannot be told from a file of yours. The dev server writes nothing and serves the story all the same.
Full Example
// vite.config.ts
import { defineConfig } from 'vite';
import { tweeTsPlugin } from '@rohal12/twee-ts/vite';
export default defineConfig({
plugins: [
tweeTsPlugin({
sources: ['src/story'],
format: 'sugarcube-2',
outputFilename: 'index.html',
compileOptions: {
startPassage: 'Prologue',
tagAliases: {
library: 'script',
theme: 'stylesheet',
},
modules: ['src/extra.js'],
trim: true,
},
}),
],
});Bundling a script entry
With entry, Vite bundles a JS or TS file and everything it imports into one self-contained script (no import/export left), which becomes the story's Story JavaScript. CSS the entry imports becomes the Story Stylesheet. The story format runs it like any story script, so macros and configuration are in place before the first passage renders. Requires Vite 8.
// vite.config.ts
import { defineConfig } from 'vite';
import { tweeTsPlugin } from '@rohal12/twee-ts/vite';
export default defineConfig(({ mode }) => ({
plugins: [
tweeTsPlugin({
sources: ['src/story'],
format: 'sugarcube-2',
entry: 'src/app/index.ts',
compileOptions: { testMode: mode === 'development' },
}),
],
}));What goes into the story, and where:
- Script and stylesheet: the bundled script is the story's Story JavaScript and runs after any script passages from
sources; the CSS it imports is the Story Stylesheet, after any stylesheet passages fromsources. - Fonts and images the entry or its CSS use are inlined as data URLs, whatever their size, so the story stays one file. An import marked
?no-inlineis emitted as a separate file: the build writes it next to the HTML and the dev server serves it at the same URL. import.meta.url: the entry runs as a classic script in the story page, soimport.meta.urlstands for the story page's URL (taken up one level for each folder of a nestedoutputFilename), andnew URL('./image.png', import.meta.url)and Vite's worker URLs (new Worker(new URL('./worker.js', import.meta.url))) give valid URLs. A worker is a file the build writes next to the HTML (underassets/), found at the URL Vite gives it frombase. With a relativebase('./'), Vite makes the URLs of such files, and of an asset marked?no-inline, relative to the script or stylesheet that names them, which the story holds inline; with a nestedoutputFilenamethe plugin makes them find the output folder instead (throughexperimental.renderBuiltUrl; one in your config decides first). An explicitly imported.map?url&no-inlineresource is kept as an asset; only generated source maps are removed. The dev server serves the files of the bundle with the media types a browser needs (a worker astext/javascript).- Source maps: in dev the script carries an inline source map. A build ships no map file; with
build.sourcemap: 'inline'the map stays inside the story. - Keep the entry outside
sources. twee-ts loads the.jsand.cssfiles it finds in source folders, unbundled, as script and stylesheet passages; the plugin warns when the entry's folder is insidesources. - Input protection: a build rejects story output that would replace the entry or a file its bundle read, including imported modules, CSS resources, worker inputs and plugin watch files. This also protects the outer build's own inputs when you configure a separate bundler input, with or without a story
entry, including tree-shaken modules. Literal#characters in module filenames and parent folders are preserved for input protection and entry updates. A directory-only plugin watch protects existing authored files at every depth, including files reached through directory links; prior generated stories in that directory remain replaceable, including their hard-link aliases. The build fails if a watched directory cannot be inspected, since a file inside may still be readable by its known name. Files are compared by filesystem identity, including symbolic and hard links. Keep the story output separate from these inputs.
Your configuration in the entry's bundle
The entry is bundled with your whole Vite configuration, in dev as in a build, so it behaves the same in both:
- What comes across: the config file, evaluated again, with everything passed to
createServer()/build()or on the command line (vite --base /game/) on top, as Vite merges them: plugins,define,resolve(aliases, conditions, extensions),base,envPrefix/envDir/.envfiles,css(modules and the rest),assetsInclude, thebuildsettings and every other key, except these, which configure servers, logging or what is built:configFile,plugins(every twee-ts instance is left out of the entry build),root,mode,logLevel,customLogger,clearScreen,publicDir,server,preview,builderanddevtools;publicDiris the outer build's own, so public files such as/logo.svgresolve (and followbase) in the entry as in the rest of the project, and the entry build never copies them. - What the entry build sets itself: the entry is its only input, bundled into one IIFE script (
build.lib,build.ssr, the bundler'sinputand the output'sformatand file names are replaced) and held in memory (nothing is written,build.watchis off). Your bundleroutputoptions apply to the entry when they are one object; an array of outputs describes your own files, and none of it applies. - The command: in dev the configuration is evaluated for the dev command, as the dev server evaluates it: a config function sees
command: 'serve',apply: 'serve'plugins run andapply: 'build'ones don't, and plugins'config,configEnvironmentandconfigResolvedhooks seecommand: 'serve'. User plugins also seeserveinapplyToEnvironmentand in hook contexts (this.environment.config.command), including hooks of plugins returned by environment selection. In a build they seebuild. Vite's own build plugins bundle the entry in both. The mode is the server's or build's (import.meta.env.MODE,DEV). - Quiet: the entry build prints no progress lines; its warnings go to the server's or build's logger, and a failed bundle is reported once, in the overlay or as the build's error.
- Where it is bundled: in a build, when the config names no input of its own, the entry is bundled inside the build itself. Otherwise, and always in dev, with a build of its own; plugin objects passed inline are then shared with that build.
Once a bundle has succeeded, a change to a file it was built from bundles it again: its modules (also one the bundler inlined, also outside the Vite root, as a shared package of a monorepo or a workspace link is) and the files its plugins watch (CSS @imports, url() targets, a plugin's addWatchFile), matched by file identity: a path through a link, and on a case-insensitive volume one in another letter case, names the same file. While it is failing, any change in the project does as well, so creating a missing import brings the story back. A folder a plugin watches (this.addWatchFile(folder), as a plugin does that serves the files of a folder through a virtual module) covers everything in it, at any depth, as the bundler's own watcher does under vite build --watch: a file added to it, deleted from it, renamed or changed there bundles the entry again and reloads the page, in dev with the watcher off too (the folder is compared with what was in it when the plugin added it before the story is served), also for a folder outside the root or named through a link, and creating a watched folder that did not exist bundles it as well. A watcher event that arrives after a request already bundled the change it reports (the module is still, state and content, what the bundle read) bundles and reloads nothing more.
An import.meta.glob() call selects files that are in no earlier bundle, so the plugin also reads each call (as Vite reads it: literal patterns, base, exhaustive, aliases and # imports) and watches the folder each pattern matches in. Adding, deleting or renaming a file there that the pattern may match (by its extension and leading dot; node_modules only for an exhaustive call) bundles the entry again, in every form of the call (eager, lazy, Object.keys()), also for a folder outside the root or one that does not exist yet. In dev this holds with the watcher off too: the folders are compared before the story is served. Under vite build --watch the folders are registered with the build's watcher, for an entry bundled inside the build and for one bundled separately, unless a folder holds a build output; then a file added to it is bundled with the next build something else starts, as with the watcher of Vite 8.0 and 8.1, which reports no change inside a folder. A module is read whole when it is JavaScript; when it is not yet (inside a build, twee-ts sees each module at its place among your plugins, so a .vue or .svelte file a later plugin compiles, or TypeScript with type arguments, comes past as written), each import.meta.glob( in its text is read on its own, as Vite reads it, so a call inside a comment there is watched too. Options are read without executing them: literal strings (including templates without substitutions), numbers, booleans and null, arrays and nested objects, literal computed keys and repeated members (the last wins), plus signed numbers and boolean negation in query data; exhaustive must be a boolean literal. Calls with other option expressions, such as identifiers, calls, accessors, spreads, computed template keys, nonliteral exhaustive values or template substitutions, are reported with a warning that names their line, and a file added for it is bundled with the next build something else starts.
Recovery of TypeScript, component text or invalid JavaScript has a shared work limit proportional to the source size. If overlapping or malformed apparent calls exhaust it, the plugin warns that the recovery work limit was reached and stops reading the remaining calls. Files selected by those calls are bundled when another change starts a build.
Following links
The entry, a module it imports, a folder above one, or a package folder may be a link (a workspace package linked into node_modules, a shared folder of a monorepo). An import written as a path to a file that is there (./dependency.js) goes through that path; every other import of the entry's graph the plugin resolves as Vite does, with resolve.preserveSymlinks on, so it knows the path every import goes through, whichever way it is spelled: a relative or absolute path, / from the root, a missing extension, a folder's index, resolve.alias, a bare package name (scoped or not, with a subpath or through its exports), a package's # subpath import, resolve.tsconfigPaths. Pointing a link on that path at another target (relinking node_modules/dependency to another version, say), or a link that link leads to (a chain of links), bundles the entry again with the new target, also when the new target makes the import resolve to another file (another exports or main of the package, a folder's index or a file of another extension), and edits to the new target follow: in dev with the watcher on and off (a link is compared before the story is served), inside the root or outside it, and under vite build --watch (see the macOS limit under Build). A module one of your plugins resolves itself is followed at the path that plugin gives, links included, and stays the plugin's choice under vite build --watch too (the plugin corrects only what Vite's own resolver remembered; a plugin of yours whose name starts with vite:, as some third-party plugins' do, cannot be told from Vite's own and is taken for one); so is a file a package's browser field puts in place of a relative path it names. Resolving the imports costs time: a graph of imports written with their extensions bundles about as fast as before, one of bare names and imports without extensions resolves each of them once more (each bundle starts with a fresh resolver, since Vite's resolver would remember a link's old target). A package installed into a node_modules folder nearer the importing module than the one the import resolved from is not looked for: it is bundled with the next change to a file the entry was bundled from, or after a restart, as Vite's own dependency handling needs.
Workers
A worker the entry starts, new Worker(new URL('./worker.js', import.meta.url)), new SharedWorker(new URL(…)), import Worker from './worker.js?worker' or ?worker&inline, is bundled by Vite in a build of its own, with the plugins of worker.plugins (yours come first, in their order). The plugin follows that build as it follows the entry's: the worker's modules, the links its imports go through and the folders of its import.meta.glob() calls count as the entry's, so adding, deleting or renaming a file a worker's glob may match, or pointing a link a worker's import goes through at another target, bundles the entry again, in dev with the watcher on and off and under vite build --watch, for nested workers too. Under vite build --watch with the entry bundled inside the build, Vite keeps each worker's bundle from one build to the next until one of the worker's files changes, and a file added to one of its glob folders is none of those: the plugin lets that worker's bundle go through Vite's own worker plugin (vite:worker), as an edit of the module with the glob would. Should a Vite version have no such plugin, or one without a watchChange hook, twee-ts warns once, and the added file is bundled when one of the worker's modules next changes (if Vite still lets go of the bundle then). A hook that is there but no longer lets go of the bundle cannot be told apart, and is not warned about. CI runs these tests on Vite 8.0 and 8.2 (the oldest release line, and the oldest whose build watch reports a change inside a folder) as well as on the locked version.
Rollup Plugin
// rollup.config.js
import { tweeTsPlugin } from '@rohal12/twee-ts/rollup';
export default {
plugins: [
tweeTsPlugin({
sources: ['src/story'],
format: 'sugarcube-2',
}),
],
};Options
interface TweeTsRollupPluginOptions {
/** Source directories/files to compile, relative to the working directory. */
sources: readonly string[];
/** Story format ID. */
format?: string;
/** Output file name, relative to the output folder. Default: 'index.html'. */
outputFilename?: string;
/** Additional compile options. */
compileOptions?: PluginCompileOptions;
}The options are checked as the Vite plugin's are (see above); there is no entry.
Errors and warnings
- Errors fail the build. When the compile reports an error (a missing starting passage, malformed Twee, a story format that can't be found), the plugin fails the build through Rollup's
this.error()and emits no HTML. Rollup reports it as an error from thetwee-tsplugin, with the file and line where the compiler knows them. The build also fails when the bundle already holds a file namedoutputFilename. - Warnings (a duplicate passage, unreadable passage metadata) go through Rollup's
this.warn(), so they reachonwarn/onLogand Rollup's warning output like any other plugin warning. The story is still emitted. - Watch mode: under
rollup --watch, a change to a source, the head file or a module builds the story again. The files are registered with Rollup's watcher assourcesnames them, also through a link: Rollup's watcher follows the link and reports a change by that name (Vite's watcher reports real paths, so the Vite plugin registers those). Rollup's watcher follows a link and watches what it led to when it was registered, so it reports no link that was pointed elsewhere: the plugin watches the folders that hold the links the inputs are reached through (each hop of a chain, the links among the folders above, and the links it finds while walking a source folder with each hop of their chains), not recursively, and starts a build when one of them leads elsewhere, then also watches the new target. The link watch writes a signal file in the temporary folder; when none can be written (a missing or read-onlyTMPDIR), the build still succeeds, the plugin warns once that a link pointed at another target is read with the next build something else starts, and the rest of the watch works as before. Paths in warnings and errors are relative to the working directory, as the CLI prints them. An edit to a fileexcludeleaves out starts no build; a folder holding such a file, or an output, is watched by what it holds, so a file added straight to it is read with the next build something else starts. A failed build is reported and the watcher keeps running, so fixing the story rebuilds it. On Linux and macOS a backslash in a file name is an ordinary character, and the plugins read such a file as it is named; Rollup's watcher reads every backslash as a separator, so it cannot watch that file, and the plugin warns that an edit to it starts no build (Vite's watcher has no such limit). - Input protection: generated story output cannot replace the outer build's entry, imported or tree-shaken modules, or plugin watch files. Inputs registered by later
generateBundlehooks are checked before writing too. Symbolic links, directory links and hard links are checked by filesystem identity. This protection also applies when Vite hosts the Rollup plugin. - Output inside
sources: nothing Rollup writes is read as a source, so the output can sit inside a source folder. That covers the story HTML of every output (outputFilenameinoutput.dir, or next tooutput.file), when a build writes several outputs too, and the chunks and assets each bundle writes, old hashed chunks from earlier builds of the same plugin instance included. Anoutput.dirinside a source folder is left out whole. Paths are compared by their real path, so a link to the output folder doesn't let it back in. Underrollup --watch,output.dirmay even be a source folder itself; writing the outputs starts no build. Whenoutput.diris a source folder itself, a hashed chunk left there by an earlier process is not known to be output and is read on the first build; write into a subfolder to avoid that. The story file is checked as in the Vite plugin: an authored file of a type the sources load at its path fails the build (OUTPUT_IS_INPUT) instead of being overwritten, while chunks and assets are written without a check.
The same holds when the Rollup plugin runs inside a vite build.
Full Example
// rollup.config.js
import { tweeTsPlugin } from '@rohal12/twee-ts/rollup';
export default {
input: 'src/entry.js', // Rollup requires an input
plugins: [
tweeTsPlugin({
sources: ['src/story'],
format: 'harlowe-3',
outputFilename: 'story.html',
compileOptions: {
tagAliases: { macro: 'widget' },
},
}),
],
output: {
dir: 'dist',
},
};Using compileOptions
Both plugins accept a compileOptions object (PluginCompileOptions, exported by both entry points) that is passed to the compiler. It takes any CompileOptions field but sources and formatId, which come from the plugin's sources and format:
import type { PluginCompileOptions } from '@rohal12/twee-ts/vite';
const compileOptions: PluginCompileOptions = {
exclude: ['src/story/**/*.png'],
startPassage: 'Prologue',
tagAliases: { library: 'script' },
modules: ['src/analytics.js'],
headFile: 'head.html',
trim: true,
twee2Compat: false,
testMode: false,
noRemote: false,
};
console.log(compileOptions);exclude never applies to headFile and modules. Keep the head file outside the source folders: an .html file in a source folder is read as a Twine story to import. outputMode is accepted too, and the story is then written in that mode under outputFilename. The dev server serves that file as compiled; only playable HTML gets the live-reload client.