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: {