Conventional wisdom states that new bloggers should focus on content instead of trying to build their own platforms, but for a dev blogger being able to dictate how a block of code looks is significant for quality of life. If I’m going to spend a lot of time discussing code, then it helps to be able to cite line numbers and sources to give context when code is copied from somewhere. It helps to be able to distinguish between in-code comments and comments used to discuss code for blogging purposes. It helps to label filenames and languages without again polluting code comments. And all of these issues are why platforms such as Medium are a no-go for me, besides the annoying paywall. There just isn’t the customization I desire.
Before switching to Shiki, I wrote another piece of code to try to get highlight.js to output the format I wanted. A key problem was regarding code with multiple syntaxes in one file, such as with Astro or React components. highlight.js struggled here, and I had to add some Javascript to break code into sub-blocks so the library could interpret them as different languages. I switched to Shiki because it doesn’t have this problem. But Shiki also doesn’t support line numbering or a lot of other things out of the box.
The other reason for using Shiki is that it is shipped with Astro by default, though this is both a pro and a con. The disadvantage is that the integration forces customizations to be applied after the fact. For example, I wouldn’t be able to apply CSS variables at the time that a code block is created, before the syntax highlighting starts. This makes solutions that rely purely on CSS such as this impractical, unless I resign to hacky methods such as <style> tags right before a code fence in Markdown.
The plan
Let’s start with a vision, a proposal, for how lines of code should generally behave. Below is a block demonstrating what it usually looks like when long lines wrap around.
In the following block, the whitespace preceding each line is in its own inline-block span, causing the wrap-around the start at a more appropriate indent.
This version is what we’re making. What does this have to do with line numbers? Since we’re already breaking up the line, might as well do this at once.
Shiki uses the Hypertext Abstract Syntax Tree (hast) to represent HTML elements. Each line is represented by a <span> element, and beneath that are a number of spans for each time the code changes color. These “token” spans each have exactly one text element as child. In other words,
Shiki Transformers were introduced as a feature some time in the past year to allow users to hook into different parts of the syntax highlighting process and inject code to alter the output. For this task, we’ll be hooking into when each line is created. Note that to keep things succinct, we’ll leave out some of the type and error checking.
/src/shiki/transforms/linenumbers.tstypescript
1importtype{ShikiTransformer}from'shiki';2importtype{Element,Text}from'hast';3Helper function to reduce number of lines it takes to create an element.4constcreateElement=(tagName:string):Element=>{5return{type:'element',tagName,properties:{},children:[]};6}78consttransformer:ShikiTransformer={9line(line:Element,index:number){We leverage the fact that the whitespace at beginnings of lines are always attached to the first color of code.10constfirstTextSpan=line.children[0]asElement;11consttextNode=firstTextSpan.children[0]asText;12consttext:string=textNode.value;Match all whitespace that starts from the beginning of the text.13constmatch:string=text.match(/^\s*/g)![0];Split the whitespace from the element.14constsplitSpan=createElement('span');15splitSpan.children=[{type:'text',value:match}];16firstTextSpan.children=[{type:'text',value:text.slice(match.length)}];Create divs for the different line parts.17constlineNumberDiv=createElement('div');18lineNumberDiv.properties['data-line-number']='';19lineNumberDiv.children=[{type:'text',value:index.toString()}];2021constlineWhitespaceDiv=createElement('div');22lineWhitespaceDiv.properties['data-line-whitespace']='';23lineWhitespaceDiv.children=[splitSpan];2425constlineCodeDiv=createElement('div');26lineCodeDiv.properties['data-line-code']='';27lineCodeDiv.children=line.children;Place these divs under the line.28line.properties['data-line']='';29line.children=[lineNumberDiv,lineWhitespaceDiv,lineCodeDiv];30returnline;31},32code(code){<code> block wraps all the lines. Use digits to set styling.33constnumLines=code.children.length;34code.properties['data-line-number-digits']=numLines.toString();35returncode;36}37};3839exportdefaulttransformer;
Styling
Complete styling might be too much in a post, but I’ll include some essentials. Astro changes class on the outer <pre> tag from shiki-code to astro-code, so I’ll use that.
css
1.astro-code{2&code{Extend every line to end even for short lines.3display:grid;Give more spacing depending on line numbering digits.4&[data-line-number-digits="1"],5&[data-line-number-digits="2"]{6width:1.5rem;7}8&[data-line-number-digits="3"]{9width:2.25rem;10}11&[data-line-number-digits="4"]{12width:3rem;13}14}1516&[data-line]{Make sure to wrap long lines.17display:flex;18white-space:pre-wrap;19overflow-x:hidden;20justify-content:flex-start;21align-items:start;2223&[data-line-number]{These make the number appear in the upper right of a line (in case of a wrapped line).24display:inline-block;25height:100%;26text-align:right;27vertical-align:top;Give a light gray color and line separating the numbering. Realistically, use @media for dark themes.28padding-right:0.5rem;29color:#bbb;30border-right:1pxsolid#bbb;Prevent line numbers from shifting when browser width is adjusted.31flex-grow:0;32flex-shrink:0;33}3435&[data-line-whitespace]{Prevent whitespace from collapsing on small screens.36display:inline-block;37vertical-align:top;38height:100%;39flex-shrink:0;40}4142&[data-line-code]{43display:inline-block;44vertical-align:top;45height:100%;Wrap long lines with a hanging indent.46text-wrap:wrap;47text-indent:4chhanging;48}49}50}