/**
* Handler for single files in 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
*/
import {renderLayout} from './templater';
import {basename, dirname, getDatetime, pathJoin} from './utils';
import Log from './log';
import CMSError from './cmserror';
import jsYaml from 'js-yaml';
import TemplateObject from './templateobject';
/**
* Represents a Markdown file installed in one of the collection directories
*/
class File extends TemplateObject {
/**
* Set of keys which cannot be set from the FrontMatter content
* These generally have a built-in or reserved purpose
* @type {string[]}
*/
static ProtectedAttributes = [
'body',
'bodyLoaded',
'config',
'content',
'name',
'permalink',
'type',
'url'
];
/**
* @param {string} url The URL of the file
* @param {string} type The type of file (i.e. posts, pages)
* @param {string} layout The layout templates of the file
* @param {Config} config Configuration from the CMS
*/
constructor(url, type, layout, config) {
super();
// Common author-defined parameters
/**
* Author name - pulled from FrontMatter
* @type {string|null}
*/
this.author = null;
/**
* Banner alt/label and URL for this page, useful for pretty headers on pages
* @type {{label: string, url: string}|null}
*/
this.banner = null;
/**
* Date published - pulled from FrontMatter or URL
* Will be converted into the requested format from Config.dateParser
*
* @see {@link Config#dateParser} for rendering info
* @type {string|null}
*/
this.date = null;
/**
* Date object holding the date published - pulled from Last-Modified header
* if date is not set otherwise
* @type {Date|null}
*/
this.datetime = null;
/**
* Set to TRUE to flag this File as draft (and not rendered to the site)
* @type {boolean}
*/
this.draft = false;
/**
* Short excerpt or teaser about the page, useful on listing pages
* @type {string|null}
*/
this.excerpt = null;
/**
* Image alt/label and URL for this page
* @type {{label: string, url: string}|null}
*/
this.image = null;
/**
* Default layout for rendering this file
* @type {string}
*/
this.layout = layout;
/**
* Window title / SEO title for this page, useful for differing from page title
* @type {string|null}
*/
this.seotitle = null;
/**
* List of tags associated with this page
* @type {string[]|null}
*/
this.tags = null;
/**
* Title for this page, generally rendered as an H1
* @type {string|null}
*/
this.title = null;
// System-defined parameters
/**
* Rendered HTML body for this File
* @type {string}
*/
this.body = null;
/**
* Set to true when the HTML body has been parsed (performance tracker)
* @type {boolean}
*/
this.bodyLoaded = false;
/**
* System configuration
* @type {Config}
*/
this.config = config;
/**
* Raw Markdown contents of this File
* @type {string}
*/
this.content = null;
/**
* Base filename of this File (without the extension)
* @type {string}
*/
this.name = null;
/**
* Browseable link to this File (includes .html)
* @type {string}
*/
this.permalink = null;
/**
* Collection type this file resides under
* @type {string}
*/
this.type = type;
/**
* Path to the raw Markdown source file
* @type {string}
*/
this.url = url;
/**
* Timestamp of the last modification, as pulled from Meta loader (when available)
* @type {null|int}
*/
this.timestamp = null;
}
/**
* Load file content from the server
*
* @returns {Promise<string>}
* @throws {CMSError}
*/
async loadContent() {
return new Promise((resolve, reject) => {
if (this.content !== null) {
// Already loaded!
resolve(this.content);
return;
}
let url = this.url;
if (this.timestamp) {
// Append a timestamp to the URL to ensure the browser gets the most updated version.
// This is required because repeated viewers will often not see updates, and getting
// some browsers *cough Chrome cough* to actually poll the server can be challenging.
url += '?t=' + this.timestamp;
}
fetch(url)
.then(response => {
if (!response.ok) {
Log.Warn(this.type, 'Unable to load file', this.url, response);
reject(new CMSError(response.status, response.statusText));
}
if (response.headers.has('Last-Modified')) {
this.datetime = response.headers.get('Last-Modified');
}
return response.text();
})
.then(content => {
this.content = content;
this.parseContent();
Log.Debug('File.loadContent/' + this.type, 'Loaded file ', this);
resolve(content);
})
.catch(e => {
Log.Warn(this.type, 'Unable to load file', this, e);
reject(new CMSError(503, e));
});
});
}
/**
* Get all tags located in this file
*
* Each set will contain the properties `name`, `count`, `url`, and `weight`.
*
* @param {null|string} sort Key ['name', 'count', 'url'] to sort results
* @returns {Object} {{name: string, count: number, url: string, weight: int}[]}
*/
getTags(sort = null) {
let tags = [];
if (this.tags && Array.isArray(this.tags)) {
this.tags.forEach(tag => {
tags.push({
name: tag,
count: 1,
weight: 1,
url: this.config.webpath + this.type + '.html?tag=' + encodeURIComponent(tag.toLowerCase())
});
});
}
if (sort) {
tags.sort((a, b) => { return a[sort] > b[sort]; });
}
return tags;
}
/**
* Get a specific meta field from this file
*
* @param {string} lookup Key to retrieve, or period-separated lookup for nested values
*/
getMeta(lookup) {
let keys = lookup.indexOf('.') === -1 ? [lookup] : lookup.split('.'),
value = this;
for (let key of keys) {
if (Object.hasOwn(value, key)) {
value = value[key];
} else {
return null;
}
}
return value;
}
/**
* Parse front matter, the content in the header of the file.
*
* Will scan through and retrieve any key:value pair within `---` tags
* at the beginning of the file.
*
* These values get set directly on the `File` object for use within templates or system use.
*/
parseFrontMatter() {
if (!this._checkHasFrontMatter()) {
// No FrontMatter, nothing to scan.
return;
}
let yaml = this.content.split(this.config.frontMatterSeperator)[1],
data;
if (yaml) {
data = jsYaml.load(yaml);
this.parseFrontMatterData(data);
}
}
/**
* Parse the actual metadata located from the frontmatter
*
* @param {object} data
*/
parseFrontMatterData(data) {
for (let [attKey, attVal] of Object.entries(data)) {
// For convenience all tags should be lowercase.
attKey = attKey.toLowerCase();
if (File.ProtectedAttributes.indexOf(attKey) !== -1) {
// To prevent the user from messing with important parameters, skip a few.
// These are calculated and used internally and really shouldn't be modified.
Log.Warn(this.type, this.url, 'has a protected key [' + attKey + '], value will NOT be parsed.');
continue;
}
if (typeof this[attKey] === 'function') {
// Do not allow methods to be overridden
Log.Warn(this.type, this.url, 'unable to load key [' + attKey + '], target is a function!');
continue;
}
this[attKey] = this._parseFrontMatterKey(attVal);
}
}
/**
* Parse filename from the URL of this file and sets to `name`
*/
parseFilename() {
this.name = basename(this.url, true);
}
/**
* Parse permalink from the URL of this file and sets to `permalink`
*/
parsePermalink() {
this.permalink = pathJoin(dirname(this.url), basename(this.url, true) + '.html');
}
/**
* Parse file date from either the FrontMatter or server Last-Modified header
*/
parseDate() {
let dateRegEx = new RegExp(this.config.dateParser);
if (this.date) {
// Date is set from markdown via the "date" inline header
this.datetime = getDatetime(this.date);
this.date = this.config.dateFormat(this.datetime);
} else if (dateRegEx.test(this.url)) {
// Date is retrieved from file URL
// Support 2023-01-02 and 2023/01/02 formats in the URL
this.date = dateRegEx.exec(this.url)[0].replace('/', '-');
this.datetime = getDatetime(this.date);
this.date = this.config.dateFormat(this.datetime);
} else if (this.datetime) {
// Lastmodified is retrieved from server response headers or set from the front content
this.datetime = getDatetime(this.datetime);
this.date = this.config.dateFormat(this.datetime);
}
}
/**
* Parse file body from the markdown content
*/
async parseBody() {
return new Promise((resolve, reject) => {
if (this.bodyLoaded) {
// Only render content if it hasn't been loaded yet, (allows for repeated calls)
resolve(this.body);
return;
}
this.loadContent()
.then(content => {
let html;
if (this._checkHasFrontMatter()) {
// Trim off the FrontMatter from the content
html = content
.split(this.config.frontMatterSeperator)
.splice(2)
.join(this.config.frontMatterSeperator);
} else {
// This file does not contain any valid formatted FrontMatter content
html = content;
}
if (this.config.markdownEngine) {
this.body = this.config.markdownEngine(html);
} else {
this.body = '<pre>' + html + '</pre>';
}
this.bodyLoaded = true;
resolve(this.body);
})
.catch(e => {
reject(e);
});
});
}
/**
* Parse file content
*
* Sets all file attributes and content.
*/
parseContent() {
this.parseFilename();
this.parsePermalink();
this.parseFrontMatter();
this.parseDate();
}
/**
* Perform a text search on this file to see if the content contains a given search query
*
* @param {string} query Query to check if this file matches against
* @returns {boolean}
*/
matchesSearch(query) {
let words = query.toLowerCase().split(' '),
found = true,
checks = '';
if (this.content) {
checks += this.content.toLowerCase();
}
if (this.title) {
checks += this.title.toLowerCase();
}
if (this.excerpt) {
checks += this.excerpt.toLowerCase();
}
words.forEach(word => {
if (checks.indexOf(word) === -1) {
// This keyword was not located anywhere, matches need to be complete when multiple words are provided.
found = false;
return false;
}
});
return found;
}
/**
* Perform an attribute search on this file to see if its metadata matches the query
*
* @param {Object} query Dictionary containing key/values to search
* @param {string} [mode=AND] "OR" or "AND" if we should check all keys or any of them
* @returns {boolean}
*
* @example
* // Match if this file is authored by Alice
* file.matchesAttributeSearch({author: 'Alice'});
*
* // Match if this file is authored by Alice or Bob
* file.matchesAttributeSearch({author: ['Alice', 'Bob']});
*
* // Match if this file is authored by Alice or Bob AND has the tag Configuration
* file.matchesAttributeSearch({author: ['Alice', 'Bob'], tags: 'Configuration'});
*
* // Match if this file is authored by Bob OR has the tag HR
* file.matchesAttributeSearch({author: 'Bob', tags: 'HR'}, 'OR');
*/
matchesAttributeSearch(query, mode) {
let found = false, matches_all = true;
mode = mode || 'AND';
for (let [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
// Multiple values, this grouping is an 'OR' automatically
let set_match = false;
for (let i = 0; i < value.length; i++) {
if (this._matchesAttribute(key, value[i])) {
set_match = true;
}
}
if (set_match) {
found = true;
} else {
matches_all = false;
}
} else {
if (this._matchesAttribute(key, value)) {
found = true;
} else {
matches_all = false;
}
}
}
if (mode.toUpperCase() === 'OR') {
// an OR check just needs at least one matching result
return found;
} else {
// an AND check (default) needs at least one matching AND all other matching
return found && matches_all;
}
}
/**
* Renders file with a configured layout
*
* @async
* @returns {Promise}
* @throws {Error}
*/
async render() {
document.title = 'Loading ' + this.url + '...';
return new Promise((resolve, reject) => {
this.parseBody().then(() => {
// Rendering a full page will update the page title
if (this.seotitle) {
document.title = this.seotitle;
} else if (this.title) {
document.title = this.title;
} else {
document.title = 'Page';
}
renderLayout(this.layout, this).then(() => {
resolve();
}).catch(e => {
reject(e);
});
}).catch(e => {
reject(e);
});
});
}
/**
* Internal method to parse a value query, including support for comparison prefixes in the string
* Supports a single value to compare and both single and array values from the metadata
*
* @param {string} key Frontmatter meta key to compare against
* @param {string|null} value Value comparing
* @returns {boolean}
* @private
*/
_matchesAttribute(key, value) {
let op = '',
check,
local_val;
if (!Object.hasOwn(this, key) || this[key] === null) {
// If the property is either not set or NULL, only NULL check value will match
// This is done separately to make the Array logic easier herein.
return value === null;
} else if (value === null) {
// If the value is null, then we only want unset attributes,
// but the above check confirmed that the value is set
return false;
}
if (value.indexOf('~ ') === 0) {
// "~ " prefix is RegExp
op = '~';
check = new RegExp(value.substring(2));
} else if (value.indexOf('>= ') === 0) {
// Mathematical operation
op = '>=';
check = value.substring(3);
} else if (value.indexOf('<= ') === 0) {
// Mathematical operation
op = '<=';
check = value.substring(3);
} else if (value.indexOf('> ') === 0) {
// Mathematical operation
op = '>';
check = value.substring(2);
} else if (value.indexOf('< ') === 0) {
// Mathematical operation
op = '<';
check = value.substring(2);
} else {
// Default case, standard comparison but done case insensitively
op = '=';
check = value.toLowerCase();
}
if (key === 'date') {
// This is a useless parameter as it's formatted into a human-friendly version,
// but we can remap it to datetime (that's probably what they wanted)
key = 'datetime';
}
if (key === 'datetime') {
// Dates must be compared against other Dates.
check = new Date(value);
}
if (Array.isArray(this[key])) {
local_val = this[key];
} else {
// To support array values, just convert everything to an array to make the logic simpler.
local_val = [this[key]];
}
for (let val of local_val) {
if (op === '~') {
if (check.exec(val) !== null) {
return true;
}
} else if (op === '>=') {
if (val >= check) {
return true;
}
} else if (op === '<=') {
if (val <= check) {
return true;
}
} else if (op === '>') {
if (val > check) {
return true;
}
} else if (op === '<') {
if (val < check) {
return true;
}
} else {
if (val.toLowerCase() === check) {
return true;
}
}
}
return false;
}
/**
* Check if this File has FrontMatter content
*
* This is important because parseContent needs to know if it needs to strip the meta fields
* @returns {boolean}
* @private
*/
_checkHasFrontMatter() {
if (this.content === null || this.content === '') {
// Failsafe checks
return false;
}
// FrontMatter always starts on line 1
let r = new RegExp('^' + this.config.frontMatterSeperator),
m = this.content.match(r);
if (m === null) {
return false;
}
// There must be at least 2 separators
r = new RegExp('^' + this.config.frontMatterSeperator + '$[^]', 'gm');
m = this.content.match(r);
return (Array.isArray(m) && m.length >= 2);
}
/**
* Parse a FrontMatter value for special functionality
* @param {Object|Array|string|boolean|null|number} value
* @returns {Object|Array|string|boolean|null|number}
*/
_parseFrontMatterKey(value) {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
value[i] = this._parseFrontMatterKey(value[i]);
}
} else if (value instanceof Date) {
// Native Date objects will utilize UTC which is probably not what the user wanted.
// Convert that date to the user's local timezone
return new Date(value.getTime() + value.getTimezoneOffset() * 60000);
} else if (value instanceof Object) {
for (let [k, v] of Object.entries(value)) {
if (k === 'src' || k === 'href') {
// Objects may have HREF or SRC attributes, treat these as such
// Fix for relatively positioned images
// An easy way to specify images in markdown files is to list them relative to the file itself.
// Take the permalink (since it's already resolved), and prepend the base to the image.
if (v.indexOf('://') === -1 && v[0] !== '/') {
if (!this.permalink) {
// Ensure the permalink for this file is ready
this.parsePermalink();
}
value[k] = pathJoin(dirname(this.permalink), v);
}
}
if (k === 'src' && !Object.hasOwn(value, 'alt')) {
// src is commonly used for images, so include an 'alt' attribute by default if not provided.
value['alt'] = basename(value[k]);
}
}
}
return value;
}
}
export default File;