refactor: move non-markdown features into MDC blocks

scarlett
NGPixel 1 month ago
parent 3d13e50be8
commit 072e1dcc42
No known key found for this signature in database

@ -117,7 +117,9 @@ Self-contained Lit components. Each lives in `blocks/block-<name>/component.js`
`rollup.config.mjs` picks up any directory matching `block-*` automatically, so a new block needs no
config change. Output goes to `blocks/compiled/`, which the backend serves statically under
`/_blocks/`. Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from
Vite's `dynamicImportVarsOptions`.
Vite's `dynamicImportVarsOptions`. A block pulling in a heavy library is fine — nothing is fetched
until its tag turns up in a page — and a library that still ships CommonJS works too, since the
rollup config runs `@rollup/plugin-commonjs` after `resolve()`.
Blocks style themselves with `:host` / `:host-context(body.body--dark)` for dark mode and read the
theme colors via CSS custom properties (`var(--q-primary)` — the `--q-` prefix is historical; the

@ -180,22 +180,12 @@
"admin.editors.markdown.allowHTML": "Allow HTML",
"admin.editors.markdown.allowHTMLHint": "Allow HTML tags in content.",
"admin.editors.markdown.general": "General",
"admin.editors.markdown.kroki": "Kroki",
"admin.editors.markdown.krokiHint": "Enable Kroki Diagrams Parser",
"admin.editors.markdown.krokiServerUrl": "Kroki Server URL",
"admin.editors.markdown.krokiServerUrlHint": "URL to the Kroki server used for image generation.",
"admin.editors.markdown.latexEngine": "LaTeX Engine",
"admin.editors.markdown.latexEngineHint": "Which engine to use to process TeX/LaTeX expressions.",
"admin.editors.markdown.lineBreaks": "Auto Line Breaks",
"admin.editors.markdown.lineBreaksHint": "Automatically add linebreaks within paragraphs.",
"admin.editors.markdown.linkify": "Auto-linking",
"admin.editors.markdown.linkifyHint": "Automatically convert URLs into clickable links.",
"admin.editors.markdown.multimdTable": "MultiMarkdown Table",
"admin.editors.markdown.multimdTableHint": "Enable support for MultiMarkdown Table features.",
"admin.editors.markdown.plantuml": "PlantUML",
"admin.editors.markdown.plantumlHint": "Enable PlantUML Parser",
"admin.editors.markdown.plantumlServerUrl": "PlantUML Server URL",
"admin.editors.markdown.plantumlServerUrlHint": "URL to the PlantUML server used for image generation.",
"admin.editors.markdown.quotes": "Quotes Style",
"admin.editors.markdown.quotesHint": "When typographer is enabled. Double + single quotes replacement pairs. e.g. «»„“ for Russian, „“‚‘ for German, etc.",
"admin.editors.markdown.saveSuccess": "Markdown editor configuration saved successfully.",
@ -1637,7 +1627,8 @@
"editor.emoji.smileysEmotion": "Smileys & Emotion",
"editor.emoji.symbols": "Symbols",
"editor.emoji.travelPlaces": "Travel & Places",
"editor.markup.admonitionDanger": "Danger / Important Admonition",
"editor.markup.admonitionDanger": "Danger / Caution Admonition",
"editor.markup.admonitionImportant": "Important Admonition",
"editor.markup.admonitionInfo": "Info / Note Admonition",
"editor.markup.admonitionSuccess": "Tip / Success Admonition",
"editor.markup.admonitionWarning": "Warning Admonition",

@ -148,14 +148,9 @@ class Sites {
isActive: true,
config: {
allowHTML: true,
kroki: false,
krokiServerUrl: 'https://kroki.io',
latexEngine: 'katex',
lineBreaks: true,
linkify: true,
multimdTable: true,
plantuml: false,
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
quotes: 'english',
tabWidth: 2,
typographer: false,
@ -326,14 +321,9 @@ class Sites {
isActive: true,
config: {
allowHTML: true,
kroki: false,
krokiServerUrl: 'https://kroki.io',
latexEngine: 'katex',
lineBreaks: true,
linkify: true,
multimdTable: true,
plantuml: false,
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
quotes: 'english',
tabWidth: 2,
typographer: false,

@ -0,0 +1,256 @@
import { LitElement, html, css, unsafeCSS } from 'lit'
import { create } from 'asciinema-player'
// -> The player's stylesheet, as a string. It is what draws the terminal, and a <link> in the page
// cannot reach into this shadow root — see the `cssAsString` plugin in rollup.config.mjs.
import playerCss from 'asciinema-player/dist/bundle/asciinema-player.css'
/**
* An attribute that means "off" when it says so.
*
* MDC writes every prop with a value `autoPlay="false"` is what the block picker produces for a
* toggle that was switched on and off again and Lit's own Boolean converter reads any string at all
* as true, that one included.
*/
const boolean = {
converter: {
fromAttribute: (value) => value !== null && value !== 'false',
toAttribute: (value) => (value ? 'true' : null)
}
}
/**
* Block Asciinema
*/
export class BlockAsciinemaElement 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: 'asciinema',
name: 'Terminal Recording',
description: 'Plays an asciinema recording — a .cast file — in a terminal player.',
icon: 'run-command',
props: [
{
name: 'src',
type: 'string',
label: 'Recording URL',
hint: 'Path or URL of the .cast file to play.',
required: true
},
{
name: 'theme',
type: 'select',
label: 'Theme',
options: [
'asciinema',
'dracula',
'gruvbox-dark',
'monokai',
'nord',
'seti',
'solarized-dark',
'solarized-light',
'tango'
],
hint: 'Terminal colours. All but solarized-light are dark.',
default: 'asciinema'
},
{
name: 'autoPlay',
type: 'boolean',
label: 'Play On Load',
hint: 'Start as soon as the page is opened, rather than waiting to be asked.',
// -> Stated, so that a toggle switched on and then off again writes nothing into the page
default: false
},
{
name: 'loop',
type: 'boolean',
label: 'Loop',
hint: 'Start again on reaching the end.',
default: false
},
{
name: 'speed',
type: 'number',
label: 'Speed',
hint: 'Playback rate. 2 plays twice as fast as it was recorded.',
default: 1
},
{
name: 'idleTimeLimit',
type: 'number',
label: 'Idle Time Limit',
hint: 'Cap the pauses in the recording at this many seconds. Empty keeps them as recorded.'
}
]
}
static get styles() {
return [
unsafeCSS(playerCss),
css`
:host {
display: block;
}
/* -> The gap below the block. On this element rather than :host: see block-index. */
.player,
.error {
margin-bottom: 16px;
}
.player {
border-radius: 5px;
/* -> The terminal paints its own background into the corners otherwise */
overflow: hidden;
}
.error {
color: var(--q-negative, #c10015);
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
border-radius: 5px;
padding: 1rem;
}
`
]
}
static get properties() {
return {
/**
* Path or URL of the .cast file
* @type {string}
*/
src: { type: String },
/**
* Name of one of the player's terminal themes
* @type {string}
*/
theme: { type: String },
/**
* Whether to start without being asked
* @type {boolean}
*/
autoPlay: boolean,
/**
* Whether to start again at the end
* @type {boolean}
*/
loop: boolean,
/**
* Playback rate, 1 being the speed it was recorded at
* @type {number}
*/
speed: { type: Number },
/**
* Longest pause to play back, in seconds
* @type {number}
*/
idleTimeLimit: { type: Number },
// Internal Properties
_error: { state: true }
}
}
constructor() {
super()
this.src = ''
this.theme = 'asciinema'
this.autoPlay = false
this.loop = false
this.speed = 1
this.idleTimeLimit = null
this._error = ''
this._player = null
}
/**
* What to give the player, out of what the author gave the block.
*
* Only the settings that were actually asked for: an option left out is the player's own default,
* which is the one that gets maintained. A speed of zero or a negative one would stop the recording
* dead, and a nonsense number would take the player with it, so that one is bounded.
*/
_options() {
const speed = Number(this.speed)
const idle = Number(this.idleTimeLimit)
return {
theme: this.theme || 'asciinema',
autoPlay: this.autoPlay,
loop: this.loop,
speed: Number.isFinite(speed) && speed > 0 ? Math.min(speed, 10) : 1,
...(Number.isFinite(idle) && idle > 0 ? { idleTimeLimit: idle } : {}),
// -> A recording is as wide as the terminal it was made in, which is rarely this column's width
fit: 'width'
}
}
/**
* Fetch the recording, and say so in the block if it cannot be had.
*
* The player is given this rather than the address itself, for the sake of what happens when the
* address is wrong. Handed a URL it fetches the file on its own, and a fetch that fails leaves an
* empty terminal sitting there with the reason in the console where an author who mistyped a path
* will not see it. Fetching it here is the only way to get hold of that failure, which is the
* common one: a typo, a file that has moved, or a host that sends no CORS headers.
*
* The response is handed over whole, which is a source the player takes as it comes; there is
* nothing to be gained by reading it here, and it lets the player stream a long recording.
*/
async _fetch(src) {
try {
const response = await fetch(src)
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`.trim())
}
return response
} catch (err) {
this._error = `This recording could not be loaded from ${src}${err.message}`
throw err
}
}
firstUpdated() {
const src = this.src?.trim()
if (!src) {
this._error = 'This player needs the address of a .cast recording.'
return
}
/*
A function rather than the recording itself, so that nothing is fetched until it is played
which is the player's own behaviour, and the right one: a page carrying a recording should not
pull the whole thing down before anybody has asked to watch it.
*/
this._player = create(
{ data: () => this._fetch(src) },
this.renderRoot.querySelector('.player'),
this._options()
)
}
disconnectedCallback() {
super.disconnectedCallback()
// -> The player keeps listeners on window and a resize observer, which outlive the element
this._player?.dispose()
this._player = null
}
render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`
}
return html`<div class="player"></div>`
}
}
window.customElements.define('block-asciinema', BlockAsciinemaElement)

@ -0,0 +1,281 @@
import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import mermaid from 'mermaid'
/**
* A number for the next drawing, so every one of them gets an id of its own.
*
* Mermaid names the SVG it produces and writes that name into the CSS it embeds in it, so two
* diagrams sharing an id would style each other. A counter rather than a random name: the ids are
* scoped to one page load, and a run of them is easier to recognise in the inspector.
*/
let drawingCount = 0
/**
* The drawing in progress, so that only ever one of them is.
*
* Mermaid is configured globally `initialize` sets the library up, not a call to it so two
* diagrams on a page asking for different themes would each set theirs and then be drawn in whichever
* one was set last. Queued, a diagram has the library to itself from the moment it configures it to
* the moment it is handed back an SVG.
*/
let queue = Promise.resolve()
/**
* Configure mermaid and draw one diagram with it, once whatever is ahead of it is done.
*/
function drawInTurn(config, id, source) {
const drawing = queue.then(() => {
mermaid.initialize(config)
return mermaid.render(id, source)
})
// -> Whether it worked or not, since a diagram that could not be drawn must not hold up the rest
queue = drawing.then(
() => {},
() => {}
)
return drawing
}
/**
* Block Diagram
*/
export class BlockDiagramElement 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: 'diagram',
name: 'Diagram',
description: 'Draws a Mermaid diagram — flowchart, sequence, class, state, ER, gantt and more.',
icon: 'workflow',
/*
A fenced block, and not only for the syntax highlighting: markdown would otherwise have its way
with the source before this ever sees it. `-->` survives, but the typographer turns `--` into a
dash, an indented line reads as a code block of its own, and `%%` comments and `#` labels are
claimed as structure. Inside a fence the text arrives exactly as it was typed.
*/
template: `\`\`\`mermaid
flowchart LR
A[Start] --> B{Ready?}
B -->|Yes| C[Ship it]
B -->|No| A
\`\`\``,
props: [
{
name: 'caption',
type: 'string',
label: 'Caption',
hint: 'Shown under the diagram.'
},
{
name: 'theme',
type: 'select',
label: 'Theme',
options: ['auto', 'default', 'dark', 'neutral', 'forest'],
hint: 'auto follows the light or dark theme the reader is using.',
default: 'auto'
},
{
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;
}
/*
Mermaid sizes the drawing itself it writes a max-width on the SVG at the width the diagram
came out to, so a small one is left at its own size and a large one shrinks to the column. Only
the height is settled here, so that shrinking keeps the shapes in proportion.
*/
svg {
max-width: 100%;
height: auto;
}
.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 {
/**
* Text shown under the diagram
* @type {string}
*/
caption: { type: String },
/**
* Mermaid theme, or `auto` to follow the reader's
* @type {string}
*/
theme: { type: String },
/**
* Where the drawing sits in the column, `left` or `center`
* @type {string}
*/
align: { type: String },
// Internal Properties
_svg: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.caption = ''
this.theme = 'auto'
this.align = 'left'
this._svg = ''
this._error = ''
this._themeWatcher = null
/** The drawing being waited on, so a stale one cannot land after a newer one. */
this._drawing = 0
/** The source, and whether it came out of a fence. Both read from the body once, on first render. */
this._source = ''
this._fenced = false
}
/**
* The theme to draw in.
*
* `auto` reads the class the app puts on the body, which is the same thing every block's CSS keys
* its dark mode off a diagram cannot do it in CSS, because mermaid picks its colours while it
* draws and writes them into the SVG.
*/
_theme() {
if (this.theme && this.theme !== 'auto') {
return this.theme
}
return document.body.classList.contains('body--dark') ? 'dark' : 'default'
}
/**
* Draw the source, or say why it could not be drawn.
*/
async _draw() {
const drawing = ++this._drawing
const config = {
startOnLoad: false,
// -> A page is authored by whoever may edit it, so the text in a diagram is treated as text:
// HTML in a label is escaped and `click` directives do nothing
securityLevel: 'strict',
// -> Mermaid's own answer to a broken diagram is to append a drawing of a bomb to the body,
// outside this element and past the page's styling. The message below is this block's job.
suppressErrorRendering: true,
theme: this._theme(),
// -> The page's own font, so a diagram reads as part of the text around it. Mermaid measures
// its labels in the same font, so the boxes come out the right size for it.
fontFamily: 'inherit'
}
try {
const { svg } = await drawInTurn(config, `block-diagram-${++drawingCount}`, this._source)
// -> A theme toggle can start a second drawing while this one is still going
if (drawing !== this._drawing) {
return
}
this._svg = svg
this._error = ''
} catch (err) {
if (drawing !== this._drawing) {
return
}
this._svg = ''
/*
Mermaid says what it could not read and where, which is the useful half. The other half is
the fence, because a diagram that renders in every other tool and not here is nearly always a
source markdown got to first see `template`.
*/
this._error = `This diagram could not be drawn: ${err.message ?? err}`
if (!this._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 `--&gt;` in the markup, and gives back what the author typed.
*/
const fence = this.querySelector('pre')
this._fenced = Boolean(fence)
this._source = ((fence ?? this).textContent ?? '').trim()
if (!this._source) {
this._error =
'This diagram is empty. Its source goes in the body of the block, inside a fenced code block.'
return
}
this._draw()
// -> Only `auto` has anything to follow; a diagram asked for a theme by name keeps it either way
if (this.theme === 'auto') {
this._themeWatcher = new MutationObserver(() => this._draw())
this._themeWatcher.observe(document.body, { attributeFilter: ['class'] })
}
}
disconnectedCallback() {
super.disconnectedCallback()
this._themeWatcher?.disconnect()
this._themeWatcher = null
}
render() {
if (this._error) {
return html`<div class="error">${this._error}</div>`
}
return html`
<div class="diagram ${this.align === 'center' ? 'is-center' : ''}">
${unsafeSVG(this._svg)}
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-diagram', BlockDiagramElement)

@ -22,6 +22,58 @@ const NO_SVG = html`
</svg>
`
/**
* What an author writes to mean "this value goes somewhere": a page, an email address, a number.
*
* The scheme has to be spelled out. A value that merely looks like a hostname is left alone, since
* plenty of ordinary facts read that way a file name, a version, a decimal and there is no test
* that tells `notes.txt` from `montreal.ca` without guessing.
*/
const SCHEME = /^(?:https?:\/\/|mailto:|tel:)/i
/**
* The link a value stands for, if it is one.
*
* The whole value has to be the address; a sentence with a URL in it is prose, and picking the link
* out of it is markdown's job, not this block's.
*
* @returns {{ href: string, label: string, isExternal: boolean } | null}
*/
function linkOf(text) {
if (!SCHEME.test(text) || /\s/.test(text)) {
return null
}
let url
try {
url = new URL(text)
} catch {
return null
}
/*
Shown without its scheme. An infobox is a column of short facts read at a glance, and `https://`
is the same four inches of boilerplate on every row of it the address is the part that says
where the row goes. The label comes off the text as typed rather than out of the parsed URL, which
would put back a trailing slash the author did not write.
*/
const label = text.replace(SCHEME, '')
if (!label) {
return null
}
return {
href: url.href,
label,
/*
The mark means "this leaves the wiki", so it is for a web address on another host the question
the page's renderer asks of a link, and for the same reason it asks it of the host and not of
the text. See `isExternalHref` in `renderers/markdown.js`, which also leaves an email address
and a telephone number unmarked: neither goes to a page at all, and both say what they are.
*/
isExternal:
(url.protocol === 'http:' || url.protocol === 'https:') &&
url.origin !== globalThis.location?.origin
}
}
/**
* One value, as it is shown.
*
@ -36,7 +88,17 @@ function valueOf(value) {
// -> Joined by hand rather than with `join`, so that a boolean among them is still drawn
return value.map((entry, index) => html`${index > 0 ? ', ' : ''}${valueOf(entry)}`)
}
return String(value)
const text = String(value)
const link = linkOf(text)
if (!link) {
return text
}
// -> No whitespace inside the anchor — hence the tags broken after their closing bracket, which is
// how the formatter keeps it out: a space beside the words is taken in by the underline on hover
// and pushes the external mark off the end of them
return html`<a class="${link.isExternal ? 'is-external-link' : ''}" href="${link.href}"
>${link.label}</a
>`
}
/**
@ -66,10 +128,15 @@ export class BlockInfoboxElement extends LitElement {
name: 'Infobox',
description: 'A summary box beside the text, filled in from a list of facts.',
icon: 'data-sheet',
template: `City: Montreal
template: `\`\`\`yaml
City: Montreal
Country: Canada
Metro: true
"Key with space": foo-bar`,
Public Transport:
Metro: true
Bus: true
Monorail: false
Website: https://montreal.ca
\`\`\``,
props: [
{
name: 'name',
@ -193,16 +260,91 @@ Metro: true
overflow-wrap: anywhere;
}
/* -> A nested mapping: its own heading across both columns, then its rows under it */
/*
-> A nested mapping: its own heading across both columns, then its rows under it
Shaded top-down rather than flat, so the heading reads as the lid of the group under it: the
pale edge catches the eye where the group starts and the colour settles into the one the box's
own name is drawn on. The two stops are declared per theme, since "lighter" in dark mode is a
lighter dark grey and not a step towards white.
*/
.group {
grid-column: 1 / -1;
padding: 7px 12px;
border-top: 1px solid var(--infobox-rule);
background-color: var(--infobox-head);
background-image: linear-gradient(to bottom, var(--infobox-head-top), var(--infobox-head));
font-weight: 600;
text-align: center;
}
/*
The rule that closes a group.
Thicker than the ones between rows, and in the border colour rather than the rule colour, so
that a row belonging to the group and a row that follows it are told apart at a glance the
heading marks where the group starts, this marks where it stops.
*/
dl > :is(dt, dd).is-group-end {
border-bottom: 3px solid var(--infobox-border);
}
/* -> At the foot of the box there is nothing to separate from, and the card's own border is there */
dl > :is(dt, dd):is(:last-child, :nth-last-child(2)) {
border-bottom: 0;
}
/*
Whatever comes next drops its own line: the thick one above it is the separation, and the two
together would read as a single rule of an odd weight.
Two selectors because a row is two children of the grid the label and the value so the
line over it is drawn twice, once per column. Leaving the second one on broke the rule in
half: nothing above the label, a hairline above the value. A group heading spans both columns
and is only ever the one element, which is why the second selector asks for a dd.
*/
dl > dd.is-group-end + *,
dl > dd.is-group-end + * + dd {
border-top: 0;
}
/*
A value that is a web address, drawn the way the page draws its links: the same colour token,
the same medium weight, the same underline on hover. The rules are repeated here because a
stylesheet in the page cannot reach into a shadow root the custom properties it declares do
reach in, which is what keeps the box in step with a re-themed site.
*/
a {
color: var(--content-link, var(--q-primary, #1976d2));
font-weight: 500;
text-decoration: none;
}
a:hover,
a:focus-visible {
text-decoration: underline;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
}
/*
A link that leaves the wiki says so the same mark, from the same masked SVG, as a link in the
text beside it. Masked rather than drawn, so it takes the link's own colour in either theme,
and sized in em so it keeps its proportion to the words. See the LINKS section of
css/_page-contents.scss.
*/
a.is-external-link::after {
content: '';
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-left: 0.25em;
background-color: currentColor;
/* -> Subordinate to the words: a marker, not a second link */
opacity: 0.7;
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14 3v2h3.59l-9.83 9.83l1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5c-1.11 0-2 .9-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2z'/%3E%3C/svg%3E");
mask-repeat: no-repeat;
mask-size: contain;
vertical-align: baseline;
}
.yes {
color: var(--q-positive, #02c39a);
vertical-align: -3px;
@ -222,12 +364,14 @@ Metro: true
--infobox-border: #d5d5d5;
--infobox-bg: #f8f9fa;
--infobox-head: #eaecf0;
--infobox-head-top: #f7f8fa;
--infobox-rule: #e3e5e8;
}
:host-context(body.body--dark) {
--infobox-border: rgba(255, 255, 255, 0.15);
--infobox-bg: #161b22;
--infobox-head: #1e232a;
--infobox-head-top: #2b323c;
--infobox-rule: rgba(255, 255, 255, 0.1);
}
`
@ -341,12 +485,15 @@ Metro: true
const isGroup = rows.length > 1 || rows[0].label !== undefined
return html`
${isGroup ? html`<div class="group">${label}</div>` : null}
${rows.map(
(row) => html`
<dt>${isGroup ? row.label : label}</dt>
<dd>${valueOf(row.value)}</dd>
${rows.map((row, index) => {
// -> The pair that closes a group carries the rule that separates it from
// whatever is listed after it
const groupEnd = isGroup && index === rows.length - 1 ? 'is-group-end' : ''
return html`
<dt class="${groupEnd}">${isGroup ? row.label : label}</dt>
<dd class="${groupEnd}">${valueOf(row.value)}</dd>
`
)}
})}
`
})}
</dl>

@ -0,0 +1,304 @@
import { LitElement, html, css } from 'lit'
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
import { mathjax } from '@mathjax/src/js/mathjax.js'
import { TeX } from '@mathjax/src/js/input/tex.js'
import { SVG } from '@mathjax/src/js/output/svg.js'
import { liteAdaptor } from '@mathjax/src/js/adaptors/liteAdaptor.js'
import { RegisterHTMLHandler } from '@mathjax/src/js/handlers/html.js'
import { MathJaxNewcmFont } from '@mathjax/mathjax-newcm-font/js/svg.js'
import { MathJaxMhchemFontExtension } from '@mathjax/mathjax-mhchem-font-extension/js/svg.js'
/*
Every TeX package the block understands, imported for its side effect: a configuration registers
itself under its name, and `PACKAGES` below is what then switches it on.
This is the set MathJax's own "all packages" bundle carries, less three of them. `html` is left out
because it exists to put HTML into the page from inside TeX a link, a class, a style attribute
which is not what a formula is for. `noerrors` and `noundefined` are left out because both answer a
mistake by drawing something: the unreadable source in place of the formula, or a black box where a
macro should have been. Without them the error reaches this file, which has a panel to say so in.
Nothing here loads anything at run time. `require` and `autoload` are absent for that reason: both
fetch a package the moment TeX asks for one, which cannot work in a bundle the block is a single
file served from /_blocks, with no MathJax install behind it to fetch from.
*/
import '@mathjax/src/js/input/tex/base/BaseConfiguration.js'
import '@mathjax/src/js/input/tex/action/ActionConfiguration.js'
import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js'
import '@mathjax/src/js/input/tex/amscd/AmsCdConfiguration.js'
import '@mathjax/src/js/input/tex/bbox/BboxConfiguration.js'
import '@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js'
import '@mathjax/src/js/input/tex/braket/BraketConfiguration.js'
import '@mathjax/src/js/input/tex/bussproofs/BussproofsConfiguration.js'
import '@mathjax/src/js/input/tex/cancel/CancelConfiguration.js'
import '@mathjax/src/js/input/tex/cases/CasesConfiguration.js'
import '@mathjax/src/js/input/tex/centernot/CenternotConfiguration.js'
import '@mathjax/src/js/input/tex/color/ColorConfiguration.js'
import '@mathjax/src/js/input/tex/colortbl/ColortblConfiguration.js'
import '@mathjax/src/js/input/tex/empheq/EmpheqConfiguration.js'
import '@mathjax/src/js/input/tex/enclose/EncloseConfiguration.js'
import '@mathjax/src/js/input/tex/extpfeil/ExtpfeilConfiguration.js'
import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js'
import '@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js'
import '@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js'
import '@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js'
import '@mathjax/src/js/input/tex/physics/PhysicsConfiguration.js'
import '@mathjax/src/js/input/tex/textcomp/TextcompConfiguration.js'
import '@mathjax/src/js/input/tex/textmacros/TextMacrosConfiguration.js'
import '@mathjax/src/js/input/tex/unicode/UnicodeConfiguration.js'
import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js'
import '@mathjax/src/js/input/tex/verb/VerbConfiguration.js'
const PACKAGES = [
'base',
'action',
'ams',
'amscd',
'bbox',
'boldsymbol',
'braket',
'bussproofs',
'cancel',
'cases',
'centernot',
'color',
'colortbl',
'empheq',
'enclose',
'extpfeil',
'gensymb',
'mathtools',
'mhchem',
'newcommand',
'physics',
'textcomp',
'textmacros',
'unicode',
'upgreek',
'verb'
]
/**
* MathJax, set up once for the page.
*
* Off the document entirely: the SVG output measures nothing in the DOM it has the metrics of every
* glyph in the font it draws with so a formula can be typeset against a document MathJax makes up
* for itself and handed back as markup. That is what makes it usable from inside a shadow root, which
* MathJax has no notion of and where its own stylesheet in the page would not reach.
*/
const adaptor = liteAdaptor()
RegisterHTMLHandler(adaptor)
const output = new SVG({
fontData: MathJaxNewcmFont,
/*
Each formula carries its own glyph definitions. The alternative, one cache for the page, is a
single hidden `svg` in the document that every formula points into and a reference from inside a
shadow root does not resolve to it, so every letter would come out blank.
*/
fontCache: 'local'
})
/*
mhchem's bonds, arrows and brackets are glyphs of their own, in a font variant the text font has no
reason to carry. Added here rather than fetched: MathJax's own build loads this on demand the first
time a `\ce` turns up, which is the one thing a bundled block cannot do.
*/
output.font.addExtension(MathJaxMhchemFontExtension)
const document_ = mathjax.document('', {
InputJax: new TeX({
packages: PACKAGES,
// -> Handing the error on rather than drawing it: see the panel in `render`
formatError: (jax, err) => {
throw err
}
}),
OutputJax: output
})
/**
* Block MathJax
*/
export class BlockMathjaxElement 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: 'mathjax',
name: 'MathJax',
description:
'Typesets a TeX formula, including chemical equations written with mhchem — \\ce{} and \\pu{}.',
icon: 'sigma',
/*
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 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 see .katex-display in css/_page-contents.scss. 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 drawing takes the colour of the text around it: MathJax paints its glyphs in currentColor,
so dark mode needs nothing here unlike a block that picks its own colours.
*/
svg {
display: block;
}
.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
_svg: { state: true },
_error: { state: true }
}
}
constructor() {
super()
this.caption = ''
this.align = 'center'
this._svg = ''
this._error = ''
}
/**
* Typeset the source, or say why it could not be.
*/
_typeset(source, fenced) {
try {
const container = document_.convert(source, { display: true })
const drawing = adaptor.firstChild(container)
/*
The formula named for a reader who cannot see it. MathJax's own answer to this is a MathML
copy of the expression alongside the drawing, which needs its stylesheet in the page to stay
hidden and its speech engine to read well neither of which a block in a shadow root has. The
source is what is left, and it is what the author wrote: imperfectly read aloud, but the
drawing already carries role="img", and an image with no name at all is worse.
*/
adaptor.setAttribute(drawing, 'aria-label', source)
this._svg = adaptor.outerHTML(drawing)
this._error = ''
} catch (err) {
this._svg = ''
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 `&amp;` and `&lt;` 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">${unsafeSVG(this._svg)}</div>
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-mathjax', BlockMathjaxElement)

@ -0,0 +1,303 @@
import { LitElement, html, css } from 'lit'
import { deflateRaw } from 'pako'
/** The default server, which is the one PlantUML runs for everybody. */
const DEFAULT_SERVER = 'https://www.plantuml.com/plantuml'
/**
* PlantUML's own alphabet for the text it carries in a URL.
*
* Base64 by shape but not by order digits first, then the letters, and `-_` for the last two so
* the standard encoders cannot be used and this is done by hand below.
*/
const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_'
/**
* A diagram source as it goes into a PlantUML URL: deflated, then written in that alphabet.
*
* Raw deflate with no zlib header, which is what the server's decoder expects. Three bytes at a time
* become four characters; a group short of three is padded with zeros, and the server disregards what
* the padding decodes to.
*
* 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 the
* server's POST endpoint, which is not implemented here.
*/
function encodeForUrl(source) {
const bytes = deflateRaw(new TextEncoder().encode(source), { level: 9 })
let encoded = ''
for (let i = 0; i < bytes.length; i += 3) {
const b1 = bytes[i]
const b2 = bytes[i + 1] ?? 0
const b3 = bytes[i + 2] ?? 0
encoded += ALPHABET[b1 >> 2]
encoded += ALPHABET[((b1 & 0x3) << 4) | (b2 >> 4)]
encoded += ALPHABET[((b2 & 0xf) << 2) | (b3 >> 6)]
encoded += ALPHABET[b3 & 0x3f]
}
return encoded
}
/**
* Block PlantUML
*/
export class BlockPlantumlElement 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: 'plantuml',
name: 'PlantUML',
description:
'Draws a PlantUML diagram — sequence, class, state, activity, mindmap, gantt and the rest.',
icon: 'polyline',
/*
Fenced, and named `plantuml` so the source is what it says it is. The fence is also what keeps
markdown off it: `->` survives, but `--` becomes a dash, a line opening with `*` or `#` is read
as a list or a heading, and an indented line becomes a code block of its own.
Passed to the server exactly as written, `@startuml` included which is why a `@startmindmap`
or a `@startgantt` works here too. Wrapping it in `@startuml` on the author's behalf would rule
every one of those out.
*/
template: `\`\`\`plantuml
@startuml
Alice -> Bob : hello
Bob --> Alice : hi
@enduml
\`\`\``,
props: [
{
name: 'server',
type: 'string',
label: 'Server',
hint: 'PlantUML server to draw with. The public one when left empty.',
// -> Written out rather than taken from DEFAULT_SERVER above: the manifest is read out of this
// file's syntax tree at build time, where a name is just a name
default: 'https://www.plantuml.com/plantuml'
},
{
name: 'format',
type: 'select',
label: 'Format',
options: ['svg', 'png'],
hint: 'svg stays sharp at any size; png is there for a server with svg switched off.',
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. PlantUML 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;
}
.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 {
/**
* PlantUML 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 },
_error: { state: true }
}
}
constructor() {
super()
this.server = DEFAULT_SERVER
this.format = 'svg'
this.caption = ''
this.align = 'left'
this._src = ''
this._error = ''
}
/**
* 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: no CORS headers, which a PlantUML behind somebody's own
* proxy may well not send. It also means the browser caches the drawing like any other image.
*/
_url(source) {
const server = (this.server?.trim() || DEFAULT_SERVER).replace(/\/+$/, '')
const format = this.format === 'png' ? 'png' : 'svg'
return `${server}/${format}/${encodeForUrl(source)}`
}
/**
* Say what went wrong, having been told only that the image did not load.
*
* Not the case of a diagram PlantUML cannot read: it answers those with a picture saying so, and a
* browser draws it whatever status came with it so a mistake in the source shows up as the
* server's own message where the diagram would have been, which is the best place for it.
*
* 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, and to read `X-PlantUML-Diagram-Error` if it is there.
* Best effort a server that sends no CORS headers refuses this second request, 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)
const reason = response.headers.get('x-plantuml-diagram-error')
if (reason) {
this._error = `PlantUML could not read this diagram: ${reason}`
} else if (!response.ok) {
this._error = `The server answered ${response.status} ${response.statusText} for this diagram.`
}
} catch {
// -> Unreachable, blocked, or simply not a PlantUML 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 ```plantuml fence.'
if (this.querySelector('img')) {
// -> The renderer's own PlantUML option claims that fence and draws it before this block is
// ever built, leaving an image where the source should be
this._error +=
'\n\nThe PlantUML option in the markdown editor settings is on, and it has already turned this fence into a diagram of its own. Switch it off to draw through this block.'
}
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' : ''}">
<div class="sheet">
<img
src="${this._src}"
alt="${this.caption || 'PlantUML diagram'}"
@error="${() => this._explain(this._src)}" />
</div>
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
</div>
`
}
}
window.customElements.define('block-plantuml', BlockPlantumlElement)

File diff suppressed because it is too large Load Diff

@ -11,12 +11,19 @@
"author": "Nicolas Giard",
"license": "AGPL-3.0",
"dependencies": {
"@mathjax/mathjax-mhchem-font-extension": "4.1.3",
"@mathjax/mathjax-newcm-font": "4.1.3",
"@mathjax/src": "4.1.3",
"asciinema-player": "3.17.0",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
"lit": "3.2.1",
"mermaid": "11.16.0",
"pako": "3.0.1",
"uqr": "0.1.3"
},
"devDependencies": {
"@rollup/plugin-commonjs": "29.0.3",
"@rollup/plugin-node-resolve": "15.3.0",
"@rollup/plugin-terser": "0.4.4",
"glob": "11.0.0",

@ -1,6 +1,7 @@
import summary from 'rollup-plugin-summary'
import terser from '@rollup/plugin-terser'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import * as glob from 'glob'
@ -120,6 +121,9 @@ export default {
blocksManifest(),
cssAsString(),
resolve(),
// -> A block's own code is ESM, but a library it pulls in need not be: mermaid reaches for dayjs,
// which ships as UMD, and rollup has no notion of `module.exports` without this
commonjs(),
terser({
ecma: 2019,
module: true

@ -24,7 +24,6 @@
"iconify-icon": "3.0.2",
"js-cookie": "3.0.8",
"jwt-decode": "4.0.0",
"katex": "0.17.0",
"ky": "2.0.2",
"lodash-es": "4.18.1",
"lowlight": "3.3.0",
@ -45,7 +44,6 @@
"markdown-it-task-lists": "2.1.1",
"mitt": "3.0.1",
"monaco-editor": "0.55.1",
"pako": "2.1.0",
"pinia": "3.0.4",
"prosemirror-commands": "1.7.1",
"prosemirror-history": "1.5.0",
@ -5711,31 +5709,6 @@
"node": ">=18"
}
},
"node_modules/katex": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
"funding": [
"https://opencollective.com/katex",
"https://github.com/sponsors/katex"
],
"license": "MIT",
"dependencies": {
"commander": "^8.3.0"
},
"bin": {
"katex": "cli.js"
}
},
"node_modules/katex/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/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -6703,12 +6676,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
"license": "(MIT AND Zlib)"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",

@ -33,7 +33,6 @@
"iconify-icon": "3.0.2",
"js-cookie": "3.0.8",
"jwt-decode": "4.0.0",
"katex": "0.17.0",
"ky": "2.0.2",
"lodash-es": "4.18.1",
"lowlight": "3.3.0",
@ -54,7 +53,6 @@
"markdown-it-task-lists": "2.1.1",
"mitt": "3.0.1",
"monaco-editor": "0.55.1",
"pako": "2.1.0",
"pinia": "3.0.4",
"prosemirror-commands": "1.7.1",
"prosemirror-history": "1.5.0",

@ -151,7 +151,6 @@ export const BUNDLED_ICONS = {
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
"mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
"mdi:car": {"body":"<path fill=\"currentColor\" d=\"m5 11l1.5-4.5h11L19 11m-1.5 5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-11 0A1.5 1.5 0 0 1 5 14.5A1.5 1.5 0 0 1 6.5 13A1.5 1.5 0 0 1 8 14.5A1.5 1.5 0 0 1 6.5 16M18.92 6c-.2-.58-.76-1-1.42-1h-11c-.66 0-1.22.42-1.42 1L3 12v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8z\"/>","width":24,"height":24},
"mdi:chart-multiline": {"body":"<path fill=\"currentColor\" d=\"M22 6.92L20.59 5.5l-2.85 3.22C15.68 6.4 12.83 5 9.61 5C6.72 5 4.07 6.16 2 8l1.42 1.42C5.12 7.93 7.27 7 9.61 7c2.74 0 5.09 1.26 6.77 3.24L13.5 13.5l-4-4L2 17l1.5 1.5l6-6l4 4l4.05-4.57c.75 1.35 1.25 2.9 1.45 4.57h2c-.22-2.32-.95-4.41-2.04-6.16z\"/>","width":24,"height":24},
"mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24},
"mdi:check-circle": {"body":"<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>","width":24,"height":24},
"mdi:checkbox-blank-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5z\"/>","width":24,"height":24},
@ -227,6 +226,7 @@ export const BUNDLED_ICONS = {
"mdi:marker": {"body":"<path fill=\"currentColor\" d=\"M18.5 1.15c-.53 0-1.04.19-1.43.58l-5.81 5.82l5.65 5.65l5.82-5.81c.77-.78.77-2.04 0-2.83l-2.84-2.83c-.39-.39-.89-.58-1.39-.58M10.3 8.5l-5.96 5.96c-.78.78-.78 2.04.02 2.85C3.14 18.54 1.9 19.77.67 21h5.66l.86-.86c.78.76 2.03.75 2.81-.02l5.95-5.96\"/>","width":24,"height":24},
"mdi:marker-cancel": {"body":"<path fill=\"currentColor\" d=\"M17.5 13c2.5 0 4.5 2 4.5 4.5S20 22 17.5 22S13 20 13 17.5s2-4.5 4.5-4.5m0 1.5c-.56 0-1.08.15-1.5.42L20.08 19c.27-.42.42-.94.42-1.5a3 3 0 0 0-3-3m-3 3a3 3 0 0 0 3 3c.56 0 1.08-.15 1.5-.42L14.92 16c-.27.42-.42.94-.42 1.5m4-16.35c.5 0 1 .19 1.39.58l2.84 2.83c.77.79.77 2.05 0 2.83l-3.78 3.77a6.54 6.54 0 0 0-3.8.28l-3.89-3.89l5.81-5.82c.39-.39.9-.58 1.43-.58M10.3 8.5l3.59 3.6A6.49 6.49 0 0 0 11 17.5c0 .5.06 1 .16 1.45L10 20.12c-.78.77-2.03.78-2.81.02l-.86.86H.67l3.69-3.69c-.8-.81-.8-2.07-.02-2.85z\"/>","width":24,"height":24},
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
"mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24},
"mdi:minus": {"body":"<path fill=\"currentColor\" d=\"M19 13H5v-2h14z\"/>","width":24,"height":24},
"mdi:near-me": {"body":"<path fill=\"currentColor\" d=\"M21 3L3 10.53v.97l6.84 2.66L12.5 21h.96z\"/>","width":24,"height":24},
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},

@ -39,11 +39,6 @@
t('editor.markup.insertBlock')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:chart-multiline" padding="sm sm" flat @click="notImplemented">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertDiagram')
}}</w-tooltip>
</w-btn>
<w-btn icon="mdi:book-plus" padding="sm sm" flat @click="insertFootnote">
<w-tooltip anchor="center right" self="center left">{{
t('editor.markup.insertFootnote')
@ -138,23 +133,41 @@
</w-item>
<w-item
clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-info}` })">
@click="insertBeforeEachLine({ content: `> `, before: `> [!NOTE]` })">
<w-item-section side>
<w-icon name="mdi:information-box" color="blue-7" />
<!--
A colour with a utility behind it. WIcon composes the class from this name, so
Tailwind never sees it while scanning and emits only the ones written out in
full somewhere in the app -- of the blues, that is this one. Asking for the 7
step, as this did, left the icon the colour of the menu text.
Nothing above may spell a class out either: the scanner reads comments too, and
would generate whatever this explanation quoted.
-->
<w-icon name="mdi:information-box" color="blue" />
</w-item-section>
<w-item-section>{{ t('editor.markup.admonitionInfo') }}</w-item-section>
</w-item>
<w-item
clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-success}` })">
@click="insertBeforeEachLine({ content: `> `, before: `> [!TIP]` })">
<w-item-section side>
<w-icon name="mdi:check-circle" color="positive" />
</w-item-section>
<w-item-section>{{ t('editor.markup.admonitionSuccess') }}</w-item-section>
</w-item>
<!-- -> The same speech bubble the page draws an IMPORTANT admonition with -->
<w-item
clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-warning}` })">
@click="insertBeforeEachLine({ content: `> `, before: `> [!IMPORTANT]` })">
<w-item-section side>
<w-icon name="mdi:message-alert" color="purple" />
</w-item-section>
<w-item-section>{{ t('editor.markup.admonitionImportant') }}</w-item-section>
</w-item>
<w-item
clickable
@click="insertBeforeEachLine({ content: `> `, before: `> [!WARNING]` })">
<w-item-section side>
<w-icon name="mdi:alert-box" color="orange" />
</w-item-section>
@ -162,7 +175,7 @@
</w-item>
<w-item
clickable
@click="insertBeforeEachLine({ content: `> `, after: `{.is-danger}` })">
@click="insertBeforeEachLine({ content: `> `, before: `> [!CAUTION]` })">
<w-item-section side>
<w-icon name="mdi:close-box" color="negative" />
</w-item-section>
@ -628,8 +641,12 @@ function insertAfter({ content, newLine, focus = true }) {
/**
* Insert content before current line
*
* `before` is a line of its own, put above the first of them the `> [!NOTE]` that opens an
* admonition. It rides along in that line's own edit rather than as an insertion of its own, so no
* two edits in the batch start at the same position.
*/
function insertBeforeEachLine({ content, after, focus = true }) {
function insertBeforeEachLine({ content, before, focus = true }) {
const edits = []
for (const selection of editor.getSelections()) {
const lineCount = selection.endLineNumber - selection.startLineNumber + 1
@ -640,18 +657,10 @@ function insertBeforeEachLine({ content, after, focus = true }) {
if (lineContent.startsWith(content)) {
lineContent = lineContent.substring(content.length)
}
const opening = before && line === lines[0] ? `${before}\n` : ''
edits.push({
range: new Range(line, 1, line, lineLength + 1),
text: `${content}${lineContent}`,
forceMoveMarkers: true
})
}
if (after) {
const lastLine = lines.at(-1)
const lineLength = editor.getModel().getLineContent(lastLine).length
edits.push({
range: new Range(lastLine, lineLength + 1, lastLine, lineLength + 1),
text: `\n${after}`,
text: `${opening}${content}${lineContent}`,
forceMoveMarkers: true
})
}

@ -117,23 +117,6 @@
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="sigma" />
<w-item-section>
<w-item-label>{{t(`admin.editors.markdown.latexEngine`)}}</w-item-label>
<w-item-label caption>{{t(`admin.editors.markdown.latexEngineHint`)}}</w-item-label>
</w-item-section>
<w-item-section class="flex-none">
<w-btn-toggle
v-model="state.config.latexEngine"
push
glossy
no-caps
toggle-color="primary"
:options="latexEngines" />
</w-item-section>
</w-item>
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="data-sheet" />
<w-item-section>
@ -204,82 +187,6 @@
</w-item-section>
</w-item>
</w-card>
<w-card class="shadow-1 pb-2 mt-4">
<w-card-section>
<div class="text-subtitle1">{{t('admin.editors.markdown.plantuml')}}</div>
</w-card-section>
<w-item tag="label">
<blueprint-icon icon="workflow" />
<w-item-section>
<w-item-label>{{t(`admin.editors.markdown.plantuml`)}}</w-item-label>
<w-item-label caption>{{t(`admin.editors.markdown.plantumlHint`)}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.plantuml"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:aria-label="t(`admin.editors.markdown.plantuml`)" />
</w-item-section>
</w-item>
<template v-if="state.config.plantuml">
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="website" />
<w-item-section>
<w-item-label>{{t(`admin.editors.markdown.plantumlServerUrl`)}}</w-item-label>
<w-item-label caption>{{t(`admin.editors.markdown.plantumlServerUrlHint`)}}</w-item-label>
</w-item-section>
<w-item-section side>
<w-input
style="width: 450px;"
outlined
v-model="state.config.plantumlServerUrl"
dense
:aria-label="t(`admin.editors.markdown.plantumlServerUrl`)" />
</w-item-section>
</w-item>
</template>
</w-card>
<w-card class="shadow-1 pb-2 mt-4">
<w-card-section>
<div class="text-subtitle1">{{t('admin.editors.markdown.kroki')}}</div>
</w-card-section>
<w-item tag="label">
<blueprint-icon icon="workflow" />
<w-item-section>
<w-item-label>{{t(`admin.editors.markdown.kroki`)}}</w-item-label>
<w-item-label caption>{{t(`admin.editors.markdown.krokiHint`)}}</w-item-label>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-model="state.config.kroki"
color="primary"
checked-icon="la:check"
unchecked-icon="la:times"
:aria-label="t(`admin.editors.markdown.kroki`)" />
</w-item-section>
</w-item>
<template v-if="state.config.kroki">
<w-separator class="my-2" inset />
<w-item>
<blueprint-icon icon="website" />
<w-item-section>
<w-item-label>{{t(`admin.editors.markdown.krokiServerUrl`)}}</w-item-label>
<w-item-label caption>{{t(`admin.editors.markdown.krokiServerUrlHint`)}}</w-item-label>
</w-item-section>
<w-item-section side>
<w-input
style="width: 450px;"
outlined
v-model="state.config.krokiServerUrl"
dense
:aria-label="t(`admin.editors.markdown.krokiServerUrl`)" />
</w-item-section>
</w-item>
</template>
</w-card>
<w-inner-loading :showing="state.loading > 0">
<w-spinner color="accent" size="lg" />
</w-inner-loading>
@ -326,12 +233,7 @@ function defaultConfig() {
quotes: 'english',
underline: true,
tabWidth: 2,
latexEngine: 'katex',
multimdTable: true,
plantuml: false,
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
kroki: false,
krokiServerUrl: 'https://kroki.io'
multimdTable: true
}
}
@ -340,11 +242,6 @@ const state = reactive({
loading: 0
})
const latexEngines = [
{ value: 'katex', label: 'KaTeX' },
{ value: 'mathjax', label: 'Mathjax' }
]
const quoteStyles = [
{ value: 'chinese', label: 'Chinese' },
{ value: 'english', label: 'English' },

@ -28,7 +28,12 @@
keystroke as the store echoes it back, and a rewritten text node puts the caret at the start of
it. `syncEditable` writes it instead, and only when the two have actually diverged.
-->
<div class="min-w-0 flex-1 p-4">
<!--
Centred rather than top-aligned: with no description the title is the only line in this column,
and left at the top it sat above the middle of the icon beside it. A page that has one is taller
than everything else in the row, so there is nothing to centre and this changes nothing.
-->
<div class="min-w-0 flex-1 flex flex-col justify-center p-4">
<div class="text-h4 page-header-title">
<span
v-if="editorStore.isActive"
@ -65,6 +70,16 @@
<!-- PAGE ACTIONS -->
<div class="flex-none p-4 flex items-center justify-end">
<template v-if="!editorStore.isActive">
<!--
Whoever is looking at a draft can already see it, so the badge is not gated on being logged
in the way the actions beside it are: it is telling a reader what they are reading, not
offering them something to do.
-->
<w-badge
v-if="pageStore.publishState === `draft`"
class="uppercase"
color="negative"
:label="t(`editor.props.draft`)" />
<w-btn
class="ml-4"
v-if="userStore.authenticated"

@ -13,9 +13,12 @@
Written against what `renderers/markdown.js` actually emits (and what `models/rendering.ts` does to
it server-side), which is: heading anchors, `pre.codeblock` with highlight.js token classes and an
optional line-number gutter, task lists, footnotes, multi-line tables, KaTeX, `img.uml-diagram` for
PlantUML/Kroki, twemoji images, and whatever classes an author attaches through `markdown-it-attrs`
(`id`, `class` and `target` are the ones the renderer allows through).
optional line-number gutter, task lists, footnotes, multi-line tables, admonitions, twemoji images,
and whatever classes an author attaches through `markdown-it-attrs` (`id`, `class` and `target` are
the ones the renderer allows through).
Maths and diagrams are not in that list: they are blocks now -- `block-mathjax`, `block-diagram`,
`block-plantuml` -- and a block styles itself inside its own shadow root, which nothing here reaches.
The measurements follow what the documentation platforms have converged on -- 16px body text, headings
at 600 with far more space above than below, ruled h1/h2, tinted code and table headers, a left-ruled
@ -82,6 +85,13 @@
--content-warning-wash: rgba(163, 90, 0, 0.09);
--content-danger: #c02636;
--content-danger-wash: rgba(192, 38, 54, 0.08);
/*
A fifth hue, for `[!IMPORTANT]`: it sits between "worth knowing" and "watch out", which is exactly
the gap the other four leave. Purple because GitHub uses purple, and a reader who knows the marker
from there should recognise the colour here.
*/
--content-important: #6f42c1;
--content-important-wash: rgba(111, 66, 193, 0.08);
/*
Code tokens, after GitHub's syntax themes.
@ -141,6 +151,8 @@
--content-warning-wash: rgba(243, 182, 97, 0.12);
--content-danger: #ff8b8b;
--content-danger-wash: rgba(255, 139, 139, 0.12);
--content-important: #c4a7ff;
--content-important-wash: rgba(196, 167, 255, 0.12);
--content-code-comment: #8b949e;
--content-code-keyword: #ff7b72;
@ -606,9 +618,10 @@
}
/*
Admonitions, from `{.is-info}` and friends on a blockquote.
Admonitions, from `{.is-info}` and friends on a blockquote -- and from a GitHub-style `> [!NOTE]`,
which `renderers/modules/github-alerts.js` turns into the very same classes plus a label.
A wash, a heavier bar and an icon: colour alone would leave the four kinds indistinguishable to a
A wash, a heavier bar and an icon: colour alone would leave the kinds indistinguishable to a
reader who cannot separate the hues, and the icon is a masked SVG rather than a glyph so it needs
no webfont -- the app dropped those, which is why the markers here used to render as tofu.
*/
@ -622,6 +635,8 @@
&:has(> .is-info),
&.is-success,
&:has(> .is-success),
&.is-important,
&:has(> .is-important),
&.is-warning,
&:has(> .is-warning),
&.is-danger,
@ -649,6 +664,17 @@
margin-bottom: 0;
}
/*
The label a GitHub-style alert opens with -- "Note", "Tip" -- standing in for the marker the
author typed. In the severity's own colour, since it is naming the severity, and close above
the text it introduces rather than a paragraph's distance from it.
*/
> .alert-title {
margin-bottom: 0.3em;
color: var(--alert-hue);
font-weight: 600;
}
code {
background-color: var(--content-surface-alt);
}
@ -656,6 +682,8 @@
&.is-info,
&:has(> .is-info) {
--alert-hue: var(--content-info);
border-left-color: var(--content-info);
background-color: var(--content-info-wash);
@ -667,6 +695,8 @@
&.is-success,
&:has(> .is-success) {
--alert-hue: var(--content-success);
border-left-color: var(--content-success);
background-color: var(--content-success-wash);
@ -676,8 +706,24 @@
}
}
/* -> `mdi:message-alert`, the same speech bubble GitHub marks an important note with */
&.is-important,
&:has(> .is-important) {
--alert-hue: var(--content-important);
border-left-color: var(--content-important);
background-color: var(--content-important-wash);
&::before {
color: var(--content-important);
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2'/%3E%3C/svg%3E");
}
}
&.is-warning,
&:has(> .is-warning) {
--alert-hue: var(--content-warning);
border-left-color: var(--content-warning);
background-color: var(--content-warning-wash);
@ -689,6 +735,8 @@
&.is-danger,
&:has(> .is-danger) {
--alert-hue: var(--content-danger);
border-left-color: var(--content-danger);
background-color: var(--content-danger-wash);
@ -1061,20 +1109,6 @@
font-size: 0.875em;
}
/*
PlantUML and Kroki return SVGs drawn in black on nothing. On a dark page that is a diagram of
invisible lines, so it gets a light card of its own -- the same treatment either way, since a
diagram is an illustration rather than part of the text.
*/
img.uml-diagram {
display: block;
margin: 1.5em auto;
padding: 0.75rem;
border: 1px solid var(--content-rule);
border-radius: 8px;
background-color: #fff;
}
/* Twemoji, which the renderer swaps in for `:shortcodes:` */
img.emoji {
display: inline-block;
@ -1172,26 +1206,6 @@
text-decoration: none;
}
// ---------------------------------------------------------------------------
// MATH
// ---------------------------------------------------------------------------
/*
KaTeX sizes itself; what it cannot do is decide what happens when an equation is wider than the
column. A display equation scrolls on its own rather than stretching the page.
*/
.katex-display {
margin: 1.4em 0;
padding: 0.2em 0;
overflow-x: auto;
overflow-y: hidden;
}
.katex {
/* -> KaTeX's default 1.21em is oversized next to 16px body text */
font-size: 1.1em;
}
// ---------------------------------------------------------------------------
// PRINT
// ---------------------------------------------------------------------------

@ -14,18 +14,6 @@ import { initializeHairlines } from './helpers/hairline'
// so no icon webfont is loaded.
import '@quasar/extras/roboto-font/roboto-font.css'
/*
KaTeX's own stylesheet, which the math in a page is drawn with.
Vendor CSS rather than something `_page-contents.scss` could express: it carries the maths fonts and
the geometry of every symbol. Without it a formula renders TWICE -- KaTeX emits both an HTML tree and
a MathML fallback, and the CSS is what hides the one the browser is not using -- which is what made
every equation appear as a broken rendering followed by its own source.
Global, but inert outside content: `.katex` appears nowhere else in the app.
*/
import 'katex/dist/katex.min.css'
import './css/tailwind.css'
import './css/app.scss'

@ -11,14 +11,10 @@ import mdMark from 'markdown-it-mark'
import mdMultiTable from 'markdown-it-multimd-table'
import mdFootnote from 'markdown-it-footnote'
import mdMdc from 'markdown-it-mdc'
import katex from 'katex'
import mdUnderline from './modules/markdown-it-underline'
import mdImsize from './modules/markdown-it-imsize'
import 'katex/dist/contrib/mhchem'
import mdGithubAlerts from './modules/github-alerts'
import twemoji from 'twemoji'
import plantuml from './modules/plantuml'
import kroki from './modules/kroki.mjs'
import katexHelper from './modules/katex'
import hljs from 'highlight.js'
@ -77,6 +73,12 @@ export class MarkdownRenderer {
if (lang === 'diagram') {
return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
} else if (['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.
*/
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
} else {
/*
@ -130,6 +132,7 @@ export class MarkdownRenderer {
.use(mdMark)
.use(mdFootnote)
.use(mdImsize)
.use(mdGithubAlerts)
/*
MDC's slot syntax, off for the same reason as inline components: it takes a line the author
@ -173,76 +176,6 @@ export class MarkdownRenderer {
this.md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true })
}
// --------------------------------
// PLANTUML
// --------------------------------
if (config.plantuml) {
plantuml.init(this.md, { server: config.plantumlServerUrl })
}
// --------------------------------
// KROKI
// --------------------------------
if (config.kroki) {
kroki.init(this.md, { server: config.krokiServerUrl })
}
// --------------------------------
// KATEX
// --------------------------------
const macros = {}
// TODO: Add mhchem (needs esm conversion)
// Add \ce, \pu, and \tripledash to the KaTeX macros.
// katex.__defineMacro('\\ce', function (context) {
// return chemParse(context.consumeArgs(1)[0], 'ce')
// })
// katex.__defineMacro('\\pu', function (context) {
// return chemParse(context.consumeArgs(1)[0], 'pu')
// })
// Needed for \bond for the ~ forms
// Raise by 2.56mu, not 2mu. We're raising a hyphen-minus, U+002D, not
// a mathematical minus, U+2212. So we need that extra 0.56.
katex.__defineMacro(
'\\tripledash',
'{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu' +
'\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}'
)
this.md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
this.md.renderer.rules.katex_inline = (tokens, idx) => {
try {
return katex.renderToString(tokens[idx].content, {
displayMode: false,
macros
})
} catch (err) {
console.warn(err)
return tokens[idx].content
}
}
this.md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
alt: ['paragraph', 'reference', 'blockquote', 'list']
})
this.md.renderer.rules.katex_block = (tokens, idx) => {
try {
return (
'<p>' +
katex.renderToString(tokens[idx].content, {
displayMode: true,
macros
}) +
'</p>'
)
} catch (err) {
console.warn(err)
return tokens[idx].content
}
}
// --------------------------------
// LINK DESTINATIONS
// --------------------------------

@ -0,0 +1,92 @@
// ------------------------------------
// Markdown - GitHub-style alerts
// ------------------------------------
/**
* The five kinds GitHub defines, and what each one becomes here.
*
* They are mapped onto the admonition classes the content stylesheet already draws the ones
* `{.is-info}` and friends attach so an alert and a hand-classed blockquote are the same object on
* the page, and there is one place where an admonition is styled. `important` is the one kind with no
* existing counterpart, and has a hue of its own in `css/_page-contents.scss`.
*
* The labels are English, as the marker itself is: what the renderer emits is stored as the page's
* HTML, so nothing here can follow the reader's locale afterwards.
*/
const KINDS = new Map([
['note', { className: 'is-info', label: 'Note' }],
['tip', { className: 'is-success', label: 'Tip' }],
['important', { className: 'is-important', label: 'Important' }],
['warning', { className: 'is-warning', label: 'Warning' }],
['caution', { className: 'is-danger', label: 'Caution' }]
])
/**
* The marker, which has to be the whole of the blockquote's first line.
*
* Anything after it on that line means the author wrote a blockquote that happens to open with
* brackets, which is what GitHub decides too and the line is then left exactly as it was typed.
*/
const MARKER = /^\[!([a-z]+)\][ \t]*(?:\n|$)/i
/**
* The label, as three tokens: a paragraph carrying a class, its inline content, and the close.
*
* The inline token is left with nothing but `content`; the core `inline` rule runs after this one and
* is what turns that into children, the same as for every other paragraph on the page.
*/
function labelTokens(state, label) {
const open = new state.Token('paragraph_open', 'p', 1)
open.attrSet('class', 'alert-title')
open.block = true
const inline = new state.Token('inline', '', 0)
inline.content = label
inline.children = []
const close = new state.Token('paragraph_close', 'p', -1)
close.block = true
return [open, inline, close]
}
export default (md) => {
/*
After `block` and so before `inline`, which is what makes this a matter of cutting a line off a
string: at this point a paragraph is still one `inline` token holding its raw source. Run after
`inline` instead and the same job means walking children and reasoning about where markdown-it put
the break a soft one, or a hard one where the author left two spaces after the marker, as the
examples in GitHub's own documentation do.
*/
md.core.ruler.after('block', 'github_alert', (state) => {
const tokens = state.tokens
for (let i = 0; i < tokens.length; i++) {
if (
tokens[i].type !== 'blockquote_open' ||
tokens[i + 1]?.type !== 'paragraph_open' ||
tokens[i + 2]?.type !== 'inline'
) {
continue
}
const marker = MARKER.exec(tokens[i + 2].content)
const kind = marker ? KINDS.get(marker[1].toLowerCase()) : null
if (!kind) {
continue
}
// -> Joined rather than set: an author may have classed the quote themselves, and `is-info` on
// top of that is what the stylesheet is written to expect
tokens[i].attrJoin('class', kind.className)
const rest = tokens[i + 2].content.slice(marker[0].length)
if (rest) {
tokens[i + 2].content = rest
tokens.splice(i + 1, 0, ...labelTokens(state, kind.label))
} else {
// -> The marker was the whole paragraph, so the label takes its place rather than joining it
tokens.splice(i + 1, 3, ...labelTokens(state, kind.label))
}
}
})
}

@ -1,175 +0,0 @@
// Test if potential opening or closing delimieter
// Assumes that there is a "$" at state.src[pos]
function isValidDelim(state, pos) {
const max = state.posMax
let canOpen = true
let canClose = true
const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1
const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1
// Check non-whitespace conditions for opening and closing, and
// check that closing delimeter isn't followed by a number
if (
prevChar === 0x20 /* " " */ ||
prevChar === 0x09 /* \t */ ||
(nextChar >= 0x30 /* "0" */ && nextChar <= 0x39) /* "9" */
) {
canClose = false
}
if (nextChar === 0x20 /* " " */ || nextChar === 0x09 /* \t */) {
canOpen = false
}
return {
canOpen,
canClose
}
}
export default {
katexInline(state, silent) {
let match, token, res, pos
if (state.src[state.pos] !== '$') {
return false
}
res = isValidDelim(state, state.pos)
if (!res.canOpen) {
if (!silent) {
state.pending += '$'
}
state.pos += 1
return true
}
// First check for and bypass all properly escaped delimieters
// This loop will assume that the first leading backtick can not
// be the first character in state.src, which is known since
// we have found an opening delimieter already.
const start = state.pos + 1
match = start
while ((match = state.src.indexOf('$', match)) !== -1) {
// Found potential $, look for escapes, pos will point to
// first non escape when complete
pos = match - 1
while (state.src[pos] === '\\') {
pos -= 1
}
// Even number of escapes, potential closing delimiter found
if ((match - pos) % 2 === 1) {
break
}
match += 1
}
// No closing delimter found. Consume $ and continue.
if (match === -1) {
if (!silent) {
state.pending += '$'
}
state.pos = start
return true
}
// Check if we have empty content, ie: $$. Do not parse.
if (match - start === 0) {
if (!silent) {
state.pending += '$$'
}
state.pos = start + 1
return true
}
// Check for valid closing delimiter
res = isValidDelim(state, match)
if (!res.canClose) {
if (!silent) {
state.pending += '$'
}
state.pos = start
return true
}
if (!silent) {
token = state.push('katex_inline', 'math', 0)
token.markup = '$'
token.content = state.src
// Extract the math part without the $
.slice(start, match)
// Escape the curly braces since they will be interpreted as
// attributes by markdown-it-attrs (the "curly_attributes"
// core rule)
.replaceAll('{', '{{')
.replaceAll('}', '}}')
}
state.pos = match + 1
return true
},
katexBlock(state, start, end, silent) {
let firstLine
let lastLine
let next
let lastPos
let found = false
let pos = state.bMarks[start] + state.tShift[start]
let max = state.eMarks[start]
if (pos + 2 > max) {
return false
}
if (state.src.slice(pos, pos + 2) !== '$$') {
return false
}
pos += 2
firstLine = state.src.slice(pos, max)
if (silent) {
return true
}
if (firstLine.trim().slice(-2) === '$$') {
// Single line expression
firstLine = firstLine.trim().slice(0, -2)
found = true
}
for (next = start; !found; ) {
next++
if (next >= end) {
break
}
pos = state.bMarks[next] + state.tShift[next]
max = state.eMarks[next]
if (pos < max && state.tShift[next] < state.blkIndent) {
// non-empty line with negative indent should stop the list:
break
}
if (state.src.slice(pos, max).trim().slice(-2) === '$$') {
lastPos = state.src.slice(0, max).lastIndexOf('$$')
lastLine = state.src.slice(pos, lastPos)
found = true
}
}
state.line = next + 1
const token = state.push('katex_block', 'math', 0)
token.block = true
token.content =
(firstLine && firstLine.trim() ? firstLine + '\n' : '') +
state.getLines(start + 1, next, state.tShift[start], true) +
(lastLine && lastLine.trim() ? lastLine : '')
token.map = [start, state.line]
token.markup = '$$'
return true
}
}

@ -1,160 +0,0 @@
import pako from 'pako'
// ------------------------------------
// Markdown - PlantUML Preprocessor
// ------------------------------------
export default {
init(mdinst, conf) {
mdinst.use(
(md, opts) => {
const openMarker = opts.openMarker || '```kroki'
const openChar = openMarker.charCodeAt(0)
const closeMarker = opts.closeMarker || '```'
const closeChar = closeMarker.charCodeAt(0)
const server = opts.server || 'https://kroki.io'
md.block.ruler.before(
'fence',
'kroki',
(state, startLine, endLine, silent) => {
let nextLine
let markup
let params
let token
let i
let autoClosed = false
let start = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// Check out the first character quickly,
// this should filter out most of non-uml blocks
//
if (openChar !== state.src.charCodeAt(start)) {
return false
}
// Check out the rest of the marker string
//
for (i = 0; i < openMarker.length; ++i) {
if (openMarker[i] !== state.src[start + i]) {
return false
}
}
markup = state.src.slice(start, start + i)
params = state.src.slice(start + i, max)
// Since start is found, we can report success here in validation mode
//
if (silent) {
return true
}
// Search for the end of the block
//
nextLine = startLine
for (;;) {
nextLine++
if (nextLine >= endLine) {
// unclosed block should be autoclosed by end of document.
// also block seems to be autoclosed by end of parent
break
}
start = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
if (start < max && state.sCount[nextLine] < state.blkIndent) {
// non-empty line with negative indent should stop the list:
// - ```
// test
break
}
if (closeChar !== state.src.charCodeAt(start)) {
// didn't find the closing fence
continue
}
if (state.sCount[nextLine] > state.sCount[startLine]) {
// closing fence should not be indented with respect of opening fence
continue
}
let closeMarkerMatched = true
for (i = 0; i < closeMarker.length; ++i) {
if (closeMarker[i] !== state.src[start + i]) {
closeMarkerMatched = false
break
}
}
if (!closeMarkerMatched) {
continue
}
// make sure tail has spaces only
if (state.skipSpaces(start + i) < max) {
continue
}
// found!
autoClosed = true
break
}
let contents = state.src
.split('\n')
.slice(startLine + 1, nextLine)
.join('\n')
// We generate a token list for the alt property, to mimic what the image parser does.
let altToken = []
// Remove leading space if any.
let alt = params ? params.slice(1) : 'uml diagram'
state.md.inline.parse(alt, state.md, state.env, altToken)
let firstlf = contents.indexOf('\n')
if (firstlf === -1) firstlf = undefined
let diagramType = contents.substring(0, firstlf)
contents = contents.substring(firstlf + 1)
const result = pako
.deflate(contents)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
token = state.push('kroki', 'img', 0)
// alt is constructed from children. No point in populating it here.
token.attrs = [
['src', `${server}/${diagramType}/svg/${result}`],
['alt', ''],
['class', 'uml-diagram prefetch-candidate']
]
token.block = true
token.children = altToken
token.info = params
token.map = [startLine, nextLine]
token.markup = markup
state.line = nextLine + (autoClosed ? 1 : 0)
return true
},
{
alt: ['paragraph', 'reference', 'blockquote', 'list']
}
)
md.renderer.rules.kroki = md.renderer.rules.image
},
{
openMarker: conf.openMarker,
closeMarker: conf.closeMarker,
server: conf.server
}
)
}
}

@ -1,202 +0,0 @@
import pako from 'pako'
// ------------------------------------
// Markdown - PlantUML Preprocessor
// ------------------------------------
export default {
init(mdinst, conf) {
mdinst.use(
(md, opts) => {
const openMarker = opts.openMarker || '```plantuml'
const openChar = openMarker.charCodeAt(0)
const closeMarker = opts.closeMarker || '```'
const closeChar = closeMarker.charCodeAt(0)
const imageFormat = opts.imageFormat || 'svg'
const server = opts.server || 'https://plantuml.requarks.io'
md.block.ruler.before(
'fence',
'uml_diagram',
(state, startLine, endLine, silent) => {
let nextLine
let i
let autoClosed = false
let start = state.bMarks[startLine] + state.tShift[startLine]
let max = state.eMarks[startLine]
// Check out the first character quickly,
// this should filter out most of non-uml blocks
//
if (openChar !== state.src.charCodeAt(start)) {
return false
}
// Check out the rest of the marker string
//
for (i = 0; i < openMarker.length; ++i) {
if (openMarker[i] !== state.src[start + i]) {
return false
}
}
const markup = state.src.slice(start, start + i)
const params = state.src.slice(start + i, max)
// Since start is found, we can report success here in validation mode
//
if (silent) {
return true
}
// Search for the end of the block
//
nextLine = startLine
for (;;) {
nextLine++
if (nextLine >= endLine) {
// unclosed block should be autoclosed by end of document.
// also block seems to be autoclosed by end of parent
break
}
start = state.bMarks[nextLine] + state.tShift[nextLine]
max = state.eMarks[nextLine]
if (start < max && state.sCount[nextLine] < state.blkIndent) {
// non-empty line with negative indent should stop the list:
// - ```
// test
break
}
if (closeChar !== state.src.charCodeAt(start)) {
// didn't find the closing fence
continue
}
if (state.sCount[nextLine] > state.sCount[startLine]) {
// closing fence should not be indented with respect of opening fence
continue
}
let closeMarkerMatched = true
for (i = 0; i < closeMarker.length; ++i) {
if (closeMarker[i] !== state.src[start + i]) {
closeMarkerMatched = false
break
}
}
if (!closeMarkerMatched) {
continue
}
// make sure tail has spaces only
if (state.skipSpaces(start + i) < max) {
continue
}
// found!
autoClosed = true
break
}
const contents = state.src
.split('\n')
.slice(startLine + 1, nextLine)
.join('\n')
// We generate a token list for the alt property, to mimic what the image parser does.
const altToken = []
// Remove leading space if any.
const alt = params ? params.slice(1) : 'uml diagram'
state.md.inline.parse(alt, state.md, state.env, altToken)
const zippedCode = encode64(
pako.deflate('@startuml\n' + contents + '\n@enduml', { to: 'string' })
)
const token = state.push('uml_diagram', 'img', 0)
// alt is constructed from children. No point in populating it here.
token.attrs = [
['src', `${server}/${imageFormat}/${zippedCode}`],
['alt', ''],
['class', 'uml-diagram']
]
token.block = true
token.children = altToken
token.info = params
token.map = [startLine, nextLine]
token.markup = markup
state.line = nextLine + (autoClosed ? 1 : 0)
return true
},
{
alt: ['paragraph', 'reference', 'blockquote', 'list']
}
)
md.renderer.rules.uml_diagram = md.renderer.rules.image
},
{
openMarker: conf.openMarker,
closeMarker: conf.closeMarker,
imageFormat: conf.imageFormat,
server: conf.server
}
)
}
}
function encode64(data) {
let r = ''
for (let i = 0; i < data.length; i += 3) {
if (i + 2 === data.length) {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
} else if (i + 1 === data.length) {
r += append3bytes(data.charCodeAt(i), 0, 0)
} else {
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
}
}
return r
}
function append3bytes(b1, b2, b3) {
const c1 = b1 >> 2
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
const c4 = b3 & 0x3f
let r = ''
r += encode6bit(c1 & 0x3f)
r += encode6bit(c2 & 0x3f)
r += encode6bit(c3 & 0x3f)
r += encode6bit(c4 & 0x3f)
return r
}
function encode6bit(raw) {
let b = raw
if (b < 10) {
return String.fromCharCode(48 + b)
}
b -= 10
if (b < 26) {
return String.fromCharCode(65 + b)
}
b -= 26
if (b < 26) {
return String.fromCharCode(97 + b)
}
b -= 26
if (b === 0) {
return '-'
}
if (b === 1) {
return '_'
}
return '?'
}
Loading…
Cancel
Save