vanilla-framework
Version:
A simple, extendable CSS framework.
555 lines (480 loc) • 20 kB
JavaScript
(function () {
if (!window.VANILLA_VERSION) {
throw Error('VANILLA_VERSION not specified.');
}
// throttling function calls, by Remy Sharp
// http://remysharp.com/2010/07/21/throttling-function-calls/
const throttle = function (fn, delay) {
let timer = null;
return function () {
const context = this,
args = arguments;
clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
};
/**
* Mapping of example keys to the regex patterns used to strip them out of an example
* @type {{body: RegExp, jinja: RegExp, title: RegExp, head: RegExp}}
*/
const EXAMPLE_CONTENT_PATTERNS = {
body: /<body[^>]*>((.|[\n\r])*)<\/body>/im,
jinja: /{% block content %}([\s\S]*?){% endblock( content)? %}/,
title: /<title[^>]*>((.|[\n\r])*)<\/title>/im,
head: /<head[^>]*>((.|[\n\r])*)<\/head>/im,
};
/**
* Object representing the structure for language option mappings.
* @typedef {Object} ExampleLanguageConfig
* @property {string} label - Human-readable label.
* @property {string} langIdentifier - Prism language identifier.
*/
/**
* Mapping of example keys to their configurations.
* @type {{jinja: ExampleLanguageConfig, css: ExampleLanguageConfig, js: ExampleLanguageConfig, html: ExampleLanguageConfig}}
*/
const EXAMPLE_LANGUAGE_OPTION_CONFIG = {
html: {
label: 'HTML',
langIdentifier: 'html',
},
css: {
label: 'CSS',
langIdentifier: 'css',
},
js: {
label: 'JS',
langIdentifier: 'js',
},
jinja: {
label: 'Jinja',
// While `jinja2` is an option on Prism, it does not seem to highlight syntax properly. So use HTML instead.
langIdentifier: 'html',
},
};
const CODEPEN_CONFIG = {
title: 'Vanilla framework example',
head: "<meta name='viewport' content='width=device-width, initial-scale=1'>",
stylesheets: [
// link to latest Vanilla CSS
// if it's a demo, use local build.css for better QA, otherwise use latest published Vanilla
/demos\.haus$/.test(window.location.host)
? `${window.location.origin}/static/build/css/build.css`
: 'https://assets.ubuntu.com/v1/vanilla_framework_version_' + VANILLA_VERSION.replaceAll('.', '_') + '_min.css',
// link to example stylesheet (to set margin on body)
'https://assets.ubuntu.com/v1/4653d9ba-example.css',
],
tags: ['Vanilla framework'],
};
document.addEventListener('DOMContentLoaded', function () {
const examples = document.querySelectorAll('.js-example');
[].slice.call(examples).forEach((placementElement) => {
renderExample(placementElement).catch((error) => {
console.error('Failed to render example', {placementElement, error});
});
});
});
/**
* `fetch()` wrapper that throws an error if the response is not OK.
* @param {String} url Address to fetch
* @param {RequestInit} opts Options for the fetch request
* @returns {Promise<Response>} Response object
* @throws {Error} If the response is not in the 200 (OK) range
*/
const fetchResponse = async function (url, opts = {}) {
try {
const response = await fetch(url, opts);
if (!response.ok) {
throw new Error(`Failed to fetch example ${url} with status ${response.status}`);
}
return response;
} catch (err) {
console.error('An error occurred while performing a fetch request', err);
throw err;
}
};
/**
* Fetch the response text of a URL.
* @param {String} url Address to fetch
* @returns {Promise<String>} Response text
* @throws {Error} If the response is not in the 200 (OK) range
*/
const fetchResponseText = async function (url) {
return fetchResponse(url).then((response) => response.text());
};
/**
* Format source code based on language
* @param {String} source - source code to format
* @param {'html'|'jinja'|'js'|'css'} lang - language of the source code
* @returns {String} formatted source code
*/
function formatSource(source, lang) {
try {
switch (lang) {
case 'html':
case 'jinja':
return window.html_beautify(source, {indent_size: 2});
case 'js':
return window.js_beautify(source, {indent_size: 2});
case 'css':
return window.css_beautify(source, {indent_size: 2});
default:
return source;
}
} catch (error) {
// If beautify fails (e.g. invalid source, CDN outage, error upstream), return original source
console.error(`Failed to format ${lang} source code: ${error}`, `This could be due to invalid ${lang} source code, an issue with the formatter, or a CDN outage.`, {source});
return source;
}
}
/**
* Create `pre`-formatted code for a block of source
* @param {String} source Formatted source code
* @param {'html'|'jinja'|'js'|'css'} lang Language of the source code
* @param {Boolean} isHidden Whether the pre-code should be hidden initially
* @returns {HTMLPreElement} Code snippet containing the source code
*/
function createPreCode(source, lang, isHidden = true) {
const code = document.createElement('code');
code.appendChild(document.createTextNode(source));
const pre = document.createElement('pre');
pre.classList.add('p-code-snippet__block');
// TODO: move max-height elsewhere to CSS?
pre.style.maxHeight = '300px';
if (isHidden) {
pre.classList.add('u-hide');
}
if (lang) {
pre.setAttribute('data-lang', lang);
pre.classList.add('language-' + (EXAMPLE_LANGUAGE_OPTION_CONFIG[lang]?.langIdentifier || lang));
}
pre.appendChild(code);
return pre;
}
/**
* Extract a section of HTML from the document
* @param {'body'|'jinja'|'title'|'head'} sectionKey The key/type of content to be extracted
* @param {String} documentHTML The example's full HTML content. This may be rendered or raw Jinja template.
* @returns {String} The requested section of the document, or an empty string if it was not found.
*/
function getExampleSection(sectionKey, documentHTML) {
const pattern = EXAMPLE_CONTENT_PATTERNS[sectionKey];
return pattern?.exec(documentHTML)?.[1]?.trim() || '';
}
/**
* Fetches the rendered HTML of an example and extracts the relevant sections for rendering and code snippets.
* @param {HTMLAnchorElement} placementElement The placeholder element for the example
* @returns {Promise<{renderedHtml: String, bodyHtml: String, title: String, jsSource: String, externalScripts: NodeListOf<Element>, cssSource: String}>} The extracted sections of the example
*/
async function fetchRenderedHtml(placementElement) {
const renderedHtml = await fetchResponseText(placementElement.href);
let bodyHtml = getExampleSection('body', renderedHtml);
// Extract JS from the body before we strip it out
let jsSource = getScriptFromSource(bodyHtml);
const externalScripts = getExternalScriptsFromSource(renderedHtml);
// Filter external scripts to only include project-local scripts (not CDN/third-party)
// and exclude utility/tool files that aren't part of the actual example
const baseUrl = new URL(placementElement.href);
const projectScripts = externalScripts.filter((scriptSrc) => {
try {
const scriptUrl = new URL(scriptSrc, baseUrl);
// Only include scripts from the same origin (project scripts)
if (scriptUrl.origin !== baseUrl.origin) {
return false;
}
// Exclude common utility files that aren't part of the actual example code
const excludedFiles = ['example-tools.js', 'example.js', 'build.js'];
const scriptPath = scriptUrl.pathname;
return !excludedFiles.some((excludedFile) => scriptPath.includes(excludedFile));
} catch (error) {
return false;
}
});
// Render external scripts, if no inline-script was found
if (!jsSource && projectScripts.length > 0) {
const projectScriptContents = await Promise.all(
projectScripts.map(async (scriptSrc) => {
try {
const absoluteUrl = new URL(scriptSrc, baseUrl).href;
const scriptContent = await fetchResponseText(absoluteUrl);
return scriptContent;
} catch (error) {
console.warn(`Failed to fetch project script: ${scriptSrc}`, error);
return `// Failed to load: ${scriptSrc}`;
}
}),
);
const allJsContent = projectScriptContents.filter(Boolean);
jsSource = allJsContent.length > 0 ? allJsContent.join('\n\n') : null;
}
jsSource = jsSource ? formatSource(jsSource, 'js') : null;
bodyHtml = formatSource(stripScriptsFromSource(bodyHtml), 'html');
const title = getExampleSection('title', renderedHtml).split('|')[0];
const headHtml = getExampleSection('head', renderedHtml);
const cssSource = formatSource(getStyleFromSource(headHtml), 'css');
return {renderedHtml, bodyHtml, title, jsSource, externalScripts, cssSource};
}
/**
* Fetches the raw Jinja template of an example and returns the Jinja content block
* @param {HTMLElement} placementElement The placeholder element for the example
* @returns {Promise<String>} The Jinja content block of the example
*/
async function fetchJinjaContentBlock(placementElement) {
// Raw templates are not served at standalone paths, so strip it from the URL if it was found.
const exampleUrl = new URL(`${placementElement.href.replace(/standalone/, '/')}`);
// Add `?raw=true` query parameter to the URL to request the raw Jinja template
const queryParams = new URLSearchParams(exampleUrl.search);
queryParams.set('raw', true);
exampleUrl.search = queryParams.toString();
const rawJinjaTemplate = await fetchResponseText(exampleUrl.toString());
return formatSource(getExampleSection('jinja', rawJinjaTemplate), 'jinja');
}
/**
* Replaces an example placeholder element with its rendered result and code snippet.
* @param {HTMLAnchorElement} placementElement `a.js-example` element used as a placeholder for the example to render
*/
async function renderExample(placementElement) {
const codeSnippet = document.createElement('div');
codeSnippet.classList.add('p-code-snippet', 'is-bordered');
const header = document.createElement('div');
header.classList.add('p-code-snippet__header');
const titleEl = document.createElement('h5');
titleEl.classList.add('p-code-snippet__title');
// Example data will be asynchronously fetched and placed here on promise resolution.
const srcData = {
html: undefined,
renderedHtml: undefined,
jinja: undefined,
css: undefined,
js: undefined,
codePen: undefined,
title: undefined,
};
const exampleRequests = [];
const fetchHtml = fetchRenderedHtml(placementElement).then(({renderedHtml, bodyHtml, title, jsSource, externalScripts, cssSource}) => {
// There are required, so throw if they failed
if (renderedHtml && bodyHtml && title) {
srcData.renderedHtml = renderedHtml;
srcData.html = bodyHtml;
srcData.title = title;
} else {
throw new Error('Failed to fetch HTML for example iframe and HTML source.');
}
// The rest of the views are optional
srcData.js = jsSource;
srcData.css = cssSource;
srcData.codePen = {
html: bodyHtml,
css: cssSource,
js: jsSource,
externalJS: externalScripts,
};
});
exampleRequests.push(fetchHtml);
if (placementElement.getAttribute('data-lang') === 'jinja') {
// Perform jinja template fetching if the example was marked as a Jinja template
const fetchJinja = fetchJinjaContentBlock(placementElement).then((contentBlock) => {
const hasJinjaTemplate = contentBlock?.length > 0;
if (hasJinjaTemplate) {
srcData.jinja = contentBlock;
}
});
exampleRequests.push(fetchJinja);
}
// Perform as much of the data fetching and processing as possible in parallel
await Promise.all(exampleRequests);
// Code after this point depends on the data above being fully fetched, so must come after an `await`
titleEl.innerText = srcData.title;
header.appendChild(titleEl);
codeSnippet.appendChild(header);
placementElement.parentNode.insertBefore(codeSnippet, placementElement);
renderIframe(codeSnippet, srcData.renderedHtml, placementElement.getAttribute('data-height'));
// Gather the languages that have source code available, in the order they should be displayed
// We can't rely on order of these languages being made available in the promise blocks above due to async nature
const languageOptions = ['jinja', 'html', 'js', 'css'].filter((lang) => srcData[lang]);
const sourceBlocks = languageOptions
// THe first language option that was found is displayed by default. The rest are viewable using dropdown.
.map((lang, idx) => createPreCode(srcData[lang], lang, idx > 0));
// Code snippet must be populated with code before Prism can highlight it
sourceBlocks.forEach((block) => codeSnippet.appendChild(block));
if (Prism) {
Prism.highlightAllUnder(codeSnippet);
}
if (srcData.codePen) {
renderCodePenEditLink(codeSnippet, srcData.codePen);
}
renderDropdown(header, languageOptions);
// The example has been rendered successfully, hide the placeholder element.
placementElement.style.display = 'none';
}
/**
* Renders a dropdown containing the code snippet options, allowing user to switch between multiple views.
* @param {HTMLDivElement} codeSnippetHeader The header element of the code snippet
* @param {('html'|'jinja'|'js'|'css')[]} codeSnippetModes List of code snippet mode options
*/
function renderDropdown(codeSnippetHeader, codeSnippetModes) {
// only add dropdown if there is more than one code block
if (codeSnippetModes.length <= 1) return;
const dropdownsEl = document.createElement('div');
dropdownsEl.classList.add('p-code-snippet__dropdowns');
const selectEl = document.createElement('select');
selectEl.classList.add('p-code-snippet__dropdown');
codeSnippetModes.forEach(function (option) {
const optionHTML = document.createElement('option');
optionHTML.value = option.toLowerCase();
optionHTML.innerText = EXAMPLE_LANGUAGE_OPTION_CONFIG[option]?.label || option.toLowerCase();
selectEl.appendChild(optionHTML);
});
dropdownsEl.appendChild(selectEl);
codeSnippetHeader.appendChild(dropdownsEl);
attachDropdownEvents(selectEl);
}
function resizeIframe(iframe) {
if (iframe.contentDocument.readyState == 'complete') {
const frameHeight = iframe.contentDocument.body.scrollHeight;
iframe.height = frameHeight + 32 + 'px'; // accommodate for body margin
}
}
function renderIframe(container, html, height) {
const iframe = document.createElement('iframe');
if (height) {
iframe.height = height + 'px';
}
container.appendChild(iframe);
const doc = iframe.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
// if height wasn't specified, try to determine it from example content
if (!height) {
// Wait for content to load before determining height
const resizeInterval = setInterval(function () {
if (iframe.contentDocument.readyState == 'complete') {
resizeIframe(iframe);
clearInterval(resizeInterval);
fixScroll();
}
}, 100);
// cancel resizing if frame didn't load in 5s
setTimeout(function () {
clearInterval(resizeInterval);
}, 5000);
window.addEventListener(
'resize',
throttle(function () {
resizeIframe(iframe);
}, 10),
);
}
return iframe;
}
function renderCodePenEditLink(snippet, sourceData) {
const html = sourceData.html === null ? '' : sourceData.html;
const css = sourceData.css === null ? '' : sourceData.css;
const js = sourceData.js === null ? '' : sourceData.js;
if (html || css || js) {
const container = document.createElement('div');
const form = document.createElement('form');
const input = document.createElement('input');
const link = document.createElement('a');
const data = {
title: CODEPEN_CONFIG.title,
head: CODEPEN_CONFIG.head,
html: html,
css: css,
js: js,
css_external: CODEPEN_CONFIG.stylesheets.join(';'),
js_external: sourceData.externalJS.join(';'),
};
// Replace double quotes to avoid errors on CodePen
const JSONstring = JSON.stringify(data).replace(/"/g, '"').replace(/'/g, ''');
container.classList.add('p-code-snippet__header');
form.setAttribute('action', 'https://codepen.io/pen/define');
form.setAttribute('method', 'POST');
form.setAttribute('target', '_blank');
input.setAttribute('type', 'hidden');
input.setAttribute('name', 'data');
input.setAttribute('value', JSONstring);
link.innerHTML = 'Edit on CodePen';
link.style.cssText = 'display: block; padding: 0.5rem 0;';
form.appendChild(input);
form.appendChild(link);
container.appendChild(form);
handleCodePenClick(link, form);
snippet.appendChild(container);
}
}
function handleCodePenClick(link, form) {
link.addEventListener('click', function (e) {
e.preventDefault();
form.submit();
});
// handle middle mouse button click
link.addEventListener('mouseup', function (e) {
if (e.which === 2) {
link.click();
}
});
}
function getStyleFromSource(source) {
const div = document.createElement('div');
div.innerHTML = source;
const style = div.querySelector('style');
return style ? style.innerHTML.trim() : null;
}
function stripScriptsFromSource(source) {
const div = document.createElement('div');
div.innerHTML = source;
const scripts = div.getElementsByTagName('script');
let i = scripts.length;
while (i--) {
scripts[i].parentNode.removeChild(scripts[i]);
}
return div.innerHTML.trim();
}
function getScriptFromSource(source) {
const div = document.createElement('div');
div.innerHTML = source;
const script = div.querySelector('script');
return script ? script.innerHTML.trim() : null;
}
function getExternalScriptsFromSource(source) {
const div = document.createElement('div');
div.innerHTML = source;
let scripts = div.querySelectorAll('script[src]');
scripts = [].slice.apply(scripts).map(function (s) {
return s.src;
});
return scripts;
}
function fixScroll() {
const titleId = window.location.hash;
if (titleId) {
const title = document.querySelector(titleId);
title.scrollIntoView();
}
}
/**
Attaches change event listener to a given select.
@param {HTMLElement} dropdown Select element belonging to a code snippet.
*/
function attachDropdownEvents(dropdown) {
dropdown.addEventListener('change', function (e) {
const snippet = e.target.closest('.p-code-snippet');
// toggle code blocks visibility based on selected language
for (let i = 0; i < dropdown.options.length; i++) {
const lang = dropdown.options[i].value;
const block = snippet && snippet.querySelector("[data-lang='" + lang + "']");
if (lang === e.target.value) {
block.classList.remove('u-hide');
block.setAttribute('aria-hidden', false);
} else {
block.classList.add('u-hide');
block.setAttribute('aria-hidden', true);
}
}
});
}
})();