feat: code blocks title, line start, line highlight

scarlett
NGPixel 2 weeks ago
parent 40ebd10360
commit f985f041d0
No known key found for this signature in database

@ -12,10 +12,11 @@
content, and it has to be, or a rendered page comes out as undifferentiated 14px text.
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, admonitions, twemoji images,
and whatever classes an author attaches through `markdown-it-attrs` (`id`, `class` and `target` are
the ones the renderer allows through).
it server-side), which is: heading anchors, `pre.codeblock` with highlight.js token classes, an
optional line-number gutter, optional per-line highlights and an optional header bar carrying a
title, 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.
@ -64,6 +65,15 @@
*/
--content-bleed: 1rem;
/*
A code panel's padding, held as properties rather than written out at each place that has to line up
with it: the row layer behind the code reaches back over it exactly, and a titled block's header bar
pads to the same edge from outside the panel. The left one is per panel and lives on `pre`, since the
line-number gutter widens it.
*/
--code-pad-y: 0.9rem;
--code-pad-x: 1.1rem;
/* Rules: the hairline between table cells and around panels, and a heavier one for a quote's bar */
--content-rule: rgba(0, 0, 0, 0.12);
--content-rule-strong: rgba(0, 0, 0, 0.26);
@ -1136,8 +1146,11 @@
panel with no edge would dissolve into the page.
*/
pre {
/* -> The one the gutter widens; see `pre.codeblock.line-numbers` */
--code-pad-left: var(--code-pad-x);
margin: 1.5em 0;
padding: 0.9rem 1.1rem;
padding: var(--code-pad-y) var(--code-pad-x) var(--code-pad-y) var(--code-pad-left);
border: 1px solid var(--content-rule);
border-radius: 8px;
background-color: var(--content-surface-code);
@ -1232,43 +1245,164 @@
}
/*
The line-number gutter, for a block of more than one line. The renderer emits one empty <span> per
line inside `.line-numbers-rows`; a counter turns those into numbers, so the digits are never part
of the text and a copied selection stays clean.
The row layer: one empty <span> per line of code, inside `.line-numbers-rows`.
Two things are drawn off these rows, and neither can be drawn off the code itself. The line numbers
come from a counter, so the digits are never part of the text and a copied selection stays clean.
And the wash behind a line named by a fence's `linesHighlight` is a row's own background -- because
highlighting a line by wrapping it would mean splitting the highlighted HTML on its newlines and
re-balancing whatever highlight.js left open across them (one multi-line comment is one span),
where a row already lands on its line by geometry alone.
So the layer is emitted for a block of more than one line, which is where a gutter is worth having,
and for a single-line block that named a line to highlight. `line-numbers` on the panel is what
says the digits are wanted; everything here is about the rows.
It bleeds out to the panel's own edges -- back over the padding on the left, gutter included, and
past the longest line on the right -- so a highlight is the full width of the block. The bleed lands
exactly on the padding box, which is where the panel clips, so nothing escapes it.
*/
pre.codeblock.line-numbers {
pre.codeblock {
position: relative;
padding-left: 3.6rem;
counter-reset: codeline;
> code {
/*
A stacking context, so that a row is painted behind the code and still above the panel: the
layer sits at a negative depth inside it, which puts it over this element's own background --
none -- and under its text, while the panel's background belongs to the `pre` outside.
*/
position: relative;
z-index: 0;
/*
Sized to the longest line rather than to the panel, so that a row is as wide as the code and a
line's highlight is still behind it once the block has been scrolled sideways. At least the
panel's width, so that a short block's rows still reach its right edge.
*/
width: max-content;
min-width: 100%;
}
.line-numbers-rows {
position: absolute;
top: 0;
left: -2.5rem;
width: 2rem;
border-right: 1px solid var(--content-rule);
left: calc(-1 * var(--code-pad-left));
right: calc(-1 * var(--code-pad-x));
z-index: -1;
/* -> One below the first line, since a row increments before it draws; see `linesStart` */
counter-reset: codeline var(--code-line-start, 0);
user-select: none;
pointer-events: none;
> span {
position: relative;
display: block;
/*
Held to a line of the code it sits behind. A row has no content of its own -- the number is
drawn by a counter and takes itself out of the flow -- so nothing else would give it a height,
and a row of no height would leave every row after it on the wrong line.
*/
height: 1lh;
counter-increment: codeline;
}
&::before {
content: counter(codeline);
display: block;
padding-right: 0.7em;
color: var(--content-ink-faint);
text-align: right;
}
/*
A highlighted line, washed with the block's own ink rather than with a colour of its own, for
the reason the copy button takes its colours the same way: a highlight.js theme paints `.hljs`,
so the panel can be any colour at all, and a mix of what is already written on it is the one
wash that reads on every one of them. The bar down the left edge is the same mix at strength --
at this subtlety the wash alone is easy to miss on a busy line.
*/
> span.is-highlighted {
background-color: color-mix(in srgb, currentColor 10%, transparent);
box-shadow: inset 2px 0 0 color-mix(in srgb, currentColor 45%, transparent);
}
}
}
/*
The gutter proper: the digits, and the rule that holds them off the code. All that separates a
numbered block from one that only has rows because it highlights a line.
*/
pre.codeblock.line-numbers {
--code-pad-left: 3.6rem;
.line-numbers-rows {
/*
Drawn once down the whole layer rather than once per row, and as the LAST thing in it -- a
highlighted row's wash is a background and would otherwise paint over the rule, breaking the
gutter's line wherever a line is marked.
Mixed from the panel's own ink rather than taken from `--content-rule`, for the reason the copy
button takes its colours that way: a highlight.js theme paints `.hljs`, so a panel can be any
colour, and the page's hairline is invisible on a dark one. Against the default palette the two
land within a shade of each other; over a highlighted row this one still reads.
*/
&::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 3.1rem;
border-left: 1px solid color-mix(in srgb, currentColor 22%, transparent);
}
> span::before {
content: counter(codeline);
position: absolute;
top: 0;
left: 0;
width: 3.1rem;
padding-right: 0.5rem;
color: var(--content-ink-faint);
text-align: right;
}
}
}
/*
A fence given a `title="..."`, drawn as a bar across the top of the block.
The bar is a sibling of the `<pre>` rather than a child of it, because the panel is the thing that
scrolls: a header inside it would slide sideways with the code and be indented by the gutter's
padding. So a titled block is a box holding the two, and the box carries the border, the radius and
the margin the panel gives up.
That box wears `hljs` as well, which is what keeps it the same colour as the code it is holding: the
theme chosen under Admin -> Theme is injected as bare `.hljs` rules (see `applyCodeBlocksTheme` in
`App.vue`) and sets a background and an ink, so a panel is only `--content-surface-code` until an
administrator picks something else. Reaching for the token here instead would leave the bar light
over a dark panel on any site that has. The token is the fallback for both, exactly as before, and
the bar's own wash is mixed from the ink it ends up with.
*/
.codeblock-titled {
margin: 1.5em 0;
border: 1px solid var(--content-rule);
border-radius: 8px;
/* -> Clips the panel's square corners to this box's radius */
overflow: hidden;
background-color: var(--content-surface-code);
color: var(--content-ink);
> .codeblock-title {
padding: 0.45rem var(--code-pad-x);
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background-color: color-mix(in srgb, currentColor 7%, transparent);
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
/* -> A file path with no spaces in it is the commonest title there is */
overflow-wrap: anywhere;
}
/* -> The box is the block's outline now; the panel keeps only its padding and its scrolling */
> pre.codeblock {
margin: 0;
border: 0;
border-radius: 0;
}
}
/*
Diagram sources -- a mermaid, plantuml or kroki fence, or a base64 `diagram` block -- reach the
page as code, and are drawn later or not at all. A quiet dashed panel says "this is a diagram that
@ -1741,6 +1875,7 @@
line-height: 1.55;
pre.codeblock,
.codeblock-titled,
table,
img,
blockquote,

@ -206,6 +206,170 @@ function iconShortcode(state, silent) {
return true
}
/**
* Everything a fence may say about itself beyond its language.
*
* ```yaml title="Some title here" linesStart="3" linesHighlight="1,3,5-8"
*
* markdown-it takes the first word of the info string as the language name and leaves the rest of the
* line alone, so this is a parse of that remainder and of nothing else. A value may be quoted with
* either quote or left bare, because bare is what anybody writes for a number; a key with no value is
* not matched at all, since none of the three means anything without one. A quoted value may hold the
* quote that delimits it if it is escaped -- `title="Say \\"hi\\""` -- which is why the backslash is
* consumed here rather than left for `unescapeAll` to find after the value has already been cut short.
*
* Keys are folded to lower case. The syntax is documented in camel case and reads better that way, but
* `linestart` is the same request typed by somebody who did not look closely, and there is nothing to
* be gained by refusing it.
*/
const FENCE_ATTRIBUTE =
/([a-z][\w-]*)\s*=\s*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([^\s"']+))/gi
function parseFenceAttributes(source, unescape = (value) => value) {
const attributes = {}
for (const match of source.matchAll(FENCE_ATTRIBUTE)) {
attributes[match[1].toLowerCase()] = unescape(match[2] ?? match[3] ?? match[4])
}
return attributes
}
/** One entry of a `linesHighlight` list: a single line, or a range written with a hyphen. */
const LINE_RANGE = /^(\d+)(?:\s*-\s*(\d+))?$/
/**
* The lines a `linesHighlight` value names, kept as the ranges it lists rather than expanded into the
* set of numbers in them -- `1-40000000` is a plausible slip of the hand and a set built from it is a
* hung tab. Nothing needs the numbers themselves; every caller only ever asks whether one line is in.
*
* An entry that is neither a number nor a range is dropped rather than failing the fence. The whole
* feature is decoration over a block that renders perfectly well without it, and the render this runs
* in is the editor's preview -- one that throws is one the editor saves as an empty page.
*
* A range written backwards (`8-5`) is read as the range it plainly means.
*/
function parseLineRanges(value) {
const ranges = []
for (const entry of (value ?? '').split(',')) {
const match = LINE_RANGE.exec(entry.trim())
if (!match) {
continue
}
const from = Number(match[1])
const to = match[2] === undefined ? from : Number(match[2])
ranges.push([Math.min(from, to), Math.max(from, to)])
}
return ranges
}
/** Whether `line` falls in any of them. */
function inRanges(ranges, line) {
return ranges.some(([from, to]) => line >= from && line <= to)
}
/**
* The number the gutter counts from, which is also what `linesHighlight` is written against: a snippet
* lifted out of a file at line 30 shows 30 against its first row, and the line an author wants marked
* is the one they can read off the gutter rather than one they have to count to.
*
* Anything that is not a whole non-negative number falls back to 1, the number a gutter counts from
* when nobody said otherwise.
*/
function parseLineStart(value) {
const parsed = Number.parseInt(value ?? '', 10)
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 1
}
/**
* The row layer of a code block: one empty `<span>` per line of code.
*
* Two things are drawn off these rows, and neither can be drawn off the code itself. The line numbers
* come from a counter, so the digits are never part of the text and a copied selection stays clean.
* And the wash behind a highlighted line is the row's own background: highlighting a line by wrapping
* it would mean splitting the highlighted HTML on its newlines and re-balancing whatever hljs left
* open across them -- a multi-line comment or string is one span -- where a row already lands on its
* line by geometry alone.
*
* Drawn for a block of more than one line, which is where a gutter is worth having, and for a
* single-line block that asked for a highlight. `aria-hidden`, because a screen reader is reading the
* code and these rows say nothing about it.
*/
function lineRows(lineCount, lineStart, highlights) {
const rows = []
for (let index = 0; index < lineCount; index++) {
rows.push(
inRanges(highlights, lineStart + index)
? '<span class="is-highlighted"></span>'
: '<span></span>'
)
}
return `<span aria-hidden="true" class="line-numbers-rows">${rows.join('')}</span>`
}
/**
* One fence, as the markup it is drawn as -- always rooted at a `<pre>`, whatever it holds.
*
* @param {string} str The code, as the author wrote it.
* @param {string} lang The first word of the info string.
* @param {object} attributes The rest of it, parsed -- see `parseFenceAttributes`. Ignored by the
* diagram branches, which are a source for something else to draw and have
* no gutter, no title bar and no lines to mark.
*/
function codeBlock(str, lang, attributes) {
if (lang === 'diagram') {
return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
}
if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is
`block-diagram` for mermaid, `block-plantuml` and `block-kroki` for the others and each
reads the text out of this `pre`. A fence on its own outside a block keeps the panel the
stylesheet gives it, which says "a diagram nobody has drawn" rather than pretending to be
a code sample.
*/
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
}
/*
`getLanguage` first, because `hljs.highlight` THROWS on a language it does not know --
`ignoreIllegals` only forgives illegal syntax within a language it does. markdown-it takes
the first word of a fence's info string as the language name, so a fence whose code starts
on the opening line (``` <!DOCTYPE rfc [) asks for a language called `<!DOCTYPE`, and the
throw took the entire render with it: an empty preview, and -- since the editor patches the
store with the result -- an empty render saved over the stored HTML.
Unknown language therefore falls back to plain code, and the fallback ESCAPES: `str` is the
author's raw source, and the unhighlighted branch used to interpolate it into the markup as
it stood. hljs escapes what it emits, so this only ever affected the unhighlighted path.
*/
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(str, { language: lang, ignoreIllegals: true })
: { value: escape(str) }
// -> `match` is null, not empty, when the code is a single line with no trailing newline
const lineCount = (highlighted.value.match(/\n/g) ?? []).length
const lineStart = parseLineStart(attributes.linesstart)
const highlights = parseLineRanges(attributes.lineshighlight)
/*
The gutter is for a block worth numbering; a one-line block is its own line number. A highlight
still needs its row, so the two are separate questions -- the class is what draws the digits, the
layer is what the wash is painted on.
*/
const numbered = lineCount > 1
const rows =
numbered || highlights.length > 0 ? lineRows(Math.max(lineCount, 1), lineStart, highlights) : ''
/*
Where the counter starts, as the value it is reset to -- one below the first line, since every row
increments before it draws. Left off entirely at the default, so that a block nobody has renumbered
carries no style attribute at all.
*/
const numbering = lineStart === 1 ? '' : ` style="--code-line-start: ${lineStart - 1}"`
// -> `lang` is escaped too: it is whatever the author typed after the backticks, and a quote
// in it would otherwise close the attribute and inject markup into the preview
return `<pre class="codeblock hljs${numbered ? ' line-numbers' : ''}"${numbering}><code class="language-${escape(lang)}">${highlighted.value}${rows}</code></pre>`
}
export class MarkdownRenderer {
constructor(config = {}) {
this.md = new MarkdownIt({
@ -213,47 +377,7 @@ export class MarkdownRenderer {
breaks: config.lineBreaks,
linkify: config.linkify,
typography: config.typographer,
quotes: quoteStyles[config.quotes] ?? quoteStyles.english,
highlight(str, lang) {
if (lang === 'diagram') {
return `<pre class="diagram">${Buffer.from(str, 'base64').toString()}</pre>`
} else if (['kroki', 'mermaid', 'plantuml'].includes(lang)) {
/*
Left as source, deliberately: a diagram is drawn by the block whose body it is
`block-diagram` for mermaid, `block-plantuml` and `block-kroki` for the others and each
reads the text out of this `pre`. A fence on its own outside a block keeps the panel the
stylesheet gives it, which says "a diagram nobody has drawn" rather than pretending to be
a code sample.
*/
return `<pre class="codeblock-${lang}"><code>${escape(str)}</code></pre>`
} else {
/*
`getLanguage` first, because `hljs.highlight` THROWS on a language it does not know --
`ignoreIllegals` only forgives illegal syntax within a language it does. markdown-it takes
the first word of a fence's info string as the language name, so a fence whose code starts
on the opening line (``` <!DOCTYPE rfc [) asks for a language called `<!DOCTYPE`, and the
throw took the entire render with it: an empty preview, and -- since the editor patches the
store with the result -- an empty render saved over the stored HTML.
Unknown language therefore falls back to plain code, and the fallback ESCAPES: `str` is the
author's raw source, and the unhighlighted branch used to interpolate it into the markup as
it stood. hljs escapes what it emits, so this only ever affected the unhighlighted path.
*/
const highlighted =
lang && hljs.getLanguage(lang)
? hljs.highlight(str, { language: lang, ignoreIllegals: true })
: { value: escape(str) }
// -> `match` is null, not empty, when the code is a single line with no trailing newline
const lineCount = (highlighted.value.match(/\n/g) ?? []).length
const lineNums =
lineCount > 1
? `<span aria-hidden="true" class="line-numbers-rows">${'<span></span>'.repeat(lineCount)}</span>`
: ''
// -> `lang` is escaped too: it is whatever the author typed after the backticks, and a quote
// in it would otherwise close the attribute and inject markup into the preview
return `<pre class="codeblock hljs ${lineCount > 1 && 'line-numbers'}"><code class="language-${escape(lang ?? '')}">${highlighted.value}${lineNums}</code></pre>`
}
}
quotes: quoteStyles[config.quotes] ?? quoteStyles.english
})
/*
MDC's INLINE component syntax is off, and deliberately: `:name` is how it writes one, which is
@ -492,26 +616,70 @@ export class MarkdownRenderer {
this.md.renderer.rules.blockquote_open = injectLineNumbers
/*
A fence carries its line in the markup rather than on the token, because the `highlight` option
above returns the whole `<pre>` and markdown-it hands a highlighted fence straight back: the
token's attributes are rendered by nobody, so `attrSet` would reach nothing.
CODE BLOCKS
-----------
Rendered here rather than through markdown-it's `highlight` option, which is where the hljs call
used to live, because the whole of the markup a fence produces is decided by its info string and
`highlight` is handed only the language off the front of it. It also cannot produce a wrapper: the
default fence rule takes the return value whole ONLY if it starts with `<pre`, and wraps it in a
`<pre><code>` of its own otherwise -- so the title bar, which has to sit outside the scrolling
panel, has nowhere to go from in there.
Worth the string surgery for the case this whole mechanism exists for. A tabset whose panels each
hold one long code sample -- per language, per platform -- is the commonest tabset there is, and
it is exactly the one where the caret has no anchor of its own to be scrolled to.
Nothing is lost by owning the rule instead. The token's own attributes were already going
unrendered for exactly the same reason, so `markdown-it-attrs` on a fence has never done anything.
The attribute only, without the `line` class the tokens above join: the class would have to go on
a tag that already carries one, and two `class` attributes on the same tag is not markup.
Three optional attributes, after the language:
```yaml title="Some title here" linesStart="3" linesHighlight="1,3,5-8"
`title` becomes a header bar above the block; `linesStart` renumbers the gutter; `linesHighlight`
washes the rows it names. See `codeBlock` for the last two and `parseFenceAttributes` for the
shape of all three. A diagram fence ignores them -- it is a source for something else to draw.
The line the fence sits on is stamped into the markup rather than onto the token, since the token
is not what renders it. It is worth the string surgery for the case the preview's scroll sync
exists for: a tabset whose panels each hold one long code sample -- per language, per platform --
is the commonest tabset there is, and exactly the one where the caret has no anchor of its own.
The attribute only, without the `line` class the tokens above join, because it would have to go on
a tag that already carries a class and two `class` attributes on one tag is not markup.
*/
const renderFence =
this.md.renderer.rules.fence ??
((tokens, idx, options, env, slf) => slf.renderToken(tokens, idx, options, env, slf))
this.md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const html = renderFence(tokens, idx, options, env, slf)
const map = tokens[idx].map
// -> Every branch of `highlight` opens with `<pre`, and so does the wrapper markdown-it puts
// around a fence it did not highlight
return map ? html.replace(/^<pre/, `<pre data-line="${map[0] + 1}"`) : html
this.md.renderer.rules.fence = (tokens, idx) => {
const token = tokens[idx]
const info = (token.info ?? '').trim()
const boundary = info.search(/\s/)
/*
Unescaped value by value rather than over the info string as a whole -- which is what the
default rule does, and what it can afford to do because it never looks inside. The string is
source text, so `\\"` in it is an author writing a quote, and unescaping first would have that
quote close the attribute it was written into: `title="Say \\"hi\\""` came out as `Say`.
*/
const unescape = (value) => this.md.utils.unescapeAll(value)
const lang = unescape(boundary < 0 ? info : info.slice(0, boundary))
const attributes = parseFenceAttributes(
boundary < 0 ? '' : info.slice(boundary + 1),
unescape
)
const html = codeBlock(token.content, lang, attributes)
const line = token.map ? ` data-line="${token.map[0] + 1}"` : ''
const title = (attributes.title ?? '').trim()
/*
The bar is a sibling of the `<pre>` and not a child of it, because the panel is the thing that
scrolls: a header inside it would slide sideways with the code and be indented by the gutter's
padding. So a titled block is a box holding both, and the box is what carries the border, the
radius and the line the editor scrolls to.
`hljs` on that box as well as on the panel, which is what keeps the two the same colour: the code
theme an administrator picks is injected as bare `.hljs` rules setting a background and an ink,
so the box has to be able to take them or the bar would stay light over a dark panel.
*/
if (title) {
return `<div class="codeblock-titled hljs"${line}><div class="codeblock-title">${escape(title)}</div>${html}</div>\n`
}
// -> Every branch of `codeBlock` opens with `<pre`
return `${html.replace(/^<pre/, `<pre${line}`)}\n`
}
// --------------------------------

Loading…
Cancel
Save