~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
235 lines · 7.4 KB Raw History
/**
 * Cutting coloured code into lines. A diff prints each line in its own row,
 * so a span the grammar opened across a line break has to be closed and
 * reopened without changing what the line says.
 */

import { describe, expect, it } from 'vitest';
import {
  diffHighlighter,
  highlightBlock,
  highlightToLines,
  languageFor,
} from '../src/lib/highlight.js';

/** The text a browser would show for a run of HTML. */
function textOf(html) {
  return html
    .replace(/<[^>]*>/g, '')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#x27;/g, "'")
    .replace(/&amp;/g, '&');
}

describe('languageFor', () => {
  it('reads the grammar off the extension', () => {
    expect(languageFor('src/index.js')).toBe('javascript');
    expect(languageFor('a/b/main.rs')).toBe('rust');
    expect(languageFor('README.md')).toBe('markdown');
    expect(languageFor('deploy.yml')).toBe('yaml');
    expect(languageFor('Sources/App/main.swift')).toBe('swift');
    expect(languageFor('app/src/main/Main.kt')).toBe('kotlin');
    expect(languageFor('build.gradle.kts')).toBe('kotlin');
    expect(languageFor('app/build.gradle')).toBe('groovy');
    expect(languageFor('Main.java')).toBe('java');
    expect(languageFor('AppDelegate.m')).toBe('objectivec');
    expect(languageFor('Info.plist')).toBe('xml');
    expect(languageFor('gradle.properties')).toBe('properties');
  });

  it('colours a Svelte component as markup', () => {
    expect(languageFor('src/App.svelte')).toBe('xml');
  });

  it('names a grammar for a file that has no extension', () => {
    expect(languageFor('Dockerfile')).toBe('dockerfile');
    expect(languageFor('docker/Dockerfile')).toBe('dockerfile');
    expect(languageFor('ios/Podfile')).toBe('ruby');
    expect(languageFor('fastlane/Fastfile')).toBe('ruby');
  });

  it('leaves an unknown file plain', () => {
    expect(languageFor('LICENSE')).toBe('');
    expect(languageFor('notes.xyz')).toBe('');
    expect(languageFor('.gitignore')).toBe('');
  });
});

describe('highlightBlock', () => {
  it('escapes the source so a file cannot become markup', () => {
    const html = highlightBlock('<script>alert(1)</script>', '');
    expect(html).not.toContain('<script>');
    expect(html).toContain('&lt;script&gt;');
  });

  it('escapes the source of a language it does colour', () => {
    const html = highlightBlock('const a = "<img onerror=x>";', 'javascript');
    expect(html).not.toMatch(/<img/);
    expect(textOf(html)).toBe('const a = "<img onerror=x>";');
  });
});

describe('highlightBlock', () => {
  it('colours the script block of a Svelte component', () => {
    const html = highlightBlock(
      '<script>\n  let count = 0;\n</script>\n<button>{count}</button>',
      languageFor('App.svelte'),
    );
    expect(html).toContain('hljs-keyword');
    expect(textOf(html)).toContain('let count = 0;');
  });
});

describe('highlightToLines', () => {
  const source = [
    '/**',
    ' * A comment that runs',
    ' * across several lines.',
    ' */',
    'const value = 1;',
  ].join('\n');

  it('returns one entry per line', () => {
    expect(highlightToLines(source, 'javascript')).toHaveLength(5);
  });

  it('says the same thing the source said, line for line', () => {
    const lines = highlightToLines(source, 'javascript');
    expect(lines.map(textOf)).toEqual(source.split('\n'));
  });

  it('keeps a span open across a line break on every line it covers', () => {
    const lines = highlightToLines(source, 'javascript');
    // The comment covers the first four lines and no more.
    for (const line of lines.slice(0, 4)) {
      expect(line).toContain('hljs-comment');
    }
    expect(lines[4]).not.toContain('hljs-comment');
  });

  it('closes every span it opens on each line', () => {
    for (const line of highlightToLines(source, 'javascript')) {
      const opened = (line.match(/<span/g) ?? []).length;
      const closed = (line.match(/<\/span>/g) ?? []).length;
      expect(closed, line).toBe(opened);
    }
  });

  it('splits plain text with no grammar', () => {
    expect(highlightToLines('a\nb\nc', '').map(textOf)).toEqual([
      'a',
      'b',
      'c',
    ]);
  });

  it('reads an empty text as one empty line', () => {
    expect(highlightToLines('', 'javascript')).toEqual(['']);
  });
});

describe('diffHighlighter', () => {
  /** The rows a diff would print, from a type and a text each. */
  function rows(marks) {
    let oldLine = 0;
    let newLine = 0;
    return marks.map(([type, text]) => ({
      type,
      text,
      oldLine: type === 'add' ? null : ++oldLine,
      newLine: type === 'delete' ? null : ++newLine,
    }));
  }

  // A doc comment gaining a line. The rows interleave both sides, so a row
  // coloured on its own would end the comment at every break.
  const before = [
    '/**',
    ' * Does a thing.',
    ' * @param {string} a',
    ' */',
    'function go(a, b) {}',
  ].join('\n');
  const after = [
    '/**',
    ' * Does a thing.',
    ' * @param {string} a - the thing',
    ' * @param {number} b',
    ' */',
    'function go(a, b) {}',
  ].join('\n');
  const marks = [
    ['context', '/**'],
    ['context', ' * Does a thing.'],
    ['delete', ' * @param {string} a'],
    ['add', ' * @param {string} a - the thing'],
    ['add', ' * @param {number} b'],
    ['context', ' */'],
    ['context', 'function go(a, b) {}'],
  ];

  it('gives back one row per line, saying what the line said', () => {
    const colour = diffHighlighter(before, after, 'javascript');
    const printed = rows(marks);
    expect(printed.map(colour).map(textOf)).toEqual(
      printed.map((row) => row.text),
    );
  });

  it('keeps the comment open across the rows it covers', () => {
    const colour = diffHighlighter(before, after, 'javascript');
    const printed = rows(marks);
    // Every row of the comment, added and deleted alike, reads as comment.
    for (const row of printed.slice(0, 6)) {
      expect(colour(row)).toContain('hljs-comment');
    }
    expect(colour(printed[6])).not.toContain('hljs-comment');
  });

  it('leaves rows alone when the file has no grammar', () => {
    const colour = diffHighlighter(before, after, '');
    const printed = rows(marks);
    expect(printed.map(colour).map(textOf)).toEqual(
      printed.map((row) => row.text),
    );
  });

  it('colours a JSX tag whose close tag is outside the hunk', () => {
    // highlight.js reads a tag as JSX only when it can see the close tag, so
    // this row reads as plain text when the hunk is coloured on its own.
    const source = [
      'function App() {',
      '  return (',
      '    <Portal>',
      '      <Positioner className="z-50" sideOffset={6}>',
      '        <Popup />',
      '      </Positioner>',
      '    </Portal>',
      '  );',
      '}',
    ].join('\n');
    const colour = diffHighlighter(source, source, 'javascript');
    const row = {
      type: 'add',
      text: '      <Positioner className="z-50" sideOffset={6}>',
      oldLine: null,
      newLine: 4,
    };
    expect(colour(row)).toContain('hljs-attr');
    expect(textOf(colour(row))).toBe(row.text);
  });

  it('falls back to the row text when the line is off the end', () => {
    const colour = diffHighlighter('', '', 'javascript');
    const row = {
      type: 'add',
      text: 'const html = "<img onerror=x>";',
      oldLine: null,
      newLine: 9,
    };
    expect(colour(row)).not.toMatch(/<img/);
    expect(textOf(colour(row))).toBe(row.text);
  });
});