Is it possible to show line numbers in code block?

Hi, I use markdown (mostly, this is Typora software to create and export to PDF) to create documentation. I use the following code to show line numbers in code blocks:

{.language .numberLines}

Example code:

изображение

result:

изображение

Also I use a forum to publish announcements and I would like to share some code in forum posts. But seems this trick doesn’t work in Discourse :(. Is there any way to show line numbers in code blocks of the forum?

8 Likes

We use highlight.js for our codeblocks, and I don’t believe they support line numbers… there’s some rationale in their documentation here: Line numbers — highlight.js 11.2.0 documentation

They can definitely be useful for reference, e.g., “you can change the font size on line 3”… so if we’re ever looking to move away from highlight.js, that would be one thing to consider.

10 Likes

Hi Kris, thank you very much for the reference!

I tried to apply a plugin via theme customization but without success. Probably somebody who knows well how to use custom JS/CSS in theme could help :slight_smile: .

1 Like

Yes. Also, on installations where lines actually break (rather than triggering horizontal scroll), seeing line numbers make it clear long lines have wrapped.

I’ve been experimenting with line numbers for both wrapping and scrolling on the Docker forum. This is not live yet, but I’d like to know if someone has any comments. So, some screenshots first:

Wrapped content

Above, line numbers help identifying when one line stops and another starts. Of course, there are other ways to render that, but this topic is about line numbers. :nerd_face:

Scrolled content

With limited padding on the side(s) where content is hidden, to indicate that scrolling will reveal more:

Or with shadows to indicate where content is hidden horizontally:

Some feedback I got showed that the small shadows may be confused with scrollbars, so different styling may be great.

Adding the line numbers client-side

When using horizontal scrolling, each line of content will use one line on the screen. So, in theory one could use JavaScript to inject a vertical <div> with a range of line numbers, and position that next to the scrolling content. But when wrapping, a single line number may apply to multiple lines of content, for which using CSS span::before along with content: counter(...) seems easier. That is what is used here.

To use ::before, each line in the preformatted code block first needs some parent element, like being wrapped in a <span> using api.decorateCookedElement. This needs the latter to run before highlight.js runs, as sometimes highlight.js does not close tags on the same line it opened them. For example, when it thinks something is Mustache/Handlebars:

docker image ls --format '{{slice (printf "%s:%s" .Repository .Tag) 0 11}}'

The above yields the closing </span> on the next line:

<code class="lang-handlebars hljs"><span class="xml">docker image ls --format '</span><span class="hljs-template-variable">{{<span class="hljs-name">slice</span> (<span class="hljs-name">printf</span> <span class="hljs-string">"%s:%s"</span> .Repository .Tag) <span class="hljs-number">0</span> <span class="hljs-number">11</span>}}</span><span class="xml">'
</span></code>

Of course, this may be a temporary bug which I probably should report. But regardless, better be safe. And the good news: it seems, today, api.decorateCookedElement(...) indeed runs prior to highlight.js.

The next JavaScript wraps each line in a <span> and adds CSS class lines-scroll or lines-wrap to indicate what style is wanted (one could even let the user toggle that). It also adds lines-shadow to enable the scroll shadows. And it adds some CSS classes to the parent <pre> and <code> to support styling, and to ensure things are only ever decorated once.

Theme JavaScript decorator

The following also enables line numbers and the other styling in the editor preview. Change the last line to include onlyStream: true to disable that:

api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines', onlyStream: true});

Add all of the following to your component’s “Common, Head” in /admin/customize/themes/:

<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
  try {
    // Combinations of 'number', 'wrap', 'scroll' and/or 'shadow'
    const classes = ['decorator', 'number', 'scroll', 'shadow'].map(c => `lines-${c}`);

    const split = /^(.*)$/mg;
    const elems = post.querySelectorAll('pre code:not(.lines-decorator)');
    elems.forEach(elem => {
      const count = elem.innerHTML.trim().match(split).length;

      // Discourse uses `<aside>` for quotes of other forum posts
      const quote = elem.closest('aside') ? ['lines-in-quote'] : []

      elem.parentElement.classList.add(`lines-count-${count}`, ...classes, ...quote);
      elem.classList.add(`lines-count-${count}`, ...classes, ...quote);
      const lineClass = ['lines-line', ...quote].join(' ');

      // Assume we don't have any event listeners on the line,
      // and trim to hide trailing blank lines
      elem.innerHTML = elem.innerHTML.trim().replace(/^(.*)$/mg, `<span class="${lineClass}">$1</span>`);
    });
  } catch (e) {
    console.error(e);
  }
}

api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines'});
</script>

Sass SCSS styling

The following supports wrapping with and without line numbering, and scrolling with or without line numbering and/or shadows. It also suppresses line numbering when there is just a single line.

This does not add line numbers to quotes of other posts, as if such quote is just a partial quote, it will still start numbering with 1. To add numbering to quotes, remove the :not(.lines-in-quote) from the line below (which occurs multiple times in the SCSS):

&.lines-number:not(.lines-count-1):not(.lines-in-quote) {

The above is also where the numbering for single-line code blocks is being suppressed.

Theme Sass styling

Add this to your component’s “Common, CSS” in /admin/customize/themes/:

pre.lines-decorator code.lines-decorator span.lines-line {
  &::before {
    box-sizing: content-box;
  }
}

pre.lines-decorator {
  --lines-bgcolor: var(--hljs-bg);
  --lines-number-width: 2em;
  --lines-number-padding-to-right-border: .5em;
  --lines-shadow-width: .8em;
  --lines-shadow-color: rgba(0, 0, 0, .15);
  // No padding for outer PRE:
  // - scrollbars not covering CODE but close to outermost edges
  // - text close to the edges when there is hidden content
  // - likewise for the shadows
  padding: 0;
  // #f5f5f5 in `code.less`
  background-color: var(--lines-bgcolor);
}

code.lines-decorator {
  padding: 1.5em;

  &.lines-wrap {
    white-space: pre-wrap;

    &:not(.lines-count-1) {
      span.lines-line {
        display: inline-block;
        position: relative;
        padding-left: 3em;
      }
    }
  }

  &.lines-scroll {
    white-space: pre;

    span.lines-line {
      padding-right: 1.5em;

      &::before {
        position: sticky;
        left: 0;
      }
    }
  }

  &.lines-wrap span.lines-line {
    // Minimum height to get border and background for empty lines (Firefox
    // only needs this for the ::before, but Chrome also for the span)
    min-height: 1.5em;

    &::before {
      position: absolute;
      left: 0;
    }
  }

  &.lines-shadow {
    background-image:
      // First two covers that scroll with the content, hence appearing
      // in front of the shadows when no scrolling is possible. These
      // match the background color for the first 25%, and next a gradient
      // to full transparency (hence assuming the covers are 4 times the
      // width of the shadows).
      linear-gradient(to right, var(--lines-bgcolor) 25%, transparent),
      linear-gradient(to left, var(--lines-bgcolor) 25%, transparent),
      // And behind that two shadows at fixed positions, to indicate
      // scrollable, hidden content if not covered.
      linear-gradient(to right, var(--lines-shadow-color), transparent),
      linear-gradient(to left, var(--lines-shadow-color), transparent);
      // Or:
      // radial-gradient(farthest-side at 0px 50%, var(--lines-shadow-color), transparent),
      // radial-gradient(farthest-side at 100% 50%, var(--lines-shadow-color), transparent);

    background-size:
      // Set the cover to 4 times the width of the actual shadow, to match
      // the 25% stop in the gradient of the cover
      calc(4 * var(--lines-shadow-width)) 100%,
      calc(4 * var(--lines-shadow-width)) 100%,
      var(--lines-shadow-width) 100%,
      var(--lines-shadow-width) 100%;

    --shadow-left: 0;

    &.lines-number:not(.lines-count-1):not(.lines-in-quote) {
      // If line numbers are visible, we need to move the shadow
      --shadow-left: calc(var(--lines-number-width) + var(--lines-number-padding-to-right-border));
    }

    background-position: var(--shadow-left) center, right center, var(--shadow-left) center, right center;

    background-attachment: local, local, scroll, scroll;
    background-repeat: no-repeat;
  }

  &.lines-number:not(.lines-count-1):not(.lines-in-quote) {
    padding-left: 0;
    counter-reset: line-numbering;

    span.lines-line::before {
      display: inline-block;
      content: counter(line-numbering);
      counter-increment: line-numbering;
      border-right: 1px solid #ccc;
      width: var(--lines-number-width);
      padding-right: var(--lines-number-padding-to-right-border);
      margin-right: .5em;
      height: 100%;
      text-align: right;
      font-family: var(--font-family);
      color: var(--primary-medium);
      background-color: var(--lines-bgcolor);
    }
  }
}

Development

For development I used the next code, allowing to set URL query parameters such as ?avbPreview to enable the JavaScript decorator hook, and things like ?avbPreview&avbClasses=number,scroll to test line numbers with scrolling but no shadows. As all CSS is scoped, this should not affect other users during testing.

Development/debug version
<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
  try {
    const params = new URL(document.location.href).searchParams;
    const preview = params.has('avbPreview');
    if (!preview) {
      return;
    }
    const classes = ['decorator'].concat(
      (params.get('avbClasses') || 'number,scroll,shadow').split(',')).map(c => `lines-${c}`);
    const debug = params.has('avbDebug');
    const split = /^(.*)$/mg;
    const elems = post.querySelectorAll('pre code:not(.lines-decorator)');
    elems.forEach(elem => {
      const count = elem.innerHTML.trim().match(split).length;
      // Discourse uses `<aside>` for quotes of other forum posts
      const quote = elem.closest('aside') ? ['lines-in-quote'] : []
      elem.parentElement.classList.add(`lines-count-${count}`, ...classes, ...quote);
      elem.classList.add(`lines-count-${count}`, ...classes, ...quote);
      const lineClass = ['lines-line', ...quote].join(' ');

      if (debug) {
        console.log('===== Rewriting; source\n' + elem.innerHTML.trim());
        console.log('===== Rewriting; result\n' + elem.innerHTML.trim().replace(split, `<span class="${lineClass}">$1</span>`));
      }

      // Assume we don't have any event listeners on the line,
      // and trim to hide trailing blank lines
      elem.innerHTML = elem.innerHTML.trim().replace(/^(.*)$/mg, `<span class="${lineClass}">$1</span>`);
    });
  } catch (e) {
    console.error(e);
  }
}

api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines'});
</script>
5 Likes

Fantastic work was done! :clap: :clap: :clap:

Many thanks, Arjan! Everything works both in default light and dark theme.


Share my configuration:
css.txt (5.7 KB)
head.txt (1.2 KB)

CSS customizations:

// Inline code colors
// Green thumbs-up
// Green assignee icon
// Grey or blue headers
// Center images
// Word wrap in code block + full content
// Fix huge topic margins
// Gitlab theme component font color fix (darker)
// Original <kbd> over  Gitlab Markdown flavors theme component
// Line numbering in code blocks by @Arjan

Components installed:

https://github.com/discourse/discourse-search-banner
https://github.com/keegangeorge/discourse-markdown-flavors.git
https://github.com/pacharanero/discourse-topic-width-desktop.git

Nice, that was quick! I guess you’ll want to use --lines-bgcolor: white; to get rid of some of the grays. :sunglasses:

1 Like

I’ve edited my post for a fix in the SCSS, to properly apply the shadow covers. This now allows for setting --lines-shadow-width to any size while still properly fading in/fading out.

It also includes an example for radial-gradient now. But that won’t change a lot for tall code blocks:

1 Like

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.