import fs from 'node:fs' import path from 'node:path' import summary from 'rollup-plugin-summary' import terser from '@rollup/plugin-terser' import resolve from '@rollup/plugin-node-resolve' import commonjs from '@rollup/plugin-commonjs' import * as glob from 'glob' /** * Turn an ESTree literal node into a plain JS value. * * Only literals, arrays and objects of literals are supported — a block definition is metadata, so * anything computed is a mistake worth failing the build over. */ function literalToValue (node, blockDir) { switch (node.type) { case 'Literal': return node.value // A backtick string with nothing interpolated is still a plain value, and the readable way to // write the multi-line ones -- a starter body for a block, say. case 'TemplateLiteral': if (node.expressions.length > 0) { throw new Error(`${blockDir}: "static definition" must contain only plain literals, got an interpolated template.`) } return node.quasis[0].value.cooked case 'ArrayExpression': return node.elements.map(el => literalToValue(el, blockDir)) case 'ObjectExpression': return Object.fromEntries(node.properties.map(prop => [ prop.key.name ?? prop.key.value, literalToValue(prop.value, blockDir) ])) default: throw new Error(`${blockDir}: "static definition" must contain only plain literals, got ${node.type}.`) } } const ASSET_MIME_TYPES = { '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.otf': 'font/otf', '.png': 'image/png', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.webp': 'image/webp', '.woff': 'font/woff', '.woff2': 'font/woff2' } /** * Loads a `.css` import as a string, with the files it points at inlined as data URIs. * * A block styles itself from inside its shadow root, which a `` in the page cannot reach — so a * library's stylesheet has to be part of the component. Rollup has no notion of CSS on its own. * * The inlining is what makes that stylesheet's own assets — leaflet's control sprites, KaTeX's font * files — arrive with it. A relative `url()` in a stylesheet resolves against the document, not * against the file it was written in, so once the CSS is a string inside a bundle those paths point * at whatever wiki page happens to be showing the block. There is nowhere to put the files that would * fix that: a block is one file served from /_blocks and mounted at a path it does not know. * * A `@font-face` offering several formats is cut down to its woff2, when it has one. Otherwise the * same face arrives three times over — woff2, woff and ttf are the same glyphs at ~1.5x, ~2x and ~4x * the bytes — and every browser that can run a block reads woff2. */ function cssAsString () { return { name: 'css-as-string', transform (code, id) { if (!id.endsWith('.css')) { return null } const baseDir = path.dirname(id) // -> Before the inlining, while a `src` list is still short enough to read: a data URI holds // commas of its own, which is exactly what splits the list here. const css = code .replace(/src\s*:\s*([^;}]+)/g, (declaration, sources) => { const parts = sources.split(/,(?![^(]*\))/) const woff2 = parts.filter(part => /\.woff2\b|format\(\s*['"]?woff2['"]?\s*\)/.test(part) ) return woff2.length > 0 && woff2.length < parts.length ? `src:${woff2.join(',')}` : declaration }) .replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, (reference, _quote, target) => { // -> Anything already addressable is left alone, `url(#default#VML)` among them: leaflet // writes that one to turn on VML in IE, and it names no file at all. if (/^(data:|https?:|\/\/|#|\/)/.test(target)) { return reference } const assetPath = path.resolve(baseDir, target.split(/[?#]/)[0]) const mimeType = ASSET_MIME_TYPES[path.extname(assetPath).toLowerCase()] if (!mimeType || !fs.existsSync(assetPath)) { this.warn(`${id}: cannot inline ${target} — no such file, or not a known asset type.`) return reference } this.addWatchFile(assetPath) return `url("data:${mimeType};base64,${fs.readFileSync(assetPath).toString('base64')}")` }) return { code: `export default ${JSON.stringify(css)}`, map: { mappings: '' } } } } } /** * Collects each block's `static definition` into `compiled/blocks.manifest.json`. * * The definitions are read from the AST rather than by importing the modules, since a component * registers itself with `customElements` on load and so cannot be imported outside a browser. */ function blocksManifest () { const definitions = new Map() return { name: 'blocks-manifest', buildStart () { definitions.clear() }, transform (code, id) { if (!id.endsWith('/component.js')) { return null } const blockDir = id.split('/').at(-2) const ast = this.parse(code) for (const node of ast.body) { const classNode = node.type === 'ExportNamedDeclaration' ? node.declaration : node if (classNode?.type !== 'ClassDeclaration') { continue } const definitionNode = classNode.body.body.find(member => member.type === 'PropertyDefinition' && member.static && member.key.name === 'definition' ) if (definitionNode) { definitions.set(blockDir, literalToValue(definitionNode.value, blockDir)) } } if (!definitions.has(blockDir)) { this.warn(`${blockDir} has no "static definition" — it will not appear in the admin area.`) } return null }, generateBundle () { this.emitFile({ type: 'asset', fileName: 'blocks.manifest.json', source: JSON.stringify([...definitions.values()], null, 2) + '\n' }) } } } export default { input: Object.fromEntries( glob.sync('@(block-*)/component.js', { ignore: [ 'dist/**', 'node_modules/**' ] }).map(file => { const fileParts = file.split('/') return [ fileParts[0], file ] }) ), output: { dir: 'compiled', format: 'es' }, plugins: [ blocksManifest(), cssAsString(), // -> `production` is stated rather than left to be inferred: since v16 the plugin picks the // `development` or `production` export condition off `process.env.NODE_ENV`, and this build // runs from a bare `npm run build` with no NODE_ENV set. Unstated, lit resolves to its // development entry and every block ships the dev-mode warnings and asserts. resolve({ exportConditions: ['production'] }), // -> A block's own code is ESM, but a library it pulls in need not be: mermaid reaches for dayjs, // which ships as UMD, and rollup has no notion of `module.exports` without this commonjs(), terser({ ecma: 2019, module: true }), summary() ] }