@bradymholt/ampify
Version:
Convert plain HTML to Google Accelerated Mobile Pages (AMP)
277 lines (243 loc) • 8.29 kB
JavaScript
const fs = require("fs");
const url = require("url");
const axios = require("axios");
const cheerio = require("cheerio");
const sizeOf = require("image-size");
module.exports = async (html, options) => {
const cheerioOptions = options || {
cwd: options.cwd || "",
round: options.round || true,
normalizeWhitespace: options.normalizeWhitespace || false,
xmlMode: options.xmlMode || false,
decodeEntities: options.decodeEntities || false,
};
const round = cheerioOptions.round ? (numb) => Math.round(numb / 5) * 5 : (numb) => numb;
// Load html
const $ = cheerio.load(html, cheerioOptions);
const headElement = $("head");
let styles = "";
let scripts = "";
/* AMP Boilerplate */
// add amp atrribute on root <html/>
$("html").first().attr("amp", "");
// set charset to utf-8
headElement.find("meta[charset]").remove();
headElement.prepend('<meta charset="utf-8">');
// Add meta viewport
headElement.find("meta[name='viewport']").remove();
headElement.append('<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">');
// AMP boilerplate styles
headElement.find("style[amp-boilerplate]").remove();
headElement.append(
'<style amp-boilerplate="">body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>'
);
// AMP script
if (headElement.find("script[src='https://cdn.ampproject.org/v0.js']").length === 0) {
scripts += "<script async src='https://cdn.ampproject.org/v0.js'></script>";
}
// Remove non-AMP scripts
$("script").each((index, element) => {
const el = $(element);
if (
(el.attr("src") && el.attr("src").indexOf("cdn.ampproject.org") > -1) ||
el.parent()[0].tagName.startsWith("amp-")
) {
// This is an AMP script so leave it alone
return;
} else {
el.remove();
}
});
// Fetch images and CSS
const promises = [];
const externalSrcContent = {};
// Fetch external images
$("img:not([width]):not([height])").each((index, element) => {
const src = $(element).attr("src");
// skip if already fetched
if (externalSrcContent[src]) {
return;
}
if (src && src.indexOf("//") !== -1) {
// set a flag
externalSrcContent[src] = true;
const imageUrl = element.attribs.src;
promises.push(
axios.get(imageUrl, { responseType: "arraybuffer" }).then((response) => {
externalSrcContent[src] = response;
})
);
}
});
// Fetch external CSS
$("link[rel=stylesheet]").each((index, element) => {
const src = $(element).attr("href");
if (isGoogleFontHostSrc(src)) {
// External links to fonts are allowed; we'll make a specific exception for Google Fonts
return;
}
if (externalSrcContent[src]) {
// We've already cached this one
return;
}
try {
if (src && src.indexOf("//") !== -1) {
let cssSrc = src;
if (src.indexOf("//") === 0) {
cssSrc = `https:${src}`;
}
externalSrcContent[src] = true;
promises.push(
axios.get(cssSrc).then((response) => {
externalSrcContent[src] = response;
})
);
}
} catch (err) {
console.dir(err);
}
});
await Promise.all(promises);
// Set <img/> element height/width dimensions
$("img:not([width]):not([height])").each((index, element) => {
const src = $(element).attr("src");
if (!src) {
return $(element).remove();
}
if (src.indexOf("//") === -1) {
let imagePath = `${options.cwd}/${$(element).attr("src")}`;
if (imagePath.includes("#")) {
// Remove hash fragment from src
imagePath = imagePath.split("#")[0];
}
if (fs.existsSync(imagePath)) {
const size = sizeOf(imagePath);
$(element).attr({
width: round(size.width),
height: round(size.height),
});
}
} else if (src.indexOf("//") !== -1) {
const response = externalSrcContent[src];
if (response === true) {
throw new Error("No image for", src);
}
const size = sizeOf(Buffer.from(response.data, "binary"));
$(element).attr({
width: round(size.width),
height: round(size.height),
});
}
});
// Fetch external stylesheet content
$("link[rel=stylesheet]").each((index, element) => {
const src = $(element).attr("href");
let path = src;
try {
if (isGoogleFontHostSrc(src)) {
return;
}
const isRemoteSrcReference = src.indexOf("//") !== -1;
if (isRemoteSrcReference) {
const response = externalSrcContent[src];
if (response === true) {
throw new Error("No CSS for", src);
}
styles += response.data;
} else {
// Local file reference
path = `${options.cwd}/${src}`;
if (fs.existsSync(path)) {
const fileContent = String(fs.readFileSync(path));
styles += fileContent;
}
}
} catch (err) {
console.dir(err);
}
$(element).remove();
});
// Gather internal styles
$("style:not([amp-boilerplate])").each((index, element) => {
styles += $(element).html();
$(element).remove();
});
// Add styles to <head/>
if (styles) {
const scrubbedStyles = scrubCss(styles);
headElement.find("style[amp-custom]").remove();
headElement.append(`<style amp-custom>${scrubbedStyles}</style>`);
}
// Handle iframes
let addYouTubeScript = false;
let addIFrameScript = false;
$("iframe").each((index, element) => {
const el = $(element);
const src = el.attr("src");
const width = el.attr("width");
const height = el.attr("height");
if (el.attr("src").match(/www\.youtu/)) {
addYouTubeScript = true;
const path = url.parse(src).pathname.split("/");
const ampYoutube = `
<amp-youtube
data-videoid="${path[path.length - 1]}"
width="${width}"
height="${height}"
layout="responsive">
</amp-youtube>`;
el.replaceWith(ampYoutube);
} else {
addIFrameScript = true;
const ampIFrame = `
<amp-iframe
src="${src}"
width="${width}"
height="${height}"
layout="responsive"
sandbox="allow-scripts">
</amp-iframe>`;
el.replaceWith(ampIFrame);
}
});
if (addYouTubeScript) {
scripts += "<script async custom-element='amp-youtube' src='https://cdn.ampproject.org/v0/amp-youtube-0.1.js'>";
}
if (addIFrameScript) {
scripts +=
"<script async custom-element='amp-iframe' src='https://cdn.ampproject.org/v0/amp-iframe-0.1.js'></script>";
}
// Remove <canvas/> elements
$("canvas").each((index, element) => {
const el = $(element);
el.remove();
});
// Convert other tags to AMP tags
let addVideoScript = false;
const includeTags = {
amp: ["img", "video"],
};
$(includeTags.amp.join(",")).each((index, element) => {
if (element.name == "video") {
addVideoScript = true;
}
const ampElement = Object.assign(element, {
name: `amp-${element.name}`,
});
$(element).html($(ampElement).html());
});
if (addVideoScript) {
scripts +=
"<script async custom-element='amp-video' src='https://cdn.ampproject.org/v0/amp-video-0.1.js'></script>";
}
// Append scripts to <head/>
headElement.append(scripts);
const outerHtml = $.html();
return outerHtml;
};
function isGoogleFontHostSrc(src) {
return src.indexOf("fonts.googleapis.com") > -1;
}
function scrubCss(css) {
return css.replace(/\!important/g, "");
}