Commit 37c2f46d authored by Robert Knight's avatar Robert Knight

Convert `src/sidebar/{media-embedder.js, render-markdown.js}` to TypeScript

Convert these modules to TS and improve the docs of several functions.
parent 0e6df86a
/** /**
* Return an HTML5 audio player with the given src URL. * Return an HTML5 audio player with the given src URL.
*
* @param {string} src
*/ */
function audioElement(src) { function audioElement(src: string): HTMLAudioElement {
const html5audio = document.createElement('audio'); const html5audio = document.createElement('audio');
html5audio.controls = true; html5audio.controls = true;
html5audio.src = src; html5audio.src = src;
...@@ -16,11 +14,14 @@ function audioElement(src) { ...@@ -16,11 +14,14 @@ function audioElement(src) {
* *
* See https://css-tricks.com/aspect-ratio-boxes/. * See https://css-tricks.com/aspect-ratio-boxes/.
* *
* @param {HTMLElement} element * @param element
* @param {number} aspectRatio - Aspect ratio as `width/height` * @param aspectRatio - Aspect ratio as `width/height`
* @return {HTMLElement} * @return - A container element which wraps `element`
*/ */
function wrapInAspectRatioContainer(element, aspectRatio) { function wrapInAspectRatioContainer(
element: HTMLElement,
aspectRatio: number
): HTMLElement {
element.style.position = 'absolute'; element.style.position = 'absolute';
element.style.top = '0'; element.style.top = '0';
element.style.left = '0'; element.style.left = '0';
...@@ -37,11 +38,8 @@ function wrapInAspectRatioContainer(element, aspectRatio) { ...@@ -37,11 +38,8 @@ function wrapInAspectRatioContainer(element, aspectRatio) {
/** /**
* Return an iframe DOM element with the given src URL. * Return an iframe DOM element with the given src URL.
*
* @param {string} src
* @param {number} [aspectRatio]
*/ */
function iframe(src, aspectRatio = 16 / 9) { function iframe(src: string, aspectRatio = 16 / 9): HTMLElement {
const iframe_ = document.createElement('iframe'); const iframe_ = document.createElement('iframe');
iframe_.src = src; iframe_.src = src;
iframe_.setAttribute('frameborder', '0'); iframe_.setAttribute('frameborder', '0');
...@@ -55,15 +53,15 @@ function iframe(src, aspectRatio = 16 / 9) { ...@@ -55,15 +53,15 @@ function iframe(src, aspectRatio = 16 / 9) {
* '\dh\dm\ds' format. If `timeValue` is numeric (only), * '\dh\dm\ds' format. If `timeValue` is numeric (only),
* it's assumed to be seconds and is left alone. * it's assumed to be seconds and is left alone.
* *
* @param {string} timeValue - value of `t` or `start` param in YouTube URL * @param timeValue - value of `t` or `start` param in YouTube URL
* @return {string} timeValue in seconds * @return timeValue in seconds
* @example * @example
* formatYouTubeTime('5m'); // returns '300' * formatYouTubeTime('5m'); // returns '300'
* formatYouTubeTime('20m10s'); // returns '1210' * formatYouTubeTime('20m10s'); // returns '1210'
* formatYouTubeTime('1h1s'); // returns '3601' * formatYouTubeTime('1h1s'); // returns '3601'
* formatYouTubeTime('10'); // returns '10' * formatYouTubeTime('10'); // returns '10'
**/ **/
function parseTimeString(timeValue) { function parseTimeString(timeValue: string): string {
const timePattern = /(\d+)([hms]?)/g; const timePattern = /(\d+)([hms]?)/g;
const multipliers = { const multipliers = {
h: 60 * 60, h: 60 * 60,
...@@ -76,7 +74,7 @@ function parseTimeString(timeValue) { ...@@ -76,7 +74,7 @@ function parseTimeString(timeValue) {
// match[2] - Unit (e.g. 'h','m','s', or empty) // match[2] - Unit (e.g. 'h','m','s', or empty)
while ((match = timePattern.exec(timeValue)) !== null) { while ((match = timePattern.exec(timeValue)) !== null) {
if (match[2]) { if (match[2]) {
const unit = /** @type {keyof multipliers} */ (match[2]); const unit = match[2] as keyof typeof multipliers;
seconds += Number(match[1]) * multipliers[unit]; seconds += Number(match[1]) * multipliers[unit];
} else { } else {
seconds += +match[1]; // Treat values missing units as seconds seconds += +match[1]; // Treat values missing units as seconds
...@@ -90,8 +88,7 @@ function parseTimeString(timeValue) { ...@@ -90,8 +88,7 @@ function parseTimeString(timeValue) {
* See https://developers.google.com/youtube/player_parameters for * See https://developers.google.com/youtube/player_parameters for
* all parameter possibilities. * all parameter possibilities.
* *
* @param {HTMLAnchorElement} link * @return Formatted filtered URL query string, e.g. '?start=90' or
* @return {string} formatted filtered URL query string, e.g. '?start=90' or
* an empty string if the filtered query is empty. * an empty string if the filtered query is empty.
* @example * @example
* // returns '?end=10&start=5' * // returns '?end=10&start=5'
...@@ -99,10 +96,8 @@ function parseTimeString(timeValue) { ...@@ -99,10 +96,8 @@ function parseTimeString(timeValue) {
* // - `t` is translated to `start` * // - `t` is translated to `start`
* // - `baz` is not allowed param * // - `baz` is not allowed param
* // - param keys are sorted * // - param keys are sorted
*
* @param {HTMLAnchorElement} link
*/ */
function youTubeQueryParams(link) { function youTubeQueryParams(link: HTMLAnchorElement): string {
const allowedParams = [ const allowedParams = [
'end', 'end',
'start', 'start',
...@@ -112,7 +107,7 @@ function youTubeQueryParams(link) { ...@@ -112,7 +107,7 @@ function youTubeQueryParams(link) {
const filteredQuery = new URLSearchParams(); const filteredQuery = new URLSearchParams();
// Copy allowed params into `filteredQuery`. // Copy allowed params into `filteredQuery`.
for (let [key, value] of linkParams) { for (const [key, value] of linkParams) {
if (!allowedParams.includes(key)) { if (!allowedParams.includes(key)) {
continue; continue;
} }
...@@ -135,13 +130,11 @@ function youTubeQueryParams(link) { ...@@ -135,13 +130,11 @@ function youTubeQueryParams(link) {
} }
return query; return query;
} }
/** /**
* Return a YouTube embed (<iframe>) DOM element for the given video ID. * Return a YouTube embed (<iframe>) DOM element for the given video ID.
*
* @param {string} id
* @param {HTMLAnchorElement} link
*/ */
function youTubeEmbed(id, link) { function youTubeEmbed(id: string, link: HTMLAnchorElement): HTMLElement {
const query = youTubeQueryParams(link); const query = youTubeQueryParams(link);
return iframe(`https://www.youtube.com/embed/${id}${query}`); return iframe(`https://www.youtube.com/embed/${id}${query}`);
} }
...@@ -150,24 +143,22 @@ function youTubeEmbed(id, link) { ...@@ -150,24 +143,22 @@ function youTubeEmbed(id, link) {
* Create an iframe embed generator for links that have the form * Create an iframe embed generator for links that have the form
* `https://<hostname>/<path containing a video ID>` * `https://<hostname>/<path containing a video ID>`
* *
* @param {string} hostname * @param hostname
* @param {RegExp} pathPattern - * @param pathPattern -
* Pattern to match against the pathname part of the link. This regex should * Pattern to match against the pathname part of the link. This regex should
* contain a single capture group which matches the video ID within the path. * contain a single capture group which matches the video ID within the path.
* @param {(videoId: string) => string} iframeUrlGenerator - * @param iframeUrlGenerator -
* Generate the URL for an embedded video iframe from a video ID * Generate the URL for an embedded video iframe from a video ID
* @param {object} options * @return Function that takes an `<a>` element and returns the embedded iframe
* @param {number} [options.aspectRatio] * for that link, or `null` if we don't have a matching embed generator.
* @return {(link: HTMLAnchorElement) => HTMLElement|null}
*/ */
function createEmbedGenerator( function createEmbedGenerator(
hostname, hostname: string,
pathPattern, pathPattern: RegExp,
iframeUrlGenerator, iframeUrlGenerator: (videoId: string) => string,
{ aspectRatio } = {} { aspectRatio }: { aspectRatio?: number } = {}
) { ): (link: HTMLAnchorElement) => HTMLElement | null {
/** @param {HTMLAnchorElement} link */ const generator = (link: HTMLAnchorElement) => {
const generator = link => {
if (link.hostname !== hostname) { if (link.hostname !== hostname) {
return null; return null;
} }
...@@ -190,151 +181,145 @@ function createEmbedGenerator( ...@@ -190,151 +181,145 @@ function createEmbedGenerator(
* *
* Each function either returns `undefined` if it can't generate an embed for * Each function either returns `undefined` if it can't generate an embed for
* the link, or a DOM element if it can. * the link, or a DOM element if it can.
*
* @type {Array<(link: HTMLAnchorElement) => (HTMLElement|null)>}
*/ */
const embedGenerators = [ const embedGenerators: Array<(link: HTMLAnchorElement) => HTMLElement | null> =
// Matches URLs like https://www.youtube.com/watch?v=rw6oWkCojpw [
function iframeFromYouTubeWatchURL(link) { // Matches URLs like https://www.youtube.com/watch?v=rw6oWkCojpw
if (link.hostname !== 'www.youtube.com') { function iframeFromYouTubeWatchURL(link) {
return null; if (link.hostname !== 'www.youtube.com') {
} return null;
}
if (!/\/watch\/?/.test(link.pathname)) {
return null;
}
const groups = /[&?]v=([^&#]+)/.exec(link.search); if (!/\/watch\/?/.test(link.pathname)) {
if (groups) { return null;
return youTubeEmbed(groups[1], link); }
}
return null;
},
// Matches URLs like https://youtu.be/rw6oWkCojpw const groups = /[&?]v=([^&#]+)/.exec(link.search);
function iframeFromYouTubeShareURL(link) { if (groups) {
if (link.hostname !== 'youtu.be') { return youTubeEmbed(groups[1], link);
}
return null; return null;
} },
// extract video ID from URL // Matches URLs like https://youtu.be/rw6oWkCojpw
const groups = /^\/([^/]+)\/?$/.exec(link.pathname); function iframeFromYouTubeShareURL(link) {
if (groups) { if (link.hostname !== 'youtu.be') {
return youTubeEmbed(groups[1], link); return null;
} }
return null;
},
// Matches URLs like https://vimeo.com/149000090
createEmbedGenerator(
'vimeo.com',
/^\/([^/?#]+)\/?$/,
id => `https://player.vimeo.com/video/${id}`
),
// Matches URLs like https://vimeo.com/channels/staffpicks/148845534
createEmbedGenerator(
'vimeo.com',
/^\/channels\/[^/]+\/([^/?#]+)\/?$/,
id => `https://player.vimeo.com/video/${id}`
),
// Matches URLs like https://flipgrid.com/s/030475b8ceff
createEmbedGenerator(
'flipgrid.com',
/^\/s\/([^/]+)$/,
id => `https://flipgrid.com/s/${id}?embed=true`
),
/** // extract video ID from URL
* Match Internet Archive URLs const groups = /^\/([^/]+)\/?$/.exec(link.pathname);
* if (groups) {
* The patterns are: return youTubeEmbed(groups[1], link);
* }
* 1. https://archive.org/embed/{slug}?start={startTime}&end={endTime}
* (Embed links)
*
* 2. https://archive.org/details/{slug}?start={startTime}&end={endTime}
* (Video page links for most videos)
*
* 3. https://archive.org/details/{slug}/start/{startTime}/end/{endTime}
* (Video page links for the TV News Archive [1])
*
* (2) and (3) allow users to copy and paste URLs from archive.org video
* details pages directly into the sidebar to generate video embeds.
*
* [1] https://archive.org/details/tv
*/
function iFrameFromInternetArchiveLink(link) {
if (link.hostname !== 'archive.org') {
return null; return null;
} },
// Matches URLs like https://vimeo.com/149000090
createEmbedGenerator(
'vimeo.com',
/^\/([^/?#]+)\/?$/,
id => `https://player.vimeo.com/video/${id}`
),
// Matches URLs like https://vimeo.com/channels/staffpicks/148845534
createEmbedGenerator(
'vimeo.com',
/^\/channels\/[^/]+\/([^/?#]+)\/?$/,
id => `https://player.vimeo.com/video/${id}`
),
// Matches URLs like https://flipgrid.com/s/030475b8ceff
createEmbedGenerator(
'flipgrid.com',
/^\/s\/([^/]+)$/,
id => `https://flipgrid.com/s/${id}?embed=true`
),
/**
* Match Internet Archive URLs
*
* The patterns are:
*
* 1. https://archive.org/embed/{slug}?start={startTime}&end={endTime}
* (Embed links)
*
* 2. https://archive.org/details/{slug}?start={startTime}&end={endTime}
* (Video page links for most videos)
*
* 3. https://archive.org/details/{slug}/start/{startTime}/end/{endTime}
* (Video page links for the TV News Archive [1])
*
* (2) and (3) allow users to copy and paste URLs from archive.org video
* details pages directly into the sidebar to generate video embeds.
*
* [1] https://archive.org/details/tv
*/
function iFrameFromInternetArchiveLink(link: HTMLAnchorElement) {
if (link.hostname !== 'archive.org') {
return null;
}
// Extract the unique slug from the path. // Extract the unique slug from the path.
const slugMatch = /^\/(embed|details)\/(.+)/.exec(link.pathname); const slugMatch = /^\/(embed|details)\/(.+)/.exec(link.pathname);
if (!slugMatch) { if (!slugMatch) {
return null; return null;
} }
// Extract start and end times, which may appear either as query string // Extract start and end times, which may appear either as query string
// params or path params. // params or path params.
let slug = slugMatch[2]; let slug = slugMatch[2];
const linkParams = new URLSearchParams(link.search); const linkParams = new URLSearchParams(link.search);
let startTime = linkParams.get('start'); let startTime = linkParams.get('start');
let endTime = linkParams.get('end'); let endTime = linkParams.get('end');
if (!startTime) { if (!startTime) {
const startPathParam = slug.match(/\/start\/([^/]+)/); const startPathParam = slug.match(/\/start\/([^/]+)/);
if (startPathParam) { if (startPathParam) {
startTime = startPathParam[1]; startTime = startPathParam[1];
slug = slug.replace(startPathParam[0], ''); slug = slug.replace(startPathParam[0], '');
}
} }
}
if (!endTime) { if (!endTime) {
const endPathParam = slug.match(/\/end\/([^/]+)/); const endPathParam = slug.match(/\/end\/([^/]+)/);
if (endPathParam) { if (endPathParam) {
endTime = endPathParam[1]; endTime = endPathParam[1];
slug = slug.replace(endPathParam[0], ''); slug = slug.replace(endPathParam[0], '');
}
} }
}
// Generate embed URL. // Generate embed URL.
const iframeUrl = new URL(`https://archive.org/embed/${slug}`); const iframeUrl = new URL(`https://archive.org/embed/${slug}`);
if (startTime) { if (startTime) {
iframeUrl.searchParams.append('start', startTime); iframeUrl.searchParams.append('start', startTime);
} }
if (endTime) { if (endTime) {
iframeUrl.searchParams.append('end', endTime); iframeUrl.searchParams.append('end', endTime);
} }
return iframe(iframeUrl.href); return iframe(iframeUrl.href);
}, },
// Matches URLs that end with .mp3, .ogg, or .wav (assumed to be audio files) // Matches URLs that end with .mp3, .ogg, or .wav (assumed to be audio files)
function html5audioFromMp3Link(link) { function html5audioFromMp3Link(link: HTMLAnchorElement) {
if ( if (
link.pathname.endsWith('.mp3') || link.pathname.endsWith('.mp3') ||
link.pathname.endsWith('.ogg') || link.pathname.endsWith('.ogg') ||
link.pathname.endsWith('.wav') link.pathname.endsWith('.wav')
) { ) {
return audioElement(link.href); return audioElement(link.href);
} }
return null; return null;
}, },
]; ];
/** /**
* Return an embed element for the given link if it's an embeddable link. * Return an embed element for the given link if it's an embeddable link.
* *
* If the link is a link for a YouTube video or other embeddable media then * If the link is a link for a YouTube video or other embeddable media then
* return an embed DOM element (for example an <iframe>) for that media. * return an embed DOM element (for example an <iframe>) for that media.
*
* Otherwise return undefined.
*
* @param {HTMLAnchorElement} link
* @return {HTMLElement|null}
*/ */
function embedForLink(link) { function embedForLink(link: HTMLAnchorElement): HTMLElement | null {
let embed; let embed;
let j; let j;
for (j = 0; j < embedGenerators.length; j++) { for (j = 0; j < embedGenerators.length; j++) {
...@@ -368,17 +353,14 @@ function embedForLink(link) { ...@@ -368,17 +353,14 @@ function embedForLink(link) {
* with no way to just insert a media link without it being embedded. * with no way to just insert a media link without it being embedded.
* *
* If the link is not a link to an embeddable media it will be left untouched. * If the link is not a link to an embeddable media it will be left untouched.
*
* @param {HTMLAnchorElement} link
* @return {HTMLElement|null}
*/ */
function replaceLinkWithEmbed(link) { function replaceLinkWithEmbed(link: HTMLAnchorElement): HTMLElement | null {
// The link's text may or may not be percent encoded. The `link.href` property // The link's text may or may not be percent encoded. The `link.href` property
// will always be percent encoded. When comparing the two we need to be // will always be percent encoded. When comparing the two we need to be
// agnostic as to which representation is used. // agnostic as to which representation is used.
if (link.href !== link.textContent) { if (link.href !== link.textContent) {
try { try {
const encodedText = encodeURI(/** @type {string} */ (link.textContent)); const encodedText = encodeURI(link.textContent!);
if (link.href !== encodedText) { if (link.href !== encodedText) {
return null; return null;
} }
...@@ -389,29 +371,36 @@ function replaceLinkWithEmbed(link) { ...@@ -389,29 +371,36 @@ function replaceLinkWithEmbed(link) {
const embed = embedForLink(link); const embed = embedForLink(link);
if (embed) { if (embed) {
/** @type {Element} */ (link.parentElement).replaceChild(embed, link); link.parentElement!.replaceChild(embed, link);
} }
return embed; return embed;
} }
export type EmbedOptions = {
/**
* Class name to apply to embed containers. An important function of this
* class is to set the width of the embed.
*/
className?: string;
};
/** /**
* Replace all embeddable link elements beneath the given element with embeds. * Replace all embeddable link elements beneath the given element with embeds.
* *
* All links to YouTube videos or other embeddable media will be replaced with * All links to YouTube videos or other embeddable media will be replaced with
* embeds of the same media. * embeds of the same media.
* *
* @param {HTMLElement} element * @param element - Root element to search for links
* @param {object} options
* @param {string} [options.className] -
* Class name to apply to embed containers. An important function of this class is to set
* the width of the embed.
*/ */
export function replaceLinksWithEmbeds(element, { className } = {}) { export function replaceLinksWithEmbeds(
element: HTMLElement,
{ className }: EmbedOptions = {}
) {
// Get a static (non-live) list of <a> children of `element`. // Get a static (non-live) list of <a> children of `element`.
// It needs to be static because we may replace these elements as we iterate over them. // It needs to be static because we may replace these elements as we iterate over them.
const links = Array.from(element.getElementsByTagName('a')); const links = Array.from(element.getElementsByTagName('a'));
for (let link of links) { for (const link of links) {
const embed = replaceLinkWithEmbed(link); const embed = replaceLinkWithEmbed(link);
if (embed) { if (embed) {
if (className) { if (className) {
......
...@@ -14,18 +14,15 @@ DOMPurify.addHook('afterSanitizeAttributes', node => { ...@@ -14,18 +14,15 @@ DOMPurify.addHook('afterSanitizeAttributes', node => {
}); });
function targetBlank() { function targetBlank() {
/** @param {string} text */ function filter(text: string) {
function filter(text) {
return text.replace(/<a href=/g, '<a target="_blank" href='); return text.replace(/<a href=/g, '<a target="_blank" href=');
} }
return [{ type: 'output', filter }]; return [{ type: 'output', filter }];
} }
/** @type {showdown.Converter} */ let converter: showdown.Converter;
let converter;
/** @param {string} markdown */ function renderMarkdown(markdown: string) {
function renderMarkdown(markdown) {
if (!converter) { if (!converter) {
// see https://github.com/showdownjs/showdown#valid-options // see https://github.com/showdownjs/showdown#valid-options
converter = new showdown.Converter({ converter = new showdown.Converter({
...@@ -44,30 +41,27 @@ function renderMarkdown(markdown) { ...@@ -44,30 +41,27 @@ function renderMarkdown(markdown) {
return converter.makeHtml(markdown); return converter.makeHtml(markdown);
} }
/** @param {number} id */ function mathPlaceholder(id: number) {
function mathPlaceholder(id) {
return '{math:' + id.toString() + '}'; return '{math:' + id.toString() + '}';
} }
/** type MathBlock = {
* @typedef MathBlock id: number;
* @prop {number} id inline: boolean;
* @prop {boolean} inline expression: string;
* @prop {string} expression };
*/
/** /**
* Parses a string containing mixed markdown and LaTeX in between * Parses a string containing mixed markdown and LaTeX in between
* '$$..$$' or '\( ... \)' delimiters and returns an object containing a * '$$..$$' or '\( ... \)' delimiters and returns an object containing a
* list of math blocks found in the string, plus the input string with math * list of math blocks found in the string, plus the input string with math
* blocks replaced by placeholders. * blocks replaced by placeholders.
*
* @param {string} content
* @return {{ content: string, mathBlocks: MathBlock[]}}
*/ */
function extractMath(content) { function extractMath(content: string): {
/** @type {MathBlock[]} */ content: string;
const mathBlocks = []; mathBlocks: MathBlock[];
} {
const mathBlocks: MathBlock[] = [];
let pos = 0; let pos = 0;
let replacedContent = content; let replacedContent = content;
...@@ -129,11 +123,7 @@ function extractMath(content) { ...@@ -129,11 +123,7 @@ function extractMath(content) {
}; };
} }
/** function insertMath(html: string, mathBlocks: MathBlock[]) {
* @param {string} html
* @param {MathBlock[]} mathBlocks
*/
function insertMath(html, mathBlocks) {
return mathBlocks.reduce((html, block) => { return mathBlocks.reduce((html, block) => {
let renderedMath; let renderedMath;
try { try {
...@@ -152,9 +142,15 @@ function insertMath(html, mathBlocks) { ...@@ -152,9 +142,15 @@ function insertMath(html, mathBlocks) {
} }
/** /**
* @param {string} markdown * Convert a string of markdown and math into sanitized HTML.
*
* Math expressions in the input are written as LaTeX and must be enclosed in
* `$$ .. $$` or `\( .. \)` delimiters to indicate block or inline math
* expressions. These expressions are extracted and rendered using KaTeX.
*
* @return - A string of sanitized HTML.
*/ */
export function renderMathAndMarkdown(markdown) { export function renderMathAndMarkdown(markdown: string): string {
// KaTeX takes care of escaping its input, so we want to avoid passing its // KaTeX takes care of escaping its input, so we want to avoid passing its
// output through the HTML sanitizer. Therefore we first extract the math // output through the HTML sanitizer. Therefore we first extract the math
// blocks from the input, render and sanitize the remaining markdown and then // blocks from the input, render and sanitize the remaining markdown and then
......
import * as mediaEmbedder from '../media-embedder.js'; import * as mediaEmbedder from '../media-embedder';
describe('sidebar/media-embedder', () => { describe('sidebar/media-embedder', () => {
function domElement(html) { function domElement(html) {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment