mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 14:01:58 -05:00
refactor: move non-markdown features into MDC blocks
This commit is contained in:
24 files changed
+2919
-865
No files matched your search
@@ -117,7 +117,9 @@ Self-contained Lit components. Each lives in `blocks/block-<name>/component.js`
|
||||
`rollup.config.mjs` picks up any directory matching `block-*` automatically, so a new block needs no
|
||||
config change. Output goes to `blocks/compiled/`, which the backend serves statically under
|
||||
`/_blocks/`. Blocks are loaded dynamically at runtime, which is why `_blocks/**` is excluded from
|
||||
Vite's `dynamicImportVarsOptions`.
|
||||
Vite's `dynamicImportVarsOptions`. A block pulling in a heavy library is fine — nothing is fetched
|
||||
until its tag turns up in a page — and a library that still ships CommonJS works too, since the
|
||||
rollup config runs `@rollup/plugin-commonjs` after `resolve()`.
|
||||
|
||||
Blocks style themselves with `:host` / `:host-context(body.body--dark)` for dark mode and read the
|
||||
theme colors via CSS custom properties (`var(--q-primary)` — the `--q-` prefix is historical; the
|
||||
|
||||
+2
-11
@@ -180,22 +180,12 @@
|
||||
"admin.editors.markdown.allowHTML": "Allow HTML",
|
||||
"admin.editors.markdown.allowHTMLHint": "Allow HTML tags in content.",
|
||||
"admin.editors.markdown.general": "General",
|
||||
"admin.editors.markdown.kroki": "Kroki",
|
||||
"admin.editors.markdown.krokiHint": "Enable Kroki Diagrams Parser",
|
||||
"admin.editors.markdown.krokiServerUrl": "Kroki Server URL",
|
||||
"admin.editors.markdown.krokiServerUrlHint": "URL to the Kroki server used for image generation.",
|
||||
"admin.editors.markdown.latexEngine": "LaTeX Engine",
|
||||
"admin.editors.markdown.latexEngineHint": "Which engine to use to process TeX/LaTeX expressions.",
|
||||
"admin.editors.markdown.lineBreaks": "Auto Line Breaks",
|
||||
"admin.editors.markdown.lineBreaksHint": "Automatically add linebreaks within paragraphs.",
|
||||
"admin.editors.markdown.linkify": "Auto-linking",
|
||||
"admin.editors.markdown.linkifyHint": "Automatically convert URLs into clickable links.",
|
||||
"admin.editors.markdown.multimdTable": "MultiMarkdown Table",
|
||||
"admin.editors.markdown.multimdTableHint": "Enable support for MultiMarkdown Table features.",
|
||||
"admin.editors.markdown.plantuml": "PlantUML",
|
||||
"admin.editors.markdown.plantumlHint": "Enable PlantUML Parser",
|
||||
"admin.editors.markdown.plantumlServerUrl": "PlantUML Server URL",
|
||||
"admin.editors.markdown.plantumlServerUrlHint": "URL to the PlantUML server used for image generation.",
|
||||
"admin.editors.markdown.quotes": "Quotes Style",
|
||||
"admin.editors.markdown.quotesHint": "When typographer is enabled. Double + single quotes replacement pairs. e.g. «»„“ for Russian, „“‚‘ for German, etc.",
|
||||
"admin.editors.markdown.saveSuccess": "Markdown editor configuration saved successfully.",
|
||||
@@ -1637,7 +1627,8 @@
|
||||
"editor.emoji.smileysEmotion": "Smileys & Emotion",
|
||||
"editor.emoji.symbols": "Symbols",
|
||||
"editor.emoji.travelPlaces": "Travel & Places",
|
||||
"editor.markup.admonitionDanger": "Danger / Important Admonition",
|
||||
"editor.markup.admonitionDanger": "Danger / Caution Admonition",
|
||||
"editor.markup.admonitionImportant": "Important Admonition",
|
||||
"editor.markup.admonitionInfo": "Info / Note Admonition",
|
||||
"editor.markup.admonitionSuccess": "Tip / Success Admonition",
|
||||
"editor.markup.admonitionWarning": "Warning Admonition",
|
||||
|
||||
@@ -148,14 +148,9 @@ class Sites {
|
||||
isActive: true,
|
||||
config: {
|
||||
allowHTML: true,
|
||||
kroki: false,
|
||||
krokiServerUrl: 'https://kroki.io',
|
||||
latexEngine: 'katex',
|
||||
lineBreaks: true,
|
||||
linkify: true,
|
||||
multimdTable: true,
|
||||
plantuml: false,
|
||||
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
|
||||
quotes: 'english',
|
||||
tabWidth: 2,
|
||||
typographer: false,
|
||||
@@ -326,14 +321,9 @@ class Sites {
|
||||
isActive: true,
|
||||
config: {
|
||||
allowHTML: true,
|
||||
kroki: false,
|
||||
krokiServerUrl: 'https://kroki.io',
|
||||
latexEngine: 'katex',
|
||||
lineBreaks: true,
|
||||
linkify: true,
|
||||
multimdTable: true,
|
||||
plantuml: false,
|
||||
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
|
||||
quotes: 'english',
|
||||
tabWidth: 2,
|
||||
typographer: false,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { LitElement, html, css, unsafeCSS } from 'lit'
|
||||
import { create } from 'asciinema-player'
|
||||
// -> The player's stylesheet, as a string. It is what draws the terminal, and a <link> in the page
|
||||
// cannot reach into this shadow root — see the `cssAsString` plugin in rollup.config.mjs.
|
||||
import playerCss from 'asciinema-player/dist/bundle/asciinema-player.css'
|
||||
|
||||
/**
|
||||
* An attribute that means "off" when it says so.
|
||||
*
|
||||
* MDC writes every prop with a value — `autoPlay="false"` is what the block picker produces for a
|
||||
* toggle that was switched on and off again — and Lit's own Boolean converter reads any string at all
|
||||
* as true, that one included.
|
||||
*/
|
||||
const boolean = {
|
||||
converter: {
|
||||
fromAttribute: (value) => value !== null && value !== 'false',
|
||||
toAttribute: (value) => (value ? 'true' : null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Asciinema
|
||||
*/
|
||||
export class BlockAsciinemaElement extends LitElement {
|
||||
/**
|
||||
* Metadata for the admin area and the editor's block picker. Collected at build time into
|
||||
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
|
||||
* plain literals. See `props` in `block-index` for what the picker does with that list.
|
||||
*/
|
||||
static definition = {
|
||||
block: 'asciinema',
|
||||
name: 'Terminal Recording',
|
||||
description: 'Plays an asciinema recording — a .cast file — in a terminal player.',
|
||||
icon: 'run-command',
|
||||
props: [
|
||||
{
|
||||
name: 'src',
|
||||
type: 'string',
|
||||
label: 'Recording URL',
|
||||
hint: 'Path or URL of the .cast file to play.',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
label: 'Theme',
|
||||
options: [
|
||||
'asciinema',
|
||||
'dracula',
|
||||
'gruvbox-dark',
|
||||
'monokai',
|
||||
'nord',
|
||||
'seti',
|
||||
'solarized-dark',
|
||||
'solarized-light',
|
||||
'tango'
|
||||
],
|
||||
hint: 'Terminal colours. All but solarized-light are dark.',
|
||||
default: 'asciinema'
|
||||
},
|
||||
{
|
||||
name: 'autoPlay',
|
||||
type: 'boolean',
|
||||
label: 'Play On Load',
|
||||
hint: 'Start as soon as the page is opened, rather than waiting to be asked.',
|
||||
// -> Stated, so that a toggle switched on and then off again writes nothing into the page
|
||||
default: false
|
||||
},
|
||||
{
|
||||
name: 'loop',
|
||||
type: 'boolean',
|
||||
label: 'Loop',
|
||||
hint: 'Start again on reaching the end.',
|
||||
default: false
|
||||
},
|
||||
{
|
||||
name: 'speed',
|
||||
type: 'number',
|
||||
label: 'Speed',
|
||||
hint: 'Playback rate. 2 plays twice as fast as it was recorded.',
|
||||
default: 1
|
||||
},
|
||||
{
|
||||
name: 'idleTimeLimit',
|
||||
type: 'number',
|
||||
label: 'Idle Time Limit',
|
||||
hint: 'Cap the pauses in the recording at this many seconds. Empty keeps them as recorded.'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return [
|
||||
unsafeCSS(playerCss),
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -> The gap below the block. On this element rather than :host: see block-index. */
|
||||
.player,
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.player {
|
||||
border-radius: 5px;
|
||||
/* -> The terminal paints its own background into the corners otherwise */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
}
|
||||
`
|
||||
]
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Path or URL of the .cast file
|
||||
* @type {string}
|
||||
*/
|
||||
src: { type: String },
|
||||
|
||||
/**
|
||||
* Name of one of the player's terminal themes
|
||||
* @type {string}
|
||||
*/
|
||||
theme: { type: String },
|
||||
|
||||
/**
|
||||
* Whether to start without being asked
|
||||
* @type {boolean}
|
||||
*/
|
||||
autoPlay: boolean,
|
||||
|
||||
/**
|
||||
* Whether to start again at the end
|
||||
* @type {boolean}
|
||||
*/
|
||||
loop: boolean,
|
||||
|
||||
/**
|
||||
* Playback rate, 1 being the speed it was recorded at
|
||||
* @type {number}
|
||||
*/
|
||||
speed: { type: Number },
|
||||
|
||||
/**
|
||||
* Longest pause to play back, in seconds
|
||||
* @type {number}
|
||||
*/
|
||||
idleTimeLimit: { type: Number },
|
||||
|
||||
// Internal Properties
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.src = ''
|
||||
this.theme = 'asciinema'
|
||||
this.autoPlay = false
|
||||
this.loop = false
|
||||
this.speed = 1
|
||||
this.idleTimeLimit = null
|
||||
this._error = ''
|
||||
this._player = null
|
||||
}
|
||||
|
||||
/**
|
||||
* What to give the player, out of what the author gave the block.
|
||||
*
|
||||
* Only the settings that were actually asked for: an option left out is the player's own default,
|
||||
* which is the one that gets maintained. A speed of zero or a negative one would stop the recording
|
||||
* dead, and a nonsense number would take the player with it, so that one is bounded.
|
||||
*/
|
||||
_options() {
|
||||
const speed = Number(this.speed)
|
||||
const idle = Number(this.idleTimeLimit)
|
||||
return {
|
||||
theme: this.theme || 'asciinema',
|
||||
autoPlay: this.autoPlay,
|
||||
loop: this.loop,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? Math.min(speed, 10) : 1,
|
||||
...(Number.isFinite(idle) && idle > 0 ? { idleTimeLimit: idle } : {}),
|
||||
// -> A recording is as wide as the terminal it was made in, which is rarely this column's width
|
||||
fit: 'width'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the recording, and say so in the block if it cannot be had.
|
||||
*
|
||||
* The player is given this rather than the address itself, for the sake of what happens when the
|
||||
* address is wrong. Handed a URL it fetches the file on its own, and a fetch that fails leaves an
|
||||
* empty terminal sitting there with the reason in the console — where an author who mistyped a path
|
||||
* will not see it. Fetching it here is the only way to get hold of that failure, which is the
|
||||
* common one: a typo, a file that has moved, or a host that sends no CORS headers.
|
||||
*
|
||||
* The response is handed over whole, which is a source the player takes as it comes; there is
|
||||
* nothing to be gained by reading it here, and it lets the player stream a long recording.
|
||||
*/
|
||||
async _fetch(src) {
|
||||
try {
|
||||
const response = await fetch(src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`.trim())
|
||||
}
|
||||
return response
|
||||
} catch (err) {
|
||||
this._error = `This recording could not be loaded from ${src} — ${err.message}`
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
const src = this.src?.trim()
|
||||
if (!src) {
|
||||
this._error = 'This player needs the address of a .cast recording.'
|
||||
return
|
||||
}
|
||||
/*
|
||||
A function rather than the recording itself, so that nothing is fetched until it is played —
|
||||
which is the player's own behaviour, and the right one: a page carrying a recording should not
|
||||
pull the whole thing down before anybody has asked to watch it.
|
||||
*/
|
||||
this._player = create(
|
||||
{ data: () => this._fetch(src) },
|
||||
this.renderRoot.querySelector('.player'),
|
||||
this._options()
|
||||
)
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback()
|
||||
// -> The player keeps listeners on window and a resize observer, which outlive the element
|
||||
this._player?.dispose()
|
||||
this._player = null
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._error) {
|
||||
return html`<div class="error">${this._error}</div>`
|
||||
}
|
||||
return html`<div class="player"></div>`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-asciinema', BlockAsciinemaElement)
|
||||
@@ -0,0 +1,281 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
/**
|
||||
* A number for the next drawing, so every one of them gets an id of its own.
|
||||
*
|
||||
* Mermaid names the SVG it produces and writes that name into the CSS it embeds in it, so two
|
||||
* diagrams sharing an id would style each other. A counter rather than a random name: the ids are
|
||||
* scoped to one page load, and a run of them is easier to recognise in the inspector.
|
||||
*/
|
||||
let drawingCount = 0
|
||||
|
||||
/**
|
||||
* The drawing in progress, so that only ever one of them is.
|
||||
*
|
||||
* Mermaid is configured globally — `initialize` sets the library up, not a call to it — so two
|
||||
* diagrams on a page asking for different themes would each set theirs and then be drawn in whichever
|
||||
* one was set last. Queued, a diagram has the library to itself from the moment it configures it to
|
||||
* the moment it is handed back an SVG.
|
||||
*/
|
||||
let queue = Promise.resolve()
|
||||
|
||||
/**
|
||||
* Configure mermaid and draw one diagram with it, once whatever is ahead of it is done.
|
||||
*/
|
||||
function drawInTurn(config, id, source) {
|
||||
const drawing = queue.then(() => {
|
||||
mermaid.initialize(config)
|
||||
return mermaid.render(id, source)
|
||||
})
|
||||
// -> Whether it worked or not, since a diagram that could not be drawn must not hold up the rest
|
||||
queue = drawing.then(
|
||||
() => {},
|
||||
() => {}
|
||||
)
|
||||
return drawing
|
||||
}
|
||||
|
||||
/**
|
||||
* Block Diagram
|
||||
*/
|
||||
export class BlockDiagramElement extends LitElement {
|
||||
/**
|
||||
* Metadata for the admin area and the editor's block picker. Collected at build time into
|
||||
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
|
||||
* plain literals. See `props` in `block-index` for what the picker does with that list.
|
||||
*/
|
||||
static definition = {
|
||||
block: 'diagram',
|
||||
name: 'Diagram',
|
||||
description: 'Draws a Mermaid diagram — flowchart, sequence, class, state, ER, gantt and more.',
|
||||
icon: 'workflow',
|
||||
/*
|
||||
A fenced block, and not only for the syntax highlighting: markdown would otherwise have its way
|
||||
with the source before this ever sees it. `-->` survives, but the typographer turns `--` into a
|
||||
dash, an indented line reads as a code block of its own, and `%%` comments and `#` labels are
|
||||
claimed as structure. Inside a fence the text arrives exactly as it was typed.
|
||||
*/
|
||||
template: `\`\`\`mermaid
|
||||
flowchart LR
|
||||
A[Start] --> B{Ready?}
|
||||
B -->|Yes| C[Ship it]
|
||||
B -->|No| A
|
||||
\`\`\``,
|
||||
props: [
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'string',
|
||||
label: 'Caption',
|
||||
hint: 'Shown under the diagram.'
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
type: 'select',
|
||||
label: 'Theme',
|
||||
options: ['auto', 'default', 'dark', 'neutral', 'forest'],
|
||||
hint: 'auto follows the light or dark theme the reader is using.',
|
||||
default: 'auto'
|
||||
},
|
||||
{
|
||||
name: 'align',
|
||||
type: 'select',
|
||||
label: 'Alignment',
|
||||
options: ['left', 'center'],
|
||||
default: 'left'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -> The gap below the block. On this element rather than :host: see block-index. */
|
||||
.diagram,
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.diagram {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.diagram.is-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/*
|
||||
Mermaid sizes the drawing itself — it writes a max-width on the SVG at the width the diagram
|
||||
came out to, so a small one is left at its own size and a large one shrinks to the column. Only
|
||||
the height is settled here, so that shrinking keeps the shapes in proportion.
|
||||
*/
|
||||
svg {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.caption {
|
||||
color: #424242;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
:host-context(body.body--dark) .caption {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Text shown under the diagram
|
||||
* @type {string}
|
||||
*/
|
||||
caption: { type: String },
|
||||
|
||||
/**
|
||||
* Mermaid theme, or `auto` to follow the reader's
|
||||
* @type {string}
|
||||
*/
|
||||
theme: { type: String },
|
||||
|
||||
/**
|
||||
* Where the drawing sits in the column, `left` or `center`
|
||||
* @type {string}
|
||||
*/
|
||||
align: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_svg: { state: true },
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.caption = ''
|
||||
this.theme = 'auto'
|
||||
this.align = 'left'
|
||||
this._svg = ''
|
||||
this._error = ''
|
||||
this._themeWatcher = null
|
||||
/** The drawing being waited on, so a stale one cannot land after a newer one. */
|
||||
this._drawing = 0
|
||||
/** The source, and whether it came out of a fence. Both read from the body once, on first render. */
|
||||
this._source = ''
|
||||
this._fenced = false
|
||||
}
|
||||
|
||||
/**
|
||||
* The theme to draw in.
|
||||
*
|
||||
* `auto` reads the class the app puts on the body, which is the same thing every block's CSS keys
|
||||
* its dark mode off — a diagram cannot do it in CSS, because mermaid picks its colours while it
|
||||
* draws and writes them into the SVG.
|
||||
*/
|
||||
_theme() {
|
||||
if (this.theme && this.theme !== 'auto') {
|
||||
return this.theme
|
||||
}
|
||||
return document.body.classList.contains('body--dark') ? 'dark' : 'default'
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the source, or say why it could not be drawn.
|
||||
*/
|
||||
async _draw() {
|
||||
const drawing = ++this._drawing
|
||||
const config = {
|
||||
startOnLoad: false,
|
||||
// -> A page is authored by whoever may edit it, so the text in a diagram is treated as text:
|
||||
// HTML in a label is escaped and `click` directives do nothing
|
||||
securityLevel: 'strict',
|
||||
// -> Mermaid's own answer to a broken diagram is to append a drawing of a bomb to the body,
|
||||
// outside this element and past the page's styling. The message below is this block's job.
|
||||
suppressErrorRendering: true,
|
||||
theme: this._theme(),
|
||||
// -> The page's own font, so a diagram reads as part of the text around it. Mermaid measures
|
||||
// its labels in the same font, so the boxes come out the right size for it.
|
||||
fontFamily: 'inherit'
|
||||
}
|
||||
try {
|
||||
const { svg } = await drawInTurn(config, `block-diagram-${++drawingCount}`, this._source)
|
||||
// -> A theme toggle can start a second drawing while this one is still going
|
||||
if (drawing !== this._drawing) {
|
||||
return
|
||||
}
|
||||
this._svg = svg
|
||||
this._error = ''
|
||||
} catch (err) {
|
||||
if (drawing !== this._drawing) {
|
||||
return
|
||||
}
|
||||
this._svg = ''
|
||||
/*
|
||||
Mermaid says what it could not read and where, which is the useful half. The other half is
|
||||
the fence, because a diagram that renders in every other tool and not here is nearly always a
|
||||
source markdown got to first — see `template`.
|
||||
*/
|
||||
this._error = `This diagram could not be drawn: ${err.message ?? err}`
|
||||
if (!this._fenced) {
|
||||
this._error +=
|
||||
'\n\nThe source has to go inside a fenced code block, or markdown rewrites it before this block sees it.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
/*
|
||||
The source is the block's body, taken from the fence markdown left behind. `textContent` is what
|
||||
undoes the escaping that put `-->` in the markup, and gives back what the author typed.
|
||||
*/
|
||||
const fence = this.querySelector('pre')
|
||||
this._fenced = Boolean(fence)
|
||||
this._source = ((fence ?? this).textContent ?? '').trim()
|
||||
if (!this._source) {
|
||||
this._error =
|
||||
'This diagram is empty. Its source goes in the body of the block, inside a fenced code block.'
|
||||
return
|
||||
}
|
||||
this._draw()
|
||||
|
||||
// -> Only `auto` has anything to follow; a diagram asked for a theme by name keeps it either way
|
||||
if (this.theme === 'auto') {
|
||||
this._themeWatcher = new MutationObserver(() => this._draw())
|
||||
this._themeWatcher.observe(document.body, { attributeFilter: ['class'] })
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback()
|
||||
this._themeWatcher?.disconnect()
|
||||
this._themeWatcher = null
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this._error) {
|
||||
return html`<div class="error">${this._error}</div>`
|
||||
}
|
||||
return html`
|
||||
<div class="diagram ${this.align === 'center' ? 'is-center' : ''}">
|
||||
${unsafeSVG(this._svg)}
|
||||
${this.caption ? html`<div class="caption">${this.caption}</div>` : null}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('block-diagram', BlockDiagramElement)
|
||||
@@ -22,6 +22,58 @@ const NO_SVG = html`
|
||||
</svg>
|
||||
`
|
||||
|
||||
/**
|
||||
* What an author writes to mean "this value goes somewhere": a page, an email address, a number.
|
||||
*
|
||||
* The scheme has to be spelled out. A value that merely looks like a hostname is left alone, since
|
||||
* plenty of ordinary facts read that way — a file name, a version, a decimal — and there is no test
|
||||
* that tells `notes.txt` from `montreal.ca` without guessing.
|
||||
*/
|
||||
const SCHEME = /^(?:https?:\/\/|mailto:|tel:)/i
|
||||
|
||||
/**
|
||||
* The link a value stands for, if it is one.
|
||||
*
|
||||
* The whole value has to be the address; a sentence with a URL in it is prose, and picking the link
|
||||
* out of it is markdown's job, not this block's.
|
||||
*
|
||||
* @returns {{ href: string, label: string, isExternal: boolean } | null}
|
||||
*/
|
||||
function linkOf(text) {
|
||||
if (!SCHEME.test(text) || /\s/.test(text)) {
|
||||
return null
|
||||
}
|
||||
let url
|
||||
try {
|
||||
url = new URL(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
/*
|
||||
Shown without its scheme. An infobox is a column of short facts read at a glance, and `https://`
|
||||
is the same four inches of boilerplate on every row of it — the address is the part that says
|
||||
where the row goes. The label comes off the text as typed rather than out of the parsed URL, which
|
||||
would put back a trailing slash the author did not write.
|
||||
*/
|
||||
const label = text.replace(SCHEME, '')
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
href: url.href,
|
||||
label,
|
||||
/*
|
||||
The mark means "this leaves the wiki", so it is for a web address on another host — the question
|
||||
the page's renderer asks of a link, and for the same reason it asks it of the host and not of
|
||||
the text. See `isExternalHref` in `renderers/markdown.js`, which also leaves an email address
|
||||
and a telephone number unmarked: neither goes to a page at all, and both say what they are.
|
||||
*/
|
||||
isExternal:
|
||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
||||
url.origin !== globalThis.location?.origin
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One value, as it is shown.
|
||||
*
|
||||
@@ -36,7 +88,17 @@ function valueOf(value) {
|
||||
// -> Joined by hand rather than with `join`, so that a boolean among them is still drawn
|
||||
return value.map((entry, index) => html`${index > 0 ? ', ' : ''}${valueOf(entry)}`)
|
||||
}
|
||||
return String(value)
|
||||
const text = String(value)
|
||||
const link = linkOf(text)
|
||||
if (!link) {
|
||||
return text
|
||||
}
|
||||
// -> No whitespace inside the anchor — hence the tags broken after their closing bracket, which is
|
||||
// how the formatter keeps it out: a space beside the words is taken in by the underline on hover
|
||||
// and pushes the external mark off the end of them
|
||||
return html`<a class="${link.isExternal ? 'is-external-link' : ''}" href="${link.href}"
|
||||
>${link.label}</a
|
||||
>`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,10 +128,15 @@ export class BlockInfoboxElement extends LitElement {
|
||||
name: 'Infobox',
|
||||
description: 'A summary box beside the text, filled in from a list of facts.',
|
||||
icon: 'data-sheet',
|
||||
template: `City: Montreal
|
||||
template: `\`\`\`yaml
|
||||
City: Montreal
|
||||
Country: Canada
|
||||
Metro: true
|
||||
"Key with space": foo-bar`,
|
||||
Public Transport:
|
||||
Metro: true
|
||||
Bus: true
|
||||
Monorail: false
|
||||
Website: https://montreal.ca
|
||||
\`\`\``,
|
||||
props: [
|
||||
{
|
||||
name: 'name',
|
||||
@@ -193,16 +260,91 @@ Metro: true
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* -> A nested mapping: its own heading across both columns, then its rows under it */
|
||||
/*
|
||||
-> A nested mapping: its own heading across both columns, then its rows under it
|
||||
|
||||
Shaded top-down rather than flat, so the heading reads as the lid of the group under it: the
|
||||
pale edge catches the eye where the group starts and the colour settles into the one the box's
|
||||
own name is drawn on. The two stops are declared per theme, since "lighter" in dark mode is a
|
||||
lighter dark grey and not a step towards white.
|
||||
*/
|
||||
.group {
|
||||
grid-column: 1 / -1;
|
||||
padding: 7px 12px;
|
||||
border-top: 1px solid var(--infobox-rule);
|
||||
background-color: var(--infobox-head);
|
||||
background-image: linear-gradient(to bottom, var(--infobox-head-top), var(--infobox-head));
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/*
|
||||
The rule that closes a group.
|
||||
Thicker than the ones between rows, and in the border colour rather than the rule colour, so
|
||||
that a row belonging to the group and a row that follows it are told apart at a glance — the
|
||||
heading marks where the group starts, this marks where it stops.
|
||||
*/
|
||||
dl > :is(dt, dd).is-group-end {
|
||||
border-bottom: 3px solid var(--infobox-border);
|
||||
}
|
||||
|
||||
/* -> At the foot of the box there is nothing to separate from, and the card's own border is there */
|
||||
dl > :is(dt, dd):is(:last-child, :nth-last-child(2)) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
Whatever comes next drops its own line: the thick one above it is the separation, and the two
|
||||
together would read as a single rule of an odd weight.
|
||||
|
||||
Two selectors because a row is two children of the grid — the label and the value — so the
|
||||
line over it is drawn twice, once per column. Leaving the second one on broke the rule in
|
||||
half: nothing above the label, a hairline above the value. A group heading spans both columns
|
||||
and is only ever the one element, which is why the second selector asks for a dd.
|
||||
*/
|
||||
dl > dd.is-group-end + *,
|
||||
dl > dd.is-group-end + * + dd {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
A value that is a web address, drawn the way the page draws its links: the same colour token,
|
||||
the same medium weight, the same underline on hover. The rules are repeated here because a
|
||||
stylesheet in the page cannot reach into a shadow root — the custom properties it declares do
|
||||
reach in, which is what keeps the box in step with a re-themed site.
|
||||
*/
|
||||
a {
|
||||
color: var(--content-link, var(--q-primary, #1976d2));
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
/*
|
||||
A link that leaves the wiki says so — the same mark, from the same masked SVG, as a link in the
|
||||
text beside it. Masked rather than drawn, so it takes the link's own colour in either theme,
|
||||
and sized in em so it keeps its proportion to the words. See the LINKS section of
|
||||
css/_page-contents.scss.
|
||||
*/
|
||||
a.is-external-link::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0.8em;
|
||||
height: 0.8em;
|
||||
margin-left: 0.25em;
|
||||
background-color: currentColor;
|
||||
/* -> Subordinate to the words: a marker, not a second link */
|
||||
opacity: 0.7;
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14 3v2h3.59l-9.83 9.83l1.41 1.41L19 6.41V10h2V3m-2 16H5V5h7V3H5c-1.11 0-2 .9-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7h-2z'/%3E%3C/svg%3E");
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.yes {
|
||||
color: var(--q-positive, #02c39a);
|
||||
vertical-align: -3px;
|
||||
@@ -222,12 +364,14 @@ Metro: true
|
||||
--infobox-border: #d5d5d5;
|
||||
--infobox-bg: #f8f9fa;
|
||||
--infobox-head: #eaecf0;
|
||||
--infobox-head-top: #f7f8fa;
|
||||
--infobox-rule: #e3e5e8;
|
||||
}
|
||||
:host-context(body.body--dark) {
|
||||
--infobox-border: rgba(255, 255, 255, 0.15);
|
||||
--infobox-bg: #161b22;
|
||||
--infobox-head: #1e232a;
|
||||
--infobox-head-top: #2b323c;
|
||||
--infobox-rule: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
`
|
||||
@@ -341,12 +485,15 @@ Metro: true
|
||||
const isGroup = rows.length > 1 || rows[0].label !== undefined
|
||||
return html`
|
||||
${isGroup ? html`<div class="group">${label}</div>` : null}
|
||||
${rows.map(
|
||||
(row) => html`
|
||||
<dt>${isGroup ? row.label : label}</dt>
|
||||
<dd>${valueOf(row.value)}</dd>
|
||||
${rows.map((row, index) => {
|
||||
// -> The pair that closes a group carries the rule that separates it from
|
||||
// whatever is listed after it
|
||||
const groupEnd = isGroup && index === rows.length - 1 ? 'is-group-end' : ''
|
||||
return html`
|
||||
<dt class="${groupEnd}">${isGroup ? row.label : label}</dt>
|
||||
<dd class="${groupEnd}">${valueOf(row.value)}</dd>
|
||||
`
|
||||
)}
|
||||
})}
|
||||
`
|
||||
})}
|
||||
</dl>
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { LitElement, html, css } from 'lit'
|
||||
import { unsafeSVG } from 'lit/directives/unsafe-svg.js'
|
||||
import { mathjax } from '@mathjax/src/js/mathjax.js'
|
||||
import { TeX } from '@mathjax/src/js/input/tex.js'
|
||||
import { SVG } from '@mathjax/src/js/output/svg.js'
|
||||
import { liteAdaptor } from '@mathjax/src/js/adaptors/liteAdaptor.js'
|
||||
import { RegisterHTMLHandler } from '@mathjax/src/js/handlers/html.js'
|
||||
import { MathJaxNewcmFont } from '@mathjax/mathjax-newcm-font/js/svg.js'
|
||||
import { MathJaxMhchemFontExtension } from '@mathjax/mathjax-mhchem-font-extension/js/svg.js'
|
||||
/*
|
||||
Every TeX package the block understands, imported for its side effect: a configuration registers
|
||||
itself under its name, and `PACKAGES` below is what then switches it on.
|
||||
|
||||
This is the set MathJax's own "all packages" bundle carries, less three of them. `html` is left out
|
||||
because it exists to put HTML into the page from inside TeX — a link, a class, a style attribute —
|
||||
which is not what a formula is for. `noerrors` and `noundefined` are left out because both answer a
|
||||
mistake by drawing something: the unreadable source in place of the formula, or a black box where a
|
||||
macro should have been. Without them the error reaches this file, which has a panel to say so in.
|
||||
|
||||
Nothing here loads anything at run time. `require` and `autoload` are absent for that reason: both
|
||||
fetch a package the moment TeX asks for one, which cannot work in a bundle — the block is a single
|
||||
file served from /_blocks, with no MathJax install behind it to fetch from.
|
||||
*/
|
||||
import '@mathjax/src/js/input/tex/base/BaseConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/action/ActionConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/ams/AmsConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/amscd/AmsCdConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/bbox/BboxConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/braket/BraketConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/bussproofs/BussproofsConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/cancel/CancelConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/cases/CasesConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/centernot/CenternotConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/color/ColorConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/colortbl/ColortblConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/empheq/EmpheqConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/enclose/EncloseConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/extpfeil/ExtpfeilConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/physics/PhysicsConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/textcomp/TextcompConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/textmacros/TextMacrosConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/unicode/UnicodeConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js'
|
||||
import '@mathjax/src/js/input/tex/verb/VerbConfiguration.js'
|
||||
|
||||
const PACKAGES = [
|
||||
'base',
|
||||
'action',
|
||||
'ams',
|
||||
'amscd',
|
||||
'bbox',
|
||||
'boldsymbol',
|
||||
'braket',
|
||||
'bussproofs',
|
||||
'cancel',
|
||||
'cases',
|
||||
'centernot',
|
||||
'color',
|
||||
'colortbl',
|
||||
'empheq',
|
||||
'enclose',
|
||||
'extpfeil',
|
||||
'gensymb',
|
||||
'mathtools',
|
||||
'mhchem',
|
||||
'newcommand',
|
||||
'physics',
|
||||
'textcomp',
|
||||
'textmacros',
|
||||
'unicode',
|
||||
'upgreek',
|
||||
'verb'
|
||||
]
|
||||
|
||||
/**
|
||||
* MathJax, set up once for the page.
|
||||
*
|
||||
* Off the document entirely: the SVG output measures nothing in the DOM — it has the metrics of every
|
||||
* glyph in the font it draws with — so a formula can be typeset against a document MathJax makes up
|
||||
* for itself and handed back as markup. That is what makes it usable from inside a shadow root, which
|
||||
* MathJax has no notion of and where its own stylesheet in the page would not reach.
|
||||
*/
|
||||
const adaptor = liteAdaptor()
|
||||
RegisterHTMLHandler(adaptor)
|
||||
|
||||
const output = new SVG({
|
||||
fontData: MathJaxNewcmFont,
|
||||
/*
|
||||
Each formula carries its own glyph definitions. The alternative, one cache for the page, is a
|
||||
single hidden `svg` in the document that every formula points into — and a reference from inside a
|
||||
shadow root does not resolve to it, so every letter would come out blank.
|
||||
*/
|
||||
fontCache: 'local'
|
||||
})
|
||||
|
||||
/*
|
||||
mhchem's bonds, arrows and brackets are glyphs of their own, in a font variant the text font has no
|
||||
reason to carry. Added here rather than fetched: MathJax's own build loads this on demand the first
|
||||
time a `\ce` turns up, which is the one thing a bundled block cannot do.
|
||||
*/
|
||||
output.font.addExtension(MathJaxMhchemFontExtension)
|
||||
|
||||
const document_ = mathjax.document('', {
|
||||
InputJax: new TeX({
|
||||
packages: PACKAGES,
|
||||
// -> Handing the error on rather than drawing it: see the panel in `render`
|
||||
formatError: (jax, err) => {
|
||||
throw err
|
||||
}
|
||||
}),
|
||||
OutputJax: output
|
||||
})
|
||||
|
||||
/**
|
||||
* Block MathJax
|
||||
*/
|
||||
export class BlockMathjaxElement extends LitElement {
|
||||
/**
|
||||
* Metadata for the admin area and the editor's block picker. Collected at build time into
|
||||
* `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be
|
||||
* plain literals. See `props` in `block-index` for what the picker does with that list.
|
||||
*/
|
||||
static definition = {
|
||||
block: 'mathjax',
|
||||
name: 'MathJax',
|
||||
description:
|
||||
'Typesets a TeX formula, including chemical equations written with mhchem — \\ce{} and \\pu{}.',
|
||||
icon: 'sigma',
|
||||
/*
|
||||
Fenced, and not as a nicety: TeX is made of the characters markdown reads as its own. A lone
|
||||
backslash goes missing, `_` and `^` open emphasis, `\\` at the end of a line is a break, and the
|
||||
typographer rewrites quotes and dashes inside the source. Inside a fence it arrives as typed.
|
||||
*/
|
||||
template: `\`\`\`latex
|
||||
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
|
||||
\`\`\``,
|
||||
props: [
|
||||
{
|
||||
name: 'caption',
|
||||
type: 'string',
|
||||
label: 'Caption',
|
||||
hint: 'Shown under the formula.'
|
||||
},
|
||||
{
|
||||
name: 'align',
|
||||
type: 'select',
|
||||
label: 'Alignment',
|
||||
options: ['center', 'left'],
|
||||
default: 'center'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* -> The gap below the block. On this element rather than :host: see block-index. */
|
||||
.formula,
|
||||
.error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.formula {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.formula.is-left {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/*
|
||||
A formula wider than the column scrolls rather than shrinks, the way a display equation in the
|
||||
text does — see .katex-display in css/_page-contents.scss. Shrinking is the wrong answer for
|
||||
something read symbol by symbol: a long derivation would end up a grey smear.
|
||||
*/
|
||||
.drawing {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
/* -> Room for the scrollbar to appear without it sitting on the descenders */
|
||||
padding: 0.2em 0;
|
||||
}
|
||||
|
||||
/*
|
||||
The drawing takes the colour of the text around it: MathJax paints its glyphs in currentColor,
|
||||
so dark mode needs nothing here — unlike a block that picks its own colours.
|
||||
*/
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.caption {
|
||||
color: #424242;
|
||||
font-size: 0.8em;
|
||||
text-align: center;
|
||||
}
|
||||
:host-context(body.body--dark) .caption {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--q-negative, #c10015);
|
||||
border: 1px dashed color-mix(in srgb, currentColor 50%, transparent);
|
||||
border-radius: 5px;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
/**
|
||||
* Text shown under the formula
|
||||
* @type {string}
|
||||
*/
|
||||
caption: { type: String },
|
||||
|
||||
/**
|
||||
* Where the formula sits in the column, `center` or `left`
|
||||
* @type {string}
|
||||
*/
|
||||
align: { type: String },
|
||||
|
||||
// Internal Properties
|
||||
_svg: { state: true },
|
||||
_error: { state: true }
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.caption = ''
|
||||
this.align = 'center'
|
||||
this._svg = ''
|
||||
this._error = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Typeset the source, or say why it could not be.
|
||||
*/
|
||||
_typeset(source, fenced) {
|
||||
try {
|
||||
const container = document_.convert(source, { display: true })
|
||||
const drawing = adaptor.firstChild(container)
|
||||
/*
|
||||
The formula named for a reader who cannot see it. MathJax's own answer to this is a MathML
|
||||
copy of the expression alongside the drawing, which needs its stylesheet in the page to stay
|
||||
hidden and its speech engine to read well — neither of which a block in a shadow root has. The
|
||||
source is what is left, and it is what the author wrote: imperfectly read aloud, but the
|
||||
drawing already carries role="img", and an image with no name at all is worse.
|
||||
*/
|
||||
adaptor.setAttribute(drawing, 'aria-label', source)
|
||||
this._svg = adaptor.outerHTML(drawing)
|
||||
this._error = ''
|
||||
} catch (err) {
|
||||
this._svg = ''
|
||||
this._error = `This formula could not be typeset: ${err.message ?? err}`
|
||||
if (!fenced) {
|
||||
this._error +=
|
||||
'\n\nThe source has to go inside a fenced code block, or markdown rewrites it before this block sees it.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
/*
|
||||
The source is the block's body, taken from the fence markdown left behind. `textContent` is what
|
||||
undoes the escaping that put `&` 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)
|
||||
Generated
+1400
-7
File diff suppressed because it is too large.
Load diff
@@ -11,12 +11,19 @@
|
||||
"author": "Nicolas Giard",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@mathjax/mathjax-mhchem-font-extension": "4.1.3",
|
||||
"@mathjax/mathjax-newcm-font": "4.1.3",
|
||||
"@mathjax/src": "4.1.3",
|
||||
"asciinema-player": "3.17.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"leaflet": "1.9.4",
|
||||
"lit": "3.2.1",
|
||||
"mermaid": "11.16.0",
|
||||
"pako": "3.0.1",
|
||||
"uqr": "0.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "29.0.3",
|
||||
"@rollup/plugin-node-resolve": "15.3.0",
|
||||
"@rollup/plugin-terser": "0.4.4",
|
||||
"glob": "11.0.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import summary from 'rollup-plugin-summary'
|
||||
import terser from '@rollup/plugin-terser'
|
||||
import resolve from '@rollup/plugin-node-resolve'
|
||||
import commonjs from '@rollup/plugin-commonjs'
|
||||
|
||||
import * as glob from 'glob'
|
||||
|
||||
@@ -120,6 +121,9 @@ export default {
|
||||
blocksManifest(),
|
||||
cssAsString(),
|
||||
resolve(),
|
||||
// -> A block's own code is ESM, but a library it pulls in need not be: mermaid reaches for dayjs,
|
||||
// which ships as UMD, and rollup has no notion of `module.exports` without this
|
||||
commonjs(),
|
||||
terser({
|
||||
ecma: 2019,
|
||||
module: true
|
||||
|
||||
Generated
-33
@@ -24,7 +24,6 @@
|
||||
"iconify-icon": "3.0.2",
|
||||
"js-cookie": "3.0.8",
|
||||
"jwt-decode": "4.0.0",
|
||||
"katex": "0.17.0",
|
||||
"ky": "2.0.2",
|
||||
"lodash-es": "4.18.1",
|
||||
"lowlight": "3.3.0",
|
||||
@@ -45,7 +44,6 @@
|
||||
"markdown-it-task-lists": "2.1.1",
|
||||
"mitt": "3.0.1",
|
||||
"monaco-editor": "0.55.1",
|
||||
"pako": "2.1.0",
|
||||
"pinia": "3.0.4",
|
||||
"prosemirror-commands": "1.7.1",
|
||||
"prosemirror-history": "1.5.0",
|
||||
@@ -5711,31 +5709,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.17.0.tgz",
|
||||
"integrity": "sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -6703,12 +6676,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"iconify-icon": "3.0.2",
|
||||
"js-cookie": "3.0.8",
|
||||
"jwt-decode": "4.0.0",
|
||||
"katex": "0.17.0",
|
||||
"ky": "2.0.2",
|
||||
"lodash-es": "4.18.1",
|
||||
"lowlight": "3.3.0",
|
||||
@@ -54,7 +53,6 @@
|
||||
"markdown-it-task-lists": "2.1.1",
|
||||
"mitt": "3.0.1",
|
||||
"monaco-editor": "0.55.1",
|
||||
"pako": "2.1.0",
|
||||
"pinia": "3.0.4",
|
||||
"prosemirror-commands": "1.7.1",
|
||||
"prosemirror-history": "1.5.0",
|
||||
|
||||
@@ -151,7 +151,6 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
|
||||
"mdi:book-plus": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 1.1.3 2.12.81 3H6c-1.11 0-2-.89-2-2V4a2 2 0 0 1 2-2h1v7l2.5-1.5L12 9V2h6a2 2 0 0 1 2 2v9.09c-.33-.05-.66-.09-1-.09c-3.31 0-6 2.69-6 6m7-1v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
|
||||
"mdi:car": {"body":"<path fill=\"currentColor\" d=\"m5 11l1.5-4.5h11L19 11m-1.5 5a1.5 1.5 0 0 1-1.5-1.5a1.5 1.5 0 0 1 1.5-1.5a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-11 0A1.5 1.5 0 0 1 5 14.5A1.5 1.5 0 0 1 6.5 13A1.5 1.5 0 0 1 8 14.5A1.5 1.5 0 0 1 6.5 16M18.92 6c-.2-.58-.76-1-1.42-1h-11c-.66 0-1.22.42-1.42 1L3 12v8a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-1h12v1a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1v-8z\"/>","width":24,"height":24},
|
||||
"mdi:chart-multiline": {"body":"<path fill=\"currentColor\" d=\"M22 6.92L20.59 5.5l-2.85 3.22C15.68 6.4 12.83 5 9.61 5C6.72 5 4.07 6.16 2 8l1.42 1.42C5.12 7.93 7.27 7 9.61 7c2.74 0 5.09 1.26 6.77 3.24L13.5 13.5l-4-4L2 17l1.5 1.5l6-6l4 4l4.05-4.57c.75 1.35 1.25 2.9 1.45 4.57h2c-.22-2.32-.95-4.41-2.04-6.16z\"/>","width":24,"height":24},
|
||||
"mdi:check": {"body":"<path fill=\"currentColor\" d=\"M21 7L9 19l-5.5-5.5l1.41-1.41L9 16.17L19.59 5.59z\"/>","width":24,"height":24},
|
||||
"mdi:check-circle": {"body":"<path fill=\"currentColor\" d=\"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10s10-4.5 10-10S17.5 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z\"/>","width":24,"height":24},
|
||||
"mdi:checkbox-blank-outline": {"body":"<path fill=\"currentColor\" d=\"M19 3H5c-1.11 0-2 .89-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m0 2v14H5V5z\"/>","width":24,"height":24},
|
||||
@@ -227,6 +226,7 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:marker": {"body":"<path fill=\"currentColor\" d=\"M18.5 1.15c-.53 0-1.04.19-1.43.58l-5.81 5.82l5.65 5.65l5.82-5.81c.77-.78.77-2.04 0-2.83l-2.84-2.83c-.39-.39-.89-.58-1.39-.58M10.3 8.5l-5.96 5.96c-.78.78-.78 2.04.02 2.85C3.14 18.54 1.9 19.77.67 21h5.66l.86-.86c.78.76 2.03.75 2.81-.02l5.95-5.96\"/>","width":24,"height":24},
|
||||
"mdi:marker-cancel": {"body":"<path fill=\"currentColor\" d=\"M17.5 13c2.5 0 4.5 2 4.5 4.5S20 22 17.5 22S13 20 13 17.5s2-4.5 4.5-4.5m0 1.5c-.56 0-1.08.15-1.5.42L20.08 19c.27-.42.42-.94.42-1.5a3 3 0 0 0-3-3m-3 3a3 3 0 0 0 3 3c.56 0 1.08-.15 1.5-.42L14.92 16c-.27.42-.42.94-.42 1.5m4-16.35c.5 0 1 .19 1.39.58l2.84 2.83c.77.79.77 2.05 0 2.83l-3.78 3.77a6.54 6.54 0 0 0-3.8.28l-3.89-3.89l5.81-5.82c.39-.39.9-.58 1.43-.58M10.3 8.5l3.59 3.6A6.49 6.49 0 0 0 11 17.5c0 .5.06 1 .16 1.45L10 20.12c-.78.77-2.03.78-2.81.02l-.86.86H.67l3.69-3.69c-.8-.81-.8-2.07-.02-2.85z\"/>","width":24,"height":24},
|
||||
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
|
||||
"mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24},
|
||||
"mdi:minus": {"body":"<path fill=\"currentColor\" d=\"M19 13H5v-2h14z\"/>","width":24,"height":24},
|
||||
"mdi:near-me": {"body":"<path fill=\"currentColor\" d=\"M21 3L3 10.53v.97l6.84 2.66L12.5 21h.96z\"/>","width":24,"height":24},
|
||||
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},
|
||||
|
||||
@@ -39,11 +39,6 @@
|
||||
t('editor.markup.insertBlock')
|
||||
}}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn icon="mdi:chart-multiline" padding="sm sm" flat @click="notImplemented">
|
||||
<w-tooltip anchor="center right" self="center left">{{
|
||||
t('editor.markup.insertDiagram')
|
||||
}}</w-tooltip>
|
||||
</w-btn>
|
||||
<w-btn icon="mdi:book-plus" padding="sm sm" flat @click="insertFootnote">
|
||||
<w-tooltip anchor="center right" self="center left">{{
|
||||
t('editor.markup.insertFootnote')
|
||||
@@ -138,23 +133,41 @@
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
@click="insertBeforeEachLine({ content: `> `, after: `{.is-info}` })">
|
||||
@click="insertBeforeEachLine({ content: `> `, before: `> [!NOTE]` })">
|
||||
<w-item-section side>
|
||||
<w-icon name="mdi:information-box" color="blue-7" />
|
||||
<!--
|
||||
A colour with a utility behind it. WIcon composes the class from this name, so
|
||||
Tailwind never sees it while scanning and emits only the ones written out in
|
||||
full somewhere in the app -- of the blues, that is this one. Asking for the 7
|
||||
step, as this did, left the icon the colour of the menu text.
|
||||
|
||||
Nothing above may spell a class out either: the scanner reads comments too, and
|
||||
would generate whatever this explanation quoted.
|
||||
-->
|
||||
<w-icon name="mdi:information-box" color="blue" />
|
||||
</w-item-section>
|
||||
<w-item-section>{{ t('editor.markup.admonitionInfo') }}</w-item-section>
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
@click="insertBeforeEachLine({ content: `> `, after: `{.is-success}` })">
|
||||
@click="insertBeforeEachLine({ content: `> `, before: `> [!TIP]` })">
|
||||
<w-item-section side>
|
||||
<w-icon name="mdi:check-circle" color="positive" />
|
||||
</w-item-section>
|
||||
<w-item-section>{{ t('editor.markup.admonitionSuccess') }}</w-item-section>
|
||||
</w-item>
|
||||
<!-- -> The same speech bubble the page draws an IMPORTANT admonition with -->
|
||||
<w-item
|
||||
clickable
|
||||
@click="insertBeforeEachLine({ content: `> `, after: `{.is-warning}` })">
|
||||
@click="insertBeforeEachLine({ content: `> `, before: `> [!IMPORTANT]` })">
|
||||
<w-item-section side>
|
||||
<w-icon name="mdi:message-alert" color="purple" />
|
||||
</w-item-section>
|
||||
<w-item-section>{{ t('editor.markup.admonitionImportant') }}</w-item-section>
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
@click="insertBeforeEachLine({ content: `> `, before: `> [!WARNING]` })">
|
||||
<w-item-section side>
|
||||
<w-icon name="mdi:alert-box" color="orange" />
|
||||
</w-item-section>
|
||||
@@ -162,7 +175,7 @@
|
||||
</w-item>
|
||||
<w-item
|
||||
clickable
|
||||
@click="insertBeforeEachLine({ content: `> `, after: `{.is-danger}` })">
|
||||
@click="insertBeforeEachLine({ content: `> `, before: `> [!CAUTION]` })">
|
||||
<w-item-section side>
|
||||
<w-icon name="mdi:close-box" color="negative" />
|
||||
</w-item-section>
|
||||
@@ -628,8 +641,12 @@ function insertAfter({ content, newLine, focus = true }) {
|
||||
|
||||
/**
|
||||
* Insert content before current line
|
||||
*
|
||||
* `before` is a line of its own, put above the first of them — the `> [!NOTE]` that opens an
|
||||
* admonition. It rides along in that line's own edit rather than as an insertion of its own, so no
|
||||
* two edits in the batch start at the same position.
|
||||
*/
|
||||
function insertBeforeEachLine({ content, after, focus = true }) {
|
||||
function insertBeforeEachLine({ content, before, focus = true }) {
|
||||
const edits = []
|
||||
for (const selection of editor.getSelections()) {
|
||||
const lineCount = selection.endLineNumber - selection.startLineNumber + 1
|
||||
@@ -640,18 +657,10 @@ function insertBeforeEachLine({ content, after, focus = true }) {
|
||||
if (lineContent.startsWith(content)) {
|
||||
lineContent = lineContent.substring(content.length)
|
||||
}
|
||||
const opening = before && line === lines[0] ? `${before}\n` : ''
|
||||
edits.push({
|
||||
range: new Range(line, 1, line, lineLength + 1),
|
||||
text: `${content}${lineContent}`,
|
||||
forceMoveMarkers: true
|
||||
})
|
||||
}
|
||||
if (after) {
|
||||
const lastLine = lines.at(-1)
|
||||
const lineLength = editor.getModel().getLineContent(lastLine).length
|
||||
edits.push({
|
||||
range: new Range(lastLine, lineLength + 1, lastLine, lineLength + 1),
|
||||
text: `\n${after}`,
|
||||
text: `${opening}${content}${lineContent}`,
|
||||
forceMoveMarkers: true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -117,23 +117,6 @@
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="sigma" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{t(`admin.editors.markdown.latexEngine`)}}</w-item-label>
|
||||
<w-item-label caption>{{t(`admin.editors.markdown.latexEngineHint`)}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section class="flex-none">
|
||||
<w-btn-toggle
|
||||
v-model="state.config.latexEngine"
|
||||
push
|
||||
glossy
|
||||
no-caps
|
||||
toggle-color="primary"
|
||||
:options="latexEngines" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="data-sheet" />
|
||||
<w-item-section>
|
||||
@@ -204,82 +187,6 @@
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-card>
|
||||
<w-card class="shadow-1 pb-2 mt-4">
|
||||
<w-card-section>
|
||||
<div class="text-subtitle1">{{t('admin.editors.markdown.plantuml')}}</div>
|
||||
</w-card-section>
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="workflow" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{t(`admin.editors.markdown.plantuml`)}}</w-item-label>
|
||||
<w-item-label caption>{{t(`admin.editors.markdown.plantumlHint`)}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle
|
||||
v-model="state.config.plantuml"
|
||||
color="primary"
|
||||
checked-icon="la:check"
|
||||
unchecked-icon="la:times"
|
||||
:aria-label="t(`admin.editors.markdown.plantuml`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<template v-if="state.config.plantuml">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="website" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{t(`admin.editors.markdown.plantumlServerUrl`)}}</w-item-label>
|
||||
<w-item-label caption>{{t(`admin.editors.markdown.plantumlServerUrlHint`)}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-input
|
||||
style="width: 450px;"
|
||||
outlined
|
||||
v-model="state.config.plantumlServerUrl"
|
||||
dense
|
||||
:aria-label="t(`admin.editors.markdown.plantumlServerUrl`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-card>
|
||||
<w-card class="shadow-1 pb-2 mt-4">
|
||||
<w-card-section>
|
||||
<div class="text-subtitle1">{{t('admin.editors.markdown.kroki')}}</div>
|
||||
</w-card-section>
|
||||
<w-item tag="label">
|
||||
<blueprint-icon icon="workflow" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{t(`admin.editors.markdown.kroki`)}}</w-item-label>
|
||||
<w-item-label caption>{{t(`admin.editors.markdown.krokiHint`)}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section avatar>
|
||||
<w-toggle
|
||||
v-model="state.config.kroki"
|
||||
color="primary"
|
||||
checked-icon="la:check"
|
||||
unchecked-icon="la:times"
|
||||
:aria-label="t(`admin.editors.markdown.kroki`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<template v-if="state.config.kroki">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item>
|
||||
<blueprint-icon icon="website" />
|
||||
<w-item-section>
|
||||
<w-item-label>{{t(`admin.editors.markdown.krokiServerUrl`)}}</w-item-label>
|
||||
<w-item-label caption>{{t(`admin.editors.markdown.krokiServerUrlHint`)}}</w-item-label>
|
||||
</w-item-section>
|
||||
<w-item-section side>
|
||||
<w-input
|
||||
style="width: 450px;"
|
||||
outlined
|
||||
v-model="state.config.krokiServerUrl"
|
||||
dense
|
||||
:aria-label="t(`admin.editors.markdown.krokiServerUrl`)" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-card>
|
||||
<w-inner-loading :showing="state.loading > 0">
|
||||
<w-spinner color="accent" size="lg" />
|
||||
</w-inner-loading>
|
||||
@@ -326,12 +233,7 @@ function defaultConfig() {
|
||||
quotes: 'english',
|
||||
underline: true,
|
||||
tabWidth: 2,
|
||||
latexEngine: 'katex',
|
||||
multimdTable: true,
|
||||
plantuml: false,
|
||||
plantumlServerUrl: 'https://www.plantuml.com/plantuml/',
|
||||
kroki: false,
|
||||
krokiServerUrl: 'https://kroki.io'
|
||||
multimdTable: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,11 +242,6 @@ const state = reactive({
|
||||
loading: 0
|
||||
})
|
||||
|
||||
const latexEngines = [
|
||||
{ value: 'katex', label: 'KaTeX' },
|
||||
{ value: 'mathjax', label: 'Mathjax' }
|
||||
]
|
||||
|
||||
const quoteStyles = [
|
||||
{ value: 'chinese', label: 'Chinese' },
|
||||
{ value: 'english', label: 'English' },
|
||||
|
||||
@@ -28,7 +28,12 @@
|
||||
keystroke as the store echoes it back, and a rewritten text node puts the caret at the start of
|
||||
it. `syncEditable` writes it instead, and only when the two have actually diverged.
|
||||
-->
|
||||
<div class="min-w-0 flex-1 p-4">
|
||||
<!--
|
||||
Centred rather than top-aligned: with no description the title is the only line in this column,
|
||||
and left at the top it sat above the middle of the icon beside it. A page that has one is taller
|
||||
than everything else in the row, so there is nothing to centre and this changes nothing.
|
||||
-->
|
||||
<div class="min-w-0 flex-1 flex flex-col justify-center p-4">
|
||||
<div class="text-h4 page-header-title">
|
||||
<span
|
||||
v-if="editorStore.isActive"
|
||||
@@ -65,6 +70,16 @@
|
||||
<!-- PAGE ACTIONS -->
|
||||
<div class="flex-none p-4 flex items-center justify-end">
|
||||
<template v-if="!editorStore.isActive">
|
||||
<!--
|
||||
Whoever is looking at a draft can already see it, so the badge is not gated on being logged
|
||||
in the way the actions beside it are: it is telling a reader what they are reading, not
|
||||
offering them something to do.
|
||||
-->
|
||||
<w-badge
|
||||
v-if="pageStore.publishState === `draft`"
|
||||
class="uppercase"
|
||||
color="negative"
|
||||
:label="t(`editor.props.draft`)" />
|
||||
<w-btn
|
||||
class="ml-4"
|
||||
v-if="userStore.authenticated"
|
||||
|
||||
@@ -13,9 +13,12 @@
|
||||
|
||||
Written against what `renderers/markdown.js` actually emits (and what `models/rendering.ts` does to
|
||||
it server-side), which is: heading anchors, `pre.codeblock` with highlight.js token classes and an
|
||||
optional line-number gutter, task lists, footnotes, multi-line tables, KaTeX, `img.uml-diagram` for
|
||||
PlantUML/Kroki, twemoji images, and whatever classes an author attaches through `markdown-it-attrs`
|
||||
(`id`, `class` and `target` are the ones the renderer allows through).
|
||||
optional line-number gutter, task lists, footnotes, multi-line tables, admonitions, twemoji images,
|
||||
and whatever classes an author attaches through `markdown-it-attrs` (`id`, `class` and `target` are
|
||||
the ones the renderer allows through).
|
||||
|
||||
Maths and diagrams are not in that list: they are blocks now -- `block-mathjax`, `block-diagram`,
|
||||
`block-plantuml` -- and a block styles itself inside its own shadow root, which nothing here reaches.
|
||||
|
||||
The measurements follow what the documentation platforms have converged on -- 16px body text, headings
|
||||
at 600 with far more space above than below, ruled h1/h2, tinted code and table headers, a left-ruled
|
||||
@@ -82,6 +85,13 @@
|
||||
--content-warning-wash: rgba(163, 90, 0, 0.09);
|
||||
--content-danger: #c02636;
|
||||
--content-danger-wash: rgba(192, 38, 54, 0.08);
|
||||
/*
|
||||
A fifth hue, for `[!IMPORTANT]`: it sits between "worth knowing" and "watch out", which is exactly
|
||||
the gap the other four leave. Purple because GitHub uses purple, and a reader who knows the marker
|
||||
from there should recognise the colour here.
|
||||
*/
|
||||
--content-important: #6f42c1;
|
||||
--content-important-wash: rgba(111, 66, 193, 0.08);
|
||||
|
||||
/*
|
||||
Code tokens, after GitHub's syntax themes.
|
||||
@@ -141,6 +151,8 @@
|
||||
--content-warning-wash: rgba(243, 182, 97, 0.12);
|
||||
--content-danger: #ff8b8b;
|
||||
--content-danger-wash: rgba(255, 139, 139, 0.12);
|
||||
--content-important: #c4a7ff;
|
||||
--content-important-wash: rgba(196, 167, 255, 0.12);
|
||||
|
||||
--content-code-comment: #8b949e;
|
||||
--content-code-keyword: #ff7b72;
|
||||
@@ -606,9 +618,10 @@
|
||||
}
|
||||
|
||||
/*
|
||||
Admonitions, from `{.is-info}` and friends on a blockquote.
|
||||
Admonitions, from `{.is-info}` and friends on a blockquote -- and from a GitHub-style `> [!NOTE]`,
|
||||
which `renderers/modules/github-alerts.js` turns into the very same classes plus a label.
|
||||
|
||||
A wash, a heavier bar and an icon: colour alone would leave the four kinds indistinguishable to a
|
||||
A wash, a heavier bar and an icon: colour alone would leave the kinds indistinguishable to a
|
||||
reader who cannot separate the hues, and the icon is a masked SVG rather than a glyph so it needs
|
||||
no webfont -- the app dropped those, which is why the markers here used to render as tofu.
|
||||
*/
|
||||
@@ -622,6 +635,8 @@
|
||||
&:has(> .is-info),
|
||||
&.is-success,
|
||||
&:has(> .is-success),
|
||||
&.is-important,
|
||||
&:has(> .is-important),
|
||||
&.is-warning,
|
||||
&:has(> .is-warning),
|
||||
&.is-danger,
|
||||
@@ -649,6 +664,17 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
The label a GitHub-style alert opens with -- "Note", "Tip" -- standing in for the marker the
|
||||
author typed. In the severity's own colour, since it is naming the severity, and close above
|
||||
the text it introduces rather than a paragraph's distance from it.
|
||||
*/
|
||||
> .alert-title {
|
||||
margin-bottom: 0.3em;
|
||||
color: var(--alert-hue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--content-surface-alt);
|
||||
}
|
||||
@@ -656,6 +682,8 @@
|
||||
|
||||
&.is-info,
|
||||
&:has(> .is-info) {
|
||||
--alert-hue: var(--content-info);
|
||||
|
||||
border-left-color: var(--content-info);
|
||||
background-color: var(--content-info-wash);
|
||||
|
||||
@@ -667,6 +695,8 @@
|
||||
|
||||
&.is-success,
|
||||
&:has(> .is-success) {
|
||||
--alert-hue: var(--content-success);
|
||||
|
||||
border-left-color: var(--content-success);
|
||||
background-color: var(--content-success-wash);
|
||||
|
||||
@@ -676,8 +706,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* -> `mdi:message-alert`, the same speech bubble GitHub marks an important note with */
|
||||
&.is-important,
|
||||
&:has(> .is-important) {
|
||||
--alert-hue: var(--content-important);
|
||||
|
||||
border-left-color: var(--content-important);
|
||||
background-color: var(--content-important-wash);
|
||||
|
||||
&::before {
|
||||
color: var(--content-important);
|
||||
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2'/%3E%3C/svg%3E");
|
||||
}
|
||||
}
|
||||
|
||||
&.is-warning,
|
||||
&:has(> .is-warning) {
|
||||
--alert-hue: var(--content-warning);
|
||||
|
||||
border-left-color: var(--content-warning);
|
||||
background-color: var(--content-warning-wash);
|
||||
|
||||
@@ -689,6 +735,8 @@
|
||||
|
||||
&.is-danger,
|
||||
&:has(> .is-danger) {
|
||||
--alert-hue: var(--content-danger);
|
||||
|
||||
border-left-color: var(--content-danger);
|
||||
background-color: var(--content-danger-wash);
|
||||
|
||||
@@ -1061,20 +1109,6 @@
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
/*
|
||||
PlantUML and Kroki return SVGs drawn in black on nothing. On a dark page that is a diagram of
|
||||
invisible lines, so it gets a light card of its own -- the same treatment either way, since a
|
||||
diagram is an illustration rather than part of the text.
|
||||
*/
|
||||
img.uml-diagram {
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--content-rule);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
/* Twemoji, which the renderer swaps in for `:shortcodes:` */
|
||||
img.emoji {
|
||||
display: inline-block;
|
||||
@@ -1172,26 +1206,6 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MATH
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/*
|
||||
KaTeX sizes itself; what it cannot do is decide what happens when an equation is wider than the
|
||||
column. A display equation scrolls on its own rather than stretching the page.
|
||||
*/
|
||||
.katex-display {
|
||||
margin: 1.4em 0;
|
||||
padding: 0.2em 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.katex {
|
||||
/* -> KaTeX's default 1.21em is oversized next to 16px body text */
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PRINT
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,18 +14,6 @@ import { initializeHairlines } from './helpers/hairline'
|
||||
// so no icon webfont is loaded.
|
||||
import '@quasar/extras/roboto-font/roboto-font.css'
|
||||
|
||||
/*
|
||||
KaTeX's own stylesheet, which the math in a page is drawn with.
|
||||
|
||||
Vendor CSS rather than something `_page-contents.scss` could express: it carries the maths fonts and
|
||||
the geometry of every symbol. Without it a formula renders TWICE -- KaTeX emits both an HTML tree and
|
||||
a MathML fallback, and the CSS is what hides the one the browser is not using -- which is what made
|
||||
every equation appear as a broken rendering followed by its own source.
|
||||
|
||||
Global, but inert outside content: `.katex` appears nowhere else in the app.
|
||||
*/
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
import './css/tailwind.css'
|
||||
import './css/app.scss'
|
||||
|
||||
|
||||
@@ -11,14 +11,10 @@ import mdMark from 'markdown-it-mark'
|
||||
import mdMultiTable from 'markdown-it-multimd-table'
|
||||
import mdFootnote from 'markdown-it-footnote'
|
||||
import mdMdc from 'markdown-it-mdc'
|
||||
import katex from 'katex'
|
||||
import mdUnderline from './modules/markdown-it-underline'
|
||||
import mdImsize from './modules/markdown-it-imsize'
|
||||
import 'katex/dist/contrib/mhchem'
|
||||
import mdGithubAlerts from './modules/github-alerts'
|
||||
import twemoji from 'twemoji'
|
||||
import plantuml from './modules/plantuml'
|
||||
import kroki from './modules/kroki.mjs'
|
||||
import katexHelper from './modules/katex'
|
||||
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
@@ -77,6 +73,12 @@ export class MarkdownRenderer {
|
||||
if (lang === 'diagram') {
|
||||
return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
|
||||
} else if (['mermaid', 'plantuml'].includes(lang)) {
|
||||
/*
|
||||
Left as source, deliberately: a diagram is drawn by the block whose body it is —
|
||||
`block-diagram` for mermaid, `block-plantuml` for the other — and each reads the text out
|
||||
of this `pre`. A fence on its own outside a block keeps the panel the stylesheet gives it,
|
||||
which says "a diagram nobody has drawn" rather than pretending to be a code sample.
|
||||
*/
|
||||
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
|
||||
} else {
|
||||
/*
|
||||
@@ -130,6 +132,7 @@ export class MarkdownRenderer {
|
||||
.use(mdMark)
|
||||
.use(mdFootnote)
|
||||
.use(mdImsize)
|
||||
.use(mdGithubAlerts)
|
||||
|
||||
/*
|
||||
MDC's slot syntax, off for the same reason as inline components: it takes a line the author
|
||||
@@ -173,76 +176,6 @@ export class MarkdownRenderer {
|
||||
this.md.use(mdMultiTable, { multiline: true, rowspan: true, headerless: true })
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
// PLANTUML
|
||||
// --------------------------------
|
||||
|
||||
if (config.plantuml) {
|
||||
plantuml.init(this.md, { server: config.plantumlServerUrl })
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
// KROKI
|
||||
// --------------------------------
|
||||
|
||||
if (config.kroki) {
|
||||
kroki.init(this.md, { server: config.krokiServerUrl })
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
// KATEX
|
||||
// --------------------------------
|
||||
|
||||
const macros = {}
|
||||
|
||||
// TODO: Add mhchem (needs esm conversion)
|
||||
// Add \ce, \pu, and \tripledash to the KaTeX macros.
|
||||
// katex.__defineMacro('\\ce', function (context) {
|
||||
// return chemParse(context.consumeArgs(1)[0], 'ce')
|
||||
// })
|
||||
// katex.__defineMacro('\\pu', function (context) {
|
||||
// return chemParse(context.consumeArgs(1)[0], 'pu')
|
||||
// })
|
||||
|
||||
// Needed for \bond for the ~ forms
|
||||
// Raise by 2.56mu, not 2mu. We're raising a hyphen-minus, U+002D, not
|
||||
// a mathematical minus, U+2212. So we need that extra 0.56.
|
||||
katex.__defineMacro(
|
||||
'\\tripledash',
|
||||
'{\\vphantom{-}\\raisebox{2.56mu}{$\\mkern2mu' +
|
||||
'\\tiny\\text{-}\\mkern1mu\\text{-}\\mkern1mu\\text{-}\\mkern2mu$}}'
|
||||
)
|
||||
this.md.inline.ruler.after('escape', 'katex_inline', katexHelper.katexInline)
|
||||
this.md.renderer.rules.katex_inline = (tokens, idx) => {
|
||||
try {
|
||||
return katex.renderToString(tokens[idx].content, {
|
||||
displayMode: false,
|
||||
macros
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
return tokens[idx].content
|
||||
}
|
||||
}
|
||||
this.md.block.ruler.after('blockquote', 'katex_block', katexHelper.katexBlock, {
|
||||
alt: ['paragraph', 'reference', 'blockquote', 'list']
|
||||
})
|
||||
this.md.renderer.rules.katex_block = (tokens, idx) => {
|
||||
try {
|
||||
return (
|
||||
'<p>' +
|
||||
katex.renderToString(tokens[idx].content, {
|
||||
displayMode: true,
|
||||
macros
|
||||
}) +
|
||||
'</p>'
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn(err)
|
||||
return tokens[idx].content
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
// LINK DESTINATIONS
|
||||
// --------------------------------
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// ------------------------------------
|
||||
// Markdown - GitHub-style alerts
|
||||
// ------------------------------------
|
||||
|
||||
/**
|
||||
* The five kinds GitHub defines, and what each one becomes here.
|
||||
*
|
||||
* They are mapped onto the admonition classes the content stylesheet already draws — the ones
|
||||
* `{.is-info}` and friends attach — so an alert and a hand-classed blockquote are the same object on
|
||||
* the page, and there is one place where an admonition is styled. `important` is the one kind with no
|
||||
* existing counterpart, and has a hue of its own in `css/_page-contents.scss`.
|
||||
*
|
||||
* The labels are English, as the marker itself is: what the renderer emits is stored as the page's
|
||||
* HTML, so nothing here can follow the reader's locale afterwards.
|
||||
*/
|
||||
const KINDS = new Map([
|
||||
['note', { className: 'is-info', label: 'Note' }],
|
||||
['tip', { className: 'is-success', label: 'Tip' }],
|
||||
['important', { className: 'is-important', label: 'Important' }],
|
||||
['warning', { className: 'is-warning', label: 'Warning' }],
|
||||
['caution', { className: 'is-danger', label: 'Caution' }]
|
||||
])
|
||||
|
||||
/**
|
||||
* The marker, which has to be the whole of the blockquote's first line.
|
||||
*
|
||||
* Anything after it on that line means the author wrote a blockquote that happens to open with
|
||||
* brackets, which is what GitHub decides too — and the line is then left exactly as it was typed.
|
||||
*/
|
||||
const MARKER = /^\[!([a-z]+)\][ \t]*(?:\n|$)/i
|
||||
|
||||
/**
|
||||
* The label, as three tokens: a paragraph carrying a class, its inline content, and the close.
|
||||
*
|
||||
* The inline token is left with nothing but `content`; the core `inline` rule runs after this one and
|
||||
* is what turns that into children, the same as for every other paragraph on the page.
|
||||
*/
|
||||
function labelTokens(state, label) {
|
||||
const open = new state.Token('paragraph_open', 'p', 1)
|
||||
open.attrSet('class', 'alert-title')
|
||||
open.block = true
|
||||
|
||||
const inline = new state.Token('inline', '', 0)
|
||||
inline.content = label
|
||||
inline.children = []
|
||||
|
||||
const close = new state.Token('paragraph_close', 'p', -1)
|
||||
close.block = true
|
||||
|
||||
return [open, inline, close]
|
||||
}
|
||||
|
||||
export default (md) => {
|
||||
/*
|
||||
After `block` and so before `inline`, which is what makes this a matter of cutting a line off a
|
||||
string: at this point a paragraph is still one `inline` token holding its raw source. Run after
|
||||
`inline` instead and the same job means walking children and reasoning about where markdown-it put
|
||||
the break — a soft one, or a hard one where the author left two spaces after the marker, as the
|
||||
examples in GitHub's own documentation do.
|
||||
*/
|
||||
md.core.ruler.after('block', 'github_alert', (state) => {
|
||||
const tokens = state.tokens
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (
|
||||
tokens[i].type !== 'blockquote_open' ||
|
||||
tokens[i + 1]?.type !== 'paragraph_open' ||
|
||||
tokens[i + 2]?.type !== 'inline'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const marker = MARKER.exec(tokens[i + 2].content)
|
||||
const kind = marker ? KINDS.get(marker[1].toLowerCase()) : null
|
||||
if (!kind) {
|
||||
continue
|
||||
}
|
||||
|
||||
// -> Joined rather than set: an author may have classed the quote themselves, and `is-info` on
|
||||
// top of that is what the stylesheet is written to expect
|
||||
tokens[i].attrJoin('class', kind.className)
|
||||
|
||||
const rest = tokens[i + 2].content.slice(marker[0].length)
|
||||
if (rest) {
|
||||
tokens[i + 2].content = rest
|
||||
tokens.splice(i + 1, 0, ...labelTokens(state, kind.label))
|
||||
} else {
|
||||
// -> The marker was the whole paragraph, so the label takes its place rather than joining it
|
||||
tokens.splice(i + 1, 3, ...labelTokens(state, kind.label))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
// Test if potential opening or closing delimieter
|
||||
// Assumes that there is a "$" at state.src[pos]
|
||||
function isValidDelim(state, pos) {
|
||||
const max = state.posMax
|
||||
let canOpen = true
|
||||
let canClose = true
|
||||
|
||||
const prevChar = pos > 0 ? state.src.charCodeAt(pos - 1) : -1
|
||||
const nextChar = pos + 1 <= max ? state.src.charCodeAt(pos + 1) : -1
|
||||
|
||||
// Check non-whitespace conditions for opening and closing, and
|
||||
// check that closing delimeter isn't followed by a number
|
||||
if (
|
||||
prevChar === 0x20 /* " " */ ||
|
||||
prevChar === 0x09 /* \t */ ||
|
||||
(nextChar >= 0x30 /* "0" */ && nextChar <= 0x39) /* "9" */
|
||||
) {
|
||||
canClose = false
|
||||
}
|
||||
if (nextChar === 0x20 /* " " */ || nextChar === 0x09 /* \t */) {
|
||||
canOpen = false
|
||||
}
|
||||
|
||||
return {
|
||||
canOpen,
|
||||
canClose
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
katexInline(state, silent) {
|
||||
let match, token, res, pos
|
||||
|
||||
if (state.src[state.pos] !== '$') {
|
||||
return false
|
||||
}
|
||||
|
||||
res = isValidDelim(state, state.pos)
|
||||
if (!res.canOpen) {
|
||||
if (!silent) {
|
||||
state.pending += '$'
|
||||
}
|
||||
state.pos += 1
|
||||
return true
|
||||
}
|
||||
|
||||
// First check for and bypass all properly escaped delimieters
|
||||
// This loop will assume that the first leading backtick can not
|
||||
// be the first character in state.src, which is known since
|
||||
// we have found an opening delimieter already.
|
||||
const start = state.pos + 1
|
||||
match = start
|
||||
while ((match = state.src.indexOf('$', match)) !== -1) {
|
||||
// Found potential $, look for escapes, pos will point to
|
||||
// first non escape when complete
|
||||
pos = match - 1
|
||||
while (state.src[pos] === '\\') {
|
||||
pos -= 1
|
||||
}
|
||||
|
||||
// Even number of escapes, potential closing delimiter found
|
||||
if ((match - pos) % 2 === 1) {
|
||||
break
|
||||
}
|
||||
match += 1
|
||||
}
|
||||
|
||||
// No closing delimter found. Consume $ and continue.
|
||||
if (match === -1) {
|
||||
if (!silent) {
|
||||
state.pending += '$'
|
||||
}
|
||||
state.pos = start
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if we have empty content, ie: $$. Do not parse.
|
||||
if (match - start === 0) {
|
||||
if (!silent) {
|
||||
state.pending += '$$'
|
||||
}
|
||||
state.pos = start + 1
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for valid closing delimiter
|
||||
res = isValidDelim(state, match)
|
||||
if (!res.canClose) {
|
||||
if (!silent) {
|
||||
state.pending += '$'
|
||||
}
|
||||
state.pos = start
|
||||
return true
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
token = state.push('katex_inline', 'math', 0)
|
||||
token.markup = '$'
|
||||
token.content = state.src
|
||||
// Extract the math part without the $
|
||||
.slice(start, match)
|
||||
// Escape the curly braces since they will be interpreted as
|
||||
// attributes by markdown-it-attrs (the "curly_attributes"
|
||||
// core rule)
|
||||
.replaceAll('{', '{{')
|
||||
.replaceAll('}', '}}')
|
||||
}
|
||||
|
||||
state.pos = match + 1
|
||||
return true
|
||||
},
|
||||
|
||||
katexBlock(state, start, end, silent) {
|
||||
let firstLine
|
||||
let lastLine
|
||||
let next
|
||||
let lastPos
|
||||
let found = false
|
||||
let pos = state.bMarks[start] + state.tShift[start]
|
||||
let max = state.eMarks[start]
|
||||
|
||||
if (pos + 2 > max) {
|
||||
return false
|
||||
}
|
||||
if (state.src.slice(pos, pos + 2) !== '$$') {
|
||||
return false
|
||||
}
|
||||
|
||||
pos += 2
|
||||
firstLine = state.src.slice(pos, max)
|
||||
|
||||
if (silent) {
|
||||
return true
|
||||
}
|
||||
if (firstLine.trim().slice(-2) === '$$') {
|
||||
// Single line expression
|
||||
firstLine = firstLine.trim().slice(0, -2)
|
||||
found = true
|
||||
}
|
||||
|
||||
for (next = start; !found; ) {
|
||||
next++
|
||||
|
||||
if (next >= end) {
|
||||
break
|
||||
}
|
||||
|
||||
pos = state.bMarks[next] + state.tShift[next]
|
||||
max = state.eMarks[next]
|
||||
|
||||
if (pos < max && state.tShift[next] < state.blkIndent) {
|
||||
// non-empty line with negative indent should stop the list:
|
||||
break
|
||||
}
|
||||
|
||||
if (state.src.slice(pos, max).trim().slice(-2) === '$$') {
|
||||
lastPos = state.src.slice(0, max).lastIndexOf('$$')
|
||||
lastLine = state.src.slice(pos, lastPos)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
state.line = next + 1
|
||||
|
||||
const token = state.push('katex_block', 'math', 0)
|
||||
token.block = true
|
||||
token.content =
|
||||
(firstLine && firstLine.trim() ? firstLine + '\n' : '') +
|
||||
state.getLines(start + 1, next, state.tShift[start], true) +
|
||||
(lastLine && lastLine.trim() ? lastLine : '')
|
||||
token.map = [start, state.line]
|
||||
token.markup = '$$'
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import pako from 'pako'
|
||||
|
||||
// ------------------------------------
|
||||
// Markdown - PlantUML Preprocessor
|
||||
// ------------------------------------
|
||||
|
||||
export default {
|
||||
init(mdinst, conf) {
|
||||
mdinst.use(
|
||||
(md, opts) => {
|
||||
const openMarker = opts.openMarker || '```kroki'
|
||||
const openChar = openMarker.charCodeAt(0)
|
||||
const closeMarker = opts.closeMarker || '```'
|
||||
const closeChar = closeMarker.charCodeAt(0)
|
||||
const server = opts.server || 'https://kroki.io'
|
||||
|
||||
md.block.ruler.before(
|
||||
'fence',
|
||||
'kroki',
|
||||
(state, startLine, endLine, silent) => {
|
||||
let nextLine
|
||||
let markup
|
||||
let params
|
||||
let token
|
||||
let i
|
||||
let autoClosed = false
|
||||
let start = state.bMarks[startLine] + state.tShift[startLine]
|
||||
let max = state.eMarks[startLine]
|
||||
|
||||
// Check out the first character quickly,
|
||||
// this should filter out most of non-uml blocks
|
||||
//
|
||||
if (openChar !== state.src.charCodeAt(start)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check out the rest of the marker string
|
||||
//
|
||||
for (i = 0; i < openMarker.length; ++i) {
|
||||
if (openMarker[i] !== state.src[start + i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
markup = state.src.slice(start, start + i)
|
||||
params = state.src.slice(start + i, max)
|
||||
|
||||
// Since start is found, we can report success here in validation mode
|
||||
//
|
||||
if (silent) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search for the end of the block
|
||||
//
|
||||
nextLine = startLine
|
||||
|
||||
for (;;) {
|
||||
nextLine++
|
||||
if (nextLine >= endLine) {
|
||||
// unclosed block should be autoclosed by end of document.
|
||||
// also block seems to be autoclosed by end of parent
|
||||
break
|
||||
}
|
||||
|
||||
start = state.bMarks[nextLine] + state.tShift[nextLine]
|
||||
max = state.eMarks[nextLine]
|
||||
|
||||
if (start < max && state.sCount[nextLine] < state.blkIndent) {
|
||||
// non-empty line with negative indent should stop the list:
|
||||
// - ```
|
||||
// test
|
||||
break
|
||||
}
|
||||
|
||||
if (closeChar !== state.src.charCodeAt(start)) {
|
||||
// didn't find the closing fence
|
||||
continue
|
||||
}
|
||||
|
||||
if (state.sCount[nextLine] > state.sCount[startLine]) {
|
||||
// closing fence should not be indented with respect of opening fence
|
||||
continue
|
||||
}
|
||||
|
||||
let closeMarkerMatched = true
|
||||
for (i = 0; i < closeMarker.length; ++i) {
|
||||
if (closeMarker[i] !== state.src[start + i]) {
|
||||
closeMarkerMatched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!closeMarkerMatched) {
|
||||
continue
|
||||
}
|
||||
|
||||
// make sure tail has spaces only
|
||||
if (state.skipSpaces(start + i) < max) {
|
||||
continue
|
||||
}
|
||||
|
||||
// found!
|
||||
autoClosed = true
|
||||
break
|
||||
}
|
||||
|
||||
let contents = state.src
|
||||
.split('\n')
|
||||
.slice(startLine + 1, nextLine)
|
||||
.join('\n')
|
||||
|
||||
// We generate a token list for the alt property, to mimic what the image parser does.
|
||||
let altToken = []
|
||||
// Remove leading space if any.
|
||||
let alt = params ? params.slice(1) : 'uml diagram'
|
||||
state.md.inline.parse(alt, state.md, state.env, altToken)
|
||||
|
||||
let firstlf = contents.indexOf('\n')
|
||||
if (firstlf === -1) firstlf = undefined
|
||||
let diagramType = contents.substring(0, firstlf)
|
||||
contents = contents.substring(firstlf + 1)
|
||||
|
||||
const result = pako
|
||||
.deflate(contents)
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
|
||||
token = state.push('kroki', 'img', 0)
|
||||
// alt is constructed from children. No point in populating it here.
|
||||
token.attrs = [
|
||||
['src', `${server}/${diagramType}/svg/${result}`],
|
||||
['alt', ''],
|
||||
['class', 'uml-diagram prefetch-candidate']
|
||||
]
|
||||
token.block = true
|
||||
token.children = altToken
|
||||
token.info = params
|
||||
token.map = [startLine, nextLine]
|
||||
token.markup = markup
|
||||
|
||||
state.line = nextLine + (autoClosed ? 1 : 0)
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
alt: ['paragraph', 'reference', 'blockquote', 'list']
|
||||
}
|
||||
)
|
||||
md.renderer.rules.kroki = md.renderer.rules.image
|
||||
},
|
||||
{
|
||||
openMarker: conf.openMarker,
|
||||
closeMarker: conf.closeMarker,
|
||||
server: conf.server
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
import pako from 'pako'
|
||||
|
||||
// ------------------------------------
|
||||
// Markdown - PlantUML Preprocessor
|
||||
// ------------------------------------
|
||||
|
||||
export default {
|
||||
init(mdinst, conf) {
|
||||
mdinst.use(
|
||||
(md, opts) => {
|
||||
const openMarker = opts.openMarker || '```plantuml'
|
||||
const openChar = openMarker.charCodeAt(0)
|
||||
const closeMarker = opts.closeMarker || '```'
|
||||
const closeChar = closeMarker.charCodeAt(0)
|
||||
const imageFormat = opts.imageFormat || 'svg'
|
||||
const server = opts.server || 'https://plantuml.requarks.io'
|
||||
|
||||
md.block.ruler.before(
|
||||
'fence',
|
||||
'uml_diagram',
|
||||
(state, startLine, endLine, silent) => {
|
||||
let nextLine
|
||||
let i
|
||||
let autoClosed = false
|
||||
let start = state.bMarks[startLine] + state.tShift[startLine]
|
||||
let max = state.eMarks[startLine]
|
||||
|
||||
// Check out the first character quickly,
|
||||
// this should filter out most of non-uml blocks
|
||||
//
|
||||
if (openChar !== state.src.charCodeAt(start)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check out the rest of the marker string
|
||||
//
|
||||
for (i = 0; i < openMarker.length; ++i) {
|
||||
if (openMarker[i] !== state.src[start + i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const markup = state.src.slice(start, start + i)
|
||||
const params = state.src.slice(start + i, max)
|
||||
|
||||
// Since start is found, we can report success here in validation mode
|
||||
//
|
||||
if (silent) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Search for the end of the block
|
||||
//
|
||||
nextLine = startLine
|
||||
|
||||
for (;;) {
|
||||
nextLine++
|
||||
if (nextLine >= endLine) {
|
||||
// unclosed block should be autoclosed by end of document.
|
||||
// also block seems to be autoclosed by end of parent
|
||||
break
|
||||
}
|
||||
|
||||
start = state.bMarks[nextLine] + state.tShift[nextLine]
|
||||
max = state.eMarks[nextLine]
|
||||
|
||||
if (start < max && state.sCount[nextLine] < state.blkIndent) {
|
||||
// non-empty line with negative indent should stop the list:
|
||||
// - ```
|
||||
// test
|
||||
break
|
||||
}
|
||||
|
||||
if (closeChar !== state.src.charCodeAt(start)) {
|
||||
// didn't find the closing fence
|
||||
continue
|
||||
}
|
||||
|
||||
if (state.sCount[nextLine] > state.sCount[startLine]) {
|
||||
// closing fence should not be indented with respect of opening fence
|
||||
continue
|
||||
}
|
||||
|
||||
let closeMarkerMatched = true
|
||||
for (i = 0; i < closeMarker.length; ++i) {
|
||||
if (closeMarker[i] !== state.src[start + i]) {
|
||||
closeMarkerMatched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!closeMarkerMatched) {
|
||||
continue
|
||||
}
|
||||
|
||||
// make sure tail has spaces only
|
||||
if (state.skipSpaces(start + i) < max) {
|
||||
continue
|
||||
}
|
||||
|
||||
// found!
|
||||
autoClosed = true
|
||||
break
|
||||
}
|
||||
|
||||
const contents = state.src
|
||||
.split('\n')
|
||||
.slice(startLine + 1, nextLine)
|
||||
.join('\n')
|
||||
|
||||
// We generate a token list for the alt property, to mimic what the image parser does.
|
||||
const altToken = []
|
||||
// Remove leading space if any.
|
||||
const alt = params ? params.slice(1) : 'uml diagram'
|
||||
state.md.inline.parse(alt, state.md, state.env, altToken)
|
||||
|
||||
const zippedCode = encode64(
|
||||
pako.deflate('@startuml\n' + contents + '\n@enduml', { to: 'string' })
|
||||
)
|
||||
|
||||
const token = state.push('uml_diagram', 'img', 0)
|
||||
// alt is constructed from children. No point in populating it here.
|
||||
token.attrs = [
|
||||
['src', `${server}/${imageFormat}/${zippedCode}`],
|
||||
['alt', ''],
|
||||
['class', 'uml-diagram']
|
||||
]
|
||||
token.block = true
|
||||
token.children = altToken
|
||||
token.info = params
|
||||
token.map = [startLine, nextLine]
|
||||
token.markup = markup
|
||||
|
||||
state.line = nextLine + (autoClosed ? 1 : 0)
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
alt: ['paragraph', 'reference', 'blockquote', 'list']
|
||||
}
|
||||
)
|
||||
md.renderer.rules.uml_diagram = md.renderer.rules.image
|
||||
},
|
||||
{
|
||||
openMarker: conf.openMarker,
|
||||
closeMarker: conf.closeMarker,
|
||||
imageFormat: conf.imageFormat,
|
||||
server: conf.server
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function encode64(data) {
|
||||
let r = ''
|
||||
for (let i = 0; i < data.length; i += 3) {
|
||||
if (i + 2 === data.length) {
|
||||
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), 0)
|
||||
} else if (i + 1 === data.length) {
|
||||
r += append3bytes(data.charCodeAt(i), 0, 0)
|
||||
} else {
|
||||
r += append3bytes(data.charCodeAt(i), data.charCodeAt(i + 1), data.charCodeAt(i + 2))
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
function append3bytes(b1, b2, b3) {
|
||||
const c1 = b1 >> 2
|
||||
const c2 = ((b1 & 0x3) << 4) | (b2 >> 4)
|
||||
const c3 = ((b2 & 0xf) << 2) | (b3 >> 6)
|
||||
const c4 = b3 & 0x3f
|
||||
let r = ''
|
||||
r += encode6bit(c1 & 0x3f)
|
||||
r += encode6bit(c2 & 0x3f)
|
||||
r += encode6bit(c3 & 0x3f)
|
||||
r += encode6bit(c4 & 0x3f)
|
||||
return r
|
||||
}
|
||||
|
||||
function encode6bit(raw) {
|
||||
let b = raw
|
||||
if (b < 10) {
|
||||
return String.fromCharCode(48 + b)
|
||||
}
|
||||
b -= 10
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(65 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b < 26) {
|
||||
return String.fromCharCode(97 + b)
|
||||
}
|
||||
b -= 26
|
||||
if (b === 0) {
|
||||
return '-'
|
||||
}
|
||||
if (b === 1) {
|
||||
return '_'
|
||||
}
|
||||
return '?'
|
||||
}
|
||||
Reference in new issue
Block a user