Я экспериментировал с нумерацией строк для переноса и прокрутки на форуме Docker. Эта функция ещё не работает в продакшене, но я хотел бы узнать ваше мнение. Сначала несколько скриншотов:
Переносимый контент
Выше нумерация строк помогает понять, где заканчивается одна строка и начинается другая. Конечно, есть и другие способы отображения, но эта тема посвящена именно нумерации строк. 
Прокручиваемый контент
С ограниченным отступом сбоку (или с обеих сторон), где контент скрыт, чтобы показать, что прокрутка раскроет больше:
Или с тенями, чтобы указать, где контент скрыт по горизонтали:
Некоторые отзывы показали, что маленькие тени могут быть ошибочно приняты за полосы прокрутки, поэтому, возможно, стоит использовать другой стиль.
Добавление нумерации строк на стороне клиента
При использовании горизонтальной прокрутки каждая строка контента занимает одну строку на экране. Таким образом, теоретически можно использовать JavaScript для вставки вертикального <div> с диапазоном номеров строк и позиционировать его рядом с прокручиваемым контентом. Но при переносе один номер строки может относиться к нескольким строкам контента, для чего использование CSS span::before вместе с content: counter(...) кажется проще. Именно это и используется здесь.
Чтобы использовать ::before, каждая строка в предварительно отформатированном блоке кода сначала должна иметь родительский элемент, например, быть обернутой в <span> с помощью api.decorateCookedElement. Это требует, чтобы api.decorateCookedElement выполнялся до highlight.js, так как иногда highlight.js не закрывает теги в той же строке, где их открыл. Например, когда он считает, что это Mustache/Handlebars:
docker image ls --format '{{slice (printf "%s:%s" .Repository .Tag) 0 11}}'
В результате вышеуказанный код закрывающий тег </span> появляется на следующей строке:
<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>
Конечно, это может быть временной ошибкой, о которой мне, вероятно, следует сообщить. Но в любом случае лучше перестраховаться. И хорошая новость: сегодня api.decorateCookedElement(...) действительно выполняется до highlight.js.
Следующий скрипт JavaScript оборачивает каждую строку в <span> и добавляет CSS-класс lines-scroll или lines-wrap, чтобы указать желаемый стиль (можно даже позволить пользователю переключать их). Также добавляется класс lines-shadow для включения теней прокрутки. Кроме того, к родительским элементам <pre> и <code> добавляются некоторые CSS-классы для поддержки стилизации и чтобы гарантировать, что элементы декорируются только один раз.
Декоратор темы JavaScript
Ниже также включается нумерация строк и другая стилизация в предпросмотре редактора. Измените последнюю строку, добавив onlyStream: true, чтобы отключить это:
api.decorateCookedElement(decorateLines, {id: 'decorate-pre-code-lines', onlyStream: true});
Добавьте всё следующее в раздел «Common, Head» вашего компонента в /admin/customize/themes/:
<script type="text/discourse-plugin" version="0.8">
const decorateLines = (post) => {
try {
// Комбинации 'number', 'wrap', 'scroll' и/или '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 использует <aside> для цитат других постов форума
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(' ');
// Предполагаем, что на строке нет слушателей событий,
// и обрезаем, чтобы скрыть завершающие пустые строки
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
Ниже представлена поддержка переноса с нумерацией строк и без неё, а также прокрутки с нумерацией строк и/или тенями и без них. Также отключается нумерация строк, если строка всего одна.
Это не добавляет номера строк к цитатам других постов, так как, если цитата частичная, нумерация всё равно начнётся с 1. Чтобы добавить нумерацию к цитатам, удалите :not(.lines-in-quote) из строки ниже (она встречается несколько раз в SCSS):
&.lines-number:not(.lines-count-1):not(.lines-in-quote) {
Выше также находится место, где отключается нумерация для однострочных блоков кода.
Стили темы Sass
Добавьте это в раздел «Common, CSS» вашего компонента в /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);
// Отступы для внешнего PRE:
// - полосы прокрутки не перекрывают CODE, но находятся близко к внешним краям
// - текст близко к краям, когда есть скрытый контент
// - аналогично для теней
padding: 0;
// #f5f5f5 в `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 {
// Минимальная высота для получения границы и фона для пустых строк (Firefox
// нуждается в этом только для ::before, но Chrome — и для span)
min-height: 1.5em;
&::before {
position: absolute;
left: 0;
}
}
&.lines-shadow {
background-image:
// Первые два слоя прокручиваются вместе с контентом, поэтому появляются
// перед тенями, когда прокрутка невозможна. Они
// соответствуют фоновому цвету для первых 25%, а затем градиент
// до полной прозрачности (предполагая, что слои в 4 раза шире теней).
linear-gradient(to right, var(--lines-bgcolor) 25%, transparent),
linear-gradient(to left, var(--lines-bgcolor) 25%, transparent),
// А за ними две тени на фиксированных позициях, чтобы указать
// прокручиваемый скрытый контент, если он не перекрыт.
linear-gradient(to right, var(--lines-shadow-color), transparent),
linear-gradient(to left, var(--lines-shadow-color), transparent);
// Или:
// 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:
// Установите слой в 4 раза шире фактической тени, чтобы соответствовать
// точке остановки 25% в градиенте слоя
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) {
// Если номера строк видны, тень нужно сдвинуть
--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);
}
}
}
Разработка
Для разработки я использовал следующий код, позволяющий устанавливать параметры URL-запроса, такие как ?avbPreview, для включения хука декоратора JavaScript, а также такие параметры, как ?avbPreview&avbClasses=number,scroll, для тестирования нумерации строк с прокруткой, но без теней. Поскольку весь CSS изолирован, это не должно влиять на других пользователей во время тестирования.
Версия для разработки/отладки
<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 использует <aside> для цитат других постов форума
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('===== Переписывание; источник\n' + elem.innerHTML.trim());
console.log('===== Переписывание; результат\n' + elem.innerHTML.trim().replace(split, `<span class="${lineClass}">$1</span>`));
}
// Предполагаем, что на строке нет слушателей событий,
// и обрезаем, чтобы скрыть завершающие пустые строки
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>