diff --git a/blocks/block-infobox/component.js b/blocks/block-infobox/component.js index 25ed026ad..57264759c 100644 --- a/blocks/block-infobox/component.js +++ b/blocks/block-infobox/component.js @@ -127,7 +127,7 @@ export class BlockInfoboxElement extends LitElement { block: 'infobox', name: 'Infobox', description: 'A summary box beside the text, filled in from a list of facts.', - icon: 'data-sheet', + icon: 'activity-feed', template: `\`\`\`yaml City: Montreal Country: Canada diff --git a/blocks/block-katex/component.js b/blocks/block-katex/component.js new file mode 100644 index 000000000..dcbc657f4 --- /dev/null +++ b/blocks/block-katex/component.js @@ -0,0 +1,234 @@ +import { LitElement, html, css, unsafeCSS } from 'lit' +import { unsafeHTML } from 'lit/directives/unsafe-html.js' +import { renderToString } from 'katex' +import katexCss from 'katex/dist/katex.min.css' +/* + mhchem, imported for its side effect: the contrib module reaches into the same katex instance this + file imports and defines `\ce`, `\pu` and the machinery behind them as macros. There is nothing to + call and nothing to configure — the import is the installation, which is why it has no binding. + + It is the KaTeX port of the same extension the MathJax block loads, so `\ce{CO2 + C -> 2 CO}` means + the same thing in both blocks. +*/ +import 'katex/contrib/mhchem' + +/* + KaTeX's stylesheet, split in two. + + A `@font-face` is a document-level thing: the rule declares a family, and a stylesheet inside a + shadow root is not where the browser looks for one. So the faces go to the document — once, when + this module loads, however many formulas the page turns out to hold — and everything else goes into + the shadow root with the component, where the class names KaTeX writes into its markup are. + + The `url()` in each face is already a data URI by this point: see `cssAsString` in + `rollup.config.mjs` for why a block cannot leave its fonts as files. +*/ +const FONT_FACE_RULE = /@font-face\{[^{}]*\}/g +const KATEX_FONT_FACES = (katexCss.match(FONT_FACE_RULE) ?? []).join('') +const KATEX_RULES = katexCss.replace(FONT_FACE_RULE, '') + +const fontSheet = new CSSStyleSheet() +fontSheet.replaceSync(KATEX_FONT_FACES) +document.adoptedStyleSheets = [...document.adoptedStyleSheets, fontSheet] + +/** + * Block KaTeX + */ +export class BlockKatexElement extends LitElement { + /** + * Metadata for the admin area and the editor's block picker. Collected at build time into + * `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be + * plain literals. See `props` in `block-index` for what the picker does with that list. + */ + static definition = { + block: 'katex', + name: 'KaTeX', + description: + 'Typesets a TeX formula with KaTeX, including chemical equations written with mhchem — \\ce{} and \\pu{}.', + icon: 'math', + /* + Fenced, and not as a nicety: TeX is made of the characters markdown reads as its own. A lone + backslash goes missing, `_` and `^` open emphasis, `\\` at the end of a line is a break, and the + typographer rewrites quotes and dashes inside the source. Inside a fence it arrives as typed. + */ + template: `\`\`\`latex +x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} +\`\`\``, + props: [ + { + name: 'caption', + type: 'string', + label: 'Caption', + hint: 'Shown under the formula.' + }, + { + name: 'align', + type: 'select', + label: 'Alignment', + options: ['center', 'left'], + default: 'center' + } + ] + } + + static get styles() { + return [ + // -> KaTeX first, so the rules below win where the two touch the same thing + unsafeCSS(KATEX_RULES), + css` + :host { + display: block; + } + + /* -> The gap below the block. On this element rather than :host: see block-index. */ + .formula, + .error { + margin-bottom: 16px; + } + + .formula { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + } + .formula.is-left { + align-items: flex-start; + } + + /* + A formula wider than the column scrolls rather than shrinks, the way a display equation in + the text does. Shrinking is the wrong answer for something read symbol by symbol: a long + derivation would end up a grey smear. + */ + .drawing { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + /* -> Room for the scrollbar to appear without it sitting on the descenders */ + padding: 0.2em 0; + } + + /* -> The block owns its spacing; KaTeX's own 1em above and below would double it up */ + .drawing .katex-display { + margin: 0; + } + + .caption { + color: #424242; + font-size: 0.8em; + text-align: center; + } + :host-context(body.body--dark) .caption { + color: rgba(255, 255, 255, 0.7); + } + + .error { + color: var(--q-negative, #c10015); + border: 1px dashed color-mix(in srgb, currentColor 50%, transparent); + border-radius: 5px; + padding: 1rem; + white-space: pre-wrap; + } + ` + ] + } + + static get properties() { + return { + /** + * Text shown under the formula + * @type {string} + */ + caption: { type: String }, + + /** + * Where the formula sits in the column, `center` or `left` + * @type {string} + */ + align: { type: String }, + + // Internal Properties + _markup: { state: true }, + _error: { state: true } + } + } + + constructor() { + super() + this.caption = '' + this.align = 'center' + this._markup = '' + this._error = '' + } + + /** + * Typeset the source, or say why it could not be. + */ + _typeset(source, fenced) { + try { + this._markup = renderToString(source, { + displayMode: true, + /* + Both output forms: the drawing a reader sees, and a MathML copy of the same expression that + KaTeX hides and a screen reader announces. That is why this block writes no aria-label — the + expression itself is in the markup, read as mathematics rather than as TeX source. + */ + output: 'htmlAndMathml', + /* + Handing the error on rather than drawing it: KaTeX's other answer to bad input is to print + the source in red where the formula should be, which says nothing about what is wrong with + it. Thrown, it reaches the catch below and the panel in `render`, with the position KaTeX + found the problem at. + */ + throwOnError: true, + /* + Macros are the one piece of state a render leaves behind: `\gdef` writes into this object, + and KaTeX would carry the definition into whatever it typesets next if every block shared + one. A formula defines macros for itself. + */ + macros: {} + // -> `trust` is left at its default. It gates \href, \url and \includegraphics, which put a + // link or a remote image into the page from inside TeX — not what a formula is for, and + // the same reason the MathJax block leaves out the `html` package. + }) + this._error = '' + } catch (err) { + this._markup = '' + this._error = `This formula could not be typeset: ${err.message ?? err}` + if (!fenced) { + this._error += + '\n\nThe source has to go inside a fenced code block, or markdown rewrites it before this block sees it.' + } + } + } + + firstUpdated() { + /* + The source is the block's body, taken from the fence markdown left behind. `textContent` is what + undoes the escaping that put `&` and `<` in the markup, and gives back what was typed. + */ + const fence = this.querySelector('pre') + const source = ((fence ?? this).textContent ?? '').trim() + if (!source) { + this._error = + 'This formula is empty. Its TeX source goes in the body of the block, inside a fenced code block.' + return + } + this._typeset(source, Boolean(fence)) + } + + render() { + if (this._error) { + return html`
${this._error}
` + } + return html` +
+
${unsafeHTML(this._markup)}
+ ${this.caption ? html`
${this.caption}
` : null} +
+ ` + } +} + +window.customElements.define('block-katex', BlockKatexElement) diff --git a/blocks/block-kroki/component.js b/blocks/block-kroki/component.js new file mode 100644 index 000000000..f3423b7a4 --- /dev/null +++ b/blocks/block-kroki/component.js @@ -0,0 +1,410 @@ +import { LitElement, html, css } from 'lit' +import { deflate } from 'pako' + +/** The default server, which is the one Kroki runs for everybody. */ +const DEFAULT_SERVER = 'https://kroki.io' + +/** + * Everything Kroki draws, as it is named in a URL. + * + * Kroki is a front end to a shelf of diagram tools rather than one of its own, so unlike PlantUML the + * language has to be named alongside the source — the same text is a valid diagram in more than one + * of these. `diagramsnet` is the one Kroki documents that is left out: the public server answers 503 + * for it. + */ +const TYPES = [ + 'actdiag', + 'blockdiag', + 'bpmn', + 'bytefield', + 'c4plantuml', + 'd2', + 'dbml', + 'ditaa', + 'erd', + 'excalidraw', + 'graphviz', + 'mermaid', + 'nomnoml', + 'nwdiag', + 'packetdiag', + 'pikchr', + 'plantuml', + 'rackdiag', + 'seqdiag', + 'structurizr', + 'svgbob', + 'symbolator', + 'tikz', + 'umlet', + 'vega', + 'vegalite', + 'wavedrom', + 'wireviz' +] + +/** How many bytes are turned into characters at a time, below. */ +const CHUNK_SIZE = 0x8000 + +/** + * A diagram source as it goes into a Kroki URL: deflated, then written as base64url. + * + * Zlib deflate — with the two-byte header, unlike PlantUML's raw stream — and then plain base64 with + * `-` and `_` for the two characters that mean something else in a URL. The padding is dropped: Kroki + * decodes with or without it, and `=` at the end of a path segment is noise. + * + * `btoa` takes a string, and spreading a whole diagram into `String.fromCharCode` at once overflows + * the stack somewhere in the tens of thousands of bytes — hence a chunk at a time. + * + * The result is about 1.4 characters per character of source, so a very large diagram can outgrow what + * a server will accept in a URL. That is a limit of this transport, and the way past it is Kroki's + * POST endpoint, which is not implemented here. + */ +function encodeForUrl(source) { + const bytes = deflate(new TextEncoder().encode(source), { level: 9 }) + let binary = '' + for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { + binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE)) + } + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +/** + * Block Kroki + */ +export class BlockKrokiElement extends LitElement { + /** + * Metadata for the admin area and the editor's block picker. Collected at build time into + * `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be + * plain literals. See `props` in `block-index` for what the picker does with that list. + */ + static definition = { + block: 'kroki', + name: 'Kroki', + description: + 'Draws a diagram through a Kroki server — Graphviz, D2, BPMN, Vega, Structurizr, TikZ and two dozen more.', + icon: 'tree-structure', + /* + Fenced, and named `kroki` whatever the diagram language turns out to be, since that is the block + reading it. The fence is also what keeps markdown off the source: `--` becomes a dash, a line + opening with `*` or `#` is read as a list or a heading, an indented line becomes a code block of + its own, and `_` opens emphasis. + */ + template: `\`\`\`kroki +digraph G { + Hello -> World +} +\`\`\``, + props: [ + { + name: 'type', + type: 'select', + label: 'Diagram type', + // -> Written out rather than taken from TYPES above: the manifest is read out of this file's + // syntax tree at build time, where a name is just a name + options: [ + 'actdiag', + 'blockdiag', + 'bpmn', + 'bytefield', + 'c4plantuml', + 'd2', + 'dbml', + 'ditaa', + 'erd', + 'excalidraw', + 'graphviz', + 'mermaid', + 'nomnoml', + 'nwdiag', + 'packetdiag', + 'pikchr', + 'plantuml', + 'rackdiag', + 'seqdiag', + 'structurizr', + 'svgbob', + 'symbolator', + 'tikz', + 'umlet', + 'vega', + 'vegalite', + 'wavedrom', + 'wireviz' + ], + hint: 'The language the source is written in. Kroki cannot tell from the text alone.', + default: 'graphviz' + }, + { + name: 'server', + type: 'string', + label: 'Server', + hint: 'Kroki server to draw with. The public one when left empty.', + default: 'https://kroki.io' + }, + { + name: 'format', + type: 'select', + label: 'Format', + options: ['svg', 'png'], + hint: 'svg stays sharp at any size; png is there for the few types that draw nothing else.', + default: 'svg' + }, + { + name: 'caption', + type: 'string', + label: 'Caption', + hint: 'Shown under the diagram.' + }, + { + name: 'align', + type: 'select', + label: 'Alignment', + options: ['left', 'center'], + default: 'left' + } + ] + } + + static get styles() { + return css` + :host { + display: block; + } + + /* -> The gap below the block. On this element rather than :host: see block-index. */ + .diagram, + .error { + margin-bottom: 16px; + } + + .diagram { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + .diagram.is-center { + align-items: center; + } + + /* + The drawing sits on white in both themes, padded, the way a QR code does. Most of what Kroki + draws with draws in black on nothing at all, so on a dark page a diagram left to the page's + background is black on black — and its own colours, where a diagram has them, are picked to + sit on paper. + */ + .sheet { + max-width: 100%; + padding: 12px; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 5px; + background-color: #fff; + /* -> A diagram wider than the column scrolls rather than shrinking to illegibility */ + overflow-x: auto; + } + :host-context(body.body--dark) .sheet { + border-color: rgba(255, 255, 255, 0.15); + } + + img { + display: block; + /* -> Its own size, up to the width of the column */ + max-width: 100%; + height: auto; + } + + /* + The fallback for a drawing that has no size of its own: see _measure below. The sheet takes + the column instead of hugging the picture, which gives the picture a width to scale against — + which is what a browser does with any image that has a shape and no size. The height is then + bounded, since a tall shape scaled to the width of a column runs to several screens, and the + drawing is fitted inside what that leaves. + */ + .diagram.is-unsized .sheet { + align-self: stretch; + } + .diagram.is-unsized img { + width: 100%; + max-height: 60vh; + object-fit: contain; + } + + .caption { + color: #424242; + font-size: 0.8em; + } + :host-context(body.body--dark) .caption { + color: rgba(255, 255, 255, 0.7); + } + + .error { + color: var(--q-negative, #c10015); + border: 1px dashed color-mix(in srgb, currentColor 50%, transparent); + border-radius: 5px; + padding: 1rem; + white-space: pre-wrap; + } + ` + } + + static get properties() { + return { + /** + * The diagram language the source is written in + * @type {string} + */ + type: { type: String }, + + /** + * Kroki server to draw with + * @type {string} + */ + server: { type: String }, + + /** + * Image format to ask the server for, `svg` or `png` + * @type {string} + */ + format: { type: String }, + + /** + * Text shown under the diagram + * @type {string} + */ + caption: { type: String }, + + /** + * Where the diagram sits in the column, `left` or `center` + * @type {string} + */ + align: { type: String }, + + // Internal Properties + _src: { state: true }, + _unsized: { state: true }, + _error: { state: true } + } + } + + constructor() { + super() + this.type = 'graphviz' + this.server = DEFAULT_SERVER + this.format = 'svg' + this.caption = '' + this.align = 'left' + this._src = '' + this._unsized = false + this._error = '' + } + + /** + * Catch a drawing that came out with no size at all. + * + * An SVG carrying a `viewBox` and no `width` has a shape but no size, and a box that shrinks to fit + * its contents has nothing to resolve against — so the picture lays out at zero and the block draws + * an empty white square. d2, pikchr, blockdiag and seqdiag write their SVG that way; graphviz, + * mermaid, ditaa and most of the rest give theirs a size and are left alone. + * + * Read after the load rather than guessed at beforehand, since the file itself cannot be inspected: + * the server it came from need not allow this page to fetch it. Both measurements are needed — a + * block inside a closed spoiler or an unselected tab measures zero throughout, and is not this. + */ + _measure(img) { + if (img.clientWidth === 0 && this.renderRoot.querySelector('.sheet')?.clientWidth > 0) { + this._unsized = true + } + } + + /** + * Where the drawing comes from. + * + * An `img` rather than markup fetched and inlined, because that is the one way of asking that needs + * nothing of the server beyond the picture: kroki.io sends no CORS headers at all, so a `fetch` for + * the same URL is refused. It also means the browser caches the drawing like any other image. + */ + _url(source) { + const server = (this.server?.trim() || DEFAULT_SERVER).replace(/\/+$/, '') + const type = TYPES.includes(this.type) ? this.type : 'graphviz' + const format = this.format === 'png' ? 'png' : 'svg' + return `${server}/${type}/${format}/${encodeForUrl(source)}` + } + + /** + * Say what went wrong, having been told only that the image did not load. + * + * Not the case of a diagram Kroki cannot read, nor of a type that does not match the source: asked + * for an image, Kroki answers both with an image saying so, and a browser draws it whatever status + * came with it — so a mistake in the source shows up as the tool's own message where the diagram + * would have been, which is the best place for it. (Asked for anything else, as a `fetch` is by + * default, the same server answers `400` and a line of text. The `Accept` header is the difference, + * and it is another reason this block draws through an `img`.) + * + * What is left is a server that did not answer, or answered with something that is not an image: a + * wrong address, a host that cannot be reached from where the reader is, a login page. The request + * is made a second time to tell those apart. Best effort — kroki.io sends no CORS headers at all, so + * that second request is refused there and the message below stands as it is. Nothing about drawing + * a diagram depends on any of it. + */ + async _explain(url) { + // -> Resolved against the page, since a server may perfectly well be a path on this wiki + const absolute = new URL(url, window.location.href) + this._error = `The diagram could not be drawn by ${absolute.origin}.` + try { + const response = await fetch(absolute) + if (!response.ok) { + this._error = `The server answered ${response.status} ${response.statusText} for this diagram.` + } + } catch { + // -> Unreachable, blocked, or simply not a Kroki server; the message above says as much + this._error += ' Check the server address, and that the page may reach it.' + } + } + + firstUpdated() { + /* + The source is the block's body, taken from the fence markdown left behind. `textContent` is what + undoes the escaping that put `-->` in the markup, and gives back what the author typed. + */ + const fence = this.querySelector('pre') + const source = ((fence ?? this).textContent ?? '').trim() + if (!source) { + this._error = + 'This diagram is empty. Its source goes in the body of the block, inside a ```kroki fence.' + return + } + this._src = this._url(source) + } + + render() { + if (this._error) { + return html`
${this._error}
` + } + /* + Nothing at all until the URL exists, which is the first thing `firstUpdated` does — and it runs + after this. An `img` rendered without one carries `src=""`, which a browser resolves to the page + itself, fetches, fails to read as an image, and reports as a failed diagram. + */ + if (!this._src) { + return null + } + return html` +
+
+ ${this.caption || `${this.type} diagram`} +
+ ${this.caption ? html`
${this.caption}
` : null} +
+ ` + } +} + +window.customElements.define('block-kroki', BlockKrokiElement) diff --git a/blocks/block-media-player/component.js b/blocks/block-media-player/component.js index f1bc69d15..e8aeca54e 100644 --- a/blocks/block-media-player/component.js +++ b/blocks/block-media-player/component.js @@ -13,7 +13,7 @@ export class BlockMediaPlayerElement extends LitElement { block: 'media-player', name: 'Media Player', description: 'Plays an audio or video file inline.', - icon: 'widescreen', + icon: 'video-playlist', props: [ { name: 'src', diff --git a/blocks/block-qr-code/component.js b/blocks/block-qr-code/component.js index d8b9e55e9..6a1dd611a 100644 --- a/blocks/block-qr-code/component.js +++ b/blocks/block-qr-code/component.js @@ -15,7 +15,7 @@ export class BlockQrCodeElement extends LitElement { block: 'qr-code', name: 'QR Code', description: 'Shows a QR code for a link or a piece of text.', - icon: 'scan-stock', + icon: 'qr', props: [ { name: 'value', diff --git a/blocks/block-tabs/component.js b/blocks/block-tabs/component.js index 5e27a5540..2623d8212 100644 --- a/blocks/block-tabs/component.js +++ b/blocks/block-tabs/component.js @@ -52,7 +52,7 @@ export class BlockTabsElement extends LitElement { block: 'tabs', name: 'Tabs', description: 'Groups content into tabbed panels.', - icon: 'right-navigation-toolbar', + icon: 'resume-template', template: `::block-tab{label="First tab"} Content of the first tab. :: diff --git a/blocks/package-lock.json b/blocks/package-lock.json index fbfeee737..908dff43c 100644 --- a/blocks/package-lock.json +++ b/blocks/package-lock.json @@ -14,6 +14,7 @@ "@mathjax/src": "4.1.3", "asciinema-player": "3.17.0", "js-yaml": "5.2.3", + "katex": "0.18.2", "leaflet": "1.9.4", "lit": "3.3.3", "mermaid": "11.16.1", @@ -2191,9 +2192,9 @@ "license": "MIT" }, "node_modules/katex": { - "version": "0.16.47", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", - "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "version": "0.18.2", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.18.2.tgz", + "integrity": "sha512-3snve4y0SXTMequLim1FMiPvLGElySam0blN5xQZBqEY3lOJz1TnuaaiugnBZBqHzlSAGmFTYL7MCQkORuLKCA==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -2329,6 +2330,31 @@ "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, + "node_modules/mermaid/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/mermaid/node_modules/katex": { + "version": "0.16.47", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, "node_modules/mhchemparser": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/mhchemparser/-/mhchemparser-4.2.1.tgz", diff --git a/blocks/package.json b/blocks/package.json index 10d6d3866..35e8d272f 100644 --- a/blocks/package.json +++ b/blocks/package.json @@ -18,6 +18,7 @@ "@mathjax/src": "4.1.3", "asciinema-player": "3.17.0", "js-yaml": "5.2.3", + "katex": "0.18.2", "leaflet": "1.9.4", "lit": "3.3.3", "mermaid": "11.16.1", diff --git a/blocks/rollup.config.mjs b/blocks/rollup.config.mjs index ff4f44c95..c17085fc9 100644 --- a/blocks/rollup.config.mjs +++ b/blocks/rollup.config.mjs @@ -1,3 +1,6 @@ +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' @@ -34,11 +37,34 @@ function literalToValue (node, blockDir) { } } +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. + * 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 { @@ -47,7 +73,35 @@ function cssAsString () { if (!id.endsWith('.css')) { return null } - return { code: `export default ${JSON.stringify(code)}`, map: { mappings: '' } } + 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: '' } } } } } diff --git a/frontend/public/_assets/icons/fluent-lightning-bolt-animated.svg b/frontend/public/_assets/icons/fluent-lightning-bolt-animated.svg new file mode 100644 index 000000000..6c7f0df1e --- /dev/null +++ b/frontend/public/_assets/icons/fluent-lightning-bolt-animated.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/_assets/icons/fluent-plugin.svg b/frontend/public/_assets/icons/fluent-plugin.svg new file mode 100644 index 000000000..d579b6edd --- /dev/null +++ b/frontend/public/_assets/icons/fluent-plugin.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-activity-feed.svg b/frontend/public/_assets/icons/ultraviolet-activity-feed.svg new file mode 100644 index 000000000..60efc2520 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-activity-feed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-math.svg b/frontend/public/_assets/icons/ultraviolet-math.svg new file mode 100644 index 000000000..78dfea00c --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-math.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-qr.svg b/frontend/public/_assets/icons/ultraviolet-qr.svg new file mode 100644 index 000000000..46ee0f905 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-qr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-video-playlist.svg b/frontend/public/_assets/icons/ultraviolet-video-playlist.svg new file mode 100644 index 000000000..330b643c0 --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-video-playlist.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/CheckUpdateDialog.vue b/frontend/src/components/CheckUpdateDialog.vue index b90fdfb54..00acf3eef 100644 --- a/frontend/src/components/CheckUpdateDialog.vue +++ b/frontend/src/components/CheckUpdateDialog.vue @@ -10,7 +10,10 @@
- +
-