/**
* Utilities for the CMS
*
* @module CMS
* @license The MIT License (MIT)
* @copyright (c) 2021 Chris Diana | https://chrisdiana.github.io/cms.js
* @copyright (c) 2025 eVAL Agency
* @author Charlie Powell
* @see https://github.com/eVAL-Agency/MarkdownMasterCMS
*/
/**
* Formats date string to a Date object converted to the user's local timezone
*
* Accepts dashes or slashes between characters, (to support YYYY/MM/DD URL directories)
*
* @param {string|Date} dateStr Date string to convert
* @returns {Date} Rendered Date object
*/
export function getDatetime(dateStr) {
let matches, dt;
if (dateStr instanceof Date) {
// The input value may be a Date if passed from FrontMatter as a YAML date, allow this.
dt = dateStr;
} else {
if ((matches = dateStr.match(/^(1[0-2]|[1-9])\/(3[0-1]|[1-2][0-9]|[0-9])\/((?:20|19)?[0-9][0-9])$/))) {
// US-based M/d/Y
if (matches[3] < 100) {
// Short format year (2-digit)
matches[3] = '20' + matches[3];
}
dateStr = [matches[3], matches[1], matches[2]].join('-');
}
dateStr = dateStr.replaceAll('/', '-');
dt = new Date(dateStr);
}
return new Date(dt.getTime() + dt.getTimezoneOffset() * 60000);
}
/**
* Function to join paths while ensuring each is separated by a slash
*
* @param {string} args
* @returns {string}
*
* @example
* pathJoin('posts', 'topic');
* // Returns 'posts/topic'
*
* pathJoin('posts', 'topic', 'README.md');
* // Returns 'posts/topic/README.md'
*/
export function pathJoin(...args) {
let path = '';
// Lazy person's os.path.join()
for (let i = 0; i < args.length; i++) {
if (args[i] === '' || args[i] === null) {
// Failsafe checks
continue;
}
if (path.endsWith('/') && args[i].startsWith('/')) {
// If paths are provided as ['/', '/posts'], we don't want to double the slashes.
path += args[i].substring(1);
} else {
path += args[i];
}
if (i + 1 < args.length && !args[i].endsWith('/')) {
path += '/';
}
}
return path;
}
/**
* Get the directory name of the requested file
*
* @param {string} path
* @returns {string}
*/
export function dirname(path) {
if (path.indexOf('/') === -1) {
// No slashes, no path
return '';
}
return path.substring(0, path.lastIndexOf('/') + 1);
}
/**
* Get the basename of a given file, optionally without the extension
*
* @param {string} path
* @param {boolean} [without_extension=false]
* @returns {string}
*/
export function basename(path, without_extension) {
if (path.indexOf('/') !== -1) {
path = path.substring(path.lastIndexOf('/') + 1);
}
if (without_extension && path.indexOf('.') !== -1) {
path = path.substring(0, path.lastIndexOf('.'));
}
return path;
}
export class AttributeBuilder {
/**
*
* @param {string|null} [attribute_string=null]
*/
constructor(attribute_string) {
/**
* @type {Object<string, {values: string[], quote_style: string|null}>}
*/
this.attributes = {};
if (attribute_string !== null) {
this.loadString(attribute_string);
}
}
/**
* Process all extended attributes as a string into their individual parts
*
* Will return a list of every key with its values and any quotations required
* note, each value will be an array of values, generally used for classes, but
* available for everything (except IDs).
*
* @param {string|null} attribute_string String of attributes (usually from MD) to load
*/
loadString(attribute_string) {
let in_quote = false,
quote_style = null,
key = null,
buffer = '';
attribute_string = attribute_string ?? null;
if (attribute_string === '' || attribute_string === null) {
return;
}
for (let i = 0; i < attribute_string.length; i++) {
// current letter
let token = attribute_string[i];
if (in_quote && token === quote_style) {
// End of quoted text, stop quoted capture
in_quote = false;
} else if (in_quote) {
// Quoted capture, just capture it directly
buffer += token;
} else if (token === '=' && buffer !== '') {
// A '=' signifies a separation of a key attribute and its value
key = buffer;
buffer = '';
} else if (token === '"' || token === '\'') {
in_quote = true;
quote_style = token;
} else if (token === ' ') {
// Attributes are space separated (unless inside a quoted string which is caught above)
this.addAttribute(key, buffer, quote_style);
quote_style = null;
buffer = '';
key = null;
} else {
// Default capture, either key or buffer, we'll figure that out later.
buffer += token;
}
}
// After processing the last record, process that last snippet
this.addAttribute(key, buffer, quote_style);
}
/**
* Add an attribute key/value pair to the list of attributes
*
* Used to standardize various shorthand declarations to their usable format,
* meant to be called once-per-pair
*
* For example, the following shorthands are supported:
* '.red' value can be passed with no key for a class name
* '#thing' value can be passed with no key for an ID
*
* @param {string|null} key
* @param {string} value
* @param {string|null} [quote_style=null]
*/
addAttribute(key, value, quote_style) {
quote_style = quote_style ?? null;
if (key == null && value[0] === '.') {
key = 'class';
value = value.substring(1);
} else if (key == null && value[0] === '#') {
key = 'id';
value = value.substring(1);
}
if (typeof (this.attributes[key]) === 'undefined') {
this.attributes[key] = {
values: [value],
quote_style: quote_style,
};
} else {
this.attributes[key].values.push(value);
}
}
/**
* Flatten a processed list of attributes (key, values, quote_style),
* to a flat string to be sent directly to the browser
*
* @returns string
*/
asString() {
let results = [];
// Everything processed, compile into a flat string
for (let k in this.attributes) {
let v = k + '=';
if (this.attributes[k].quote_style === null) {
// Default to double quotes
this.attributes[k].quote_style = '"';
}
v += this.attributes[k].quote_style + this.getValue(k) + this.attributes[k].quote_style;
results.push(v);
}
return results.join(' ');
}
/**
* Get the value for the requested key
*
* @param {string} key
* @returns {string}
*/
getValue(key) {
if (typeof(this.attributes[key]) === 'undefined') {
// No key set, no value to provide.
return '';
} else if (key === 'id') {
// Hard code to only support one ID (to keep people from doing something silly)
return this._parseIDValue(this.attributes[key].values[0]);
} else {
// Everything else can have multiple if they want.
return this.attributes[key].values.join(' ');
}
}
/**
* Parse an ID to an acceptable value, IDs cannot start with a number,
* contain spaces or special characters, and should not end with a dash.
*
* @param {string} id
* @returns {string}
* @private
*/
_parseIDValue(id) {
id = id
.toLowerCase()
.replace(/\W/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/(^-|-$)/g, '');
if (id.match(/^[0-9]/)) {
id = 'id-' + id;
}
return id;
}
}
/**
* Remove trailing 'c's. Equivalent to str.replace(/c*$/, '').
* /c*$/ is vulnerable to REDOS.
*
* @param {string} str
* @param {string} [c=' ']
* @param {boolean} [invert=false] Remove suffix of non-c chars instead. Default falsey.
*
* @see marked/src/helpers.js
*/
export function rtrim(str, c, invert = false) {
c = c ?? ' ';
const l = str.length;
if (l === 0) {
return '';
}
// Length of suffix matching the invert condition.
let suffLen = 0;
// Step left until we fail to match the invert condition.
while (suffLen < l) {
const currChar = str.charAt(l - suffLen - 1);
if (currChar === c && !invert) {
suffLen++;
} else if (currChar !== c && invert) {
suffLen++;
} else {
break;
}
}
return str.slice(0, l - suffLen);
}