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 = (
+
+ ) 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(
-
- ) 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;
-}