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`
+
+ `
+ }
+}
+
+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 @@
-
+
diff --git a/frontend/src/components/HeaderSearch.vue b/frontend/src/components/HeaderSearch.vue
index 59c8621f5..948d2a133 100644
--- a/frontend/src/components/HeaderSearch.vue
+++ b/frontend/src/components/HeaderSearch.vue
@@ -4,8 +4,11 @@
The positioning context for the panel below, and the width it matches. The toolbar cannot be
it: the panel would then span the toolbar's padding as well, and with no positioned ancestor
at all it stretched to the whole window.
+
+ Full toolbar height rather than just the field's, with the field centred inside it, so that
+ `top: 100%` on the panel lands on the bottom edge of the header instead of 12px above it.
-->
-
+
{
/*
Hangs off the field, matching its width -- `left: 0; right: 0` against the wrapper rather than a
width of its own, so the two cannot drift apart.
+
+ The wrapper is the full height of the header, so `top: 100%` puts the panel flush against its
+ bottom edge; square top corners then read as a continuation of the header rather than a card
+ floating under it.
*/
.searchpanel {
position: absolute;
- top: calc(100% + 6px);
+ top: 100%;
left: 0;
right: 0;
z-index: 10;
background-color: rgba(0, 0, 0, 0.7);
- border-radius: 12px;
+ border-radius: 0 0 12px 12px;
color: #fff;
padding: 0.5rem 1rem 1rem;
backdrop-filter: blur(7px) saturate(180%);
diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss
index 956384dfe..d194fbbb3 100644
--- a/frontend/src/css/_page-contents.scss
+++ b/frontend/src/css/_page-contents.scss
@@ -124,6 +124,16 @@
/* -> A pasted URL or a long identifier wraps rather than widening the whole column */
overflow-wrap: break-word;
+ /*
+ Content never stacks above the app's own chrome. A block's internals are free to use whatever
+ z-indexes they need -- leaflet alone puts its panes at 400 and its controls at 800, and a shadow
+ root is not a stacking context, so those numbers land in whichever context the page provides.
+ Against the header's 20 the map won, and the search panel hanging off the header was drawn behind
+ it. Isolating here gives every block a ceiling of this element's own place in the page, whatever
+ it does inside.
+ */
+ isolation: isolate;
+
@at-root .body--dark & {
--content-ink: rgba(255, 255, 255, 0.87);
--content-ink-muted: rgba(255, 255, 255, 0.62);
@@ -919,10 +929,11 @@
}
/*
- Diagram sources -- a mermaid or plantuml fence, or a base64 `diagram` block -- reach the page as
- code, and are drawn later or not at all. A quiet dashed panel says "this is a diagram that has not
- been drawn" rather than pretending to be a code sample.
+ Diagram sources -- a mermaid, plantuml or kroki fence, or a base64 `diagram` block -- reach the
+ page as code, and are drawn later or not at all. A quiet dashed panel says "this is a diagram that
+ has not been drawn" rather than pretending to be a code sample.
*/
+ pre.codeblock-kroki,
pre.codeblock-mermaid,
pre.codeblock-plantuml,
pre.diagram {
diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue
index a820bfdf8..684d197d9 100644
--- a/frontend/src/layouts/AdminLayout.vue
+++ b/frontend/src/layouts/AdminLayout.vue
@@ -160,7 +160,7 @@
active-class="bg-primary text-white"
v-if="userStore.can(`manage:sites`)">
-
+ {{ t('admin.blocks.title') }}
diff --git a/frontend/src/pages/AdminBlocks.vue b/frontend/src/pages/AdminBlocks.vue
index 4805ad296..bc800a8d1 100644
--- a/frontend/src/pages/AdminBlocks.vue
+++ b/frontend/src/pages/AdminBlocks.vue
@@ -2,7 +2,7 @@
@@ -384,6 +384,17 @@ const showToc = computed(() => {
}).length > 0
)
})
+/*
+ Same question for the tags, and for the same reason: `showTags` is what the page ASKED for, and on a
+ page carrying none that left a "Tags" heading over an empty space.
+
+ Held open while the tag editor is in use, so that removing the last tag does not take the field being
+ typed into away with it. That only arises mid-edit -- with no tags to start from there is no edit
+ button to reach the mode through.
+*/
+const showTags = computed(() => {
+ return pageStore.showTags && (pageStore.tags?.length > 0 || state.tagEditMode)
+})
/*
Whether this user may save a change to the page, which is what editing the tags amounts to -- the tags
go up with the rest of the page rather than through an endpoint of their own. So the test is the pair
diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js
index 9c4a26c75..301fea9f0 100644
--- a/frontend/src/renderers/markdown.js
+++ b/frontend/src/renderers/markdown.js
@@ -72,12 +72,13 @@ export class MarkdownRenderer {
highlight(str, lang) {
if (lang === 'diagram') {
return `
${Buffer.from(str, 'base64').toString()}
`
- } else if (['mermaid', 'plantuml'].includes(lang)) {
+ } else if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is —
- `block-diagram` for mermaid, `block-plantuml` for the other — and each reads the text out
- of this `pre`. A fence on its own outside a block keeps the panel the stylesheet gives it,
- which says "a diagram nobody has drawn" rather than pretending to be a code sample.
+ `block-diagram` for mermaid, `block-plantuml` and `block-kroki` for the others — and each
+ reads the text out of this `pre`. A fence on its own outside a block keeps the panel the
+ stylesheet gives it, which says "a diagram nobody has drawn" rather than pretending to be
+ a code sample.
*/
return `