@metalsmith/permalinks
Version:
A metalsmith plugin for permalinks.
443 lines (424 loc) • 15.4 kB
JavaScript
import path from 'path';
import slugify from 'slugify';
import * as route from 'regexparam';
import get from 'dlv';
function toDate(maybeDate) {
if (!(maybeDate instanceof Date)) return new Date(maybeDate);
return maybeDate;
}
function getWeekOfYear(date) {
const d = toDate(date);
const firstOfYear = new Date(d.getUTCFullYear(), 0, 1, 0 - d.getTimezoneOffset() / 60);
const week = 7 * 24 * 60 * 60 * 1000;
return ((d - firstOfYear) / week).toFixed(0);
}
// important: keys must be sorted from longest to shortest for matching
const dateTokens = {
// year (full)
YYYY(date) {
return toDate(date).getUTCFullYear();
},
MMMM(date, locale = 'en-US') {
return new Intl.DateTimeFormat(locale, {
month: 'long'
}).format(toDate(date));
},
MMM(date, locale = 'en-US') {
return new Intl.DateTimeFormat(locale, {
month: 'short'
}).format(toDate(date));
},
dddd(date, locale = 'en-US') {
return new Intl.DateTimeFormat(locale, {
weekday: 'long'
}).format(toDate(date));
},
ddd(date, locale = 'en-US') {
return new Intl.DateTimeFormat(locale, {
weekday: 'short'
}).format(toDate(date));
},
dd(date, locale = 'en-US') {
return this.ddd(date, locale).slice(0, 2);
},
// year (2 last digits)
YY(date) {
const y = this.YYYY(date).toFixed(0);
if (y.length > 2) return y.slice(-2);
return y;
},
// date num (zero-padded)
DD(date) {
const d = toDate(date).getUTCDate();
return d.toString().padStart(2, '0');
},
// month num (zero-padded)
MM(date) {
const m = toDate(date).getUTCMonth() + 1;
return (m > 9 ? '' : '0') + m;
},
// week of year (zero-padded)
WW(date) {
const w = getWeekOfYear(date);
return w.padStart(2, '0');
},
// unix ms timestamp
x(date) {
return toDate(date).valueOf();
},
// unix timestamp
X(date) {
return (this.x(date) / 1000).toFixed(0);
},
// date num
D(date) {
return toDate(date).getUTCDate();
},
// day of week
d(date) {
return toDate(date).getUTCDay();
},
// week of year
W: getWeekOfYear,
// month num
M(date) {
return toDate(date).getUTCMonth() + 1;
},
// quarter
Q(date) {
return Math.ceil(this.M(date) / 4);
}
};
const dateTokenKeys = Object.keys(dateTokens);
function formatDate(date, format, locale) {
const result = [];
while (format.length) {
let token;
for (const tokenKey of dateTokenKeys) {
const match = format.match(tokenKey);
if (match && match.index === 0) {
token = match[0];
break;
}
}
const mappable = !!token;
if (!token) token = format.slice(0, 1);
format = format.slice(token.length);
if (mappable) token = dateTokens[token](date, locale);
result.push(token);
}
return result.join('');
}
function dateFormatter(format, locale) {
return function formatDateFn(date) {
return formatDate(date, format, locale);
};
}
const dupeHandlers = {
error(targetPath, filesObj, filename, opts) {
const target = path.join(targetPath, opts.directoryIndex);
const currentBaseName = path.basename(filename);
if (filesObj[target] && currentBaseName !== opts.directoryIndex) {
return new Error(`Destination path collision for source file "${filename}" with target "${target}"`);
}
return target;
},
index(targetPath, filesObj, filename, opts) {
let target,
counter = 0,
postfix = '';
do {
target = path.join(`${targetPath}${postfix}`, opts.directoryIndex);
postfix = `-${++counter}`;
} while (filesObj[target]);
return target;
},
overwrite(targetPath, filesObj, filename, opts) {
return path.join(targetPath || '', opts.directoryIndex);
}
};
/**
* [Slugify options](https://github.com/simov/slugify#options)
*
* @typedef {Object} SlugifyOptions
* @property {boolean} extend extend known unicode symbols with a `{'char': 'replacement'}` object
* @property {string} [replacement='-'] replace spaces with replacement character, defaults to `-`
* @property {RegExp} [remove] remove characters that match regex
* @property {boolean} [lower=true] convert to lower case, defaults to `true`
* @property {boolean} [strict=false] strip special characters except replacement, defaults to `false`
* @property {string} [locale] language code of the locale to use
* @property {boolean} [trim=true] trim leading and trailing replacement chars, defaults to `true`
*/
/**
* @callback slugFunction
* @param {string} filepath
* @returns {string} slug
*/
/**
* @callback dateFunction
* @param {Date} date
* @returns {string} formattedDate
*/
/**
* Linkset definition
*
* @typedef {Object} Linkset
* @property {string|string[]|Object.<string,*>} [match="**\/*.html"]
* * A glob pattern or array of glob patterns passed to {@linkcode Metalsmith.match}, or an object whose `key:value` pairs
* will be used to match files when at least one `key:value` pair matches, and transform their permalinks according to the rules in this linkset.
* @property {string} pattern A permalink pattern to transform file paths into, e.g. `blog/:date/:title`
* @property {SlugifyOptions|slugFunction} [slug] [Slugify options](https://github.com/simov/slugify) or a custom slug function of the form `(pathpart) => string`
* @property {string|dateFunction} [date='YYYY/MM/DD'] [Date format string](https://github.com/metalsmith/permalinks/#date-formatting) to transform Date link parts into, or a custom date formatting function. Defaults to `YYYY/MM/DD`.
*/
/**
* `@metalsmith/permalinks` options & default linkset
*
* @typedef {Object} Options
* @property {string} [pattern=':dirname?/:basename'] A permalink pattern to transform file paths into, e.g. `blog/:date/:title`. Default is `:dirname?/:basename`.
* @property {string} [date='YYYY/MM/DD'] [Date format string](https://github.com/metalsmith/permalinks/#date-formatting) to transform Date link parts into, or a custom date formatting function. Defaults to `YYYY/MM/DD`.
* @property {string} [directoryIndex='index.html'] Basename of the permalinked file (default: `index.html`)
* @property {boolean} [trailingSlash=false] Whether a trailing `/` should be added to the `file.permalink` property. Useful to avoid redirects on servers which do not have a built-in rewrite module enabled.
* @property {'error'|'index'|'overwrite'|Function} [duplicates='error'] How to handle duplicate target URI's.
* @property {Linkset[]} [linksets] An array of additional linksets
* @property {SlugifyOptions|slugFunction} [slug] {@link SlugifyOptions} or a custom slug function of the form `(pathpart) => string`
*/
// These are the invalid path chars on Windows, on *nix systems all are valid except forward slash.
// However, it is highly unlikely that anyone would want these to appear in a file path and they can still be overridden if necessary
const invalidPathChars = '[<>:"|?*]';
const defaultSlugifyRemoveChars = '[^\\w\\s$_+~.()!\\-@\\/]+';
const emptyStr = '';
const dash = '-';
const defaultLinkset = {
match: '**/*.html',
pattern: ':dirname?/:basename',
date: {
format: 'YYYY/MM/DD',
locale: 'en-US'
},
slug: {
lower: true,
remove: new RegExp(`${defaultSlugifyRemoveChars}|${invalidPathChars}`, 'g'),
extend: {
// by default slugify strips these, resulting in word concatenation. Map these chars to dash to force a word break
':': dash,
'|': dash,
'/': dash,
// by default slugify translates these to "smaller" & "greater", unwanted when a <html> tag is in the permalink
'<': emptyStr,
'>': emptyStr
}
}
};
/** @type {Options} */
const defaultOptions = {
trailingSlash: false,
linksets: [],
duplicates: 'error',
directoryIndex: 'index.html'
};
/**
* Maps the slugify function to slug to maintain compatibility
*
* @param {String} text
* @param {Object} options
*
* @return {String}
*/
function slugFn(options = defaultLinkset.slug) {
options = Object.assign({}, defaultLinkset.slug, options);
if (typeof options.extend === 'object' && options.extend !== null) {
slugify.extend(options.extend);
}
return function defaultSlugFn(text) {
return slugify(text, options);
};
}
const normalizeLinkset = (linkset, defaultLs = defaultLinkset) => {
linkset = {
...defaultLs,
...linkset
};
if (typeof linkset.slug !== 'function') {
linkset.slug = slugFn(linkset.slug);
}
if (typeof linkset.date !== 'function') {
linkset.date = typeof linkset.date === 'string' ? dateFormatter(linkset.date, defaultLs.date.locale) : dateFormatter((linkset || defaultLs).date.format, linkset.date.locale);
}
return linkset;
};
/**
* Normalize an options argument.
*
* @param {string|Options} options
* @return {Object}
*/
const normalizeOptions = options => {
if (typeof options === 'string') {
options = {
pattern: options
};
}
options = Object.assign({}, defaultOptions, options);
if (options.duplicates && Object.keys(dupeHandlers).includes(options.duplicates)) {
options.duplicates = dupeHandlers[options.duplicates];
}
// eslint-disable-next-line prefer-const
let {
trailingSlash,
linksets,
duplicates,
directoryIndex,
...defaultLs
} = options;
defaultLs = normalizeLinkset(defaultLs, defaultLinkset);
linksets = linksets.map(ls => normalizeLinkset(ls, defaultLs)).concat([defaultLs]);
return {
trailingSlash,
duplicates,
linksets,
directoryIndex
};
};
/**
* Replace a `pattern` with a file's `data`.
*
* @param {Object} options
* @param {Object} data
*
* @return {Mixed} String or Null
*/
const replace = ({
pattern,
...options
}, data) => {
// regexparam has logic that interprets a dot as start of an extension name
// we don't want this here, so we replace it temporarily with a NUL char
const remapped = pattern.replace(/\./g, '\0');
const {
keys
} = route.parse(remapped);
const ret = {};
for (let i = 0, key; key = keys[i++];) {
const keypath = key.replace(/\0/g, '.');
const val = get(data, keypath);
const isOptional = remapped.match(`${key}\\?`);
if (!val || Array.isArray(val) && val.length === 0) {
if (isOptional) {
ret[key] = '';
continue;
}
throw new Error(`Could not substitute ':${keypath}' in pattern '${pattern}', '${keypath}' is undefined`);
}
if (val instanceof Date) {
ret[key] = options.date(val);
} else if (key === 'dirname') {
ret[key] = val;
} else {
ret[key] = options.slug((typeof val === 'boolean' ? key : val).toString());
}
}
let transformed = route.inject(remapped, ret);
if (path.basename(transformed) === path.basename(options.directoryIndex, path.extname(options.directoryIndex))) transformed = path.dirname(transformed);
// handle absolute paths
if (transformed.startsWith('/')) return transformed.slice(1);
return transformed;
};
/**
* Metalsmith plugin that renames files so that they're permalinked properly
* for a static site, aka that `about.html` becomes `about/index.html`.
*
* @param {Options} options
* @returns {import('metalsmith').Plugin}
*/
function permalinks(options) {
const normalizedOptions = normalizeOptions(options);
const defaultLinkset = normalizedOptions.linksets[normalizedOptions.linksets.length - 1];
const findLinkset = (file, path, metalsmith) => {
const set = normalizedOptions.linksets.find(ls => {
if (typeof ls.match === 'string' || Array.isArray(ls.match)) return !!metalsmith.match(ls.match, [path]).length;
return Object.keys(ls.match).some(key => {
if (file[key] === ls.match[key]) {
return true;
}
if (Array.isArray(file[key]) && file[key].includes(ls.match[key])) {
return true;
}
});
});
if (!set) return defaultLinkset;
return set;
};
return function permalinks(files, metalsmith, done) {
const debug = metalsmith.debug('@metalsmith/permalinks');
debug.info('Running with options (normalized): %O', normalizedOptions);
if (normalizedOptions.relative || normalizedOptions.linksets.find(ls => ls && ls.relative)) {
return done(new Error('The "relative" option is no longer supported.'));
}
const makeUnique = normalizedOptions.duplicates;
const patternMatch = normalizedOptions.linksets[normalizedOptions.linksets.length - 1].match;
metalsmith.match(patternMatch, Object.keys(files)).forEach(file => {
// when permalink is false, set the permalink property to the current file path and return
if (files[file].permalink === false) {
debug('Skipping permalink for file "%s"', file);
files[file].permalink = file;
return;
}
const data = files[file];
const fileSpecificPermalink = data.permalink;
const hasOwnPermalinkDeclaration = !!fileSpecificPermalink;
const linkset = findLinkset(data, file, metalsmith);
const permalinkTransformContext = {
...normalizedOptions,
...defaultLinkset,
...linkset
};
if (hasOwnPermalinkDeclaration) permalinkTransformContext.pattern = fileSpecificPermalink;
debug('Applying pattern: "%s" to file: "%s"', linkset.pattern, file);
let ppath;
// Override the path with `permalink` option. Before the replace call, so placeholders can also be used in front-matter
if (Object.prototype.hasOwnProperty.call(data, 'permalink')) {
ppath = data.permalink;
}
try {
ppath = replace(permalinkTransformContext, {
...data,
basename: path.basename(file, path.extname(file)),
dirname: path.dirname(file) === '.' ? '' : path.dirname(file)
});
} catch (err) {
return done(new Error(`${err.message} for file '${file}'`));
}
// invalid on Windows, but best practice not to use them anyway
if (new RegExp(invalidPathChars).test(ppath)) {
const msg = `Permalink "${ppath}" for file "${file}" contains invalid filepath characters (one of :|<>"*?) after resolution with linkset pattern "${linkset.pattern}"`;
debug.error(msg);
return done(new Error(msg));
}
const out = makeUnique(path.normalize(ppath), files, file, normalizedOptions);
if (out instanceof Error) {
return done(out);
}
// add to permalink data for use in links in templates
const {
join,
normalize
} = path.posix;
// files matched for permalinking that are already at their destination (/index.html) have an empty string permalink ('')
// normalize('') results in '.', which we don't want here
let permalink = ppath.length ? normalize(ppath.replace(/\\/g, '/')) : ppath;
// only rewrite data.permalink when a file-specific permalink contains :pattern placeholders
if (hasOwnPermalinkDeclaration) {
if (permalink !== fileSpecificPermalink) data.permalink = permalink;
} else {
// only add trailingSlash when permalink !== ''
if (permalink && normalizedOptions.trailingSlash) permalink = join(permalink, './');
data.permalink = permalink;
}
delete files[file];
files[out] = data;
debug('Moved file "%s" to "%s" (permalink = "%s")', file, out, data.permalink);
});
done();
};
}
export { permalinks as default };