You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
wiki/frontend/vite.config.js

240 lines
9.3 KiB

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// -> A named import: js-yaml 5 ships ESM with no default export, so `import yaml from` throws
import { load as loadYaml } from 'js-yaml'
import fs from 'node:fs'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import tailwindcss from '@tailwindcss/vite'
import vueDevTools from 'vite-plugin-vue-devtools'
const TWEMOJI_ROUTE = '/_assets/svg/twemoji'
/**
* Fails the build unless every emoji a page can contain has an SVG in `svgDir`.
*
* The parser and the artwork are two dependencies of the same upstream release (see below), so they
* can drift apart on an upgrade with nothing to say so -- and what that looks like is a page with a
* broken image in it, or an emoji quietly left to whatever font the reader has. Both are cheap to
* rule out here: the renderer hands the emoji plugin's tokens to twemoji and nothing else -- a raw 🎉
* typed into a page stays a character -- so `markdown-it-emoji`'s shortcode map IS the vocabulary, and
* running the parser over it yields exactly the set of files a page can ask for.
*
* `@twemoji/api` is pinned a patch behind for this reason: 17.0.3 moved to `@twemoji/parser` 17.0.2,
* which stopped matching ✌️ ☝️ 🕵️ 🏋️ and six others at all, leaving them as text. This is the check
* that catches it.
*/
async function verifyTwemojiCoverage(svgDir) {
const [{ default: twemoji }, { default: shortcodes }] = await Promise.all([
import('@twemoji/api'),
import('markdown-it-emoji/lib/data/full.mjs')
])
const unmatched = []
const missing = []
for (const [shortcode, emoji] of Object.entries(shortcodes)) {
const icons = []
twemoji.parse(emoji, {
callback(icon) {
if (icon) {
icons.push(icon)
}
// -> Nothing is being rendered here; the callback is only how the names are read back out
return false
}
})
if (icons.length === 0) {
unmatched.push(`:${shortcode}:`)
continue
}
for (const icon of icons) {
if (!fs.existsSync(path.join(svgDir, `${icon}.svg`))) {
missing.push(`:${shortcode}: (${icon}.svg)`)
}
}
}
const complaints = [
unmatched.length > 0 &&
`${unmatched.length} the parser no longer matches: ${unmatched.join(' ')}`,
missing.length > 0 && `${missing.length} with no SVG in ${svgDir}: ${missing.join(' ')}`
].filter(Boolean)
if (complaints.length > 0) {
throw new Error(
`twemoji: ${complaints.join('; ')}. Check that '@twemoji/api' and the 'twemoji-assets' tarball in package.json still name the same upstream release.`
)
}
}
/**
* Makes the twemoji SVGs reachable at `/_assets/svg/twemoji/<codepoints>.svg`, which is the `src` the
* markdown renderer writes for every emoji (`src/renderers/markdown.js`).
*
* They are neither committed nor imported: the set is ~4000 files and 18 MB, every one of which a page
* may ask for and none of which is a build input -- nothing in the source names an individual icon, so
* Vite has no way to discover them. So they are copied into the build output alongside `public/_assets/`
* and read from `node_modules` on the fly in dev; under `public/` they would be 4000 files in git for a
* directory that is derived.
*
* `@twemoji/api` is the parser alone -- the artwork has never been published to npm, by Twitter or by
* the fork that maintains it now, and the one package that did (`@twemoji/svg`) stopped at Unicode 15.
* So `package.json` takes it from the upstream repository at a pinned tag, as a tarball dependency
* (`twemoji-assets`). npm records its integrity hash in the lockfile like any other dependency, so it
* is fetched once at install time and the build itself needs no network.
*/
function twemojiAssets() {
const svgDir = path.join(
path.dirname(createRequire(import.meta.url).resolve('twemoji-assets/package.json')),
'assets/svg'
)
let outDir = null
return {
name: 'wiki-twemoji-assets',
configResolved(config) {
outDir = path.resolve(config.root, config.build.outDir)
},
configureServer(server) {
// -> connect strips the prefix, so `req.url` is just the file name here
server.middlewares.use(TWEMOJI_ROUTE, (req, res, next) => {
// -> Both a traversal guard and a cheap 404 for anything that is not one of these files
const name = path.basename(req.url.split('?')[0])
if (!/^[0-9a-f]+(-[0-9a-f]+)*\.svg$/.test(name)) {
next()
return
}
fs.promises.readFile(path.join(svgDir, name)).then((svg) => {
res.setHeader('Content-Type', 'image/svg+xml')
res.end(svg)
}, next)
})
},
// -> Not `emitFile`: 4000 assets through rollup for files that need no processing at all
async writeBundle() {
await verifyTwemojiCoverage(svgDir)
await fs.promises.cp(svgDir, path.join(outDir, TWEMOJI_ROUTE.slice(1)), { recursive: true })
}
}
}
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
const userConfig =
mode === 'development'
? {
dev: { port: 3001, hmrClientPort: 3001 },
...loadYaml(
fs.readFileSync(fileURLToPath(new URL('../config.yml', import.meta.url)), 'utf8')
)
}
: {}
return {
build: {
assetsDir: '_assets',
chunkSizeWarningLimit: 5000,
dynamicImportVarsOptions: {
include: ['!/_blocks/**']
},
outDir: '../assets',
rollupOptions: {
// -> A second entry alongside the app: the markdown pipeline on its own, so the backend can
// drive it in a headless browser to re-render a page server-side
input: {
main: fileURLToPath(new URL('./index.html', import.meta.url)),
renderer: fileURLToPath(new URL('./src/renderers/headless.js', import.meta.url))
},
output: {
// -> The renderer keeps a fixed name because it is referenced from a static page served by
// the backend, which has no way to look up a hashed one
entryFileNames: (chunk) =>
chunk.name === 'renderer' ? '_assets/renderer.js' : '_assets/[name]-[hash].js'
}
},
target: 'es2022'
},
plugins: [
vue({
template: {
/*
`/_assets/...` paths are served by the BACKEND at runtime; they are not build inputs and
there is nothing at that path on disk to resolve. Vue's default would turn each one into
an import and fail the build. Quasar's Vite plugin used to supply this same setting.
*/
transformAssetUrls: { includeAbsolute: false },
// -> `iconify-icon` is a custom element registered by its package, not a Vue component
compilerOptions: {
isCustomElement: (tag) => tag === 'iconify-icon'
}
}
}),
tailwindcss(),
twemojiAssets(),
vueDevTools()
],
css: {
preprocessorOptions: {
scss: {
/*
Every SFC style block gets these, which is what Quasar's Vite plugin used to do with its
`sassVariables` option. Without it each file would have to import them itself, and the
app's stylesheets are written against bare `$primary` / `$dark-3` / `$grey-4`.
*/
additionalData: `@use '@/css/_theme.scss' as *; @use '@/css/_palette.scss' as *;`
}
}
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
/*
markdown-it 15 dropped its `markdown-it/lib/*` subpath exports and put the parser internals
on the main export as static classes. `markdown-it-mdc` still imports the old path, so
without this the build fails to resolve it -- see the shim for the rest.
*/
/*
monaco-editor 0.56 declares `"./*.js": "./esm/vs/*.js"` in its exports map, so the full
`monaco-editor/esm/vs/...` path a dependency writes now resolves to `esm/vs/esm/vs/...` and
fails. y-monaco imports the API entry that way; this points it at the same file the app's
own `monaco-editor` import lands on, which matters beyond resolving at all -- two copies of
that module would give the binding a different `Range` class than the editor's.
*/
'monaco-editor/esm/vs/editor/editor.api.js': 'monaco-editor/editor/editor.api.js',
'markdown-it/lib/token.mjs': fileURLToPath(
new URL('./src/renderers/modules/markdown-it-token.js', import.meta.url)
)
}
},
server: {
// https: true
open: false, // opens browser window automatically
host: '0.0.0.0',
allowedHosts: true,
port: userConfig.dev?.port,
proxy: [
'_api',
'_blocks',
'_collab',
'_files',
'_icons',
'_site',
'_terminal',
'_thumb',
'_user'
].reduce((result, key) => {
result[`/${key}`] = {
target: {
host: '127.0.0.1',
port: userConfig.port
},
// -> `_collab` and `_terminal` are websockets; the rest are unaffected by this being on
ws: true
}
return result
}, {}),
hmr: {
clientPort: userConfig.dev?.hmrClientPort
}
}
}
})