Skip to content

Add gutters, line number option #95

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
May 17, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/factory/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,28 @@ function createLineElement(line, meta, index, language, getLineClassName, tokens
const lineClassName = joinClassNames(getLineClassName(lineData), 'grvsc-line');
const attrs = mergeAttributes({ class: lineClassName }, line.attrs);
const children = typeof tokens === 'string' ? [tokens] : mergeSimilarTokens(tokens);
return span(attrs, children, { whitespace: TriviaRenderFlags.NoWhitespace });
const gutterCells = line.gutterCells.map(createGutterCellElement);
if (gutterCells.length) {
gutterCells.unshift(span({ class: 'grvsc-gutter-pad' }, undefined, { whitespace: TriviaRenderFlags.NoWhitespace }));
}
return span(
attrs,
[...gutterCells, span({ class: 'grvsc-source' }, children, { whitespace: TriviaRenderFlags.NoWhitespace })],
{ whitespace: TriviaRenderFlags.NoWhitespace }
);
}

/** @param {GutterCell | undefined} cell */
function createGutterCellElement(cell) {
return span(
{
class: joinClassNames('grvsc-gutter', cell && cell.className),
'aria-hidden': 'true',
'data-content': cell && cell.text
},
[],
{ whitespace: TriviaRenderFlags.NoWhitespace }
);
}

/**
Expand Down
7 changes: 6 additions & 1 deletion src/graphql/getCodeBlockDataFromRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ function getCodeBlockDataFromRegistry(registry, key, codeBlock, getWrapperClassN

const wrapperClassNameValue = getWrapperClassName();
const themeClassNames = flatMap(possibleThemes, getThemeClassNames);
const preClassName = joinClassNames('grvsc-container', wrapperClassNameValue, ...themeClassNames);
const preClassName = joinClassNames(
'grvsc-container',
wrapperClassNameValue,
codeBlock.className,
...themeClassNames
);
const codeClassName = 'grvsc-code';
const [defaultTheme, additionalThemes] = partitionOne(possibleThemes, t =>
t.conditions.some(c => c.condition === 'default')
Expand Down
8 changes: 6 additions & 2 deletions src/registerCodeNode.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
const tokenizeWithTheme = require('./tokenizeWithTheme');
const { getTransformedLines } = require('./transformers');
const { getGrammar } = require('./storeUtils');
const { joinClassNames } = require('./renderers/css');
const { uniq } = require('./utils');

/**
* @template {Keyable} TKey
Expand Down Expand Up @@ -42,6 +44,7 @@ async function registerCodeBlock(
tokenTypes = grammarData.tokenTypes;
}

const addedClassNames = joinClassNames(...uniq(lines.map(l => l.setContainerClassName)));
const grammar = languageId && (await registry.loadGrammarWithConfiguration(scope, languageId, { tokenTypes }));
codeBlockRegistry.register(registryKey, {
lines,
Expand All @@ -50,7 +53,8 @@ async function registerCodeBlock(
languageName,
possibleThemes,
isTokenized: !!grammar,
tokenizationResults: possibleThemes.map(theme => tokenizeWithTheme(lines, theme, grammar, registry))
tokenizationResults: possibleThemes.map(theme => tokenizeWithTheme(lines, theme, grammar, registry)),
className: addedClassNames || undefined
});
} finally {
unlockRegistry();
Expand Down Expand Up @@ -82,7 +86,7 @@ async function registerCodeSpan(
const [registry, unlockRegistry] = await getTextMateRegistry();
try {
/** @type {Line[]} */
const lines = [{ text, data: {}, attrs: {} }];
const lines = [{ text, data: {}, attrs: {}, gutterCells: [] }];
const { tokenTypes, languageId } = getGrammar(scope, grammarCache);
const grammar = await registry.loadGrammarWithConfiguration(scope, languageId, { tokenTypes });
codeBlockRegistry.register(registryKey, {
Expand Down
1 change: 1 addition & 0 deletions src/renderers/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ function renderHTML(element) {

const { tagName, attributes, children } = element;
const attrs = Object.keys(attributes)
.filter(attr => attributes[attr] !== undefined)
.map(attr => ` ${attr}="${escapeHTML(attributes[attr])}"`)
.join('');

Expand Down
88 changes: 85 additions & 3 deletions src/transformers/getTransformedLines.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// @ts-check

const { joinClassNames } = require('../renderers/css');

/**
*
* @param {LineTransformer[]} transformers
Expand All @@ -9,15 +11,22 @@
* @returns {Promise<Line[]>}
*/
async function getTransformedLines(transformers, text, languageName, meta) {
/** @type {Line[]} */
/** @type {Omit<Line, 'gutterCells'>[]} */
const result = [];
const rawLines = text.split(/\r?\n/);
const prevTransformerStates = [];
const gutterCellsPerTransformer = [];
/** @type {GutterCell[][][]} */
const gutterCells = [];

linesLoop: for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex++) {
let line = rawLines[lineIndex];
/** @type {GutterCell[][]} */
const lineGutterCells = [];
const attrs = {};
const graphQLData = {};
/** @type {string[]} */
const addedContainerClassNames = [];
for (let i = 0; i < transformers.length; i++) {
const transformer = transformers[i];
const state = prevTransformerStates[i];
Expand All @@ -30,17 +39,90 @@ async function getTransformedLines(transformers, text, languageName, meta) {
});

prevTransformerStates[i] = txResult.state;
if (txResult.setContainerClassName) {
addedContainerClassNames.push(txResult.setContainerClassName);
}
if (!txResult.line) {
continue linesLoop;
}
if (txResult.gutterCells) {
gutterCellsPerTransformer[i] = Math.max(txResult.gutterCells.length, gutterCellsPerTransformer[i] || 0);
lineGutterCells[i] = txResult.gutterCells;
} else {
gutterCellsPerTransformer[i] = Math.max(0, gutterCellsPerTransformer[i] || 0);
}

line = txResult.line.text;
Object.assign(attrs, txResult.line.attrs);
Object.assign(graphQLData, txResult.data);
}
result.push({ text: line, attrs, data: graphQLData });
gutterCells.push(lineGutterCells);
result.push({
text: line,
attrs,
data: graphQLData,
setContainerClassName: joinClassNames(...addedContainerClassNames) || undefined
});
}

return result;
const flattenedGutterCells = flattenGutterCells(gutterCells, gutterCellsPerTransformer);
return result.map((line, i) => ({ ...line, gutterCells: flattenedGutterCells[i] }));
}

/**
* Transforms a 3D array of gutter cells into a 2D array of gutter cells.
* The input is in the form of gutter cells per line transformer per line,
* whereas the output is is gutter cells per line. Each line transformer can
* return more than one gutter cell, and need not return the same number of
* cells for each line, so the flattening must be done in a way that ensures
* that each line transformer has its gutter cells aligned to the same index
* in every line. For example, for the input
*
* ```
* [
* [[t0], [t1a, t1b], [t2]], // Line 1
* [undefined, [t1], [t2]], // Line 2
* [[t0a, t0b], undefined, [t2a, t2b]] // Line 3
* ]
* ```
*
* we would flatten to
*
* ```
* [
* [t0, undefined, t1a, t1b, t2, undefined], // Line 1
* [undefined, undefined, t1, undefined, t2, undefined], // Line 2
* [t0a, t0b, undefined, undefined, t2a, t2b] // Line 3
* ]
* ```
*
* such that each of the three transformers (t0, t1, t2) reserve two gutter
* cells for itself, padding empty spaces in the final array with `undefined`
* to ensure correct vertical alignment.
*
* The parameter `gutterCellsPerTransformer` can be derived from `gutterCells`,
* but as an optimization, we already know it from previously iterating through
* line transformer results.
*
* @param {GutterCell[][][]} gutterCells
* @param {number[]} gutterCellsPerTransformer
* @returns {GutterCell[][]}
*/
function flattenGutterCells(gutterCells, gutterCellsPerTransformer) {
const totalGutterCells = gutterCellsPerTransformer.reduce((a, b) => a + b, 0);
return gutterCells.map(transformerResults => {
/** @type {GutterCell[]} */
const result = Array(totalGutterCells).fill(undefined);
for (let i = 0; i < transformerResults.length; i++) {
const currentTransformerCells = transformerResults[i];
if (currentTransformerCells) {
for (let j = 0; j < currentTransformerCells.length; j++) {
result[(gutterCellsPerTransformer[i - 1] || 0) + j] = currentTransformerCells[j];
}
}
}
return result;
});
}

module.exports = getTransformedLines;
81 changes: 13 additions & 68 deletions src/transformers/highlightDirectiveLineTransformer.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,12 @@
// @ts-check
const { highlightLine } = require('./transformerUtils');
const { highlightLine, getCommentContent, getCommentRegExp } = require('./transformerUtils');
const { getScope } = require('../storeUtils');

/**
* @param {string} language
* @param {string} scope
* @param {Record<string, (message: string) => string>} languageCommentMap
* @return {(commentMessage: string) => string} curried function taking a string argument and
* prefixing/wrapping that with a language's comment syntax
*/
const getCommentForLanguage = (language, scope, languageCommentMap) => message => {
// example: languageCommentMap = {js: str => `// ${str}`}
if (languageCommentMap[language]) {
return languageCommentMap[language](message);
}

switch (scope) {
case 'source.python':
case 'source.ruby':
case 'source.shell':
case 'source.perl':
case 'source.coffee':
case 'source.yaml':
return `# ${message}`;
case 'source.css':
case 'source.c':
case 'source.cpp':
case 'source.objc':
case 'source.css.less':
return `/* ${message} */`;
case 'text.html.derivative':
case 'text.xml':
case 'text.html.markdown':
return `<!-- ${message} -->`;
case 'source.clojure':
return `; ${message}`;
case 'source.sql':
return `-- ${message}`;
default:
return `// ${message}`;
}
};

/**
* @param {string} text
* @param {(directive: string) => string} commentWrapper
* @return {(directive: string) => boolean} curried function taking a directive string and checking
* whether it equals the line text
*/
const textIsHighlightDirective = (text, commentWrapper) => directive =>
['// ' + directive, commentWrapper(directive)].includes(text.trim());

/**
* @param {Record<string, (message: string) => string>} languageCommentMap user-defined object mapping language keys to commenting functions
* @param {Record<string, string>} languageAliases
* @param {GatsbyCache} cache
*/
function createHighlightDirectiveLineTransformer(languageCommentMap, languageAliases, cache) {
function createHighlightDirectiveLineTransformer(languageAliases, cache) {
let grammarCache;
/** @type {LineTransformer<HighlightCommentTransfomerState>} */
const transformer = async ({ line, language, state }) => {
Expand All @@ -65,30 +15,24 @@ function createHighlightDirectiveLineTransformer(languageCommentMap, languageAli
}

const scope = getScope(language, grammarCache, languageAliases);
const commentWrapper = getCommentForLanguage(language, scope, languageCommentMap);
const isDirective = textIsHighlightDirective(line.text, commentWrapper);
if (isDirective('highlight-start')) {
const commentContent = getCommentContent(line.text, scope, /*trim*/ true);

if (commentContent === 'highlight-start') {
return { state: { inHighlightRange: true } }; // no `line` - drop this line from output
}
if (isDirective('highlight-end')) {
if (commentContent === 'highlight-end') {
return { state: { inHighlightRange: false } }; // again no `line`
}
if (isDirective('highlight-next-line')) {
if (commentContent === 'highlight-next-line') {
return { state: { highlightNextLine: true } }; // again no `line`
}
if (
line.text.endsWith(commentWrapper('highlight-line')) ||
line.text.endsWith('// highlight-line') ||
(state && state.inHighlightRange)
) {
if (commentContent === 'highlight-line' || (state && state.inHighlightRange)) {
// return attrs with added class name, text with comment removed, current state
return {
line: highlightLine(
line,
line.text.replace(commentWrapper('highlight-line'), '').replace('// highlight-line', '')
),
line: highlightLine(line, line.text.replace(getCommentRegExp(scope), '')),
state,
data: { isHighlighted: true }
data: { isHighlighted: true },
setContainerClassName: 'grvsc-has-line-highlighting'
};
}
if (state && state.highlightNextLine) {
Expand All @@ -98,7 +42,8 @@ function createHighlightDirectiveLineTransformer(languageCommentMap, languageAli
return {
line: highlightLine(line),
state: { ...state, highlightNextLine: false },
data: { isHighlighted: true }
data: { isHighlighted: true },
setContainerClassName: 'grvsc-has-line-highlighting'
};
}
return { line, state }; // default: don’t change anything, propagate state to next call
Expand Down
3 changes: 2 additions & 1 deletion src/transformers/highlightMetaTransformer.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ const highlightMetaTransformer = ({ meta, line, state = getInitialState(meta) })
state: {
lineNumber: state.lineNumber + 1,
highlightedLines: isHighlighted ? state.highlightedLines.slice(1) : state.highlightedLines
}
},
setContainerClassName: isHighlighted ? 'grvsc-has-line-highlighting' : undefined
};
};

Expand Down
7 changes: 6 additions & 1 deletion src/transformers/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @ts-check
const { highlightMetaTransformer } = require('./highlightMetaTransformer');
const { createHighlightDirectiveLineTransformer } = require('./highlightDirectiveLineTransformer');
const { createLineNumberLineTransformer } = require('./lineNumberTransformer');
const getTransformedLines = require('./getTransformedLines');

/**
Expand All @@ -9,7 +10,11 @@ const getTransformedLines = require('./getTransformedLines');
* @returns {LineTransformer[]}
*/
function getDefaultLineTransformers(pluginOptions, cache) {
return [createHighlightDirectiveLineTransformer({}, pluginOptions.languageAliases, cache), highlightMetaTransformer];
return [
createHighlightDirectiveLineTransformer(pluginOptions.languageAliases, cache),
highlightMetaTransformer,
createLineNumberLineTransformer(pluginOptions.languageAliases, cache)
];
}

/**
Expand Down
Loading