Skip to content

Commit

Permalink
Allow inline marking of plaintext strings (withastro#1163)
Browse files Browse the repository at this point in the history
  • Loading branch information
hippotastic authored Aug 2, 2022
1 parent d649ada commit c07daf4
Show file tree
Hide file tree
Showing 5 changed files with 224 additions and 144 deletions.
33 changes: 22 additions & 11 deletions integrations/astro-code-snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,10 +140,13 @@ export function remarkCodeSnippets(): Plugin<[], Root> {
* Meta information is the string after the opening code fence and language name.
*/
function parseMeta(meta: string) {
// Try to find the meta property `title="..."`, store its value and remove it from meta
const titleMatch = meta.match(/(?:\s|^)title\s*=\s*"(.*?)(?<!\\)"/);
const title = titleMatch?.[1];
meta = (titleMatch?.[0] && meta.replace(titleMatch[0], '')) || meta;
// Try to find the meta property `title="..."` or `title='...'`,
// store its value and remove it from meta
let title: string | undefined;
meta = meta.replace(/(?:\s|^)title\s*=\s*(["'])(.*?)(?<!\\)\1/, (_, __, content) => {
title = content;
return '';
});

// Find line marking definitions inside curly braces, with an optional marker type prefix.
//
Expand All @@ -158,24 +161,32 @@ function parseMeta(meta: string) {
return '';
});

// Find inline marking definitions inside forward slashes, with an optional marker type prefix.
// Find inline marking definitions inside single or double quotes (to match plaintext strings)
// or forward slashes (to match regular expressions), with an optional marker type prefix.
//
// Examples:
// - `/sidebar/` (if no marker type prefix is given, it defaults to `mark`)
// - `mark=/sidebar/` (all common regular expression features are supported)
// - `mark=/slot="(.*?)"/` (if capture groups are contained, these will be marked)
// Examples for plaintext strings:
// - `"Astro.props"` (if no marker type prefix is given, it defaults to `mark`)
// - `ins="<Button />"` (matches will be marked with "inserted" style)
// - `del="<p class=\"hi\">"` (special chars in the search string can be escaped by `\`)
// - `del='<p class="hi">'` (use single quotes to make it easier to match double quotes)
//
// Examples for regular expressions:
// - `/sidebar/` (if no marker type prefix is given, it defaults to `mark`)
// - `mark=/astro-[a-z]+/` (all common regular expression features are supported)
// - `mark=/slot="(.*?)"/` (if capture groups are contained, these will be marked)
// - `del=/src\/pages\/.*\.astro/` (escaping special chars with a backslash works, too)
// - `ins=/this|that/`
const inlineMarkings: string[] = [];
meta = meta.replace(/(?:\s|^)(?:([a-zA-Z]+)\s*=\s*)?(\/.*?(?<!\\)\/)(?=\s|$)/g, (_, prefix, expression) => {
inlineMarkings.push(`${prefix || 'mark'}=${expression}`);
meta = meta.replace(/(?:\s|^)(?:([a-zA-Z]+)\s*=\s*)?([/"'])(.*?)(?<!\\)\2(?=\s|$)/g, (_, prefix, delimiter, expression) => {
inlineMarkings.push(`${prefix || 'mark'}=${delimiter}${expression}${delimiter}`);
return '';
});

return {
title,
lineMarkings,
inlineMarkings,
meta,
};
}

Expand Down
84 changes: 58 additions & 26 deletions src/components/CodeSnippet/CodeSnippet.astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@ import { InlineMarkingDefinition, LineMarkingDefinition, MarkerType, MarkerTypeO
export interface Props {
lang?: string;
title?: string;
removedLineIndex?: number;
removedLineCount?: number;
removedLineIndex?: string;
removedLineCount?: string;
lineMarkings?: string;
inlineMarkings?: string;
}
const { lang = '', removedLineIndex = 0, removedLineCount = 0, title = '', lineMarkings = '', inlineMarkings = '' } = Astro.props as Props;
const { lang = '', removedLineIndex = '', removedLineCount = '', title = '', lineMarkings = '', inlineMarkings = '' } = Astro.props as Props;
const isTerminal = ['shellscript', 'shell', 'bash', 'sh', 'zsh'].includes(lang);
const intRemovedLineIndex = parseInt(removedLineIndex) || 0;
const intRemovedLineCount = parseInt(removedLineCount) || 0;
// Generate HTML code from the title (if any), improving the ability to wrap long file paths
// into multiple lines by inserting a line break opportunity after each slash
Expand All @@ -32,36 +34,58 @@ function applyMarkings(highlightedCodeHtml: string, strLineMarkings: string, str
strLineMarkings,
// Syntax: [mark=|del=|ins=]{2-5,7}
/^(?:(.*)=){(.+)}$/,
`Invalid code snippet line marking: Expected a range like "{2-5,7}"
with optional prefixes "mark=", "del=" or "ins=", but got "$entry"`
).map(({ markerType, content }) => {
`Invalid code snippet line marking: Expected a range like "{2-5,7}",
optionally with one of the prefixes "mark=", "del=" or "ins=", but got "$entry"`
).map(({ markerType, groupValues: [content] }) => {
const lines = rangeParser(content);
// If any lines were removed during preprocessing,
// automatically shift marked line numbers accordingly
const shiftedLines = lines.map((lineNum) => {
if (lineNum <= intRemovedLineIndex)
return lineNum;
if (lineNum > intRemovedLineIndex + intRemovedLineCount)
return lineNum - intRemovedLineCount;
return -1;
});
return {
markerType,
lines: rangeParser(content).map((lineNum) => {
// If any lines were removed during preprocessing,
// automatically shift marked line numbers accordingly
return lineNum - (lineNum > removedLineIndex ? removedLineCount : 0);
}),
lines: shiftedLines,
};
});
const inlineMarkings: InlineMarkingDefinition[] = parseMarkingDefinition(
strInlineMarkings,
// Syntax: [mark=|del=|ins=]/hi [a-z]+/
/^(?:(.*)=)\/(.+)\/$/,
`Invalid code snippet inline marking: Expected a RegExp like "/hi [a-z]+/"
with optional prefixes "mark=", "del=" or "ins=", but got "$entry"`
).map(({ markerType, content }) => {
let regExp: RegExp;
try {
// Try to use regular expressions with capture group indices
regExp = new RegExp(content, 'gd');
} catch (error) {
// Use fallback if unsupported
regExp = new RegExp(content, 'g');
// Syntax for plaintext strings:
// - Double quotes: [mark=|del=|ins=]"<Button />"
// - Single quotes: [mark=|del=|ins=]'<p class="hi">'
//
// Syntax for regular expressions:
// - Forward slashes: [mark=|del=|ins=]/hi [a-z]+/
/^(?:(.*)=)([/"'])(.+)\2$/,
`Invalid code snippet inline marking: Expected either a string in single or double quotes,
or a RegExp in forward slashes like "/hi [a-z]+/", optionally with one of the prefixes
"mark=", "del=" or "ins=", but got "$entry"`
).map(({ markerType, groupValues: [delimiter, content] }) => {
let text: string | undefined;
let regExp: RegExp | undefined;
if (delimiter === '/') {
try {
// Try to use regular expressions with capture group indices
regExp = new RegExp(content, 'gd');
} catch (error) {
// Use fallback if unsupported
regExp = new RegExp(content, 'g');
}
} else {
text = content;
}
return {
markerType,
text,
regExp,
};
});
Expand All @@ -84,15 +108,18 @@ function parseMarkingDefinition(serializedArr: string, parts: RegExp, parseError
const markerType = (rawMarkerType as MarkerType) || 'mark';
const isValid = matches && MarkerTypeOrder.includes(markerType);
if (entry && !isValid) {
console.error(parseErrorMsg.replace('$entry', entry).replace(/(\r?\n)\s+/g, '$1'));
const formattedParseErrorMsg = parseErrorMsg
.replace('$entry', entry)
.replace(/\r?\n\s+/g, ' ');
console.error(`*** ${formattedParseErrorMsg}\n`);
}
return {
entry,
markerType: markerType,
content: isValid ? matches[2] : '',
groupValues: isValid ? matches.slice(2) : [],
};
})
.filter((entry) => entry.content);
.filter((entry) => entry.groupValues.length);
}
---
<style lang="scss" is:global>
Expand Down Expand Up @@ -148,6 +175,11 @@ function parseMarkingDefinition(serializedArr: string, parts: RegExp, parseError

& span {
position: relative;

// This pseudo-element makes the background color of empty lines visible
&.empty::before {
content: ' ';
}
}

// Support line-level mark/ins/del
Expand Down
124 changes: 79 additions & 45 deletions src/components/CodeSnippet/shiki-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,49 +46,11 @@ export class ShikiLine {
applyInlineMarkings(inlineMarkings: InlineMarkingDefinition[]) {
const markedRanges: MarkedRange[] = [];

// Go through all definitions, find matches for their regular expressions in textLine,
// Go through all definitions, find matches for their text or regExp in textLine,
// and fill markedRanges with their capture groups or entire matches
inlineMarkings.forEach((inlineMarking) => {
const matches = this.textLine.matchAll(inlineMarking.regExp);
for (const match of matches) {
const fullMatchIndex = match.index as number;
// Read the start and end ranges from the `indices` property,
// which is made available through the RegExp flag `d`
// (and unfortunately not recognized by TypeScript)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let groupIndices = (match as any).indices as ([start: number, end: number] | null)[];
// If accessing the group indices is unsupported, use fallback logic
if (!groupIndices || !groupIndices.length) {
// Try to find the position of each capture group match inside the full match
groupIndices = match.map((groupValue) => {
const groupIndex = groupValue ? match[0].indexOf(groupValue) : -1;
if (groupIndex === -1) return null;
const groupStart = fullMatchIndex + groupIndex;
const groupEnd = groupStart + groupValue.length;
return [groupStart, groupEnd];
});
}
// Remove null group indices
groupIndices = groupIndices.filter((range) => range);
// If there are no non-null indices, use the full match instead
if (!groupIndices.length) {
groupIndices = [[fullMatchIndex, fullMatchIndex + match[0].length]];
}
// If there are multiple non-null indices, remove the first one
// as it is the full match and we only want to mark capture groups
if (groupIndices.length > 1) {
groupIndices.shift();
}
// Create marked ranges from all remaining group indices
groupIndices.forEach((range) => {
if (!range) return;
markedRanges.push({
markerType: inlineMarking.markerType,
start: range[0],
end: range[1],
});
});
}
const matches = this.getInlineMarkingDefinitionMatches(inlineMarking);
markedRanges.push(...matches);
});

if (!markedRanges.length) return;
Expand Down Expand Up @@ -137,15 +99,24 @@ export class ShikiLine {
}

renderToHtml() {
const tokensHtml = this.tokens
const classValue = [...this.classes].join(' ');

// Build the line's inner HTML code by rendering all contained tokens
let innerHtml = this.tokens
.map((token) => {
if (token.tokenType === 'marker') return `<${token.closing ? '/' : ''}${token.markerType}>`;
return `<span style="color:${token.color}">${token.innerHtml}</span>`;
})
.join('');
const classValue = [...this.classes].join(' ');

return `${this.beforeClassValue}${classValue}${this.afterClassValue}${tokensHtml}${this.afterTokens}`;

// Browsers don't seem render the background color of completely empty lines,
// so if the rendered inner HTML code is empty and we want to mark the line,
// we need to add some content to make the background color visible.
// To keep the copy & paste result unchanged at the same time, we add an empty span
// and attach a CSS class that displays a space inside a ::before pseudo-element.
if (!innerHtml && this.getLineMarkerType() !== undefined) innerHtml = '<span class="empty"></span>';

return `${this.beforeClassValue}${classValue}${this.afterClassValue}${innerHtml}${this.afterTokens}`;
}

getLineMarkerType(): MarkerType {
Expand All @@ -160,6 +131,69 @@ export class ShikiLine {
this.classes.add(newType.toString());
}

private getInlineMarkingDefinitionMatches(inlineMarking: InlineMarkingDefinition) {
const markedRanges: MarkedRange[] = [];

if (inlineMarking.text) {
let idx = this.textLine.indexOf(inlineMarking.text, 0);
while (idx > -1) {
markedRanges.push({
markerType: inlineMarking.markerType,
start: idx,
end: idx + inlineMarking.text.length,
});
idx = this.textLine.indexOf(inlineMarking.text, idx + inlineMarking.text.length);
}
return markedRanges;
}

if (inlineMarking.regExp) {
const matches = this.textLine.matchAll(inlineMarking.regExp);
for (const match of matches) {
const fullMatchIndex = match.index as number;
// Read the start and end ranges from the `indices` property,
// which is made available through the RegExp flag `d`
// (and unfortunately not recognized by TypeScript)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let groupIndices = (match as any).indices as ([start: number, end: number] | null)[];
// If accessing the group indices is unsupported, use fallback logic
if (!groupIndices || !groupIndices.length) {
// Try to find the position of each capture group match inside the full match
groupIndices = match.map((groupValue) => {
const groupIndex = groupValue ? match[0].indexOf(groupValue) : -1;
if (groupIndex === -1) return null;
const groupStart = fullMatchIndex + groupIndex;
const groupEnd = groupStart + groupValue.length;
return [groupStart, groupEnd];
});
}
// Remove null group indices
groupIndices = groupIndices.filter((range) => range);
// If there are no non-null indices, use the full match instead
if (!groupIndices.length) {
groupIndices = [[fullMatchIndex, fullMatchIndex + match[0].length]];
}
// If there are multiple non-null indices, remove the first one
// as it is the full match and we only want to mark capture groups
if (groupIndices.length > 1) {
groupIndices.shift();
}
// Create marked ranges from all remaining group indices
groupIndices.forEach((range) => {
if (!range) return;
markedRanges.push({
markerType: inlineMarking.markerType,
start: range[0],
end: range[1],
});
});
}
return markedRanges;
}

throw new Error(`Missing matching logic for inlineMarking=${JSON.stringify(inlineMarking)}`);
}

private textPositionToTokenPosition(textPosition: number): InsertionPoint {
for (const [tokenIndex, token] of this.tokens.entries()) {
if (token.tokenType !== 'syntax') continue;
Expand Down
3 changes: 2 additions & 1 deletion src/components/CodeSnippet/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ export type LineMarkingDefinition = {

export type InlineMarkingDefinition = {
markerType: MarkerType;
regExp: RegExp;
text?: string;
regExp?: RegExp;
};

export type MarkedRange = {
Expand Down
Loading

0 comments on commit c07daf4

Please sign in to comment.