diff --git a/script/server b/script/server index 2a6715c7..23f2e4a9 100755 --- a/script/server +++ b/script/server @@ -3,6 +3,8 @@ set -e +rm -rf _site + # When the second command in the foreground is killed with Ctrl-c, the first # command in the background is killed also. # Based on https://unix.stackexchange.com/a/204619 diff --git a/src_js/Config.ts b/src_js/Config.ts index ffea6a76..0b2ce47a 100644 --- a/src_js/Config.ts +++ b/src_js/Config.ts @@ -1,5 +1,5 @@ import Storage from './utils/Storage'; -import { CodeblockVariant } from './components/main_content/types'; +import { CodeblockVariant } from './components/main_content/enhanced_code_blocks/codeblockConsts'; const SUBTHEME_NAME_STORAGE_KEY = 'spec_subtheme_name'; const SUBTHEME_MODE_STORAGE_KEY = 'spec_subtheme_mode'; diff --git a/src_js/components/main_content/enhanced_code_blocks/__tests__/parseCodeHighlightRanges.test.ts b/src_js/components/main_content/enhanced_code_blocks/__tests__/parseCodeHighlightRanges.test.ts new file mode 100644 index 00000000..4cfb4021 --- /dev/null +++ b/src_js/components/main_content/enhanced_code_blocks/__tests__/parseCodeHighlightRanges.test.ts @@ -0,0 +1,65 @@ +import { parseCodeHighlightRanges } from '../parseCodeHighlightRanges'; + +describe('parseCodeHighlightRanges', () => { + test('empty string', () => { + expect(parseCodeHighlightRanges('', 30)).toEqual(new Set()); + }); + + test('invalid string', () => { + expect(parseCodeHighlightRanges('spam and eggs', 30)).toEqual(new Set()); + }); + + test('invalid comma-separated string', () => { + expect(parseCodeHighlightRanges('spam,eggs', 30)).toEqual(new Set()); + }); + + test('single line number', () => { + expect(parseCodeHighlightRanges('24', 30)).toEqual(new Set([24])); + }); + + test('single line number out of bounds', () => { + expect(parseCodeHighlightRanges('32', 30)).toEqual(new Set()); + }); + + test('single line number at upper bound', () => { + expect(parseCodeHighlightRanges('30', 30)).toEqual(new Set([30])); + }); + + test('single line number at lower bound', () => { + expect(parseCodeHighlightRanges('1', 30)).toEqual(new Set([1])); + }); + + test('single range', () => { + expect(parseCodeHighlightRanges('18-21', 30)).toEqual( + new Set([18, 19, 20, 21]), + ); + }); + + test('single range completely out of bounds', () => { + expect(parseCodeHighlightRanges('32-38', 30)).toEqual(new Set()); + }); + + test('single range partially out of bounds', () => { + expect(parseCodeHighlightRanges('28-32', 30)).toEqual(new Set()); + }); + + test('single range with inverted bounds', () => { + expect(parseCodeHighlightRanges('28-26', 30)).toEqual(new Set()); + }); + + test('multiple single numbers', () => { + expect(parseCodeHighlightRanges('4,0,6', 30)).toEqual(new Set([4, 6])); + }); + + test('multiple ranges', () => { + expect(parseCodeHighlightRanges('14-15,24-32,12-14', 30)).toEqual( + new Set([12, 13, 14, 15]), + ); + }); + + test('multiple ranges and lines', () => { + expect(parseCodeHighlightRanges('4, 24-27, 3-5', 30)).toEqual( + new Set([3, 4, 5, 24, 25, 26, 27]), + ); + }); +}); diff --git a/src_js/components/main_content/__tests__/useEnhancedCodeBlocks.test.ts b/src_js/components/main_content/enhanced_code_blocks/__tests__/useEnhancedCodeBlocks.test.ts similarity index 71% rename from src_js/components/main_content/__tests__/useEnhancedCodeBlocks.test.ts rename to src_js/components/main_content/enhanced_code_blocks/__tests__/useEnhancedCodeBlocks.test.ts index 70fb6b80..6edf49bd 100644 --- a/src_js/components/main_content/__tests__/useEnhancedCodeBlocks.test.ts +++ b/src_js/components/main_content/enhanced_code_blocks/__tests__/useEnhancedCodeBlocks.test.ts @@ -1,8 +1,6 @@ -import useEnhancedCodeBlocks, { - parseCodeHighlightRanges, -} from '../useEnhancedCodeBlocks'; +import useEnhancedCodeBlocks from '../useEnhancedCodeBlocks'; -jest.mock('../../../Config', () => ({ +jest.mock('../../../../Config', () => ({ USE_LEGACY_CODE_BLOCKS: false, })); @@ -134,67 +132,3 @@ describe('useEnhancedCodeBlocks', () => { }); }); }); - -describe('parseCodeHighlightRanges', () => { - test('empty string', () => { - expect(parseCodeHighlightRanges('', 30)).toEqual(new Set()); - }); - - test('invalid string', () => { - expect(parseCodeHighlightRanges('spam and eggs', 30)).toEqual(new Set()); - }); - - test('invalid comma-separated string', () => { - expect(parseCodeHighlightRanges('spam,eggs', 30)).toEqual(new Set()); - }); - - test('single line number', () => { - expect(parseCodeHighlightRanges('24', 30)).toEqual(new Set([24])); - }); - - test('single line number out of bounds', () => { - expect(parseCodeHighlightRanges('32', 30)).toEqual(new Set()); - }); - - test('single line number at upper bound', () => { - expect(parseCodeHighlightRanges('30', 30)).toEqual(new Set([30])); - }); - - test('single line number at lower bound', () => { - expect(parseCodeHighlightRanges('1', 30)).toEqual(new Set([1])); - }); - - test('single range', () => { - expect(parseCodeHighlightRanges('18-21', 30)).toEqual( - new Set([18, 19, 20, 21]), - ); - }); - - test('single range completely out of bounds', () => { - expect(parseCodeHighlightRanges('32-38', 30)).toEqual(new Set()); - }); - - test('single range partially out of bounds', () => { - expect(parseCodeHighlightRanges('28-32', 30)).toEqual(new Set()); - }); - - test('single range with inverted bounds', () => { - expect(parseCodeHighlightRanges('28-26', 30)).toEqual(new Set()); - }); - - test('multiple single numbers', () => { - expect(parseCodeHighlightRanges('4,0,6', 30)).toEqual(new Set([4, 6])); - }); - - test('multiple ranges', () => { - expect(parseCodeHighlightRanges('14-15,24-32,12-14', 30)).toEqual( - new Set([12, 13, 14, 15]), - ); - }); - - test('multiple ranges and lines', () => { - expect(parseCodeHighlightRanges('4, 24-27, 3-5', 30)).toEqual( - new Set([3, 4, 5, 24, 25, 26, 27]), - ); - }); -}); diff --git a/src_js/components/main_content/enhanced_code_blocks/codeblockConsts.ts b/src_js/components/main_content/enhanced_code_blocks/codeblockConsts.ts new file mode 100644 index 00000000..0126cd67 --- /dev/null +++ b/src_js/components/main_content/enhanced_code_blocks/codeblockConsts.ts @@ -0,0 +1,31 @@ +export enum CodeblockVariant { + ENHANCED = 'enhanced', + NO_LINE_NUMBERS = 'no-line-numbers', + LEGACY = 'legacy', +} + +/** + * The class used on each element that represents the contents of the code + * block. + */ +export const CODEBLOCK_LINE_CLASS = 'primer-spec-code-block-line-code'; + +/** + * We use the following class to ensure that we don't double-process code + * blocks. + */ +export const CODEBLOCK_PROCESSED_CLASS = 'primer-spec-code-block-processed'; + +/** + * Since we want to linkify code block titles, this is the class used to + * identify them to AnchorJS. + */ +export const CODEBLOCK_TITLE_CLASS = 'primer-spec-code-block-title'; + +/** + * We perform special handling for blocks in the `console` language: If a user + * clicks the line number, the entire line will be highlighted EXCLUDING the + * prompt (`$`) at the beginning, if it exists. + * See the special handling in `createCodeBlockLine()`. + */ +export const LANGUAGE_CONSOLE = 'console'; diff --git a/src_js/components/main_content/enhanced_code_blocks/createEnhancedCodeBlock.tsx b/src_js/components/main_content/enhanced_code_blocks/createEnhancedCodeBlock.tsx new file mode 100644 index 00000000..a9b9d2e8 --- /dev/null +++ b/src_js/components/main_content/enhanced_code_blocks/createEnhancedCodeBlock.tsx @@ -0,0 +1,253 @@ +/** @jsx JSXDom.h */ +import * as JSXDom from 'jsx-dom'; +import { parseCodeHighlightRanges } from './parseCodeHighlightRanges'; +import clsx from 'clsx'; +import { + CODEBLOCK_TITLE_CLASS, + LANGUAGE_CONSOLE, + CODEBLOCK_LINE_CLASS, +} from './codeblockConsts'; +import { genCopyButton } from './genCopyButton'; + +// We use this to keep track of click-then-drag on line numbers to select +// multiple lines simultaneously. +let mouseDownStartLine: number | null = null; + +/** + * Given a list of configuration options, return an enhanced code block + * `HTMLElement`. This method is _not_ responsible for actually inserting the + * `HTMLElement` into the DOM. + */ +export function createEnhancedCodeBlock(options: { + codeblockNumericId: number; + rawContent: string; + language: string | null; + rawHighlightRanges: string | null; + title?: string | null; + anchorId?: string | null; + showLineNumbers: boolean; +}): HTMLElement | null { + const { + codeblockNumericId, + rawContent, + language, + rawHighlightRanges, + title, + anchorId, + showLineNumbers, + } = options; + + const lines = rawContent.split('\n'); + if (lines.length === 0) { + console.warn('useEnhancedCodeBlocks: Code Block appears to have no lines!'); + return null; + } + const lastLine = lines[lines.length - 1]; + if (lastLine === '' || lastLine === '') { + lines.pop(); + } + + const highlightRanges = parseCodeHighlightRanges( + rawHighlightRanges, + lines.length, + ); + + const codeblockId = `primer-spec-code-block-${codeblockNumericId}`; + + const header = genCodeBlockHeader(title, anchorId); + const enhancedCodeBlock = ( +
+ {header} +
+ + {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + { + if (mouseDownStartLine != null && e.target != null) { + let el = e.target as HTMLElement | null; + while (el && el.tagName !== 'TABLE') { + const match = el.id.match( + /^primer-spec-code-block-(?:\d+)-L(?:C|R)?(\d+)$/, + ); + if (match && match[1] != null) { + selectLines(codeblockId, mouseDownStartLine, +match[1]); + break; + } else { + el = el.parentNode as HTMLElement; + } + } + } + }} + onMouseLeave={() => { + mouseDownStartLine = null; + }} + onMouseUp={() => { + mouseDownStartLine = null; + }} + > + {lines.map((line, lineNumber) => + createCodeBlockLine({ + codeblockId, + language, + line, + lineNumber: lineNumber + 1, + shouldHighlight: highlightRanges.has(lineNumber + 1), + showLineNumbers, + }), + )} + +
+ {lines.length > 1 + ? genCopyButton(codeblockId, language === LANGUAGE_CONSOLE) + : null} +
+
+ ); + return enhancedCodeBlock as HTMLElement; +} + +function createCodeBlockLine(options: { + codeblockId: string; + language: string | null; + line: string; + lineNumber: number; + shouldHighlight: boolean; + showLineNumbers: boolean; +}): HTMLElement { + const { + codeblockId, + language, + line, + lineNumber, + shouldHighlight, + showLineNumbers, + } = options; + + const L_ID = `${codeblockId}-L${lineNumber}`; + const LC_ID = `${codeblockId}-LC${lineNumber}`; + const LR_ID = `${codeblockId}-LR${lineNumber}`; + const codeblockLine = ( + + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} + { + e.preventDefault(); + mouseDownStartLine = lineNumber; + selectLines(codeblockId, mouseDownStartLine, mouseDownStartLine); + }} + /> + + + ) as HTMLElement; + + // SPECIAL HANDLING for `console` blocks: When a user clicks the line number + // to select the entire line, attempt to exclude the leading prompt + // symbol (`$`). + if (language === LANGUAGE_CONSOLE) { + const codeLine = codeblockLine.querySelector( + `.${CODEBLOCK_LINE_CLASS}`, + ) as HTMLElement; + const firstChild = codeLine.firstChild as HTMLElement | null; + if (firstChild?.tagName === 'SPAN' && firstChild.classList.contains('gp')) { + // This prompt needs to be excluded from selection. + // (1) Remove the original LC_ID + codeLine.id = ''; + // (2) Find children to exclude from selection. Do this by searching for + // the first child that is not of class `gp` (Generic::Prompt) or + // `w` (Whitespace) + const children = [...codeLine.childNodes]; + const childrenToExcludeFromSelection = []; + let i = 0; + for (; i < children.length; ++i) { + const child = children[i] as HTMLElement; + if ( + 'classList' in child && + (child.classList.contains('gp') || child.classList.contains('w')) + ) { + childrenToExcludeFromSelection.push(child); + } else { + break; + } + } + const childrenToIncludeInSelection = children.slice(i); + // (3) Wrap remaining children in a new with id=LC_ID. + codeLine.innerHTML = ''; + codeLine.appendChild({childrenToExcludeFromSelection}); + codeLine.appendChild( + {childrenToIncludeInSelection}, + ); + } + } + + return codeblockLine; +} + +function genCodeBlockHeader(title?: string | null, anchorId?: string | null) { + if (title == null) { + return null; + } + return ( +
+ + {title} + +
+ ); +} + +/** + * Using the Selection API, select all content between `startLine_` and + * `endLine_` for the codeblock identified by `codeblockId`. + */ +function selectLines( + codeblockId: string, + startLine_: number, + endLine_: number, +) { + let startLine = startLine_; + let endLine = endLine_; + if (startLine > endLine) { + // The range is inverted (for example, start selecting from line 4 to + // line 2). + startLine = endLine_; + endLine = startLine_; + } + const startNode = document.getElementById(`${codeblockId}-LC${startLine}`); + const endNode = document.getElementById(`${codeblockId}-LC${endLine}`); + if (!startNode || !endNode) { + console.error( + 'Primer Spec Code Block: selectLines: start or end nodes are null. Please report this issue on https://github.com/eecs485staff/primer-spec/issues. Thanks!', + ); + return; + } + + const range = document.createRange(); + range.setStart(startNode, 0); + range.setEnd(endNode, endNode.childNodes.length); + document.getSelection()?.removeAllRanges(); + document.getSelection()?.addRange(range); +} diff --git a/src_js/components/main_content/enhanced_code_blocks/genCopyButton.tsx b/src_js/components/main_content/enhanced_code_blocks/genCopyButton.tsx new file mode 100644 index 00000000..82762549 --- /dev/null +++ b/src_js/components/main_content/enhanced_code_blocks/genCopyButton.tsx @@ -0,0 +1,103 @@ +/** @jsx JSXDom.h */ +import * as JSXDom from 'jsx-dom'; +import { CODEBLOCK_LINE_CLASS } from './codeblockConsts'; + +export function genCopyButton(codeblockId: string, isConsoleBlock?: boolean) { + return ( +
+ +
+ ); +} + +const DEFAULT_COPY_LINES_MAP_FN = (line: HTMLElement) => line.innerText; +const CONSOLE_COPY_LINES_MAP_FN = (line: HTMLElement) => { + // (1) Skip console output lines + // (Class name 'go' refers to the Rouge class `Generic::Output`.) + const outputText = line.querySelector('.go'); + if (outputText) { + return null; + } + // (2) If there's a console prompt, skip it + const shadowLine = line.cloneNode(true) as HTMLElement; + let prompt: Element | null = null; + while ((prompt = shadowLine.querySelector('span.gp'))) { + // (2.1) If there is a space after the prompt, remove it + // (to dedent the command) + if (prompt.nextElementSibling?.classList.contains('w')) { + const whitespaceEl = prompt.nextElementSibling; + whitespaceEl.textContent = + whitespaceEl.textContent?.replace(' ', '') ?? null; + } + prompt.remove(); + } + return shadowLine.innerText; +}; + +/** + * Copy the text of a codeblock into the clipboard. Optionally accepts a custom + * map/filter method to extract text from each line. + * + * @param codeblock The codeblock whose lines need to be copied + * @param mapFn (OPTIONAL) A method that extracts text from a given line HTMLElement + */ +async function copyLines( + codeblock: HTMLElement, + mapFn: ( + line: HTMLElement, + ) => string | null | void = DEFAULT_COPY_LINES_MAP_FN, +) { + const lines = codeblock.querySelectorAll( + `.${CODEBLOCK_LINE_CLASS}`, + ) as NodeListOf; + const linesOfText = [...lines].map((line) => mapFn(line)).filter(Boolean); + const text = linesOfText.join('\n'); + await navigator.clipboard.writeText(text); +} diff --git a/src_js/components/main_content/enhanced_code_blocks/parseCodeHighlightRanges.ts b/src_js/components/main_content/enhanced_code_blocks/parseCodeHighlightRanges.ts new file mode 100644 index 00000000..645aebbe --- /dev/null +++ b/src_js/components/main_content/enhanced_code_blocks/parseCodeHighlightRanges.ts @@ -0,0 +1,57 @@ +/** + * Parse a string reprenting a list of line numbers, some of which may be + * ranges. The parsed output is a Set of line numbers that are included in the + * range. + * + * For instance, the string `'13, 24-26, 25-27'` is parsed as + * `Set([13, 24, 25, 26, 27])` + * + * @param rawHighlightRanges A comma-separated string representing ranges + * @param maxLineNumber The maximum valid line number + */ +export function parseCodeHighlightRanges( + rawHighlightRanges: string | null, + maxLineNumber: number, +): Set { + const highlightedLines = new Set(); + if (!rawHighlightRanges) { + return highlightedLines; + } + + const ranges = rawHighlightRanges.split(','); + ranges.forEach((range) => { + // First check if it's a single number + const potentialLineNum = +range; + if (isNumWithinInclusiveRange(potentialLineNum, 1, maxLineNumber)) { + highlightedLines.add(potentialLineNum); + } else { + const rangeParts = range.trim().split('-'); + if (rangeParts.length === 2) { + const lower = +rangeParts[0]; + const upper = +rangeParts[1]; + if ( + isNumWithinInclusiveRange(lower, 1, maxLineNumber) && + isNumWithinInclusiveRange(upper, 1, maxLineNumber) && + lower <= upper + ) { + for (let i = lower; i <= upper; ++i) { + highlightedLines.add(i); + } + } + } + } + }); + return highlightedLines; +} + +/** + * Return a boolean indicating whether `num` is in the range [`lower`, `upper`] + * (inclusive). + */ +function isNumWithinInclusiveRange( + num: number | null, + lower: number, + upper: number, +): boolean { + return num != null && !Number.isNaN(num) && num >= lower && num <= upper; +} diff --git a/src_js/components/main_content/enhanced_code_blocks/useEnhancedCodeBlocks.tsx b/src_js/components/main_content/enhanced_code_blocks/useEnhancedCodeBlocks.tsx new file mode 100644 index 00000000..108d908d --- /dev/null +++ b/src_js/components/main_content/enhanced_code_blocks/useEnhancedCodeBlocks.tsx @@ -0,0 +1,266 @@ +/** @jsx JSXDom.h */ +import { RefObject } from 'preact'; +import * as JSXDom from 'jsx-dom'; +import AnchorJS from 'anchor-js'; +import slugify from '@sindresorhus/slugify'; +import Config from '../../../Config'; +import { + CodeblockVariant, + CODEBLOCK_PROCESSED_CLASS, + CODEBLOCK_TITLE_CLASS, +} from './codeblockConsts'; +import { createEnhancedCodeBlock } from './createEnhancedCodeBlock'; + +/** + * A custom hook that enhances code blocks that are longer than two lines. + * These enhancecd code blocks show line numbers, and can optionally highlight + * lines. + * + * This method is the main entrypoint for enhancing code blocks. + * + * @param mainElRef A ref to the `
` element from MainContent + */ +export default function useEnhancedCodeBlocks( + mainElRef: RefObject, +): () => void { + if (!mainElRef.current) { + throw new Error( + 'Primer Spec: Main Content: Expected main content ref to be initialized.', + ); + } + + // First enhance codeblocks formatted by Jekyll + Rouge + const numCodeBlocks = enhanceBlocks( + mainElRef.current.querySelectorAll('div.highlighter-rouge'), + getCodeElFromJekyllRougeCodeblock, + 0, + ); + // Then attempt to enhance ordinary
 blocks.
+  enhanceBlocks(
+    mainElRef.current.querySelectorAll('pre'),
+    getCodeElFromPreCodeblock,
+    numCodeBlocks,
+  );
+
+  return () => {};
+}
+
+function getCodeElFromJekyllRougeCodeblock(
+  codeblock: HTMLElement,
+): HTMLElement | null {
+  // The original structure of a codeblock:
+  // 
+ //
+ //
+  //       
+  //         [contents]
+  //       
+  //     
+ //
+ //
+ // + // Notice that `contents` is wrapped in a pre-formatted block. Hence, we will + // use newlines in `contents` to demarcate lines, and we need to preserve + // whitespace within the line. + const codeEl = + codeblock.firstElementChild?.firstElementChild?.firstElementChild; + if (codeEl == null) { + console.warn( + 'useEnhancedCodeBlocks: Code Block has malformed structure. See Primer Spec Docs for expected structure. https://github.com/eecs485staff/primer-spec/blob/main/docs/USAGE_ADVANCED.md#enhanced-code-blocks', + 'codeblock', + codeblock, + ); + return null; + } + + return codeEl as HTMLElement; +} + +function getCodeElFromPreCodeblock(codeblock: HTMLElement): HTMLElement | null { + // The structure of a
 codeblock:
+  // 
+  //    
+  //     [contents]
+  //   
+  // 
+ if ( + codeblock.childNodes.length === 1 && + codeblock.firstElementChild?.tagName === 'CODE' + ) { + return codeblock.firstElementChild as HTMLElement; + } + return codeblock; +} + +/** + * Gather metadata about the code block, create the code block, then replace + * the existing DOM node with the new enhanced code block. + * + * @param codeblocks Output from `.querySelectorAll()` + * @param getContents A method that extracts a string with the codeblock contents given a codeblock element + * @param startId The ID to use for the first enhanced code block + */ +function enhanceBlocks( + codeblocks: NodeListOf, + getCodeEl: (node: HTMLElement) => HTMLElement | null, + startId = 0, +): number { + let nextCodeBlockId = startId; + + [...codeblocks] + .filter( + (codeblock: HTMLElement) => + codeblock.querySelector(`.${CODEBLOCK_PROCESSED_CLASS}`) == null && + codeblock.closest(`.${CODEBLOCK_PROCESSED_CLASS}`) == null, + ) + .forEach((codeblock) => { + if (shouldRetainLegacyCodeBlock(codeblock)) { + // We decided not to enhance this block. Mark it as processed. + codeblock.classList.add(CODEBLOCK_PROCESSED_CLASS); + return; + } + const codeblockNumericId = nextCodeBlockId++; + + const codeblockParent = codeblock.parentElement; + if (!codeblockParent) { + console.warn('useEnhancedCodeBlocks: Codeblock missing parent'); + return; + } + + const codeblockContentsEl = getCodeEl(codeblock); + if (codeblockContentsEl == null) { + return; + } + const codeblockContents = getCodeblockContents(codeblockContentsEl); + + const title = codeblock.dataset['title'] || null; + const anchorId = title + ? createCodeBlockAnchorId(codeblockNumericId, title) + : null; + + const enhancedCodeBlock = createEnhancedCodeBlock({ + codeblockNumericId, + rawContent: codeblockContents, + language: getCodeBlockLanguage(codeblock), + rawHighlightRanges: codeblock.dataset['highlight'] || null, + title, + anchorId, + showLineNumbers: + getCodeblockVariant(codeblock) !== CodeblockVariant.NO_LINE_NUMBERS, + }); + if (!enhancedCodeBlock) { + return; + } + + // Clear the old code block and replace with the enhanced block + codeblockParent.replaceChild( +
+ {enhancedCodeBlock} +
, + codeblock, + ); + }); + + // We need to add anchors to Code Block titles if applicable + new AnchorJS().add(`.${CODEBLOCK_TITLE_CLASS}`); + + return nextCodeBlockId; +} + +function shouldRetainLegacyCodeBlock(codeblock: HTMLElement): boolean { + // Don't mess with Mermaid blocks, they'll be handled by the Mermaid plugin. + if (codeblock.querySelector('.language-mermaid') != null) { + return true; + } + return getCodeblockVariant(codeblock) === CodeblockVariant.LEGACY; +} + +function getCodeblockVariant(codeblock: HTMLElement): CodeblockVariant { + const rawVariant = codeblock.dataset[ + 'variant' + ]?.toLowerCase() as CodeblockVariant | null; + if (rawVariant && Object.values(CodeblockVariant).includes(rawVariant)) { + return rawVariant as CodeblockVariant; + } + return Config.DEFAULT_CODEBLOCK_VARIANT; +} + +/***********/ +/** UTILS **/ +/***********/ + +/** + * Given an element, return the codeblock's language (if present) if the + * element's `classList` contains a class of the form `language-[language]`. + */ +function getCodeBlockLanguage(codeblockSrc: Element): string | null { + for (const className of codeblockSrc.classList) { + if (className.startsWith('language-')) { + return className.replace('language-', ''); + } + } + return null; +} + +function createCodeBlockAnchorId( + codeblockNumericId: number, + title: string, +): string { + return `${slugify(title)}-${codeblockNumericId}`; +} + +/** + * Given a codeblock / pre element, return a string reprensenting the HTML of + * the codeblock. + * + * One edge case that this method handles: Lines split within a single span. + * Consider the following codeblock (observe lines 3-4): + * ```html + * Line 1 + * Line 2 + * Line 3 + * Line 4 + * ``` + * Since the rest of the code assumes that "\n" characters separate lines, we + * need to ensure that each line starts with its own span if necessary. The + * output of this method should be: + * ```html + * Line 1 + * Line 2 + * Line 3 + * Line 4 + * ``` + */ +function getCodeblockContents(codeEl: HTMLElement): string { + const resultNode = codeEl.cloneNode() as HTMLElement; + codeEl.childNodes.forEach((childNode) => { + if (childNode.nodeType === Node.ELEMENT_NODE) { + if ( + (childNode as HTMLElement).tagName === 'SPAN' && + childNode.textContent != null + ) { + const lines = childNode.textContent.split('\n'); + lines.forEach((line, i) => { + // Ignore empty lines within a span, but still insert the \n. + if (line) { + const lineEl = childNode.cloneNode() as HTMLElement; + lineEl.textContent = line; + resultNode.appendChild(lineEl); + } + // Append a new line except after the last line in this span + if (i < lines.length - 1) { + resultNode.appendChild(document.createTextNode('\n')); + } + }); + } + } else { + resultNode.appendChild(childNode.cloneNode(true)); + } + }); + return resultNode.innerHTML; +} diff --git a/src_js/components/main_content/index.tsx b/src_js/components/main_content/index.tsx index 0aacc98d..50a782f7 100644 --- a/src_js/components/main_content/index.tsx +++ b/src_js/components/main_content/index.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import Config from '../../Config'; import { usePrintInProgress } from '../../utils/hooks/print'; import useTaskListCheckboxes from './useTaskListCheckboxes'; -import useEnhancedCodeBlocks from './useEnhancedCodeBlocks'; +import useEnhancedCodeBlocks from './enhanced_code_blocks/useEnhancedCodeBlocks'; import useMermaidDiagrams from './useMermaidDiagrams'; import useTooltippedAbbreviations from './useTooltippedAbbreviations'; import usePrefersDarkMode from '../../utils/hooks/usePrefersDarkMode'; diff --git a/src_js/components/main_content/types.ts b/src_js/components/main_content/types.ts deleted file mode 100644 index ba312952..00000000 --- a/src_js/components/main_content/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum CodeblockVariant { - ENHANCED = 'enhanced', - NO_LINE_NUMBERS = 'no-line-numbers', - LEGACY = 'legacy', -} diff --git a/src_js/components/main_content/useEnhancedCodeBlocks.tsx b/src_js/components/main_content/useEnhancedCodeBlocks.tsx deleted file mode 100644 index cffe4b95..00000000 --- a/src_js/components/main_content/useEnhancedCodeBlocks.tsx +++ /dev/null @@ -1,664 +0,0 @@ -/** @jsx JSXDom.h */ -import { RefObject } from 'preact'; -import * as JSXDom from 'jsx-dom'; -import clsx from 'clsx'; -import AnchorJS from 'anchor-js'; -import slugify from '@sindresorhus/slugify'; -import Config from '../../Config'; -import { CodeblockVariant } from './types'; - -const CODEBLOCK_LINE_CLASS = 'primer-spec-code-block-line-code'; -// We use the following class to ensure that we don't double-process code -// blocks. -const CODEBLOCK_PROCESSED_CLASS = 'primer-spec-code-block-processed'; -// Since we want to linkify code block titles, this is the class used to -// identify them to AnchorJS. -const CODEBLOCK_TITLE_CLASS = 'primer-spec-code-block-title'; -// We perform special handling for blocks in the `console` language: If a user -// clicks the line number, the entire line will be highlighted EXCLUDING the -// prompt (`$`) at the beginning, if it exists. -// See the special handling in `createCodeBlockLine()`. -const LANGUAGE_CONSOLE = 'console'; - -// We use this to keep track of click-then-drag on line numbers to select -// multiple lines simultaneously. -let mouseDownStartLine: number | null = null; - -/** - * A custom hook that enhances code blocks that are longer than two lines. - * These enhancecd code blocks show line numbers, and can optionally highlight - * lines. - * @param mainElRef A ref to the `
` element from MainContent - */ -export default function useEnhancedCodeBlocks( - mainElRef: RefObject, -): () => void { - if (!mainElRef.current) { - throw new Error( - 'Primer Spec: Main Content: Expected main content ref to be initialized.', - ); - } - - // First enhance codeblocks formatted by Jekyll + Rouge - const numCodeBlocks = enhanceBlocks( - mainElRef.current.querySelectorAll('div.highlighter-rouge'), - getCodeElFromJekyllRougeCodeblock, - 0, - ); - // Then attempt to enhance ordinary
 blocks.
-  enhanceBlocks(
-    mainElRef.current.querySelectorAll('pre'),
-    getCodeElFromPreCodeblock,
-    numCodeBlocks,
-  );
-
-  return () => {};
-}
-
-function getCodeElFromJekyllRougeCodeblock(
-  codeblock: HTMLElement,
-): HTMLElement | null {
-  // The original structure of a codeblock:
-  // 
- //
- //
-  //       
-  //         [contents]
-  //       
-  //     
- //
- //
- // - // Notice that `contents` is wrapped in a pre-formatted block. Hence, we will - // use newlines in `contents` to demarcate lines, and we need to preserve - // whitespace within the line. - const codeEl = - codeblock.firstElementChild?.firstElementChild?.firstElementChild; - if (codeEl == null) { - console.warn( - 'useEnhancedCodeBlocks: Code Block has malformed structure. See Primer Spec Docs for expected structure. https://github.com/eecs485staff/primer-spec/blob/main/docs/USAGE_ADVANCED.md#enhanced-code-blocks', - 'codeblock', - codeblock, - ); - return null; - } - - return codeEl as HTMLElement; -} - -function getCodeElFromPreCodeblock(codeblock: HTMLElement): HTMLElement | null { - // The structure of a
 codeblock:
-  // 
-  //    
-  //     [contents]
-  //   
-  // 
- if ( - codeblock.childNodes.length === 1 && - codeblock.firstElementChild?.tagName === 'CODE' - ) { - return codeblock.firstElementChild as HTMLElement; - } - return codeblock; -} - -/** - * @param codeblocks Output from `.querySelectorAll()` - * @param getContents A method that extracts a string with the codeblock contents given a codeblock element - * @param startId The ID to use for the first enhanced code block - */ -function enhanceBlocks( - codeblocks: NodeListOf, - getCodeEl: (node: HTMLElement) => HTMLElement | null, - startId = 0, -): number { - let nextCodeBlockId = startId; - - [...codeblocks] - .filter( - (codeblock: HTMLElement) => - codeblock.querySelector(`.${CODEBLOCK_PROCESSED_CLASS}`) == null && - codeblock.closest(`.${CODEBLOCK_PROCESSED_CLASS}`) == null, - ) - .forEach((codeblock) => { - if (shouldRetainLegacyCodeBlock(codeblock)) { - // We decided not to enhance this block. Mark it as processed. - codeblock.classList.add(CODEBLOCK_PROCESSED_CLASS); - return; - } - const codeblockNumericId = nextCodeBlockId++; - - const codeblockParent = codeblock.parentElement; - if (!codeblockParent) { - console.warn('useEnhanccedCodeBlocks: Codeblock missing parent'); - return; - } - - const codeblockContentsEl = getCodeEl(codeblock); - if (codeblockContentsEl == null) { - return; - } - const codeblockContents = getCodeblockContents(codeblockContentsEl); - - const title = codeblock.dataset['title'] || null; - const anchorId = title - ? createCodeBlockAnchorId(codeblockNumericId, title) - : null; - - const enhancedCodeBlock = createEnhancedCodeBlock({ - codeblockNumericId, - rawContent: codeblockContents, - language: getCodeBlockLanguage(codeblock), - rawHighlightRanges: codeblock.dataset['highlight'] || null, - title, - anchorId, - showLineNumbers: - getCodeblockVariant(codeblock) !== CodeblockVariant.NO_LINE_NUMBERS, - }); - if (!enhancedCodeBlock) { - return; - } - - // Clear the old code block and replace with the enhanced block - codeblockParent.replaceChild( -
- {enhancedCodeBlock} -
, - codeblock, - ); - }); - - // We need to add anchors to Code Block titles if applicable - new AnchorJS().add(`.${CODEBLOCK_TITLE_CLASS}`); - - return nextCodeBlockId; -} - -function shouldRetainLegacyCodeBlock(codeblock: HTMLElement): boolean { - // Don't mess with Mermaid blocks, they'll be handled by the Mermaid plugin. - if (codeblock.querySelector('.language-mermaid') != null) { - return true; - } - return getCodeblockVariant(codeblock) === CodeblockVariant.LEGACY; -} - -function getCodeblockVariant(codeblock: HTMLElement): CodeblockVariant { - const rawVariant = codeblock.dataset[ - 'variant' - ]?.toLowerCase() as CodeblockVariant | null; - if (rawVariant && Object.values(CodeblockVariant).includes(rawVariant)) { - return rawVariant as CodeblockVariant; - } - return Config.DEFAULT_CODEBLOCK_VARIANT; -} - -function createEnhancedCodeBlock(options: { - codeblockNumericId: number; - rawContent: string; - language: string | null; - rawHighlightRanges: string | null; - title?: string | null; - anchorId?: string | null; - showLineNumbers: boolean; -}): HTMLElement | null { - const { - codeblockNumericId, - rawContent, - language, - rawHighlightRanges, - title, - anchorId, - showLineNumbers, - } = options; - - const lines = rawContent.split('\n'); - if (lines.length === 0) { - console.warn('useEnhancedCodeBlocks: Code Block appears to have no lines!'); - return null; - } - const lastLine = lines[lines.length - 1]; - if (lastLine === '' || lastLine === '') { - lines.pop(); - } - - const highlightRanges = parseCodeHighlightRanges( - rawHighlightRanges, - lines.length, - ); - - const codeblockId = `primer-spec-code-block-${codeblockNumericId}`; - - const header = genCodeBlockHeader(title, anchorId); - const enhancedCodeBlock = ( -
- {header} -
- - {/* eslint-disable-next-line jsx-a11y/mouse-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} - { - if (mouseDownStartLine != null && e.target != null) { - let el = e.target as HTMLElement | null; - while (el && el.tagName !== 'TABLE') { - const match = el.id.match( - /^primer-spec-code-block-(?:\d+)-L(?:C|R)?(\d+)$/, - ); - if (match && match[1] != null) { - selectLines(codeblockId, mouseDownStartLine, +match[1]); - break; - } else { - el = el.parentNode as HTMLElement; - } - } - } - }} - onMouseLeave={() => { - mouseDownStartLine = null; - }} - onMouseUp={() => { - mouseDownStartLine = null; - }} - > - {lines.map((line, lineNumber) => - createCodeBlockLine({ - codeblockId, - language, - line, - lineNumber: lineNumber + 1, - shouldHighlight: highlightRanges.has(lineNumber + 1), - showLineNumbers, - }), - )} - -
- {lines.length > 1 ? genCopyButton(codeblockId, language) : null} -
-
- ); - return enhancedCodeBlock as HTMLElement; -} - -function createCodeBlockLine(options: { - codeblockId: string; - language: string | null; - line: string; - lineNumber: number; - shouldHighlight: boolean; - showLineNumbers: boolean; -}): HTMLElement { - const { - codeblockId, - language, - line, - lineNumber, - shouldHighlight, - showLineNumbers, - } = options; - - const L_ID = `${codeblockId}-L${lineNumber}`; - const LC_ID = `${codeblockId}-LC${lineNumber}`; - const LR_ID = `${codeblockId}-LR${lineNumber}`; - const codeblockLine = ( - - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */} - { - e.preventDefault(); - mouseDownStartLine = lineNumber; - selectLines(codeblockId, mouseDownStartLine, mouseDownStartLine); - }} - /> - - - ) as HTMLElement; - - // SPECIAL HANDLING for `console` blocks: When a user clicks the line number - // to select the entire line, attempt to exclude the leading prompt - // symbol (`$`). - if (language === LANGUAGE_CONSOLE) { - const codeLine = codeblockLine.querySelector( - `.${CODEBLOCK_LINE_CLASS}`, - ) as HTMLElement; - const firstChild = codeLine.firstChild as HTMLElement | null; - if (firstChild?.tagName === 'SPAN' && firstChild.classList.contains('gp')) { - // This prompt needs to be excluded from selection. - // (1) Remove the original LC_ID - codeLine.id = ''; - // (2) Find children to exclude from selection. Do this by searching for - // the first child that is not of class `gp` (Generic::Prompt) or - // `w` (Whitespace) - const children = [...codeLine.childNodes]; - const childrenToExcludeFromSelection = []; - let i = 0; - for (; i < children.length; ++i) { - const child = children[i] as HTMLElement; - if ( - 'classList' in child && - (child.classList.contains('gp') || child.classList.contains('w')) - ) { - childrenToExcludeFromSelection.push(child); - } else { - break; - } - } - const childrenToIncludeInSelection = children.slice(i); - // (3) Wrap remaining children in a new with id=LC_ID. - codeLine.innerHTML = ''; - codeLine.appendChild({childrenToExcludeFromSelection}); - codeLine.appendChild( - {childrenToIncludeInSelection}, - ); - } - } - - return codeblockLine; -} - -function genCopyButton(codeblockId: string, language: string | null) { - return ( -
- -
- ); -} - -const DEFAULT_COPY_LINES_MAP_FN = (line: HTMLElement) => line.innerText; -const CONSOLE_COPY_LINES_MAP_FN = (line: HTMLElement) => { - // (1) Skip console output lines - // (Class name 'go' refers to the Rouge class `Generic::Output`.) - const outputText = line.querySelector('.go'); - if (outputText) { - return null; - } - // (2) If there's a console prompt, skip it - const shadowLine = line.cloneNode(true) as HTMLElement; - let prompt: Element | null = null; - while ((prompt = shadowLine.querySelector('span.gp'))) { - // (2.1) If there is a space after the prompt, remove it - // (to dedent the command) - if (prompt.nextElementSibling?.classList.contains('w')) { - const whitespaceEl = prompt.nextElementSibling; - whitespaceEl.textContent = - whitespaceEl.textContent?.replace(' ', '') ?? null; - } - prompt.remove(); - } - return shadowLine.innerText; -}; -/** - * Copy the text of a codeblock into the clipboard. Optionally accepts a custom - * map/filter method to extract text from each line. - * - * @param codeblock The codeblock whose lines need to be copied - * @param mapFn (OPTIONAL) A method that extracts text from a given line HTMLElement - */ -async function copyLines( - codeblock: HTMLElement, - mapFn: ( - line: HTMLElement, - ) => string | null | void = DEFAULT_COPY_LINES_MAP_FN, -) { - const lines = codeblock.querySelectorAll( - `.${CODEBLOCK_LINE_CLASS}`, - ) as NodeListOf; - const linesOfText = [...lines].map((line) => mapFn(line)).filter(Boolean); - const text = linesOfText.join('\n'); - await navigator.clipboard.writeText(text); -} - -function genCodeBlockHeader(title?: string | null, anchorId?: string | null) { - if (title == null) { - return null; - } - return ( -
- - {title} - -
- ); -} - -/***********/ -/** UTILS **/ -/***********/ - -/** - * Given an element, return the codeblock's language (if present) if the - * element's `classList` contains a class of the form `language-[language]`. - */ -function getCodeBlockLanguage(codeblockSrc: Element): string | null { - for (const className of codeblockSrc.classList) { - if (className.startsWith('language-')) { - return className.replace('language-', ''); - } - } - return null; -} - -/** - * Parse a string reprenting a list of line numbers, some of which may be - * ranges. The parsed output is a Set of line numbers that are included in the - * range. - * - * For instance, the string `'13, 24-26, 25-27'` is parsed as - * `Set([13, 24, 25, 26, 27])` - * - * @param rawHighlightRanges A comma-separated string representing ranges - * @param maxLineNumber The maximum valid line number - */ -export function parseCodeHighlightRanges( - rawHighlightRanges: string | null, - maxLineNumber: number, -): Set { - const highlightedLines = new Set(); - if (!rawHighlightRanges) { - return highlightedLines; - } - - const ranges = rawHighlightRanges.split(','); - ranges.forEach((range) => { - // First check if it's a single number - const potentialLineNum = +range; - if (isNumWithinInclusiveRange(potentialLineNum, 1, maxLineNumber)) { - highlightedLines.add(potentialLineNum); - } else { - const rangeParts = range.trim().split('-'); - if (rangeParts.length === 2) { - const lower = +rangeParts[0]; - const upper = +rangeParts[1]; - if ( - isNumWithinInclusiveRange(lower, 1, maxLineNumber) && - isNumWithinInclusiveRange(upper, 1, maxLineNumber) && - lower <= upper - ) { - for (let i = lower; i <= upper; ++i) { - highlightedLines.add(i); - } - } - } - } - }); - return highlightedLines; -} - -/** - * Return a boolean indicating whether `num` is in the range [`lower`, `upper`] - * (inclusive). - */ -function isNumWithinInclusiveRange( - num: number | null, - lower: number, - upper: number, -): boolean { - return num != null && !Number.isNaN(num) && num >= lower && num <= upper; -} - -/** - * Using the Selection API, select all content between `startLine_` and - * `endLine_` for the codeblock identified by `codeblockId`. - */ -function selectLines( - codeblockId: string, - startLine_: number, - endLine_: number, -) { - let startLine = startLine_; - let endLine = endLine_; - if (startLine > endLine) { - // The range is inverted (for example, start selecting from line 4 to - // line 2). - startLine = endLine_; - endLine = startLine_; - } - const startNode = document.getElementById(`${codeblockId}-LC${startLine}`); - const endNode = document.getElementById(`${codeblockId}-LC${endLine}`); - if (!startNode || !endNode) { - console.error( - 'Primer Spec Code Block: selectLines: start or end nodes are null. Please report this issue on https://github.com/eecs485staff/primer-spec/issues. Thanks!', - ); - return; - } - - const range = document.createRange(); - range.setStart(startNode, 0); - range.setEnd(endNode, endNode.childNodes.length); - document.getSelection()?.removeAllRanges(); - document.getSelection()?.addRange(range); -} - -function createCodeBlockAnchorId( - codeblockNumericId: number, - title: string, -): string { - return `${slugify(title)}-${codeblockNumericId}`; -} - -/** - * Given a codeblock / pre element, return a string reprensenting the HTML of - * the codeblock. - * - * One edge case that this method handles: Lines split within a single span. - * Consider the following codeblock (observe lines 3-4): - * ```html - * Line 1 - * Line 2 - * Line 3 - * Line 4 - * ``` - * Since the rest of the code assumes that "\n" characters separate lines, we - * need to ensure that each line starts with its own span if necessary. The - * output of this method should be: - * ```html - * Line 1 - * Line 2 - * Line 3 - * Line 4 - * ``` - */ -function getCodeblockContents(codeEl: HTMLElement): string { - const resultNode = codeEl.cloneNode() as HTMLElement; - codeEl.childNodes.forEach((childNode) => { - if (childNode.nodeType === Node.ELEMENT_NODE) { - if ( - (childNode as HTMLElement).tagName === 'SPAN' && - childNode.textContent != null - ) { - const lines = childNode.textContent.split('\n'); - lines.forEach((line, i) => { - // Ignore empty lines within a span, but still insert the \n. - if (line) { - const lineEl = childNode.cloneNode() as HTMLElement; - lineEl.textContent = line; - resultNode.appendChild(lineEl); - } - // Append a new line except after the last line in this span - if (i < lines.length - 1) { - resultNode.appendChild(document.createTextNode('\n')); - } - }); - } - } else { - resultNode.appendChild(childNode.cloneNode(true)); - } - }); - return resultNode.innerHTML; -}