feat: add more blocks + new icons

scarlett
NGPixel 1 month ago
parent 12ec007bb8
commit 5021b31a0a
No known key found for this signature in database

@ -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

@ -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`<div class="error">${this._error}</div>`
}
return html`
<div class="formula ${this.align === 'left' ? 'is-left' : ''}">
<div class="drawing">${unsafeHTML(this._markup)}</div>
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-katex', BlockKatexElement)

@ -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 `--&gt;` 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`<div class="error">${this._error}</div>`
}
/*
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`
<div
class="diagram ${this.align === 'center' ? 'is-center' : ''} ${
this._unsized ? 'is-unsized' : ''
}">
<div class="sheet">
<img
src="${this._src}"
alt="${this.caption || `${this.type} diagram`}"
@load="${(e) => this._measure(e.target)}"
@error="${() => this._explain(this._src)}" />
</div>
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-kroki', BlockKrokiElement)

@ -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',

@ -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',

@ -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.
::

@ -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",

@ -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",

@ -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 `<link>` 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: '' } }
}
}
}

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 96 96" width="96px" height="96px">
<style>
/* Both paths use these two gradients, and both gradients carry the same pair of
blues — so animating the stops flashes the whole bolt, rounded joints included. */
stop:nth-child(1) { animation: flash-deep 11s linear infinite }
stop:nth-child(2) { animation: flash-bright 11s linear infinite }
/* Three bursts per 11s cycle at uneven spacing: one single, one double, one triple
flicker, so the pattern never settles into a readable rhythm. Lit ~7% of the time. */
@keyframes flash-deep {
0%, 7% { stop-color: #0076d0 }
7.05%, 8.15% { stop-color: #5cc8f5 }
8.4%, 38% { stop-color: #0076d0 }
38.05%, 38.9% { stop-color: #5cc8f5 }
39.15%, 39.9% { stop-color: #0076d0 }
39.95%, 40.8% { stop-color: #5cc8f5 }
41.05%, 74% { stop-color: #0076d0 }
74.05%, 74.6% { stop-color: #5cc8f5 }
74.8%, 75.3% { stop-color: #0076d0 }
75.35%, 75.85% { stop-color: #5cc8f5 }
76.05%, 76.7% { stop-color: #0076d0 }
76.75%, 77.6% { stop-color: #5cc8f5 }
77.85%, 100% { stop-color: #0076d0 }
}
@keyframes flash-bright {
0%, 7% { stop-color: #3498db }
7.05%, 8.15% { stop-color: #a5e5ff }
8.4%, 38% { stop-color: #3498db }
38.05%, 38.9% { stop-color: #a5e5ff }
39.15%, 39.9% { stop-color: #3498db }
39.95%, 40.8% { stop-color: #a5e5ff }
41.05%, 74% { stop-color: #3498db }
74.05%, 74.6% { stop-color: #a5e5ff }
74.8%, 75.3% { stop-color: #3498db }
75.35%, 75.85% { stop-color: #a5e5ff }
76.05%, 76.7% { stop-color: #3498db }
76.75%, 77.6% { stop-color: #a5e5ff }
77.85%, 100% { stop-color: #3498db }
}
@media (prefers-reduced-motion: reduce) {
stop { animation: none }
}
</style>
<defs>
<linearGradient id="linear0" gradientUnits="userSpaceOnUse" x1="53.879002" y1="85.043251" x2="121.141747" y2="85.043251" gradientTransform="matrix(0.55814,0,0,0.55814,0,0)">
<stop offset="0" style="stop-color:rgb(0%,46.27451%,81.568629%);stop-opacity:1;"/>
<stop offset="1" style="stop-color:rgb(20.392157%,59.607846%,85.882354%);stop-opacity:1;"/>
</linearGradient>
<linearGradient id="linear1" gradientUnits="userSpaceOnUse" x1="53.75" y1="86" x2="121.833328" y2="86" gradientTransform="matrix(0.55814,0,0,0.55814,0,0)">
<stop offset="0" style="stop-color:rgb(0%,46.27451%,81.568629%);stop-opacity:1;"/>
<stop offset="1" style="stop-color:rgb(20.392157%,59.607846%,85.882354%);stop-opacity:1;"/>
</linearGradient>
</defs>
<g id="surface7148703">
<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear0);" d="M 57.914062 10.5625 L 56 8 L 38 8 L 36.03125 9.652344 L 30.070312 49.464844 L 32 52 L 49 52 L 42.039062 85.601562 L 45.769531 86.933594 L 67.613281 43.179688 L 66 40 L 51 40 Z M 57.914062 10.5625 "/>
<path style=" stroke:none;fill-rule:nonzero;fill:url(#linear1);" d="M 56 8 C 54.894531 8 54 8.894531 54 10 C 54 11.105469 54.894531 12 56 12 C 57.105469 12 58 11.105469 58 10 C 58 8.894531 57.105469 8 56 8 Z M 38 8 C 36.894531 8 36 8.894531 36 10 C 36 11.105469 36.894531 12 38 12 C 39.105469 12 40 11.105469 40 10 C 40 8.894531 39.105469 8 38 8 Z M 32 48 C 30.894531 48 30 48.894531 30 50 C 30 51.105469 30.894531 52 32 52 C 33.105469 52 34 51.105469 34 50 C 34 48.894531 33.105469 48 32 48 Z M 64 42 C 64 43.105469 64.894531 44 66 44 C 67.105469 44 68 43.105469 68 42 C 68 40.894531 67.105469 40 66 40 C 64.894531 40 64 40.894531 64 42 Z M 44 84 C 42.894531 84 42 84.894531 42 86 C 42 87.105469 42.894531 88 44 88 C 45.105469 88 46 87.105469 46 86 C 46 84.894531 45.105469 84 44 84 Z M 44 84 "/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0,0,256,256" width="96px" height="96px" fill-rule="nonzero"><defs><linearGradient x1="4.526" y1="10.089" x2="34.908" y2="36.472" gradientUnits="userSpaceOnUse" id="color-1"><stop offset="0" stop-color="#beff5c"></stop><stop offset="0.999" stop-color="#72a91f"></stop></linearGradient></defs><g fill="none" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.33333,5.33333)"><path d="M30,10h6c1.1,0 2,0.9 2,2v5h-10v-5c0,-1.1 0.9,-2 2,-2z" fill="#94d82d"></path><path d="M12,10h6c1.1,0 2,0.9 2,2v5h-10v-5c0,-1.1 0.9,-2 2,-2z" fill="#94d82d"></path><rect x="28" y="15" width="10" height="2" fill="#000000" opacity="0.05"></rect><rect x="28" y="15.5" width="10" height="1.5" fill="#000000" opacity="0.07"></rect><rect x="10" y="15" width="10" height="2" fill="#000000" opacity="0.05"></rect><rect x="10" y="15.5" width="10" height="1.5" fill="#000000" opacity="0.07"></rect><path d="M8,16h32c1.1,0 2,0.9 2,2v18c0,1.1 -0.9,2 -2,2h-32c-1.1,0 -2,-0.9 -2,-2v-18c0,-1.1 0.9,-2 2,-2z" fill="url(#color-1)"></path></g></g></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M6,35.5c-1.378,0-2.5-1.122-2.5-2.5V6c0-1.378,1.122-2.5,2.5-2.5h28c1.378,0,2.5,1.122,2.5,2.5v27 c0,1.378-1.122,2.5-2.5,2.5H6z"/><path fill="#4788c7" d="M34,4c1.103,0,2,0.897,2,2v27c0,1.103-0.897,2-2,2H6c-1.103,0-2-0.897-2-2V6c0-1.103,0.897-2,2-2 H34 M34,3H6C4.343,3,3,4.343,3,6v27c0,1.657,1.343,3,3,3h28c1.657,0,3-1.343,3-3V6C37,4.343,35.657,3,34,3L34,3z"/><path fill="#b6dcfe" d="M31 11H9c-1.105 0-2-.895-2-2v0c0-1.105.895-2 2-2h22c1.105 0 2 .895 2 2v0C33 10.105 32.105 11 31 11zM31 18H9c-1.105 0-2-.895-2-2v0c0-1.105.895-2 2-2h22c1.105 0 2 .895 2 2v0C33 17.105 32.105 18 31 18zM31 25H9c-1.105 0-2-.895-2-2v0c0-1.105.895-2 2-2h22c1.105 0 2 .895 2 2v0C33 24.105 32.105 25 31 25z"/><g><path fill="#b6dcfe" d="M31,32H9c-1.105,0-2-0.895-2-2v0c0-1.105,0.895-2,2-2h22c1.105,0,2,0.895,2,2v0 C33,31.105,32.105,32,31,32z"/></g></svg>

After

Width:  |  Height:  |  Size: 933 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M13.249 37.5L7.273 22.5 2.5 22.5 2.5 17.5 10.659 17.5 15.766 30.529 24.358 2.5 37.5 2.5 37.5 7.5 28.054 7.5 18.858 37.5z"/><path fill="#4788c7" d="M37,3v4h-8.576h-0.739l-0.217,0.707L18.489,37h-4.901L7.864,22.63L7.613,22H6.935H3v-4h7.318 l4.459,11.377l1.047,2.67l0.841-2.742L24.727,3H37 M38,2H23.988l-8.28,27.012L11,17H2v6h4.935l5.975,15h0.043h6.276l9.196-30H38V2 L38,2z"/></svg>

After

Width:  |  Height:  |  Size: 490 B

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#4788c7" d="M30 6H34V10H30zM16 2H18V4H16zM18 4H22V6H18zM22 6H24V8H22zM22 2H24V4H22zM20 8H22V10H20zM18 10H20V12H18zM22 10H24V12H22zM22 14H24V16H22zM20 28H22V30H20zM18 30H20V32H18zM16 32H18V34H16zM20 32H22V34H20zM20 36H22V38H20zM20 16H22V28H20zM14 16H16V18H14zM10 16H12V18H10zM6 16H8V18H6zM4 20H6V22H4zM8 20H10V22H8zM12 20H14V22H12zM14 22H16V24H14zM16 24H18V26H16zM18 26H20V28H18zM16 28H18V30H16zM32 30H34V32H32zM30 32H32V34H30zM34 32H36V34H34zM34 36H36V38H34zM28 30H30V32H28zM32 34H34V36H32zM22 34H30V36H22zM26 28H38V30H26zM6 22H8V24H6zM2 18H14V20H2zM36 16H38V18H36zM32 16H34V18H32zM28 16H30V18H28zM26 20H28V22H26zM30 20H32V22H30zM34 20H36V22H34zM36 22H38V24H36zM28 22H30V24H28zM22 24H24V26H22zM26 24H28V26H26zM30 24H32V26H30zM32 26H34V28H32zM24 26H26V28H24z"/><path fill="#4788c7" d="M18 18H36V20H18zM16 6H18V22H16zM36 4v8h-8V4H36M38 2H26v12h12V2L38 2zM6 6H10V10H6z"/><path fill="#4788c7" d="M12 4v8H4V4H12M14 2H2v12h12V2L14 2zM6 30H10V34H6z"/><path fill="#4788c7" d="M12,28v8H4v-8H12 M14,26H2v12h12V26L14,26z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#b6dcfe" d="M7.5 4.5H32.5V10.5H7.5z"/><path fill="#4788c7" d="M32,5v5H8V5H32 M33,4H7v7h26V4L33,4z"/><path fill="#dff0fe" d="M4.5 7.5H35.5V13.5H4.5z"/><path fill="#4788c7" d="M35,8v5H5V8H35 M36,7H4v7h32V7L36,7z"/><g><path fill="#fff" d="M1.5 10.5H38.5V35.5H1.5z"/><path fill="#4788c7" d="M38,11v24H2V11H38 M39,10H1v26h38V10L39,10z"/></g><g><path fill="#98ccfd" d="M15 16L15 30 28 23z"/></g></svg>

After

Width:  |  Height:  |  Size: 495 B

@ -10,7 +10,10 @@
</w-card-section>
<w-card-section>
<div class="p-4 text-center">
<img src="/_assets/illustrations/undraw_going_up.svg" style="width: 150px" />
<img
src="/_assets/illustrations/undraw_going_up.svg"
class="mx-auto"
style="width: 150px" />
</div>
<template v-if="state.isLoading">
<w-linear-progress indeterminate size="lg" rounded />

@ -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.
-->
<div class="header-search relative min-w-0 flex-1">
<div class="header-search relative flex h-full min-w-0 flex-1 flex-col justify-center">
<div class="header-search-field" :class="{ 'is-focused': state.searchIsFocused }">
<w-circular-progress
v-if="siteStore.searchIsLoading && route.path !== `/_search`"
@ -309,15 +312,19 @@ onBeforeUnmount(() => {
/*
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%);

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

@ -160,7 +160,7 @@
active-class="bg-primary text-white"
v-if="userStore.can(`manage:sites`)">
<w-item-section avatar>
<w-icon name="img:/_assets/icons/fluent-rfid-tag.svg" />
<w-icon name="img:/_assets/icons/fluent-plugin.svg" />
</w-item-section>
<w-item-section>{{ t('admin.blocks.title') }}</w-item-section>
</w-item>

@ -2,7 +2,7 @@
<w-page class="admin-flags">
<div class="flex flex-wrap p-4 items-center">
<div class="flex-none">
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-rfid-tag.svg" />
<img class="admin-icon animated fadeInLeft" src="/_assets/icons/fluent-plugin.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.blocks.title') }}</div>

@ -4,7 +4,7 @@
<div class="flex-none">
<img
class="admin-icon animated fadeInLeft"
src="/_assets/icons/fluent-lightning-bolt.svg" />
src="/_assets/icons/fluent-lightning-bolt-animated.svg" />
</div>
<div class="min-w-0 flex-1 pl-4">
<div class="text-h5 text-primary animated fadeInLeft">{{ t('admin.webhooks.title') }}</div>

@ -197,7 +197,7 @@
</div>
</template>
<!-- Tags -->
<template v-if="pageStore.showTags">
<template v-if="showTags">
<w-separator v-if="showToc" />
<div
class="p-4"
@ -236,7 +236,7 @@
</div>
</template>
<template v-if="siteStore.features.ratingsMode !== `off` && pageStore.allowRatings">
<w-separator v-if="showToc || pageStore.showTags" />
<w-separator v-if="showToc || showTags" />
<!-- Rating -->
<div class="p-4 flex items-center">
<w-icon class="mr-2" name="la:star-half-alt" color="grey" />
@ -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

@ -72,12 +72,13 @@ export class MarkdownRenderer {
highlight(str, lang) {
if (lang === 'diagram') {
return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
} 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 `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
} else {

Loading…
Cancel
Save