diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d808fd4fc..ab409044b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@simplewebauthn/browser": "13.3.0",
"@tailwindcss/vite": "4.3.3",
+ "@twemoji/api": "17.0.2",
"@xterm/xterm": "6.0.0",
"browser-fs-access": "0.38.0",
"clipboard": "2.0.11",
@@ -44,7 +45,7 @@
"sortablejs-vue3": "1.3.0",
"tailwindcss": "4.3.3",
"temporal-polyfill": "1.0.2",
- "twemoji": "14.0.2",
+ "twemoji-assets": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3",
"uuid": "14.0.1",
"v-network-graph": "0.9.23",
"vue": "3.5.40",
@@ -2341,6 +2342,30 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
+ "node_modules/@twemoji/api": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/@twemoji/api/-/api-17.0.2.tgz",
+ "integrity": "sha512-5xIdBcFLpRW4goXNdNNz497TzMHF2a76yElEaqISxVPj+KpJB82KuXHczfjhhTCsPaP1lAhM3qPtvAhMG7e71A==",
+ "license": "MIT AND CC-BY-4.0",
+ "dependencies": {
+ "@twemoji/parser": "17.0.1",
+ "fs-extra": "^8.0.1",
+ "jsonfile": "^5.0.0",
+ "universalify": "^0.1.2"
+ }
+ },
+ "node_modules/@twemoji/api/node_modules/@twemoji/parser": {
+ "version": "17.0.1",
+ "resolved": "https://registry.npmjs.org/@twemoji/parser/-/parser-17.0.1.tgz",
+ "integrity": "sha512-Uiq5sq1Wx91Y6C0DczMlu4Gj4nC/0z8UIhT7S53hi/JATaS1oLM5jXygjot7STtyVoIagfnpsBCWaNy3ayfyFw==",
+ "license": "MIT"
+ },
+ "node_modules/@twemoji/parser": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/@twemoji/parser/-/parser-17.0.2.tgz",
+ "integrity": "sha512-X/P7pHsGOxnrupQYUVetIeuxBGgffFu8CLwoPMMjH9CWmQvlXiCpbTW/BXxMOCWXQojgHdmgdvm6IsCqAQ5nxA==",
+ "license": "MIT"
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@@ -3053,6 +3078,29 @@
"integrity": "sha512-v1qaIj8F6il3QX5bkN37/VD1CjewUWpzyagwElPvWy0PjLsoxo2yR6zrrvovtjVJJfk++PJjMjSovPC54EJhdA==",
"license": "MIT"
},
+ "node_modules/fs-extra": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+ "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^4.0.0",
+ "universalify": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=6 <7 || >=8"
+ }
+ },
+ "node_modules/fs-extra/node_modules/jsonfile": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+ "license": "MIT",
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3305,6 +3353,18 @@
"node": ">=6"
}
},
+ "node_modules/jsonfile": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
+ "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^0.1.2"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
"node_modules/kolorist": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz",
@@ -4507,66 +4567,17 @@
"license": "0BSD",
"optional": true
},
- "node_modules/twemoji": {
- "version": "14.0.2",
- "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
- "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
- "license": "MIT",
+ "node_modules/twemoji-assets": {
+ "name": "@twemoji/api",
+ "version": "17.0.3",
+ "resolved": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3",
+ "integrity": "sha512-SQpW4KJellkw8iVDagVqmGo7MCkZV0UfvRok18wLuYWI7FfsmEwLOaWOoE2TowQ6UJqlxsj3lsSlcuomyP0bsQ==",
+ "license": "MIT AND CC-BY-4.0",
"dependencies": {
+ "@twemoji/parser": "17.0.2",
"fs-extra": "^8.0.1",
"jsonfile": "^5.0.0",
- "twemoji-parser": "14.0.0",
- "universalify": "^0.1.2"
- }
- },
- "node_modules/twemoji-parser": {
- "version": "14.0.0",
- "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
- "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
- "license": "MIT"
- },
- "node_modules/twemoji/node_modules/fs-extra": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
- "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
- "license": "MIT",
- "dependencies": {
- "graceful-fs": "^4.2.0",
- "jsonfile": "^4.0.0",
- "universalify": "^0.1.0"
- },
- "engines": {
- "node": ">=6 <7 || >=8"
- }
- },
- "node_modules/twemoji/node_modules/fs-extra/node_modules/jsonfile": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
- "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
- "license": "MIT",
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/twemoji/node_modules/jsonfile": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
- "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
- "license": "MIT",
- "dependencies": {
"universalify": "^0.1.2"
- },
- "optionalDependencies": {
- "graceful-fs": "^4.1.6"
- }
- },
- "node_modules/twemoji/node_modules/universalify": {
- "version": "0.1.2",
- "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
- "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
- "license": "MIT",
- "engines": {
- "node": ">= 4.0.0"
}
},
"node_modules/uc.micro": {
@@ -4588,6 +4599,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/universalify": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/unplugin": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 54dd3918c..2f49eba3d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -19,6 +19,7 @@
"dependencies": {
"@simplewebauthn/browser": "13.3.0",
"@tailwindcss/vite": "4.3.3",
+ "@twemoji/api": "17.0.2",
"@xterm/xterm": "6.0.0",
"browser-fs-access": "0.38.0",
"clipboard": "2.0.11",
@@ -53,7 +54,7 @@
"sortablejs-vue3": "1.3.0",
"tailwindcss": "4.3.3",
"temporal-polyfill": "1.0.2",
- "twemoji": "14.0.2",
+ "twemoji-assets": "https://codeload.github.com/jdecked/twemoji/tar.gz/refs/tags/v17.0.3",
"uuid": "14.0.1",
"v-network-graph": "0.9.23",
"vue": "3.5.40",
diff --git a/frontend/src/assets/quasar-logo-vertical.svg b/frontend/src/assets/quasar-logo-vertical.svg
deleted file mode 100644
index 821083104..000000000
--- a/frontend/src/assets/quasar-logo-vertical.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
\ No newline at end of file
diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js
index 7f78664bb..9c4a26c75 100644
--- a/frontend/src/renderers/markdown.js
+++ b/frontend/src/renderers/markdown.js
@@ -14,7 +14,7 @@ import mdMdc from 'markdown-it-mdc'
import mdUnderline from './modules/markdown-it-underline'
import mdImsize from './modules/markdown-it-imsize'
import mdGithubAlerts from './modules/github-alerts'
-import twemoji from 'twemoji'
+import twemoji from '@twemoji/api'
import hljs from 'highlight.js'
@@ -198,6 +198,17 @@ export class MarkdownRenderer {
// TWEMOJI
// --------------------------------
+ /*
+ Drawn from this instance, never from a CDN: the callback replaces twemoji's default `base` +
+ size + extension entirely, so the `src` is the whole path and nothing upstream is contacted for
+ it. `vite.config.js` puts the SVGs at `/_assets/svg/twemoji/` — copied into the build output,
+ served out of `node_modules` in dev — so the two have to agree on this path.
+
+ The artwork comes from the same upstream project as this parser, at a pinned tag (see
+ `twemoji-assets` in `package.json`). They are separate dependencies, so the build checks that
+ every emoji a page can hold still resolves to a file — an emoji the parser knows and the asset
+ set does not is a broken image in a page.
+ */
this.md.renderer.rules.emoji = (token, idx) => {
return twemoji.parse(token[idx].content, {
callback(icon, opts) {
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 3048ba189..cbbe2bc26 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -3,10 +3,119 @@ 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/.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 =
@@ -59,6 +168,7 @@ export default defineConfig(({ mode }) => {
}
}),
tailwindcss(),
+ twemojiAssets(),
vueDevTools()
],
css: {