Docker 포럼에서 줄 번호를 줄바꿈(wrapping)과 스크롤링에 모두 적용하는 실험을 하고 있습니다. 아직 라이브로 배포되지는 않았지만, 혹시 의견을 주실 분이 계신지 궁금합니다. 먼저 몇 가지 스크린샷을 보여드리겠습니다:
줄바꿈된 콘텐츠
위 이미지에서 줄 번호는 한 줄이 끝나고 다른 줄이 시작되는 위치를 식별하는 데 도움이 됩니다. 물론 이를 렌더링하는 다른 방법들도 있지만, 이 주제의 핵심은 줄 번호입니다. 
스크롤된 콘텐츠
숨겨진 콘텐츠가 있는 쪽의 패딩을 제한하여, 스크롤하면 더 많은 내용이 보임을 나타내는 방식:
또는 가로 방향으로 콘텐츠가 숨겨진 위치를 나타내기 위해 그림자를 사용하는 방식:
받은 피드백 중에는 작은 그림자가 스크롤바와 혼동될 수 있다는 의견이 있어, 다른 스타일링을 적용하는 것이 좋겠다고 하였습니다.
클라이언트 측에서 줄 번호 추가하기
가로 스크롤링을 사용할 때, 콘텐츠의 각 줄은 화면에서 한 줄을 차지합니다. 따라서 이론적으로는 JavaScript를 사용하여 줄 번호 범위를 가진 세로 <div>를 삽입하고 이를 스크롤되는 콘텐츠 옆에 배치할 수 있습니다. 하지만 줄바꿈(wrapping)을 사용할 경우, 하나의 줄 번호가 여러 줄의 콘텐츠에 적용될 수 있으며, 이 경우 CSS span::before와 content: counter(...)를 함께 사용하는 것이 더 쉬운 것 같습니다. 여기서는 이 방식을 사용하고 있습니다.
::before를 사용하려면, 사전 형식화된 코드 블록의 각 줄에 <span>으로 감싸는 등 부모 요소가 먼저 필요합니다. 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});
다음 내용을 모두 /admin/customize/themes/의 해당 컴포넌트 "Common, Head"에 추가하세요:
<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(' ');
// 줄에 이벤트 리스너가 없다고 가정하고,
// 뒤쪽의 빈 줄을 숨기기 위해 trim을 적용합니다
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 스타일링
다음 내용을 /admin/customize/themes/의 해당 컴포넌트 "Common, CSS"에 추가하세요:
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;
// `code.less`의 #f5f5f5
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);
}
}
}
개발
개발을 위해 다음 코드를 사용하여, ?avbPreview와 같은 URL 쿼리 파라미터를 설정하여 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('===== Rewriting; source\n' + elem.innerHTML.trim());
console.log('===== Rewriting; result\n' + elem.innerHTML.trim().replace(split, `<span class="${lineClass}">$1</span>`));
}
// 줄에 이벤트 리스너가 없다고 가정하고,
// 뒤쪽의 빈 줄을 숨기기 위해 trim을 적용합니다
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>