Last active
September 16, 2025 14:56
-
-
Save Alhadis/2a35868a8b59cd80b3889b3afa90059d to your computer and use it in GitHub Desktop.
GitHub's BlobAnchor module
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * @version 9550ebb3836e | |
| * @fileoverview | |
| * Port of GitHub's `blob-anchor.ts` module, used by its front-end | |
| * to select ranges of text from a file permalink's fragment identifier. | |
| */ | |
| "use strict"; | |
| const {Point, Range} = require("atom"); | |
| module.exports = { | |
| formatBlobRange, | |
| parseBlobOffset, | |
| parseBlobRange, | |
| }; | |
| /** | |
| * Convert a blob-anchor range into its fragment identifier format. | |
| * | |
| * @example | |
| * formatBlobRange({start: {row: 3}}) === "L3"; | |
| * formatBlobRange({start: {row: 3}, end: {row: 5}}) === "L3-L5"; | |
| * formatBlobRange({start: {row: 3, column: 1}, end: {row: 5, column: 5}}) === "L3C1-L5C5"; | |
| * formatBlobRange({start: {row: 3, column: 1}, end: {row: 5,}}) === "L3C1-L5"; | |
| * @param {Atom.Range} | |
| * @return {String} | |
| * @api public | |
| */ | |
| function formatBlobRange(range){ | |
| range = normaliseRange(range); | |
| if(range.start.compare(range.end) > 0) | |
| range = new Range(range.end, range.start); | |
| const {start, end} = range; | |
| if(start.column && end.column) return `L${start.row}C${start.column}-L${end.row}C${end.column}`; | |
| if(start.column) return `L${start.row}C${start.column}-L${end.row}`; | |
| if(end.column) return `L${start.row}-L${end.row}C${end.column}`; | |
| if(start.row === end.row) return `L${start.row}`; | |
| return `L${start.row}-L${end.row}`; | |
| } | |
| /** | |
| * Convert a range-compatible object into a range instance. | |
| * | |
| * @param {Atom.Range|Atom.Point[]|Object} input | |
| * @throws {TypeError} Argument must be a valid range object. | |
| * @return {Atom.Range} | |
| * @api private | |
| */ | |
| function normaliseRange(input){ | |
| if(input instanceof Range) | |
| return input; | |
| if(Array.isArray(input)) | |
| return Range.fromObject(input); | |
| if("object" === typeof input && null !== input){ | |
| const {start, end = start} = input; | |
| return Range.fromObject({start, end}); | |
| } | |
| throw new TypeError(`Invalid range object: ${input}`); | |
| } | |
| /** | |
| * Parse a line/column pair from a fragment identifier. | |
| * | |
| * @example parseBlobOffset("L4C15") === Atom.Point(4, 15); | |
| * @param {String} input | |
| * @return {?Atom.Point} | |
| * @api public | |
| */ | |
| function parseBlobOffset(input){ | |
| const line = input.match(/L(\d+)/); | |
| const column = input.match(/C(\d+)/); | |
| if(!line) return null; | |
| return new Point( | |
| parseInt(line[1], 10), | |
| column ? parseInt(column[1], 10) : null, | |
| ); | |
| } | |
| /** | |
| * Parse a blob-anchor range a fragment identifier. | |
| * | |
| * @example | |
| * parseBlobRange("#L3") === Atom.Range({row: 3}, {row: 3}); | |
| * parseBlobRange("L3-L5") === Atom.Range({row: 3}, {row: 5}); | |
| * parseBlobRange("") === null; | |
| * @param {String} input | |
| * @return {?Atom.Range} | |
| * @api public | |
| */ | |
| function parseBlobRange(input){ | |
| const lines = `${input}`.match(/#?L(\d+)(C(\d+))?/g); | |
| if(!lines) return null; | |
| if(1 === lines.length){ | |
| const offset = parseBlobOffset(lines[0]); | |
| return offset && new Range(offset, offset); | |
| } | |
| if(2 === lines.length){ | |
| let start = parseBlobOffset(lines[0]); | |
| let end = parseBlobOffset(lines[1]); | |
| if(!start || !end) return null; | |
| if(end.compare(start) > 0) | |
| [start, end] = [end, start]; | |
| return new Range(start, end); | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| export interface BlobOffset{ | |
| // Line number | |
| // Lines start at 1. | |
| line: number | |
| // Optional column number. | |
| // Like lines, columns are 1-indexed. | |
| column: number | null | |
| } | |
| export interface BlobRange{ | |
| // Starting offset of range. | |
| start: BlobOffset | |
| // End offset of range. | |
| // End offset may be the same as the start. This indicates a collapsed range. | |
| // Should always be initialized to a value that is equal or greater than the | |
| // start range such the range is always in ascending order. Reversed ranges | |
| // have undefined behavior. | |
| end: BlobOffset | |
| } | |
| // Inverse of `formatBlobRange`. | |
| // | |
| // Examples | |
| // | |
| // parseBlobRange("#L3") | |
| // // => {start: {line: 3}} | |
| // | |
| // parseBlobRange("L3-L5") | |
| // // => {start: {line: 3}, end: {line: 5}} | |
| // | |
| // parseBlobRange("") | |
| // // => null | |
| export function parseBlobRange(str: string): BlobRange | undefined{ | |
| const lines = str.match(/#?(?:L)(\d+)((?:C)(\d+))?/g) | |
| if(!lines) | |
| return; | |
| else if(lines.length === 1){ | |
| const offset = parseBlobOffset(lines[0]); | |
| if(!offset) return; | |
| return Object.freeze({start: offset, end: offset}); | |
| } | |
| else if(lines.length === 2){ | |
| const startOffset = parseBlobOffset(lines[0]); | |
| const endOffset = parseBlobOffset(lines[1]!); | |
| if(!startOffset || !endOffset) return; | |
| return ascendingBlobRange(Object.freeze({ | |
| start: startOffset, | |
| end: endOffset, | |
| })); | |
| } | |
| } | |
| // Inverse of `parseBlobRange`. | |
| // | |
| // Examples | |
| // | |
| // formatBlobRange({start: {line: 3}}) | |
| // // => "L3" | |
| // | |
| // formatBlobRange({start: {line: 3}, end: {line: 5}}) | |
| // // => "L3-L5" | |
| // | |
| // formatBlobRange({start: {line: 3, column: 1}, end: {line: 5, column: 5}}) | |
| // // => "L3C1-L5C5" | |
| // | |
| // formatBlobRange({start: {line: 3, column: 1}, end: {line: 5,}}) | |
| // // => "L3C1-L5" | |
| export function formatBlobRange(blobRange: BlobRange): string { | |
| const {start, end} = ascendingBlobRange(blobRange); | |
| if(start.column != null && end.column != null) | |
| return `L${start.line}C${start.column}-L${end.line}C${end.column}`; | |
| else if(start.column != null) | |
| return `L${start.line}C${start.column}-L${end.line}`; | |
| else if(end.column != null) | |
| return `L${start.line}-L${end.line}C${end.column}`; | |
| else if(start.line === end.line) | |
| return `L${start.line}`; | |
| else | |
| return `L${start.line}-L${end.line}`; | |
| } | |
| // Returns a String containing the file prefix with trailing dash. | |
| // | |
| // Examples | |
| // | |
| // parseAnchorPrefix("#file-zshrc-L3") | |
| // // => "file-zshrc-" | |
| // | |
| // parseAnchorPrefix("file-zshrc-L3-L5") | |
| // // => "file-zshrc-" | |
| // | |
| // parseAnchorPrefix("") | |
| // // => "" | |
| function parseAnchorPrefix(str: string): string { | |
| const match = str.length < 5000 && str.match(/(file-.+?-)L\d+?/i); | |
| return match ? match[1]! : ""; | |
| } | |
| export type AnchorInfo = { | |
| blobRange: BlobRange | |
| anchorPrefix: string | |
| }; | |
| // Examples | |
| // | |
| // parseFileAnchor("#file-zshrc-L3") | |
| // // => { blobRange: {start: {line: 3}}, anchorPrefix: "file-zshrc-" } | |
| // | |
| // parseFileAnchor("file-zshrc-L3-L5") | |
| // // => { blobRange: {start: {line: 3}, end: {line: 5}}, anchorPrefix: "file-zshrc-" } | |
| // | |
| // parseFileAnchor("") | |
| // // => { blobRange: null, anchorPrefix: undefined } | |
| export function parseFileAnchor(str: string): AnchorInfo { | |
| const blobRange = parseBlobRange(str)!; | |
| const anchorPrefix = parseAnchorPrefix(str); | |
| return {blobRange, anchorPrefix}; | |
| } | |
| // Formats line number range pair as an anchor String. | |
| // | |
| // Examples | |
| // | |
| // formatBlobRangeAnchor({blobRange: {start: {line: 3}}, anchorPrefix: ""}) | |
| // // => "#L3" | |
| // | |
| // formatBlobRangeAnchor({blobRange: {start: {line: 3}}, anchorPrefix: "file-zshrc-"}) | |
| // // => "#file-zshrc-L3" | |
| // | |
| // formatBlobRangeAnchor({blobRange: {start: {line: 3}, end: {line: 5}}, anchorPrefix: ""}) | |
| // // => "#L3-L5" | |
| // | |
| // formatBlobRangeAnchor({blobRange: {start: {line: 3, column: 1}, end: {line: 5, column: 5}}, anchorPrefix: ""}) | |
| // // => "#L3C1-L5C5" | |
| // | |
| // formatBlobRangeAnchor({blobRange: {start: {line: 3}, end: {line: 5}}, anchorPrefix: "file-zshrc-"}) | |
| // // => "#file-zshrc-L3-L5" | |
| // | |
| // formatBlobRangeAnchor({blobRange: null, anchorPrefix: ""}) | |
| // // => "#" | |
| // | |
| // formatBlobRangeAnchor({blobRange: null, anchorPrefix: "file-zshrc-"}) | |
| // // => "#" | |
| export function formatBlobRangeAnchor({anchorPrefix, blobRange}: AnchorInfo): string{ | |
| if(!blobRange) return "#" | |
| return `#${anchorPrefix}${formatBlobRange(blobRange)}`; | |
| } | |
| function parseBlobOffset(str: string): BlobOffset | null{ | |
| const lineMatch = str.match(/L(\d+)/); | |
| const columnMatch = str.match(/C(\d+)/); | |
| if(lineMatch) | |
| return Object.freeze({ | |
| line: parseInt(lineMatch[1]!), | |
| column: columnMatch ? parseInt(columnMatch[1]!) : null, | |
| }); | |
| else | |
| return null; | |
| } | |
| export function DOMRangeFromBlob( | |
| blobRange: BlobRange, | |
| getLineElement: (line: number) => Node | null, | |
| ): Range | undefined{ | |
| const [startContainer, _startOffset] = findRangeOffset(blobRange.start, true, getLineElement); | |
| const [endContainer, _endOffset] = findRangeOffset(blobRange.end, false, getLineElement); | |
| if(!startContainer || !endContainer) return; | |
| // Treat -1 as full line selection | |
| let startOffset = _startOffset; | |
| let endOffset = _endOffset; | |
| if(startOffset === -1) startOffset = 0; | |
| if(endOffset === -1) endOffset = endContainer.childNodes.length; | |
| if(!startContainer.ownerDocument) throw new Error(`DOMRange needs to be inside document`); | |
| const range = startContainer.ownerDocument.createRange(); | |
| range.setStart(startContainer, startOffset); | |
| range.setEnd(endContainer, endOffset); | |
| return range; | |
| } | |
| function findRangeOffset( | |
| offset: BlobOffset, | |
| lookAhead: boolean, | |
| getLineElement: (n: number) => Node | null, | |
| ): [Node | null, number]{ | |
| const error: [null, number] = [null, 0]; | |
| const lineElement = getLineElement(offset.line); | |
| if(!lineElement) return error; | |
| if(offset.column == null) | |
| return [lineElement, -1]; | |
| let column = offset.column - 1; | |
| const textNodes = getAllTextNodes(lineElement); | |
| for(let i = 0; i < textNodes.length; i++){ | |
| const textNode = textNodes[i]! | |
| // TODO: length might be buggy with emoji | |
| const nextC = column - (textNode.textContent || "").length; | |
| if(nextC === 0){ | |
| const nextTextNode = textNodes[i + 1]; | |
| if(lookAhead && nextTextNode) | |
| return [nextTextNode, 0]; | |
| else | |
| return [textNode, column]; | |
| } | |
| else if(nextC < 0) | |
| return [textNode, column]; | |
| column = nextC; | |
| } | |
| return error; | |
| } | |
| // Get a flat list of text nodes in depth first order. | |
| function getAllTextNodes(el: Node): Node[]{ | |
| if(el.nodeType === Node.TEXT_NODE) | |
| return [el]; | |
| if(!el.childNodes || !el.childNodes.length) return []; | |
| let list: Node[] = []; | |
| for(const node of el.childNodes) | |
| list = list.concat(getAllTextNodes(node)); | |
| return list; | |
| } | |
| // Sorts range start and end offsets to be in ascending order. | |
| function ascendingBlobRange(range: BlobRange): BlobRange{ | |
| const offsets = [range.start, range.end]; | |
| offsets.sort(compareBlobOffsets); | |
| if(offsets[0] === range.start && offsets[1] === range.end) | |
| return range; | |
| else | |
| return Object.freeze({ | |
| start: offsets[0]!, | |
| end: offsets[1]!, | |
| }); | |
| } | |
| // Compare line offsets. May be used with Array.sort | |
| function compareBlobOffsets(a: BlobOffset, b: BlobOffset): number{ | |
| if(a.line === b.line && a.column === b.column) | |
| return 0; | |
| else if(a.line === b.line && typeof a.column === "number" && typeof b.column === "number") | |
| return a.column - b.column; | |
| else | |
| return a.line - b.line; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/sh | |
| set -e | |
| tmp="${TMPDIR%/}" | |
| tsc -t esnext --strict --outDir "$tmp" blob-anchor.ts | |
| unexpand -t4 "$tmp/blob-anchor.js" | diff -U4 blob-anchor.js - |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment