diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index 965ad2606..426f9844f 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -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 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 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 `
` 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,
diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js
index 0f34863ee..ae2a0e5f6 100644
--- a/frontend/src/renderers/markdown.js
+++ b/frontend/src/renderers/markdown.js
@@ -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 `` 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)
+        ? ''
+        : ''
+    )
+  }
+  return ``
+}
+
+/**
+ * One fence, as the markup it is drawn as -- always rooted at a `
`, 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 `
${Buffer.from(str, 'base64').toString()}
` + } + 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 `
${escape(str)}
` + } + + /* + `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 (``` `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 `
${highlighted.value}${rows}
` +} + 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 `
${Buffer.from(str, 'base64').toString()}
` - } 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 `
${escape(str)}
` - } 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 (``` `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 - ? `` - : '' - // -> `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 `
${highlighted.value}${lineNums}
` - } - } + 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 `
` 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 `` 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 ` {
+      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 `
` 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 `
${escape(title)}
${html}
\n` + } + // -> Every branch of `codeBlock` opens with `