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