mirror of https://github.com/requarks/wiki
parent
3d13e50be8
commit
072e1dcc42
@ -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 `-->` 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)
|
||||
@ -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 `&` 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">${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 `-->` 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
@ -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…
Reference in new issue