]

Thorne Brandt

Mobile Menu

Portfolio > Development > This Website

This Website

This website that you are looking at is powered by Ruby on Rails and React/Redux admin page.








There's a simple WYSIWYG, image uploader and tagging system.





Github repo

Rails is traditional a strong choice for multi-page applications ( such as a blog. ) and I specifically chose it because of the ease of server side rendering for SEO. ActiveRecord for the backend is pretty simple to work with. I chose Redux for the admin precisely because I didn't know it and this was a great project to experiment creating a WYSIWYG from scratch using Test-Driven Development.

Here are the tests for the WYSIWYG. This was one of my first exercises of TDD for a larger project. While this isn't the cleanest WYSWIG in the world, and makes a lot of opinions ( for example, it adds grid tags around image tags ) , it serves my purpose for quickly creating content for a blog post. These tests were written first to fail, and then I wrote some regex to make them pass.



import * as helpers from '../Posts/helpers/';

describe('textToHTML', () => {
  it('parses html', () => {
    const body = 'hey';
    const bodyHTML = '<p>\nhey\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('removes extraneous paragraphs', () => {
    const body = ''
    const bodyHTML = '';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('parses html line breaks', () => {
    const body = 'firstLine\nsecondLine\nthirdLine';
    const bodyHTML = '<p>\nfirstLine\n<br/>\nsecondLine\n<br/>\nthirdLine\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('converts <p> tags to line breaks', () => {
    const bodyHTML = '<p>\nP tag here\n</p>';
    const body = 'P tag here\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts <img> markup to img[]', () => {
    const body = 'img[images/fakeImage.jpg]';
    const bodyHTML = '\n<div class="row">\n' +
            '<div class="col-lg-12">\n' +
            '<img class="preview" src="images/fakeImage.jpg"/>\n' +
            '</div>\n' +
            '</div>\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts double <img> markup to imgs[]', () => {
    const body = 'img[images/fakeImage.jpg,images/fakeImage2.jpg]';
    const bodyHTML = '\n<div class="row">\n' +
            '<div class="col-lg-6">\n' +
            '<img class="preview" src="images/fakeImage.jpg"/>\n' +
            '</div>\n' +
            '<div class="col-lg-6">\n' +
            '<img class="preview" src="images/fakeImage2.jpg"/>\n' +
            '</div>\n' +
            '</div>\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts double <img> markup to imgs[]', () => {
    const body = 'img[images/fakeImage.jpg,images/fakeImage2.jpg]';
    const bodyHTML = '\n<div class="row">\n' +
            '<div class="col-lg-6">\n' +
            '<img class="preview" src="images/fakeImage.jpg"/>\n' +
            '</div>\n' +
            '<div class="col-lg-6">\n' +
            '<img class="preview" src="images/fakeImage2.jpg"/>\n' +
            '</div>\n' +
            '</div>\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts <video> markup to vid[]', () => {
    const body = 'vid[fakeVideo]';
    const bodyHTML = [
      '<video controls poster="fakePoster.png">\n',
      '<source src="/videos/fakeVideo.webm" type="video/webm">\n',
      '<source src="/videos/fakeVideo.ogv" type="video/ogg">\n',
      '<source src="/videos/fakeVideo.mp4" type="video/mp4">\n',
      '</video>'
    ].join('');
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts multiple <p> tags to line breaks', () => {
    const bodyHTML = '<p>P tag here</p><p>Second Paragraph</p>';
    const body = 'P tag here\n\nSecond Paragraph\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts a <p> tag that include a class with line breaks', () => {
    const bodyHTML = '<p class="hey">tag with class</p>';
    const body = 'tag with class\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('converts <br> tags with line break', () => {
    const bodyHTML = '<p>FirstLine<br/>SecondLine</p>';
    const body = 'FirstLine\nSecondLine\n';
    expect(helpers.HTMLToText(bodyHTML)).toEqual(body);
  });

  it('parses html double line breaks as paragraphs', () => {
    const body = 'firstParagraph\n\nsecondParagraph';
    const bodyHTML = '<p>\nfirstParagraph\n</p>\n<p>\nsecondParagraph\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('determines an image', () => {
    expect(helpers.isImage("image.jpg")).toBe(true);
    expect(helpers.isImage("image.png")).toBe(true);
  });

  it('determines a video', () => {
    expect(helpers.isVideo("image.ogv")).toBe(true);
    expect(helpers.isVideo("image.mp4")).toBe(true);
  });

  it('determines if name', () => {
    expect(helpers.isName("oneString")).toBe(true);
    expect(helpers.isName("o String")).toBe(false);
  });

  it('doesnt allow invalid images', () => {
    expect(helpers.isImage("n.onImagejpg")).toBe(false);
    expect(helpers.isImage("i mage.jpg")).toBe(false);
    expect(helpers.isImage("image\nnewline.jpg")).toBe(false);
  });

  it('doesnt allow invalid videos', () => {
    expect(helpers.isImage("videomp4")).toBe(false);
    expect(helpers.isImage("v ideo.mp4")).toBe(false);
    expect(helpers.isImage("vid/coolvid.mp4")).toBe(false);
    expect(helpers.isImage("vid\ncoolvid.mp4")).toBe(false);
  });

  it('parses img tags', () => {
    const body = 'img[images/fakeImage.jpg]';
    const bodyHTML = '\n<div class="row">\n' +
            '<div class="col-lg-12">\n' +
            '<img class="preview" src="images/fakeImage.jpg"/>\n' +
            '</div>\n' +
            '</div>\n';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('parses imgs (plural) tags', () => {
    const body = 'imgs[images/fakeImage.jpg,images/fakeImage2.jpg]';
    const bodyHTML = '\n<div class="row">\n' +
            '<div class="col-lg-6">\n' +
            '<img class="preview" src="images/fakeImage.jpg"/>\n' +
            '</div>\n' +
            '<div class="col-lg-6">\n' +
            '<img class="preview" src="images/fakeImage2.jpg"/>\n' +
            '</div>\n' +
            '</div>\n';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('parses img tags with text around them', () => {
    const body = 'I am going to show an image. img[images/fakeImage.jpg] after image';
    const bodyHTML = '<p>\nI am going to show an image. \n</p>\n' +
            '<div class="row">\n' +
            '<div class="col-lg-12">\n' +
            '<img class="preview" src="images/fakeImage.jpg"/>\n' +
            '</div>\n' +
            '</div>\n' +
            '<p>\n'+
            ' after image\n' +
            '</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('parses code tags', () => {
    const body = '```alert("hello");```'
    const bodyHTML = '<p>\n<pre><code>alert("hello");</code></pre>\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('does not add html line breaks to code tags', () => {
    const body = 'line break after this\n```alert("hello");\nalert("goodbye");\nalert("wait");```'
    const bodyHTML = '<p>\nline break after this\n<br/>\n<pre><code>alert("hello");\nalert("goodbye");\nalert("wait");</code></pre>\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('adds less than and greater than to html codes', () => {
    const body = '```<p>hello</p>```';
    const bodyHTML = '<p>\n<pre><code><p>hello</p></code></pre>\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('does not parse incomplete image tags', () => {
    const body = 'img[images/fakeImage.jpg'
    const bodyHTML = '<p>\nimg[images/fakeImage.jpg\n</p>';
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });

  it('strips file extension', () => {
    const fileName = 'fakeExtension.mov';
    const stripped = 'fakeExtension';
    expect(helpers.stripExtension(fileName)).toBe(stripped);
  });

  it('parses video tags', () => {
    const body = 'vid[video]';
    const bodyArray = [
      '<p>\n<video controls poster="/images/video.png">\n',
      '<source src="/videos/video.webm" type="video/webm">\n',
      '<source src="/videos/video.ogv" type="video/ogg">\n',
      '<source src="/videos/video.mp4" type="video/mp4">\n',
      '</video>\n</p>'
    ];
    const bodyHTML = bodyArray.join('');
    expect(helpers.textToHTML(body)).toEqual(bodyHTML);
  });
});

...and then the actual methods looked like this.


const parseVideos = (body) => {
	const reg = /vid\[([^\]]+)\]/g;
	const bodyArray = [
		'<video controls poster="/images/$1.png">\n',
		'<source src="/videos/$1.webm" type="video/webm">\n',
		'<source src="/videos/$1.ogv" type="video/ogg">\n',
		'<source src="/videos/$1.mp4" type="video/mp4">\n',
		'</video>'
	];
	return body.replace(reg, bodyArray.join(''));
};

It's assuming that I have uploaded three versions of the video for different browsers, as well as an image with the same name. If there's a discrepancy, I can always switch to html mode for fine tuning. Since it's react, it's fairly trivial to display a live preview, so it's a quick scrolldown to see if the post looks correctly.

This complete file is here

There's also some interesting code in the uploaderActions.


export const chooseFile = (target) => dispatch => {
	let file, reader;	
	dispatch(loading(true));
	if(target.files.length){
		file = target.files[target.files.length - 1];
		if(file.type.match(/image.*/)){
			if(window.FileReader) {
				reader = new FileReader();
				reader.addEventListener('load', (e) => {
					dispatch(loading(false));
					return dispatch(showImagePreview(e.target.result, file));
				});
			}
		} else {
			dispatch(loading(false));
			return dispatch(showFileSize(file));
		}
		reader.readAsDataURL(file);
	} else {
		dispatch(loading(false));
		return dispatch(invalidFile);
	}
};

export const showImagePreview = (src, file) => {
	return ({
		type: 'SHOW_IMAGE_PREVIEW',
		src,
		file
	});
};


export const uploadFile = (e, file) => dispatch => {
	e.preventDefault();
	let fetchFile;
	let fd = new FormData();
	if(file.type.match(/image.*/)){
		fetchFile = fetchImage;
		fd.append('image', file);
	} else if(file.type.match(/video.*/)) {
		fetchFile = fetchVideo;
		fd.append('video', file);
	} else {
		return dispatch(throwError('Didn\'t recognize that file type: ' + file.type));
	}
	dispatch(loading(true));
	return fetchFile(fd)
		.then(response => response.json())
		.then((json) => {
			dispatch(loading(false));
			return dispatch(addFile(json));
		})
		.catch((e) => { 
			dispatch(loading(false));
			const errorString = 'There was a problem with uploading the file\n' + e;
			return dispatch(throwError(errorString));
		});
};

This will determine the type of file that I want to upload, determine file size, show an image preview ( if it's an image ) , then show the path which is easily copy pasted in to the WYSWIG.

Contact me if you'd be interested in something like this tool for your personal site and I can provide some assistance.