Skip to content

Support single file html build/export #2581

@ppenguin

Description

@ppenguin

Is your feature request related to a problem? Please describe.
I need to be able to distribute a rich presentation (i.e. build with components and assets) as a single, self-contained file that users without any tooling can just open in their browser and enjoy the full experience.

Describe the solution you'd like
Offer something like SLIDEV_SINGLEFILE=1 slidev build mypres.md or slidev build-single mypres.md or similar, that outputs a single, self-contained html file with all assets embedded (likely leveraging vite-plugin-singlefile).

Describe alternatives you've considered
I could do this before with revealjs and pandoc.

Some experimentation results
I did some experimentation (note that I'm a total ts/vite noob) and with some artificial help arrived at a vite-config that seems to be working, but it was brutal and could probably be realized much better/more robust.

In my specific case, a complicating factor is that I wanted to have my complete slidev environment (sl1dev-toolkit) as a reusable and reproducible nix devShell flake, including my themes and components. It also needs to work offline. A solution that worked well is to have the node_modules dir realized by the toolkit flake, and in the content (presentation) repo it's simply a link farm to the nix store. With the added benefit that templates/themes are consistent and not duplicated.

Here the vite.config.ts

import { defineConfig } from 'vite'
import { loadEnv } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { viteSingleFile } from 'vite-plugin-singlefile'

const require = createRequire(import.meta.url)
const slidevTypes = require.resolve('@slidev/types/package.json')
  .replace('/package.json', '')
const env = loadEnv('', process.cwd(), '')
const assets = env['SLIDEV_SL1DEV_ASSETS'] ?? ''
const singleFile = process.env.SLIDEV_SINGLEFILE === '1'
let outDir = 'dist'

export default defineConfig({
  resolve: {
    alias: {
      '@sl1dev-assets': assets,
      '@slidev/types': slidevTypes,  // resolve from local node_modules
      'markdown-it': require.resolve('markdown-it'),
      'plotly.js-dist': require.resolve('plotly.js-dist'),
    },
  },
  plugins: [
    {
      name: 'sl1dev-assets',
      configureServer(server) {
        server.middlewares.use('/sl1dev-assets', (req, res, next) => {
          const filePath = path.join(assets, req.url ?? '')
          if (fs.existsSync(filePath)) {
            res.setHeader('Content-Type', getContentType(filePath))
            fs.createReadStream(filePath).pipe(res)
          } else {
            next()
          }
        })
      },
    },
    ...(singleFile ? [{
      name: 'sl1dev-force-singlefile',
      enforce: 'post' as const,
      config() {
        return {
          base: './',
          define: { __SLIDEV_HASH_ROUTE__: true },
        }
      },
      configResolved(config) {
        outDir = config.build.outDir
        config.build.rollupOptions.output.inlineDynamicImports = true
        delete config.build.rollupOptions.output.manualChunks
      },
      closeBundle() {
        setTimeout(() => {
          for (const f of ['404.html', '_redirects']) {
            const p = path.resolve(outDir, f)
            if (fs.existsSync(p)) fs.unlinkSync(p)
          }
        }, 100)
      },
    }, viteSingleFile({ removeViteModuleLoader: true })] : []),
  ],
  server: {
    fs: { allow: [assets, '.'] },
    watch: {
      ignored: (path) => path.includes('/nix/store/'),
    },
  },
  build: {
    rollupOptions: {
      plugins: [
        {
          // Two problems solved here:
          //
          // 1. /sl1dev-assets/* URL paths — served by the dev server
          //    middleware at runtime but invisible to Rollup during build.
          //    Map them to the real filesystem path in the assets directory.
          //
          // 2. Nix store importers — components installed into the Nix store
          //    have no node_modules ancestor, so Rollup's default resolver
          //    fails on their bare-specifier imports. Resolve those through
          //    the toolkit's require, which does have node_modules in scope.
          name: 'resolve-sl1dev',
          resolveId(id: string, importer: string | undefined) {
            if (id.startsWith('/sl1dev-assets/') && assets) {
              return path.join(assets, id.slice('/sl1dev-assets/'.length))
            }
            if (
              importer?.includes('/nix/store/') &&
              !id.startsWith('.') &&
              !path.isAbsolute(id)
            ) {
              try {
                return require.resolve(id)
              } catch {
                return null
              }
            }
          },
        },
      ],

    },
    ...(singleFile ? {
      assetsInlineLimit: 100_000_000,
    } : {}),
  },
  define: {
    'import.meta.env.VITE_SL1DEV_ASSETS': JSON.stringify(assets),
  },
  optimizeDeps: {
    include: ['plotly.js-dist'],
  },
})

function getContentType(filePath: string) {
  const ext = path.extname(filePath)
  return { '.png': 'image/png', '.svg': 'image/svg+xml', '.jpg': 'image/jpeg' }[ext] ?? 'application/octet-stream'
}

The env vars are set by the flake so that the store paths to assets can be passed

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions