gatsby-remark-copy-linked-files
Version:
Find files which are linked to from markdown and copy them to the public directory
271 lines (256 loc) • 8.87 kB
JavaScript
;
/* eslint-disable @babel/no-invalid-this */
const visit = require(`unist-util-visit`);
const isRelativeUrl = require(`is-relative-url`);
const fsExtra = require(`fs-extra`);
const path = require(`path`);
const _ = require(`lodash`);
const cheerio = require(`cheerio`);
const imageSize = require(`probe-image-size`);
const DEPLOY_DIR = `public`;
const invalidDestinationDirMessage = dir => `[gatsby-remark-copy-linked-files You have supplied an invalid destination directory. The destination directory must be a child but was: ${dir}`;
// dest must be a child
const destinationIsValid = dest => !path.relative(`./`, dest).startsWith(`..`);
const validateDestinationDir = dir => {
if (typeof dir === `undefined`) {
return true;
} else if (typeof dir === `string`) {
// need to pass dummy data for validation to work
return destinationIsValid(`${dir}/n/h/a`);
} else if (_.isFunction(dir)) {
// need to pass dummy data for validation to work
return destinationIsValid(`${dir({
name: `n`,
hash: `h`,
absolutePath: `a`
})}`);
} else {
return false;
}
};
const defaultDestination = linkNode => `${linkNode.internal.contentDigest}/${linkNode.name}.${linkNode.extension}`;
const getDestination = (linkNode, dir) => {
if (_.isFunction(dir)) {
return `${dir({
name: linkNode.name,
hash: linkNode.internal.contentDigest,
absolutePath: linkNode.absolutePath
})}.${linkNode.extension}`;
} else if (_.isString(dir)) {
return `${dir}/${defaultDestination(linkNode)}`;
} else {
return defaultDestination(linkNode);
}
};
const newPath = (linkNode, options) => {
const {
destinationDir
} = options;
const destination = getDestination(linkNode, destinationDir);
const paths = [process.cwd(), DEPLOY_DIR, destination];
return path.posix.join(...paths);
};
const newLinkURL = (linkNode, options, pathPrefix) => {
const {
destinationDir
} = options;
const destination = getDestination(linkNode, destinationDir);
const startsWithSlash = destination.startsWith(`/`);
return `${pathPrefix ? pathPrefix : ``}${startsWithSlash ? `` : `/`}${destination}`;
};
function toArray(buf) {
const arr = new Array(buf.length);
for (let i = 0; i < buf.length; i++) {
arr[i] = buf[i];
}
return arr;
}
module.exports = ({
files,
markdownNode,
markdownAST,
pathPrefix,
getNode
}, pluginOptions = {}) => {
const defaults = {
ignoreFileExtensions: [`png`, `jpg`, `jpeg`, `bmp`, `tiff`]
};
const {
destinationDir
} = pluginOptions;
if (!validateDestinationDir(destinationDir)) return Promise.reject(invalidDestinationDirMessage(destinationDir));
const options = _.defaults({}, pluginOptions, defaults);
const filesToCopy = new Map();
// Copy linked files to the destination directory and modify the AST to point
// to new location of the files.
const visitor = link => {
if (isRelativeUrl(link.url) && getNode(markdownNode.parent).dir) {
const linkPath = path.posix.join(getNode(markdownNode.parent).dir, link.url);
const linkNode = _.find(files, file => {
if (file && file.absolutePath) {
return file.absolutePath === linkPath;
}
return null;
});
if (linkNode && linkNode.absolutePath) {
const newFilePath = newPath(linkNode, options);
// Prevent unneeded copying
if (linkPath === newFilePath) return;
const linkURL = newLinkURL(linkNode, options, pathPrefix);
link.url = linkURL;
filesToCopy.set(linkPath, newFilePath);
}
}
};
// Takes a node and generates the needed images and then returns
// the needed HTML replacement for the image
const generateImagesAndUpdateNode = function (image, node) {
const imagePath = path.posix.join(getNode(markdownNode.parent).dir, image.attr(`src`));
const imageNode = _.find(files, file => {
if (file && file.absolutePath) {
return file.absolutePath === imagePath;
}
return null;
});
if (!imageNode || !imageNode.absolutePath) {
return;
}
const initialImageSrc = image.attr(`src`);
// The link object will be modified to the new location so we'll
// use that data to update our ref
const link = {
url: image.attr(`src`)
};
visitor(link);
node.value = node.value.replace(new RegExp(image.attr(`src`), `g`), link.url);
let dimensions;
if (!image.attr(`width`) || !image.attr(`height`)) {
dimensions = imageSize.sync(toArray(fsExtra.readFileSync(imageNode.absolutePath)));
}
// Generate default alt tag
const srcSplit = initialImageSrc.split(`/`);
const fileName = srcSplit[srcSplit.length - 1];
const fileNameNoExt = fileName.replace(/\.[^/.]+$/, ``);
const defaultAlt = fileNameNoExt.replace(/[^A-Z0-9]/gi, ` `);
image.attr(`alt`, image.attr(`alt`) ? image.attr(`alt`) : defaultAlt);
image.attr(`width`, image.attr(`width`) ? image.attr(`width`) : dimensions.width);
image.attr(`height`, image.attr(`height`) ? image.attr(`height`) : dimensions.height);
};
visit(markdownAST, `link`, link => {
const ext = link.url.split(`.`).pop();
if (options.ignoreFileExtensions.includes(ext)) {
return;
}
visitor(link);
});
visit(markdownAST, `definition`, definition => {
const ext = definition.url.split(`.`).pop();
if (options.ignoreFileExtensions.includes(ext)) {
return;
}
visitor(definition);
});
// This will only work for markdown img tags
visit(markdownAST, `image`, image => {
const ext = image.url.split(`.`).pop();
if (options.ignoreFileExtensions.includes(ext)) {
return;
}
// Just make sure the parent node has dir
if (markdownNode.parent && !getNode(markdownNode.parent).dir) {
return;
}
const imagePath = path.posix.join(getNode(markdownNode.parent).dir, image.url);
const imageNode = _.find(files, file => {
if (file && file.absolutePath) {
return file.absolutePath === imagePath;
}
return false;
});
if (imageNode) {
visitor(image);
}
});
// For each HTML Node
visit(markdownAST, [`html`, `jsx`], node => {
const $ = cheerio.load(node.value);
function processUrl({
url,
isRequired
}) {
try {
const ext = url.split(`.`).pop();
if (!options.ignoreFileExtensions.includes(ext) || isRequired) {
// The link object will be modified to the new location so we'll
// use that data to update our ref
const link = {
url
};
visitor(link);
node.value = node.value.replace(new RegExp(url, `g`), link.url);
}
} catch (err) {
// Ignore
}
}
// extracts all elements that have the provided url attribute
function extractUrlAttributeAndElement(selection, attribute) {
return selection
// extract the elements that have the attribute
.map(function () {
const element = $(this);
const url = $(this).attr(attribute);
if (url && isRelativeUrl(url)) {
return {
url,
element
};
}
return undefined;
})
// cheerio object -> array
.toArray()
// filter out empty or undefined values
.filter(Boolean);
}
// Handle Images
extractUrlAttributeAndElement($(`img[src]`), `src`).forEach(({
url,
element
}) => {
try {
const ext = url.split(`.`).pop();
if (!options.ignoreFileExtensions.includes(ext)) {
generateImagesAndUpdateNode(element, node);
}
} catch (err) {
// Ignore
}
});
// Handle video tags.
extractUrlAttributeAndElement($(`video source[src], video[src]`), `src`).forEach(processUrl);
// Handle video poster.
extractUrlAttributeAndElement($(`video[poster]`), `poster`).forEach(extractedUrlAttributeAndElement => processUrl({
...extractedUrlAttributeAndElement,
isRequired: true
}));
// Handle audio tags.
extractUrlAttributeAndElement($(`audio source[src], audio[src]`), `src`).forEach(processUrl);
// Handle flash embed tags.
extractUrlAttributeAndElement($(`object param[value]`), `value`).forEach(processUrl);
// Handle a tags.
extractUrlAttributeAndElement($(`a[href]`), `href`).forEach(processUrl);
return;
});
return Promise.all(Array.from(filesToCopy, async ([linkPath, newFilePath]) => {
// Don't copy anything if the file already exists at the location.
if (!fsExtra.existsSync(newFilePath)) {
try {
await fsExtra.ensureDir(path.dirname(newFilePath));
await fsExtra.copy(linkPath, newFilePath);
} catch (err) {
console.error(`error copying file`, err);
}
}
}));
};