Skip to content

Commit

Permalink
Improve code snippets (withastro#1116)
Browse files Browse the repository at this point in the history
* Add code snippet wrapper

* Add tabs and file name extraction

* Add terminal CSS style

* Slightly improve terminal style

* Fix ESLint error

* Tweak tab & highlight colors

* More refinements

* Fix edge case, allow opt-out from name extraction

* Update src/components/CodeSnippet.astro

Co-authored-by: Chris Swithinbank <[email protected]>

* Fix color contrast based on a11y testing

* Fix terminal dot display issue in mobile FF

Co-authored-by: Yan Thomas <[email protected]>
  • Loading branch information
hippotastic and yanthomasdev authored Jul 27, 2022
1 parent f9d44e7 commit 9e923ea
Show file tree
Hide file tree
Showing 7 changed files with 423 additions and 32 deletions.
2 changes: 2 additions & 0 deletions astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { h } from 'hastscript';

import { tokens, foregroundPrimary, backgroundPrimary } from './syntax-highlighting-theme';
import { astroAsides } from './integrations/astro-asides';
import { astroCodeSnippets } from './integrations/astro-code-snippets';
import { remarkFallbackLang } from './plugins/remark-fallback-lang';

import { escapeHtml } from './src/util';
Expand Down Expand Up @@ -49,6 +50,7 @@ export default defineConfig({
},
}),
astroAsides(),
astroCodeSnippets(),
],
vite: {
plugins: [vitePreact()],
Expand Down
259 changes: 259 additions & 0 deletions integrations/astro-code-snippets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import type { AstroIntegration } from 'astro';
import type { BlockContent, Root, Parent } from 'mdast';
import type { Plugin, Transformer } from 'unified';
import type { BuildVisitor } from 'unist-util-visit/complex-types';
import rangeParser from 'parse-numeric-range';
import { visit } from 'unist-util-visit';

const CodeSnippetTagname = 'AutoImportedCodeSnippet';
const LanguageGroups = {
code: ['astro', 'cjs', 'htm', 'html', 'js', 'jsx', 'mjs', 'svelte', 'ts', 'tsx', 'vue'],
data: ['env', 'json', 'yaml', 'yml'],
styles: ['css', 'less', 'sass', 'scss', 'styl', 'stylus'],
textContent: ['markdown', 'md', 'mdx'],
};
const FileNameCommentRegExp = new RegExp(
[
// Start of line
`^`,
// Optional whitespace
`\\s*`,
// Mandatory comment start (`//`, `#` or `<!--`)
`(?://|#|<!--)`,
// Optional whitespace
`\\s*`,
// Optional sequence of characters, followed by a Japanese colon or a regular colon (`:`),
// but not by `://`. Matches strings like `File name:`, but not `https://example.com/test.md`.
`(?:(.*?)(?:\\uff1a|:(?!//)))?`,
// Optional whitespace
`\\s*`,
// Optional sequence of characters allowed in file paths
`([\\w./[\\]\\\\-]*`,
// Mandatory dot and supported file extension
`\\.(?:${Object.values(LanguageGroups).flat().sort().join('|')}))`,
// Optional whitespace
`\\s*`,
// Optional HTML comment end (`-->`)
`(?:-->)?`,
// Optional whitespace
`\\s*`,
// End of line
`$`,
].join('')
);

export interface CodeSnippetWrapper extends Parent {
type: 'codeSnippetWrapper';
children: BlockContent[];
}

declare module 'mdast' {
interface BlockContentMap {
codeSnippetWrapper: CodeSnippetWrapper;
}
}

export function remarkCodeSnippets(): Plugin<[], Root> {
const visitor: BuildVisitor<Root, 'code'> = (code, index, parent) => {
if (index === null || parent === null) return;

// Parse optional meta information after the opening code fence,
// trying to get a meta title and an array of highlighted lines
const { title: metaTitle, highlightedLines } = parseMeta(code.meta || '');
let title = metaTitle;

// Preprocess the code
const { preprocessedCode, extractedFileName } = preprocessCode(
code.value,
code.lang || '',
// Only try to extract a file name from the code if no meta title was found above
title === undefined
);
code.value = preprocessedCode;
if (extractedFileName) {
title = extractedFileName;
}

// If there was no title in the meta information or in the code, check if the previous
// Markdown paragraph contains a file name that we can use as a title
if (title === undefined && index > 0) {
// Check the previous node to see if it matches our requirements
const prev = parent.children[index - 1];
const strongContent =
// The previous node must be a paragraph...
prev.type === 'paragraph' &&
// ...it must contain exactly one child with strong formatting...
prev.children.length === 1 &&
prev.children[0].type === 'strong' &&
// ...this child must also contain exactly one child
prev.children[0].children.length === 1 &&
// ...which is the result of this expression
prev.children[0].children[0];

// Require the strong content to be either raw text or inline code and retrieve its value
const prevParaStrongTextValue = strongContent && strongContent.type === 'text' && strongContent.value;
const prevParaStrongCodeValue = strongContent && strongContent.type === 'inlineCode' && strongContent.value;
const potentialFileName = prevParaStrongTextValue || prevParaStrongCodeValue;

// Check if it's a file name
const matches = potentialFileName && FileNameCommentRegExp.exec(`// ${potentialFileName}`);
if (matches) {
// Yes, store the file name and replace the paragraph with an empty node
title = matches[2];
parent.children[index - 1] = {
type: 'html',
value: '',
};
}
}

const codeSnippetWrapper: CodeSnippetWrapper = {
type: 'codeSnippetWrapper',
data: {
hName: CodeSnippetTagname,
hProperties: {
lang: code.lang,
title,
highlightedLines,
},
},
children: [code],
};

parent.children.splice(index, 1, codeSnippetWrapper);
};

const transformer: Transformer<Root> = (tree) => {
visit(tree, 'code', visitor);
};

return function attacher() {
return transformer;
};
}

/**
* Parses the given meta information string and returns contained supported properties.
*
* Meta information is the string after the opening code fence and language name.
*
* In the following example, it's `title="example.js" {1,3-4}`:
*
* ````md
* ```js title="example.js" {1,3-4}
* // Line 1, this will be highlighted
* // Line 2
* // Line 3, this will be highlighted
* // Line 4, this will be highlighted
* ```
* ````
*/
function parseMeta(meta: string) {
// Try to find the meta property `title="..."` and extract its value
const titleMatch = meta.match(/title="(.*)"/);
const title = titleMatch?.[1];

// Remove the matched string (if any) from meta
meta = (titleMatch?.[0] && meta.replace(titleMatch[0], '')) || meta;

// Parse numeric ranges like `{4-5,10}` into an array of highlighted lines (if any)
const highlightedLines = rangeParser(meta.match(/{(.*)}/)?.[1] || '');

return {
title,
highlightedLines,
};
}

/**
* Preprocesses the given raw code snippet before being handed to the syntax highlighter.
*
* Does the following things:
* - Trims empty lines at the beginning or end of the code block
* - If `extractFileName` is true, checks the first lines for a comment line with a file name.
* - If a matching line is found, removes it from the code
* and returns the extracted file name in the result object.
* - Normalizes whitespace and line endings
*/
function preprocessCode(code: string, lang: string, extractFileName: boolean) {
let extractedFileName: string | undefined;

// Split the code into lines and remove any empty lines at the beginning & end
const lines = code.split(/\r?\n/);
while (lines.length > 0 && lines[0].trim().length === 0) {
lines.shift();
}
while (lines.length > 0 && lines[lines.length - 1].trim().length === 0) {
lines.pop();
}

// If requested, try to find a file name comment in the first 5 lines of the given code
if (extractFileName) {
const lineIdx = lines.slice(0, 4).findIndex((line) => {
const matches = FileNameCommentRegExp.exec(line);
if (matches) {
extractedFileName = matches[2];
return true;
}
return false;
});

// If the syntax highlighting language is contained in our known language groups,
// ensure that the extracted file name has an extension that matches the group
if (extractedFileName) {
const languageGroup = Object.values(LanguageGroups).find((group) => group.includes(lang));
const fileExt = extractedFileName.match(/\.([^.]+)$/)?.[1];
if (languageGroup && fileExt && !languageGroup.includes(fileExt)) {
// The file extension does not match the syntax highlighting language,
// so it's not a valid file name for this code snippet
extractedFileName = undefined;
}
}

// Was a valid file name comment line found?
if (extractedFileName) {
// Yes, remove it from the code
lines.splice(lineIdx, 1);
// If the following line is empty, remove it as well
if (!lines[lineIdx]?.trim().length) {
lines.splice(lineIdx, 1);
}
}
}

// If only one line is left, trim any leading indentation
if (lines.length === 1) lines[0] = lines[0].trimStart();

// Rebuild code with normalized line endings
let preprocessedCode = lines.join('\n');

// Convert tabs to 2 spaces
preprocessedCode = preprocessedCode.replace(/\t/g, ' ');

return {
preprocessedCode,
extractedFileName,
};
}

/**
* Astro integration that adds our code snippets remark plugin
* and auto-imports the `CodeSnippet` component everywhere.
*/
export function astroCodeSnippets(): AstroIntegration {
return {
name: '@astrojs/code-snippets',
hooks: {
'astro:config:setup': ({ injectScript, updateConfig }) => {
updateConfig({
markdown: {
remarkPlugins: [remarkCodeSnippets()],
},
});

// Auto-import the Aside component and attach it to the global scope
injectScript('page-ssr', `import ${CodeSnippetTagname} from "~/components/CodeSnippet.astro"; global.${CodeSnippetTagname} = ${CodeSnippetTagname};`);
},
},
};
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"htmlparser2": "^7.2.0",
"kleur": "^4.1.5",
"node-fetch": "^3.2.6",
"parse-numeric-range": "^1.3.0",
"preact": "^10.8.2",
"prettier": "^2.7.1",
"prompts": "^2.4.2",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 1 addition & 29 deletions public/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ article > section nav :is(ul, ol) > * + * {
margin-top: inherit;
}

article > section li > :is(p, pre, blockquote):not(:first-child) {
article > section li > :is(p, pre, .code-snippet, blockquote):not(:first-child) {
margin-top: 1rem;
}

Expand Down Expand Up @@ -311,10 +311,6 @@ code {
word-break: break-word;
}

pre.astro-code > code {
all: unset;
}

/*RTL Fix Code dir*/
[dir='rtl'] code {
unicode-bidi: plaintext;
Expand Down Expand Up @@ -377,34 +373,10 @@ th {
text-align: left;
}

pre {
--glow-border: 1px solid var(--theme-glow-highlight);
border-top: var(--glow-border);
border-bottom: var(--glow-border);
box-shadow: 0 0 var(--theme-glow-blur) var(--theme-glow-diffuse), inset 0 0 var(--theme-glow-blur) var(--theme-glow-diffuse);
/* override inline styles generated by shiki */
background-color: var(--theme-code-bg) !important;
color: var(--theme-code-text) !important;
line-height: 1.65;
}

blockquote code {
background-color: var(--theme-bg);
}

@media (min-width: 37.75em) {
pre {
--padding-inline: 1.25rem;
border: var(--glow-border);
margin-left: 0;
margin-right: 0;
}
}

pre:focus {
outline: 4px solid var(--theme-accent);
}

blockquote {
margin: 2rem 0 2rem 0;
padding: 1.25em 1.5rem;
Expand Down
Loading

0 comments on commit 9e923ea

Please sign in to comment.