diff --git a/.babelrc b/.babelrc
index 06a818d..6a750ee 100644
--- a/.babelrc
+++ b/.babelrc
@@ -3,5 +3,5 @@
["@babel/env", {
"modules": false
}]
- ],
+ ]
}
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..354624a
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,34 @@
+# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
+
+name: CMS.JS CI Test
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [12.x]
+ # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
+
+ steps:
+ - uses: actions/checkout@v3
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v3
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+ # Install dependencies in a ci mode
+ - run: npm ci
+ # Check for issues and compile the base code
+ - run: npm run compile
+ # Minify the application (to ensure minification works without error)
+ - run: npm run minify
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b7c1c9f..e66ff3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,6 @@
.DS_Store
node_modules/*
-release/*
+release/*.tgz
*.swp
npm-debug.log
-examples/js/cms.js
+
diff --git a/README.md b/README.md
index c7874cf..5593192 100644
--- a/README.md
+++ b/README.md
@@ -44,9 +44,10 @@ CMS.js supports two website modes, Github and Server. Host your website on Githu
1. Clone the [starter repo](https://github.com/chrisdiana/cms.js-starter): `git clone https://github.com/chrisdiana/cms.js-starter.git` or download the [latest release here](https://github.com/chrisdiana/cms.js/releases/latest)
2. Configure `js/config.js` to your liking
-3. Make sure to set your Github settings in `js/config.js` if using Github mode
-4. If using Github mode, create a new branch from your master or working branch called `gh-pages` (Github's default branch for hosting)
-5. Visit your site! (which should be located at `https://yourusername.github.io/cms.js-starter`)
+3. Configure `.htaccess` to your web URL path if installed in a subdirectory
+4. Make sure to set your Github settings in `js/config.js` if using Github mode
+5. If using Github mode, create a new branch from your master or working branch called `gh-pages` (Github's default branch for hosting)
+6. Visit your site! (which should be located at `https://yourusername.github.io/cms.js-starter`)
## CDN
@@ -112,3 +113,19 @@ All forms of contribution are welcome: bug reports, bug fixes, pull requests and
## List of contributors
You can find the list of contributors [here](https://github.com/chrisdiana/cms.js/graphs/contributors).
+
+
+## Building
+
+### Instructions for Windows
+
+@todo, (I don't have windows, sorry)
+
+### Instructions for Debian/Ubuntu
+
+```bash
+sudo apt install npm
+# from within the cms.js project directory,
+NODE_ENV=dev npm install
+sudo a2enmod include rewrite
+```
\ No newline at end of file
diff --git a/dist/cms.es.js b/dist/cms.es.js
index d710cd8..036adea 100644
--- a/dist/cms.es.js
+++ b/dist/cms.es.js
@@ -1,10 +1,9 @@
-/*! @chrisdiana/cmsjs v2.0.1 | MIT (c) 2021 Chris Diana | https://github.com/chrisdiana/cms.js */
+/*! @chrisdiana/cmsjs v2.0.1~cdp1337-20221105 | MIT (c) 2022 Chris Diana | https://github.com/chrisdiana/cms.js */
function _classCallCheck(instance, Constructor) {
if (!(instance instanceof Constructor)) {
throw new TypeError("Cannot call a class as a function");
}
}
-
function _defineProperties(target, props) {
for (var i = 0; i < props.length; i++) {
var descriptor = props[i];
@@ -14,10 +13,12 @@ function _defineProperties(target, props) {
Object.defineProperty(target, descriptor.key, descriptor);
}
}
-
function _createClass(Constructor, protoProps, staticProps) {
if (protoProps) _defineProperties(Constructor.prototype, protoProps);
if (staticProps) _defineProperties(Constructor, staticProps);
+ Object.defineProperty(Constructor, "prototype", {
+ writable: false
+ });
return Constructor;
}
@@ -42,7 +43,9 @@ var defaults = {
debug: false,
messageClassName: 'cms-messages',
onload: function onload() {},
- onroute: function onroute() {}
+ onroute: function onroute() {},
+ webpath: '/',
+ titleSearchResults: 'Search Results'
};
var messageContainer;
@@ -54,12 +57,12 @@ var messages = {
LAYOUT_LOAD_ERROR: 'ERROR: Error loading layout. Check the layout file to make sure it exists.',
NOT_READY_WARNING: 'WARNING: Not ready to perform action'
};
+
/**
* Creates message container element
* @function
* @param {string} classname - Container classname.
*/
-
function createMessageContainer(classname) {
messageContainer = document.createElement('div');
messageContainer.className = classname;
@@ -69,6 +72,7 @@ function createMessageContainer(classname) {
messageContainer.style.top = '0px';
document.body.appendChild(messageContainer);
}
+
/**
* Handle messages
* @function
@@ -77,8 +81,6 @@ function createMessageContainer(classname) {
* @description
* Used for debugging purposes.
*/
-
-
function handleMessage(debug, message) {
if (debug) messageContainer.innerHTML = message;
return message;
@@ -94,19 +96,19 @@ function handleMessage(debug, message) {
function get(url, callback) {
var req = new XMLHttpRequest();
req.open('GET', url, true);
-
req.onreadystatechange = function () {
if (req.readyState === 4) {
if (req.status === 200) {
- callback(req.response, false);
+ // Add support for returning the Last-Modified header for lazy timestamps
+ callback(req.response, false, req.getResponseHeader('Last-Modified'));
} else {
- callback(req, req.statusText);
+ callback(req, req.statusText, null);
}
}
};
-
req.send();
}
+
/**
* Extend utility function for extending objects.
* @function
@@ -115,69 +117,48 @@ function get(url, callback) {
* @param {function} callback - Callback function after completion.
* @returns {object} Extended target object.
*/
-
function extend(target, opts, callback) {
var next;
-
if (typeof opts === 'undefined') {
opts = target;
}
-
for (next in opts) {
if (Object.prototype.hasOwnProperty.call(opts, next)) {
target[next] = opts[next];
}
}
-
if (callback) {
callback();
}
-
return target;
}
+
/**
* Utility function for getting a function name.
* @function
* @param {function} func - The function to get the name
* @returns {string} Name of function.
*/
-
function getFunctionName(func) {
var ret = func.toString();
ret = ret.substr('function '.length);
ret = ret.substr(0, ret.indexOf('('));
return ret;
}
+
/**
* Checks if the file URL with file extension is a valid file to load.
* @function
* @param {string} fileUrl - File URL
* @returns {boolean} Is valid.
*/
-
function isValidFile(fileUrl, extension) {
if (fileUrl) {
var ext = fileUrl.split('.').pop();
return ext === extension.replace('.', '') || ext === 'html' ? true : false;
}
}
-/**
- * Get URL paths without parameters.
- * @function
- * @returns {string} URL Path
- */
-
-function getPathsWithoutParameters() {
- return window.location.hash.split('/').map(function (path) {
- if (path.indexOf('?') >= 0) {
- path = path.substring(0, path.indexOf('?'));
- }
- return path;
- }).filter(function (path) {
- return path !== '#';
- });
-}
/**
* Get URL parameter by name.
* @function
@@ -185,45 +166,45 @@ function getPathsWithoutParameters() {
* @param {string} url - URL
* @returns {string} Parameter value
*/
-
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[[]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^]*)|&|#|$)'),
- results = regex.exec(url);
+ results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
+
/**
* Get Github URL based on configuration.
* @function
* @param {string} type - Type of file.
* @returns {string} GIthub URL
*/
-
function getGithubUrl(type, gh) {
var url = [gh.host, 'repos', gh.username, gh.repo, 'contents', type + '?ref=' + gh.branch];
if (gh.prefix) url.splice(5, 0, gh.prefix);
return url.join('/');
}
+
/**
* Formats date string to datetime
* @param {string} dateString - Date string to convert.
* @returns {object} Formatted datetime
*/
-
function getDatetime(dateStr) {
var dt = new Date(dateStr);
return new Date(dt.getTime() - dt.getTimezoneOffset() * -60000);
}
+
/**
* @param {string} filepath - Full file path including file name.
* @returns {string} filename
*/
-
function getFilenameFromPath(filepath) {
- return filepath.split('\\').pop().split('/').pop();
+ //return filepath.split('\\').pop().split('/').pop();
+ return filepath.split('\\').pop();
}
/**
@@ -232,10 +213,10 @@ function getFilenameFromPath(filepath) {
* @param {string} text - HTML text to be evaluated.
* @returns {string} Rendered template with injected data.
*/
-
function Templater(text) {
return new Function('data', 'var output=' + JSON.stringify(text).replace(/<%=(.+?)%>/g, '"+($1)+"').replace(/<%(.+?)%>/g, '";$1\noutput+="') + ';return output;');
}
+
/**
* Load template from URL.
* @function
@@ -244,13 +225,13 @@ function Templater(text) {
* @param {object} data - Data to load into template.
* @param {function} callback - Callback function
*/
-
function loadTemplate(url, data, callback) {
get(url, function (success, error) {
if (error) callback(success, error);
callback(Templater(success)(data), error);
});
}
+
/**
* Renders the layout into the main container.
* @function renderLayout
@@ -258,15 +239,16 @@ function loadTemplate(url, data, callback) {
* @param {string} layout - Filename of layout.
* @param {object} data - Data passed to template.
*/
-
-function renderLayout(layout, config, data) {
+function renderLayout(layout, config, data, callback) {
config.container.innerHTML = '';
- var url = [config.layoutDirectory, '/', layout, '.html'].join('');
+ var url = [config.webpath, '/', config.layoutDirectory, '/', layout, '.html'].join('');
loadTemplate(url, data, function (success, error) {
if (error) {
handleMessage(messages['LAYOUT_LOAD_ERROR']);
+ callback(null, error);
} else {
config.container.innerHTML = success;
+ callback('rendered', null);
}
});
}
@@ -280,105 +262,119 @@ function renderLayout(layout, config, data) {
var Markdown = /*#__PURE__*/function () {
function Markdown() {
_classCallCheck(this, Markdown);
-
- this.rules = [// headers - fix link anchor tag regex
+ this.rules = [
+ // headers - fix link anchor tag regex
{
regex: /(#+)(.*)/g,
replacement: function replacement(text, chars, content) {
var level = chars.length;
return '$1'
- }, // quote
+ },
+ // quote
{
regex: /:"(.*?)":/g,
replacement: '$1
'
- }, // block code
+ },
+ // block code
{
regex: /```[a-z]*\n[\s\S]*?\n```/g,
replacement: function replacement(text) {
text = text.replace(/```/gm, '');
return '
' + text.trim() + ''; } - }, // js code + }, + // js code { regex: /&&&[a-z]*\n[\s\S]*?\n&&&/g, replacement: function replacement(text) { text = text.replace(/```/gm, ''); return ''; } - }, // inline code + }, + // inline code { regex: /`(.*?)`/g, replacement: '
$1
'
- }, // ul lists
+ },
+ // ul lists
{
regex: /\n\*(.*)/g,
replacement: function replacement(text, item) {
return '\n' + item.trim() + ''; } - }, // horizontal rule + }, + // horizontal rule { regex: /\n-{5,}/g, replacement: '\n
' + trimmed + '
\n'; } - }, // fix extra ul + }, + // fix extra ul { regex: /<\/ul>\s?/g, replacement: '\n' }]; } - _createClass(Markdown, [{ key: "render", value: function render(text) { @@ -389,7 +385,6 @@ var Markdown = /*#__PURE__*/function () { return text.trim(); } }]); - return Markdown; }(); @@ -400,12 +395,10 @@ var Markdown = /*#__PURE__*/function () { * @param {string} type - The type of file (i.e. posts, pages). * @param {object} layout - The layout templates of the file. */ - var File = /*#__PURE__*/function () { function File(url, type, layout, config) { _classCallCheck(this, File); - - this.url = type === 'SERVER' ? type + '/' + url : url; + this.url = url; this.type = type; this.layout = layout; this.config = config; @@ -414,6 +407,7 @@ var File = /*#__PURE__*/function () { this.name; this.extension; this.title; + this.seotitle; this.excerpt; this.date; this.datetime; @@ -421,7 +415,9 @@ var File = /*#__PURE__*/function () { this.body; this.permalink; this.tags; + this.image; } + /** * Get file content. * @method @@ -431,44 +427,56 @@ var File = /*#__PURE__*/function () { * Get the file's HTML content and set the file object html * attribute to the file content. */ - - _createClass(File, [{ key: "getContent", value: function getContent(callback) { var _this = this; - - get(this.url, function (success, error) { + get(this.url, function (success, error, lastModified) { if (error) callback(success, error); - _this.content = success; // check if the response returns a string instead - // of an response object + _this.content = success; + // Patch to retrieve the last modified timestamp automatically from the server. + // If "datetime" is assigned in the content, it'll override the server header. + if (lastModified) { + _this.datetime = lastModified; + } + + // check if the response returns a string instead + // of an response object if (typeof _this.content === 'string') { callback(success, error); } }); } + /** * Parse front matter. * @method * @description * Overrides post attributes if front matter is available. */ - }, { key: "parseFrontMatter", value: function parseFrontMatter() { var yaml = this.content.split(this.config.frontMatterSeperator)[1]; - if (yaml) { var attributes = {}; yaml.split(/\n/g).forEach(function (attributeStr) { - var attribute = attributeStr.split(':'); - attribute[1] && (attributes[attribute[0].trim()] = attribute[1].trim()); + // Fix https://github.com/chrisdiana/cms.js/issues/95 by splitting ONLY on the first occurrence of a colon. + if (attributeStr.indexOf(':') !== -1) { + var attPos = attributeStr.indexOf(':'), + attKey = attributeStr.substr(0, attPos).trim(), + attVal = attributeStr.substr(attPos + 1).trim(); + if (attVal !== '') { + // Only retrieve this key/value if the value is not an empty string. (false is allowed) + attributes[attKey] = attVal; + } + } }); extend(this, attributes, null); } } + /** * Set list attributes. * @method @@ -476,40 +484,41 @@ var File = /*#__PURE__*/function () { * Sets front matter attributes that are specified as list attributes to * an array by splitting the string by commas. */ - }, { key: "setListAttributes", value: function setListAttributes() { var _this2 = this; - this.config.listAttributes.forEach(function (attribute) { - if (_this2.hasOwnProperty(attribute) && _this2[attribute]) { + // Keep ESLint from complaining + // ref https://ourcodeworld.com/articles/read/1425/how-to-fix-eslint-error-do-not-access-objectprototype-method-hasownproperty-from-target-object-no-prototype-builtins + if (Object.getOwnPropertyDescriptor(_this2, attribute) && _this2[attribute]) { _this2[attribute] = _this2[attribute].split(',').map(function (item) { return item.trim(); }); } }); } + /** * Sets filename. * @method */ - }, { key: "setFilename", value: function setFilename() { this.name = this.url.substr(this.url.lastIndexOf('/')).replace('/', '').replace(this.config.extension, ''); } + /** * Sets permalink. * @method */ - }, { key: "setPermalink", value: function setPermalink() { - this.permalink = ['#', this.type, this.name].join('/'); + this.permalink = this.config.mode === 'GITHUB' ? ['#', this.type, this.name].join('/') : this.url.substring(0, this.url.length - this.config.extension.length) + '.html'; } + /** * Set file date. * @method @@ -517,33 +526,36 @@ var File = /*#__PURE__*/function () { * Check if file has date in front matter otherwise use the date * in the filename. */ - }, { key: "setDate", value: function setDate() { var 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)) { - this.date = dateRegEx.exec(this.url); + // Date is retrieved from file URL + this.date = dateRegEx.exec(this.url)[0]; 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); } } + /** * Set file body. * @method * @description * Sets the body of the file based on content after the front matter. */ - }, { key: "setBody", value: function setBody() { var html = this.content.split(this.config.frontMatterSeperator).splice(2).join(this.config.frontMatterSeperator); - if (this.html) { this.body = html; } else { @@ -555,13 +567,13 @@ var File = /*#__PURE__*/function () { } } } + /** * Parse file content. * @method * @description * Sets all file attributes and content. */ - }, { key: "parseContent", value: function parseContent() { @@ -572,19 +584,47 @@ var File = /*#__PURE__*/function () { this.setDate(); this.setBody(); } + + /** + * Check if this file matches a given query + * + * @param {string} query Query to check if this file matches against + * @returns {boolean} + */ + }, { + key: "matchesSearch", + value: function matchesSearch(query) { + var _this3 = this; + var words = query.toLowerCase().split(' '), + found = true; + words.forEach(function (word) { + if (_this3.content.toLowerCase().indexOf(word) === -1 && _this3.title.toLowerCase().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; + } + /** * Renders file. * @method * @async */ - }, { key: "render", - value: function render() { - return renderLayout(this.layout, this.config, this); + value: function render(callback) { + if (this.seotitle) { + document.title = this.seotitle; + } else if (this.title) { + document.title = this.title; + } else { + document.title = 'Page'; + } + return renderLayout(this.layout, this.config, this, callback); } }]); - return File; }(); @@ -594,172 +634,219 @@ var File = /*#__PURE__*/function () { * @param {string} type - The type of file collection (i.e. posts, pages). * @param {object} layout - The layouts of the file collection type. */ - var FileCollection = /*#__PURE__*/function () { function FileCollection(type, layout, config) { _classCallCheck(this, FileCollection); - this.type = type; this.layout = layout; this.config = config; this.files = []; + this.directories = []; this[type] = this.files; + this.directoriesScanned = 0; } + /** - * Initialize file collection. - * @method - * @async - * @param {function} callback - Callback function + * Generic function to assist with debug logging without needing if ... everywhere. + * @param {...any} args mixed arguments to pass */ - - _createClass(FileCollection, [{ + key: "debuglog", + value: function debuglog() { + if (this.config.debug) { + var _console; + (_console = console).log.apply(_console, arguments); + } + } + + /** + * Initialize file collection. + * @method + * @async + * @param {function} callback - Callback function + */ + }, { key: "init", value: function init(callback) { var _this = this; - this.getFiles(function (success, error) { if (error) handleMessage(messages['DIRECTORY_ERROR']); - _this.loadFiles(function (success, error) { if (error) handleMessage(messages['GET_FILE_ERROR']); callback(); }); }); } + /** * Get file list URL. * @method * @param {string} type - Type of file collection. * @returns {string} URL of file list */ - }, { key: "getFileListUrl", value: function getFileListUrl(type, config) { - return config.mode === 'GITHUB' ? getGithubUrl(type, config.github) : type; + return config.mode === 'GITHUB' ? getGithubUrl(type, config.github) : this.config.webpath + type; } + /** * Get file URL. * @method * @param {object} file - File object. * @returns {string} File URL */ - }, { key: "getFileUrl", value: function getFileUrl(file, mode, type) { - return mode === 'GITHUB' ? file['download_url'] : "".concat(type, "/").concat(getFilenameFromPath(file.getAttribute('href'))); + if (mode === 'GITHUB') { + return file['download_url']; + } else { + var href = getFilenameFromPath(file.getAttribute('href')); + if (href[0] === '/') { + // Absolutely resolved paths should be returned unmodified + return href; + } else { + // Relatively linked URLs get appended to the parent directory + if (type[type.length - 1] === '/') { + // parent directory ends in a trailing slash + return type + href; + } else { + // No trailing slash, so adjust as necessary + return type + '/' + href; + } + } + } } + /** * Get file elements. * @param {object} data - File directory or Github data. * @returns {array} File elements */ - }, { key: "getFileElements", value: function getFileElements(data) { - var fileElements; // Github Mode + var fileElements; + // Github Mode if (this.config.mode === 'GITHUB') { fileElements = JSON.parse(data); - } // Server Mode + } + // Server Mode else { - // convert the directory listing to a DOM element - var listElement = document.createElement('div'); - listElement.innerHTML = data; // get the links in the directory listing - - fileElements = [].slice.call(listElement.getElementsByTagName('a')); - } - + // convert the directory listing to a DOM element + var listElement = document.createElement('div'); + listElement.innerHTML = data; + // get the links in the directory listing + fileElements = [].slice.call(listElement.getElementsByTagName('a')); + } return fileElements; } + /** * Get files from file listing and set to file collection. * @method * @async * @param {function} callback - Callback function */ - }, { key: "getFiles", value: function getFiles(callback) { - var _this2 = this; + this.directories = [this.getFileListUrl(this.type, this.config)]; + this.scanDirectory(callback, this.directories[0], true); + } - get(this.getFileListUrl(this.type, this.config), function (success, error) { - if (error) callback(success, error); // find the file elements that are valid files, exclude others + /** + * Perform the underlying directory lookup + * @method + * @async + * @param {function} callback - Callback function + * @param {string} directory - Directory URL to scan + * @param {boolean} recurse - Set to FALSE to prevent further recursion + */ + }, { + key: "scanDirectory", + value: function scanDirectory(callback, directory, recurse) { + var _this2 = this; + this.debuglog('Scanning directory', directory); + get(directory, function (success, error) { + if (error) callback(success, error); + // find the file elements that are valid files, exclude others _this2.getFileElements(success).forEach(function (file) { - var fileUrl = _this2.getFileUrl(file, _this2.config.mode, _this2.type); - + var fileUrl = _this2.getFileUrl(file, _this2.config.mode, directory); if (isValidFile(fileUrl, _this2.config.extension)) { + // Regular markdown file _this2.files.push(new File(fileUrl, _this2.type, _this2.layout.single, _this2.config)); + } else if (recurse && _this2.config.mode !== 'GITHUB' && fileUrl[fileUrl.length - 1] === '/' && fileUrl !== _this2.config.webpath) { + // in SERVER mode, support recursing ONE directory deep. + // Allow this for any directory listing NOT absolutely resolved (they will just point back to the parent directory) + _this2.directories.push(fileUrl); + _this2.scanDirectory(callback, fileUrl, false); } }); - - callback(success, error); + _this2.directoriesScanned++; + if (_this2.directoriesScanned === _this2.directories.length) { + callback(success, error); + } }); } + /** * Load files and get file content. * @method * @async * @param {function} callback - Callback function */ - }, { key: "loadFiles", value: function loadFiles(callback) { var _this3 = this; - - var promises = []; // Load file content - + var promises = []; + // Load file content this.files.forEach(function (file, i) { file.getContent(function (success, error) { if (error) callback(success, error); promises.push(i); - file.parseContent(); // Execute after all content is loaded - + file.parseContent(); + // Execute after all content is loaded if (_this3.files.length == promises.length) { callback(success, error); } }); }); } + /** * Search file collection by attribute. * @method - * @param {string} attribute - Attribue in file to search. * @param {string} search - Search query. - * @returns {object} File object */ - }, { key: "search", - value: function search(attribute, _search) { + value: function search(_search) { this[this.type] = this.files.filter(function (file) { - var attr = file[attribute].toLowerCase().trim(); - return attr.indexOf(_search.toLowerCase().trim()) >= 0; + return file.matchesSearch(_search); }); } + /** * Reset file collection files. * @method */ - }, { key: "resetSearch", value: function resetSearch() { this[this.type] = this.files; } + /** * Get files by tag. * @method * @param {string} query - Search query. - * @returns {array} Files array + * @returns {File[]} Files array */ - }, { key: "getByTag", value: function getByTag(query) { @@ -771,34 +858,77 @@ var FileCollection = /*#__PURE__*/function () { } }); } + + /** + * Get all tags located form this collection + * + * Each set will contain the properties `name` and `count` + * + * @returns {Object[]} + */ + }, { + key: "getTags", + value: function getTags() { + var tags = [], + tagNames = []; + this.files.forEach(function (file) { + if (file.tags) { + file.tags.forEach(function (tag) { + var pos = tagNames.indexOf(tag); + if (pos === -1) { + // New tag discovered + tags.push({ + name: tag, + count: 1 + }); + tagNames.push(tag); + } else { + // Existing tag + tags[pos].count++; + } + }); + } + }); + return tags; + } + /** * Get file by permalink. * @method * @param {string} permalink - Permalink to search. * @returns {object} File object. */ - }, { key: "getFileByPermalink", value: function getFileByPermalink(permalink) { - return this.files.filter(function (file) { - return file.permalink === permalink; - })[0]; + var _this4 = this; + this.debuglog('Retrieving file by permalink', permalink); + var foundFiles = this.files.filter(function (file) { + return file.permalink === permalink || file.permalink === _this4.config.webpath + permalink; + }); + if (foundFiles.length === 0) { + throw 'Requested file could not be located'; + } + return foundFiles[0]; } + /** * Renders file collection. * @method * @async * @returns {string} Rendered layout */ - }, { key: "render", - value: function render() { - return renderLayout(this.layout.list, this.config, this); + value: function render(callback) { + if (this.layout.title) { + document.title = this.layout.title; + } else { + document.title = 'Listing'; + } + return renderLayout(this.layout.list, this.config, this, callback); } }]); - return FileCollection; }(); @@ -807,57 +937,71 @@ var FileCollection = /*#__PURE__*/function () { * @constructor * @param {object} options - Configuration options. */ - var CMS = /*#__PURE__*/function () { function CMS(view, options) { _classCallCheck(this, CMS); - this.ready = false; + /** @property FileCollection[] */ this.collections = {}; this.filteredCollections = {}; this.state; this.view = view; this.config = Object.assign({}, defaults, options); - this.init(); } + /** - * Init - * @method - * @description - * Initializes the application based on the configuration. Sets up up config object, - * hash change event listener for router, and loads the content. + * Generic function to assist with debug logging without needing if ... everywhere. + * @param {...any} args mixed arguments to pass */ - - _createClass(CMS, [{ + key: "debuglog", + value: function debuglog() { + if (this.config.debug) { + var _console; + (_console = console).log.apply(_console, arguments); + } + } + + /** + * Init + * @method + * @description + * Initializes the application based on the configuration. Sets up up config object, + * hash change event listener for router, and loads the content. + */ + }, { key: "init", value: function init() { var _this = this; - // create message container element if debug mode is enabled if (this.config.debug) { createMessageContainer(this.config.messageClassName); } - if (this.config.elementId) { // setup container this.config.container = document.getElementById(this.config.elementId); - + this.view.addEventListener('click', function (e) { + if (e.target && e.target.nodeName === 'A') { + _this.listenerLinkClick(e); + } + }); if (this.config.container) { // setup file collections this.initFileCollections(function () { // check for hash changes - _this.view.addEventListener('hashchange', _this.route.bind(_this), false); // start router by manually triggering hash change - - - _this.view.dispatchEvent(new HashChangeEvent('hashchange')); // register plugins and run onload events - - + _this.view.addEventListener('hashchange', _this.route.bind(_this), false); + // AND check for location.history changes (for SEO reasons) + _this.view.addEventListener('popstate', function (event) { + console.log('popping', event); + _this.route(); + }); + // start router by manually triggering hash change + //this.view.dispatchEvent(new HashChangeEvent('hashchange')); + _this.route(); + // register plugins and run onload events _this.ready = true; - _this.registerPlugins(); - - _this.config.onload(); + _this.onload(); }); } else { handleMessage(this.config.debug, messages['ELEMENT_ID_ERROR']); @@ -866,64 +1010,186 @@ var CMS = /*#__PURE__*/function () { handleMessage(this.config.debug, messages['ELEMENT_ID_ERROR']); } } + + /** + * Handle processing links clicked, will re-route to the history for applicable links. + * + * @param {Event} e Click event from user + */ + }, { + key: "listenerLinkClick", + value: function listenerLinkClick(e) { + var _this2 = this; + var targetHref = e.target.href; + + // Scan if this link was a link to one of the articles, + // we don't want to intercept non-page links. + this.config.types.forEach(function (type) { + if (targetHref.indexOf(window.location.origin + _this2.config.webpath + type.name + '/') === 0 && targetHref.substring(targetHref.length - 5) === '.html') { + // Target link is a page within a registered type path + _this2.historyPushState(targetHref); + e.preventDefault(); + return false; + } + if (targetHref.indexOf(window.location.origin + _this2.config.webpath + type.name + '.html') === 0) { + // Target link is a listing page for a registered type path + _this2.historyPushState(targetHref); + e.preventDefault(); + return false; + } + }); + if (targetHref === window.location.origin + this.config.webpath) { + // Target link is the homepage, this one can be handled too + this.historyPushState(targetHref); + e.preventDefault(); + return false; + } + console.log(targetHref); + e.preventDefault(); + } + + /** + * Function called automatically upon initialization + * by default will just call config.onload to preserve backwards compatibility + * + * @method + */ + }, { + key: "onload", + value: function onload() { + this.config.onload(); + } + + /** + * Function called when routing to a new page + * by default will just call config.onroute to preserve backwards compatibility + * + * @method + */ + }, { + key: "onroute", + value: function onroute() { + this.config.onroute(); + } + /** * Initialize file collections * @method * @async */ - }, { key: "initFileCollections", value: function initFileCollections(callback) { - var _this2 = this; - + var _this3 = this; var promises = []; - var types = []; // setup collections and routes + var types = []; + // setup collections and routes this.config.types.forEach(function (type) { - _this2.collections[type.name] = new FileCollection(type.name, type.layout, _this2.config); + _this3.collections[type.name] = new FileCollection(type.name, type.layout, _this3.config); types.push(type.name); - }); // init collections + }); + // init collections types.forEach(function (type, i) { - _this2.collections[type].init(function () { - promises.push(i); // reverse order to display newest posts first for post types - + _this3.collections[type].init(function () { + _this3.debuglog('Initialized collection ' + type); + promises.push(i); + // reverse order to display newest posts first for post types if (type.indexOf('post') === 0) { - _this2.collections[type][type].reverse(); - } // Execute after all content is loaded - - + _this3.collections[type][type].reverse(); + } + // Execute after all content is loaded if (types.length == promises.length) { callback(); } }); }); } + + /** + * Retrieve the current path URL broken down into individual pieces + * @returns {array} The segments of the URL broken down by directory + */ + }, { + key: "getPathsFromURL", + value: function getPathsFromURL() { + var paths = window.location.pathname.substring(this.config.webpath.length).split('/'); + if (paths.length >= 1 && paths[0].substring(paths[0].length - 5) === '.html') { + // First node (aka type) has HTML extension, just trim that off. + // This is done because /posts needs to be browseable separately, + // so we need a way to distinguish between that and the HTML version. + paths[0] = paths[0].substring(0, paths[0].length - 5); + } + return paths; + } + + /** + * REPLACE the window location, ONLY really useful on initial pageload + * + * Use historyPushState instead for most interactions where the user may click 'back' + * @param {string} url URL to replace + */ + }, { + key: "historyReplaceState", + value: function historyReplaceState(url) { + window.history.replaceState({}, '', url); + // Immediately trigger route to switch to the new content. + this.route(); + } + }, { + key: "historyPushState", + value: function historyPushState(url) { + window.history.pushState({}, '', url); + // Immediately trigger route to switch to the new content. + this.route(); + } }, { key: "route", value: function route() { - var paths = getPathsWithoutParameters(); - var type = paths[0]; - var filename = paths[1]; - var collection = this.collections[type]; - var query = getParameterByName('query') || ''; - var tag = getParameterByName('tag') || ''; - this.state = window.location.hash.substr(1); // Default view - + var _this4 = this; + this.debuglog('Initializing routing'); + var paths = this.getPathsFromURL(), + type = paths[0], + filename = paths.splice(1).join('/'), + collection = this.collections[type], + query = getParameterByName('s') || '', + tag = getParameterByName('tag') || '', + mode = '', + file = null; + this.debuglog('Paths retrieved from URL:', { + type: type, + filename: filename, + collection: collection + }); + this.state = window.location.hash.substr(1); if (!type) { - window.location = ['#', this.config.defaultView].join('/'); - } // List and single views - else { + // Default view + this.historyReplaceState(this.config.webpath + this.config.defaultView + '.html'); + // route will be re-called immediately upon updating the state, so stop here. + return; + } else { + // List and single views + try { if (filename) { // Single view - var permalink = ['#', type, filename.trim()].join('/'); - collection.getFileByPermalink(permalink).render(); + file = collection.getFileByPermalink([type, filename.trim()].join('/')); + mode = 'single'; + file.render(function () { + _this4.onroute({ + type: type, + file: file, + mode: mode, + query: query, + tag: tag, + collection: collection + }); + }); } else if (collection) { // List view if (query) { // Check for queries - collection.search('title', query); + collection.search(query); } else if (tag) { // Check for tags collection.getByTag(tag); @@ -931,44 +1197,61 @@ var CMS = /*#__PURE__*/function () { // Reset search collection.resetSearch(); } - - collection.render(); + mode = 'listing'; + collection.render(function () { + _this4.onroute({ + type: type, + file: file, + mode: mode, + query: query, + tag: tag, + collection: collection + }); + }); } else { - // Error view - renderLayout(this.config.errorLayout, this.config, {}); + throw 'Unknown request'; } - } // onroute event - - - this.config.onroute(); + } catch (e) { + console.error(e); + renderLayout(this.config.errorLayout, this.config, {}, function () { + _this4.onroute({ + type: type, + file: file, + mode: mode, + query: query, + tag: tag, + collection: collection + }); + }); + mode = 'error'; + } + } } + /** * Register plugins. * @method * @description * Set up plugins based on user configuration. */ - }, { key: "registerPlugins", value: function registerPlugins() { - var _this3 = this; - + var _this5 = this; this.config.plugins.forEach(function (plugin) { var name = getFunctionName(plugin); - - if (!_this3[name]) { - _this3[name] = plugin; + if (!_this5[name]) { + _this5[name] = plugin; } }); } + /** * Sort method for file collections. * @method * @param {string} type - Type of file collection. * @param {function} sort - Sorting function. */ - }, { key: "sort", value: function sort(type, _sort) { @@ -979,6 +1262,7 @@ var CMS = /*#__PURE__*/function () { handleMessage(messages['NOT_READY_WARNING']); } } + /** * Search method for file collections. * @method @@ -986,19 +1270,12 @@ var CMS = /*#__PURE__*/function () { * @param {string} attribute - File attribute to search. * @param {string} search - Search query. */ - }, { key: "search", - value: function search(type, attribute, _search) { - if (this.ready) { - this.collections[type].search(attribute, _search); - this.collections[type].render(); - } else { - handleMessage(messages['NOT_READY_WARNING']); - } + value: function search(type, _search) { + this.historyPushState(this.config.webpath + type + '.html?s=' + encodeURIComponent(_search)); } }]); - return CMS; }(); @@ -1013,4 +1290,4 @@ var main = (function (options) { return new CMS(window, options); }); -export default main; +export { main as default }; diff --git a/dist/cms.js b/dist/cms.js index 927d732..ccd7128 100644 --- a/dist/cms.js +++ b/dist/cms.js @@ -1,4 +1,4 @@ -/*! @chrisdiana/cmsjs v2.0.1 | MIT (c) 2021 Chris Diana | https://github.com/chrisdiana/cms.js */ +/*! @chrisdiana/cmsjs v2.0.1~cdp1337-20221105 | MIT (c) 2022 Chris Diana | https://github.com/chrisdiana/cms.js */ var CMS = (function () { 'use strict'; @@ -7,7 +7,6 @@ var CMS = (function () { throw new TypeError("Cannot call a class as a function"); } } - function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; @@ -17,10 +16,12 @@ var CMS = (function () { Object.defineProperty(target, descriptor.key, descriptor); } } - function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); + Object.defineProperty(Constructor, "prototype", { + writable: false + }); return Constructor; } @@ -45,7 +46,9 @@ var CMS = (function () { debug: false, messageClassName: 'cms-messages', onload: function onload() {}, - onroute: function onroute() {} + onroute: function onroute() {}, + webpath: '/', + titleSearchResults: 'Search Results' }; var messageContainer; @@ -57,12 +60,12 @@ var CMS = (function () { LAYOUT_LOAD_ERROR: 'ERROR: Error loading layout. Check the layout file to make sure it exists.', NOT_READY_WARNING: 'WARNING: Not ready to perform action' }; + /** * Creates message container element * @function * @param {string} classname - Container classname. */ - function createMessageContainer(classname) { messageContainer = document.createElement('div'); messageContainer.className = classname; @@ -72,6 +75,7 @@ var CMS = (function () { messageContainer.style.top = '0px'; document.body.appendChild(messageContainer); } + /** * Handle messages * @function @@ -80,8 +84,6 @@ var CMS = (function () { * @description * Used for debugging purposes. */ - - function handleMessage(debug, message) { if (debug) messageContainer.innerHTML = message; return message; @@ -97,19 +99,19 @@ var CMS = (function () { function get(url, callback) { var req = new XMLHttpRequest(); req.open('GET', url, true); - req.onreadystatechange = function () { if (req.readyState === 4) { if (req.status === 200) { - callback(req.response, false); + // Add support for returning the Last-Modified header for lazy timestamps + callback(req.response, false, req.getResponseHeader('Last-Modified')); } else { - callback(req, req.statusText); + callback(req, req.statusText, null); } } }; - req.send(); } + /** * Extend utility function for extending objects. * @function @@ -118,69 +120,48 @@ var CMS = (function () { * @param {function} callback - Callback function after completion. * @returns {object} Extended target object. */ - function extend(target, opts, callback) { var next; - if (typeof opts === 'undefined') { opts = target; } - for (next in opts) { if (Object.prototype.hasOwnProperty.call(opts, next)) { target[next] = opts[next]; } } - if (callback) { callback(); } - return target; } + /** * Utility function for getting a function name. * @function * @param {function} func - The function to get the name * @returns {string} Name of function. */ - function getFunctionName(func) { var ret = func.toString(); ret = ret.substr('function '.length); ret = ret.substr(0, ret.indexOf('(')); return ret; } + /** * Checks if the file URL with file extension is a valid file to load. * @function * @param {string} fileUrl - File URL * @returns {boolean} Is valid. */ - function isValidFile(fileUrl, extension) { if (fileUrl) { var ext = fileUrl.split('.').pop(); return ext === extension.replace('.', '') || ext === 'html' ? true : false; } } - /** - * Get URL paths without parameters. - * @function - * @returns {string} URL Path - */ - - function getPathsWithoutParameters() { - return window.location.hash.split('/').map(function (path) { - if (path.indexOf('?') >= 0) { - path = path.substring(0, path.indexOf('?')); - } - return path; - }).filter(function (path) { - return path !== '#'; - }); - } /** * Get URL parameter by name. * @function @@ -188,45 +169,45 @@ var CMS = (function () { * @param {string} url - URL * @returns {string} Parameter value */ - function getParameterByName(name, url) { if (!url) url = window.location.href; name = name.replace(/[[]]/g, '\\$&'); var regex = new RegExp('[?&]' + name + '(=([^]*)|&|#|$)'), - results = regex.exec(url); + results = regex.exec(url); if (!results) return null; if (!results[2]) return ''; return decodeURIComponent(results[2].replace(/\+/g, ' ')); } + /** * Get Github URL based on configuration. * @function * @param {string} type - Type of file. * @returns {string} GIthub URL */ - function getGithubUrl(type, gh) { var url = [gh.host, 'repos', gh.username, gh.repo, 'contents', type + '?ref=' + gh.branch]; if (gh.prefix) url.splice(5, 0, gh.prefix); return url.join('/'); } + /** * Formats date string to datetime * @param {string} dateString - Date string to convert. * @returns {object} Formatted datetime */ - function getDatetime(dateStr) { var dt = new Date(dateStr); return new Date(dt.getTime() - dt.getTimezoneOffset() * -60000); } + /** * @param {string} filepath - Full file path including file name. * @returns {string} filename */ - function getFilenameFromPath(filepath) { - return filepath.split('\\').pop().split('/').pop(); + //return filepath.split('\\').pop().split('/').pop(); + return filepath.split('\\').pop(); } /** @@ -235,10 +216,10 @@ var CMS = (function () { * @param {string} text - HTML text to be evaluated. * @returns {string} Rendered template with injected data. */ - function Templater(text) { return new Function('data', 'var output=' + JSON.stringify(text).replace(/<%=(.+?)%>/g, '"+($1)+"').replace(/<%(.+?)%>/g, '";$1\noutput+="') + ';return output;'); } + /** * Load template from URL. * @function @@ -247,13 +228,13 @@ var CMS = (function () { * @param {object} data - Data to load into template. * @param {function} callback - Callback function */ - function loadTemplate(url, data, callback) { get(url, function (success, error) { if (error) callback(success, error); callback(Templater(success)(data), error); }); } + /** * Renders the layout into the main container. * @function renderLayout @@ -261,15 +242,16 @@ var CMS = (function () { * @param {string} layout - Filename of layout. * @param {object} data - Data passed to template. */ - - function renderLayout(layout, config, data) { + function renderLayout(layout, config, data, callback) { config.container.innerHTML = ''; - var url = [config.layoutDirectory, '/', layout, '.html'].join(''); + var url = [config.webpath, '/', config.layoutDirectory, '/', layout, '.html'].join(''); loadTemplate(url, data, function (success, error) { if (error) { handleMessage(messages['LAYOUT_LOAD_ERROR']); + callback(null, error); } else { config.container.innerHTML = success; + callback('rendered', null); } }); } @@ -283,105 +265,119 @@ var CMS = (function () { var Markdown = /*#__PURE__*/function () { function Markdown() { _classCallCheck(this, Markdown); - - this.rules = [// headers - fix link anchor tag regex + this.rules = [ + // headers - fix link anchor tag regex { regex: /(#+)(.*)/g, replacement: function replacement(text, chars, content) { var level = chars.length; return '' + content.trim() + ' '; } - }, // image + }, + // image { regex: /!\[([^[]+)\]\(([^)]+)\)/g, replacement: '' - }, // hyperlink + }, + // hyperlink { regex: /\[([^[]+)\]\(([^)]+)\)/g, replacement: '$1' - }, // bold + }, + // bold { regex: /(\*\*|__)(.*?)\1/g, replacement: '$2' - }, // emphasis + }, + // emphasis { regex: /(\*|_)(.*?)\1/g, replacement: '$2' - }, // del + }, + // del { regex: /~~(.*?)~~/g, replacement: '$1' - }, // quote + }, + // quote { regex: /:"(.*?)":/g, replacement: '$1' - }, // block code + }, + // block code { regex: /```[a-z]*\n[\s\S]*?\n```/g, replacement: function replacement(text) { text = text.replace(/```/gm, ''); return '' + text.trim() + ''; } - }, // js code + }, + // js code { regex: /&&&[a-z]*\n[\s\S]*?\n&&&/g, replacement: function replacement(text) { text = text.replace(/```/gm, ''); return ''; } - }, // inline code + }, + // inline code { regex: /`(.*?)`/g, replacement: '$1
' - }, // ul lists + }, + // ul lists { regex: /\n\*(.*)/g, replacement: function replacement(text, item) { return '\n\n\t
'; } - }, // ol lists + }, + // ol lists { regex: /\n[0-9]+\.(.*)/g, replacement: function replacement(text, item) { return '\n- ' + item.trim() + '
\n\n\t
'; } - }, // blockquotes + }, + // blockquotes { regex: /\n(>|>)(.*)/g, replacement: function replacement(text, tmp, item) { return '\n- ' + item.trim() + '
\n' + item.trim() + ''; } - }, // horizontal rule + }, + // horizontal rule { regex: /\n-{5,}/g, replacement: '\n
' - }, // add paragraphs + }, + // add paragraphs { regex: /\n([^\n]+)\n/g, replacement: function replacement(text, line) { var trimmed = line.trim(); - if (/^<\/?(ul|ol|li|h|p|bl)/i.test(trimmed)) { return '\n' + line + '\n'; } - return '\n' + trimmed + '
\n'; } - }, // fix extra ul + }, + // fix extra ul { regex: /<\/ul>\s?/g, replacement: '' - }, // fix extra ol + }, + // fix extra ol { regex: /<\/ol>\s?
/g, replacement: '' - }, // fix extra blockquote + }, + // fix extra blockquote { regex: /<\/blockquote>
/g, replacement: '\n' }]; } - _createClass(Markdown, [{ key: "render", value: function render(text) { @@ -392,7 +388,6 @@ var CMS = (function () { return text.trim(); } }]); - return Markdown; }(); @@ -403,12 +398,10 @@ var CMS = (function () { * @param {string} type - The type of file (i.e. posts, pages). * @param {object} layout - The layout templates of the file. */ - var File = /*#__PURE__*/function () { function File(url, type, layout, config) { _classCallCheck(this, File); - - this.url = type === 'SERVER' ? type + '/' + url : url; + this.url = url; this.type = type; this.layout = layout; this.config = config; @@ -417,6 +410,7 @@ var CMS = (function () { this.name; this.extension; this.title; + this.seotitle; this.excerpt; this.date; this.datetime; @@ -424,7 +418,9 @@ var CMS = (function () { this.body; this.permalink; this.tags; + this.image; } + /** * Get file content. * @method @@ -434,44 +430,56 @@ var CMS = (function () { * Get the file's HTML content and set the file object html * attribute to the file content. */ - - _createClass(File, [{ key: "getContent", value: function getContent(callback) { var _this = this; - - get(this.url, function (success, error) { + get(this.url, function (success, error, lastModified) { if (error) callback(success, error); - _this.content = success; // check if the response returns a string instead - // of an response object + _this.content = success; + // Patch to retrieve the last modified timestamp automatically from the server. + // If "datetime" is assigned in the content, it'll override the server header. + if (lastModified) { + _this.datetime = lastModified; + } + + // check if the response returns a string instead + // of an response object if (typeof _this.content === 'string') { callback(success, error); } }); } + /** * Parse front matter. * @method * @description * Overrides post attributes if front matter is available. */ - }, { key: "parseFrontMatter", value: function parseFrontMatter() { var yaml = this.content.split(this.config.frontMatterSeperator)[1]; - if (yaml) { var attributes = {}; yaml.split(/\n/g).forEach(function (attributeStr) { - var attribute = attributeStr.split(':'); - attribute[1] && (attributes[attribute[0].trim()] = attribute[1].trim()); + // Fix https://github.com/chrisdiana/cms.js/issues/95 by splitting ONLY on the first occurrence of a colon. + if (attributeStr.indexOf(':') !== -1) { + var attPos = attributeStr.indexOf(':'), + attKey = attributeStr.substr(0, attPos).trim(), + attVal = attributeStr.substr(attPos + 1).trim(); + if (attVal !== '') { + // Only retrieve this key/value if the value is not an empty string. (false is allowed) + attributes[attKey] = attVal; + } + } }); extend(this, attributes, null); } } + /** * Set list attributes. * @method @@ -479,40 +487,41 @@ var CMS = (function () { * Sets front matter attributes that are specified as list attributes to * an array by splitting the string by commas. */ - }, { key: "setListAttributes", value: function setListAttributes() { var _this2 = this; - this.config.listAttributes.forEach(function (attribute) { - if (_this2.hasOwnProperty(attribute) && _this2[attribute]) { + // Keep ESLint from complaining + // ref https://ourcodeworld.com/articles/read/1425/how-to-fix-eslint-error-do-not-access-objectprototype-method-hasownproperty-from-target-object-no-prototype-builtins + if (Object.getOwnPropertyDescriptor(_this2, attribute) && _this2[attribute]) { _this2[attribute] = _this2[attribute].split(',').map(function (item) { return item.trim(); }); } }); } + /** * Sets filename. * @method */ - }, { key: "setFilename", value: function setFilename() { this.name = this.url.substr(this.url.lastIndexOf('/')).replace('/', '').replace(this.config.extension, ''); } + /** * Sets permalink. * @method */ - }, { key: "setPermalink", value: function setPermalink() { - this.permalink = ['#', this.type, this.name].join('/'); + this.permalink = this.config.mode === 'GITHUB' ? ['#', this.type, this.name].join('/') : this.url.substring(0, this.url.length - this.config.extension.length) + '.html'; } + /** * Set file date. * @method @@ -520,33 +529,36 @@ var CMS = (function () { * Check if file has date in front matter otherwise use the date * in the filename. */ - }, { key: "setDate", value: function setDate() { var 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)) { - this.date = dateRegEx.exec(this.url); + // Date is retrieved from file URL + this.date = dateRegEx.exec(this.url)[0]; 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); } } + /** * Set file body. * @method * @description * Sets the body of the file based on content after the front matter. */ - }, { key: "setBody", value: function setBody() { var html = this.content.split(this.config.frontMatterSeperator).splice(2).join(this.config.frontMatterSeperator); - if (this.html) { this.body = html; } else { @@ -558,13 +570,13 @@ var CMS = (function () { } } } + /** * Parse file content. * @method * @description * Sets all file attributes and content. */ - }, { key: "parseContent", value: function parseContent() { @@ -575,19 +587,47 @@ var CMS = (function () { this.setDate(); this.setBody(); } + + /** + * Check if this file matches a given query + * + * @param {string} query Query to check if this file matches against + * @returns {boolean} + */ + }, { + key: "matchesSearch", + value: function matchesSearch(query) { + var _this3 = this; + var words = query.toLowerCase().split(' '), + found = true; + words.forEach(function (word) { + if (_this3.content.toLowerCase().indexOf(word) === -1 && _this3.title.toLowerCase().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; + } + /** * Renders file. * @method * @async */ - }, { key: "render", - value: function render() { - return renderLayout(this.layout, this.config, this); + value: function render(callback) { + if (this.seotitle) { + document.title = this.seotitle; + } else if (this.title) { + document.title = this.title; + } else { + document.title = 'Page'; + } + return renderLayout(this.layout, this.config, this, callback); } }]); - return File; }(); @@ -597,172 +637,219 @@ var CMS = (function () { * @param {string} type - The type of file collection (i.e. posts, pages). * @param {object} layout - The layouts of the file collection type. */ - var FileCollection = /*#__PURE__*/function () { function FileCollection(type, layout, config) { _classCallCheck(this, FileCollection); - this.type = type; this.layout = layout; this.config = config; this.files = []; + this.directories = []; this[type] = this.files; + this.directoriesScanned = 0; } + /** - * Initialize file collection. - * @method - * @async - * @param {function} callback - Callback function + * Generic function to assist with debug logging without needing if ... everywhere. + * @param {...any} args mixed arguments to pass */ - - _createClass(FileCollection, [{ + key: "debuglog", + value: function debuglog() { + if (this.config.debug) { + var _console; + (_console = console).log.apply(_console, arguments); + } + } + + /** + * Initialize file collection. + * @method + * @async + * @param {function} callback - Callback function + */ + }, { key: "init", value: function init(callback) { var _this = this; - this.getFiles(function (success, error) { if (error) handleMessage(messages['DIRECTORY_ERROR']); - _this.loadFiles(function (success, error) { if (error) handleMessage(messages['GET_FILE_ERROR']); callback(); }); }); } + /** * Get file list URL. * @method * @param {string} type - Type of file collection. * @returns {string} URL of file list */ - }, { key: "getFileListUrl", value: function getFileListUrl(type, config) { - return config.mode === 'GITHUB' ? getGithubUrl(type, config.github) : type; + return config.mode === 'GITHUB' ? getGithubUrl(type, config.github) : this.config.webpath + type; } + /** * Get file URL. * @method * @param {object} file - File object. * @returns {string} File URL */ - }, { key: "getFileUrl", value: function getFileUrl(file, mode, type) { - return mode === 'GITHUB' ? file['download_url'] : "".concat(type, "/").concat(getFilenameFromPath(file.getAttribute('href'))); + if (mode === 'GITHUB') { + return file['download_url']; + } else { + var href = getFilenameFromPath(file.getAttribute('href')); + if (href[0] === '/') { + // Absolutely resolved paths should be returned unmodified + return href; + } else { + // Relatively linked URLs get appended to the parent directory + if (type[type.length - 1] === '/') { + // parent directory ends in a trailing slash + return type + href; + } else { + // No trailing slash, so adjust as necessary + return type + '/' + href; + } + } + } } + /** * Get file elements. * @param {object} data - File directory or Github data. * @returns {array} File elements */ - }, { key: "getFileElements", value: function getFileElements(data) { - var fileElements; // Github Mode + var fileElements; + // Github Mode if (this.config.mode === 'GITHUB') { fileElements = JSON.parse(data); - } // Server Mode + } + // Server Mode else { - // convert the directory listing to a DOM element - var listElement = document.createElement('div'); - listElement.innerHTML = data; // get the links in the directory listing - - fileElements = [].slice.call(listElement.getElementsByTagName('a')); - } - + // convert the directory listing to a DOM element + var listElement = document.createElement('div'); + listElement.innerHTML = data; + // get the links in the directory listing + fileElements = [].slice.call(listElement.getElementsByTagName('a')); + } return fileElements; } + /** * Get files from file listing and set to file collection. * @method * @async * @param {function} callback - Callback function */ - }, { key: "getFiles", value: function getFiles(callback) { - var _this2 = this; + this.directories = [this.getFileListUrl(this.type, this.config)]; + this.scanDirectory(callback, this.directories[0], true); + } - get(this.getFileListUrl(this.type, this.config), function (success, error) { - if (error) callback(success, error); // find the file elements that are valid files, exclude others + /** + * Perform the underlying directory lookup + * @method + * @async + * @param {function} callback - Callback function + * @param {string} directory - Directory URL to scan + * @param {boolean} recurse - Set to FALSE to prevent further recursion + */ + }, { + key: "scanDirectory", + value: function scanDirectory(callback, directory, recurse) { + var _this2 = this; + this.debuglog('Scanning directory', directory); + get(directory, function (success, error) { + if (error) callback(success, error); + // find the file elements that are valid files, exclude others _this2.getFileElements(success).forEach(function (file) { - var fileUrl = _this2.getFileUrl(file, _this2.config.mode, _this2.type); - + var fileUrl = _this2.getFileUrl(file, _this2.config.mode, directory); if (isValidFile(fileUrl, _this2.config.extension)) { + // Regular markdown file _this2.files.push(new File(fileUrl, _this2.type, _this2.layout.single, _this2.config)); + } else if (recurse && _this2.config.mode !== 'GITHUB' && fileUrl[fileUrl.length - 1] === '/' && fileUrl !== _this2.config.webpath) { + // in SERVER mode, support recursing ONE directory deep. + // Allow this for any directory listing NOT absolutely resolved (they will just point back to the parent directory) + _this2.directories.push(fileUrl); + _this2.scanDirectory(callback, fileUrl, false); } }); - - callback(success, error); + _this2.directoriesScanned++; + if (_this2.directoriesScanned === _this2.directories.length) { + callback(success, error); + } }); } + /** * Load files and get file content. * @method * @async * @param {function} callback - Callback function */ - }, { key: "loadFiles", value: function loadFiles(callback) { var _this3 = this; - - var promises = []; // Load file content - + var promises = []; + // Load file content this.files.forEach(function (file, i) { file.getContent(function (success, error) { if (error) callback(success, error); promises.push(i); - file.parseContent(); // Execute after all content is loaded - + file.parseContent(); + // Execute after all content is loaded if (_this3.files.length == promises.length) { callback(success, error); } }); }); } + /** * Search file collection by attribute. * @method - * @param {string} attribute - Attribue in file to search. * @param {string} search - Search query. - * @returns {object} File object */ - }, { key: "search", - value: function search(attribute, _search) { + value: function search(_search) { this[this.type] = this.files.filter(function (file) { - var attr = file[attribute].toLowerCase().trim(); - return attr.indexOf(_search.toLowerCase().trim()) >= 0; + return file.matchesSearch(_search); }); } + /** * Reset file collection files. * @method */ - }, { key: "resetSearch", value: function resetSearch() { this[this.type] = this.files; } + /** * Get files by tag. * @method * @param {string} query - Search query. - * @returns {array} Files array + * @returns {File[]} Files array */ - }, { key: "getByTag", value: function getByTag(query) { @@ -774,34 +861,77 @@ var CMS = (function () { } }); } + + /** + * Get all tags located form this collection + * + * Each set will contain the properties `name` and `count` + * + * @returns {Object[]} + */ + }, { + key: "getTags", + value: function getTags() { + var tags = [], + tagNames = []; + this.files.forEach(function (file) { + if (file.tags) { + file.tags.forEach(function (tag) { + var pos = tagNames.indexOf(tag); + if (pos === -1) { + // New tag discovered + tags.push({ + name: tag, + count: 1 + }); + tagNames.push(tag); + } else { + // Existing tag + tags[pos].count++; + } + }); + } + }); + return tags; + } + /** * Get file by permalink. * @method * @param {string} permalink - Permalink to search. * @returns {object} File object. */ - }, { key: "getFileByPermalink", value: function getFileByPermalink(permalink) { - return this.files.filter(function (file) { - return file.permalink === permalink; - })[0]; + var _this4 = this; + this.debuglog('Retrieving file by permalink', permalink); + var foundFiles = this.files.filter(function (file) { + return file.permalink === permalink || file.permalink === _this4.config.webpath + permalink; + }); + if (foundFiles.length === 0) { + throw 'Requested file could not be located'; + } + return foundFiles[0]; } + /** * Renders file collection. * @method * @async * @returns {string} Rendered layout */ - }, { key: "render", - value: function render() { - return renderLayout(this.layout.list, this.config, this); + value: function render(callback) { + if (this.layout.title) { + document.title = this.layout.title; + } else { + document.title = 'Listing'; + } + return renderLayout(this.layout.list, this.config, this, callback); } }]); - return FileCollection; }(); @@ -810,57 +940,71 @@ var CMS = (function () { * @constructor * @param {object} options - Configuration options. */ - var CMS = /*#__PURE__*/function () { function CMS(view, options) { _classCallCheck(this, CMS); - this.ready = false; + /** @property FileCollection[] */ this.collections = {}; this.filteredCollections = {}; this.state; this.view = view; this.config = Object.assign({}, defaults, options); - this.init(); } + /** - * Init - * @method - * @description - * Initializes the application based on the configuration. Sets up up config object, - * hash change event listener for router, and loads the content. + * Generic function to assist with debug logging without needing if ... everywhere. + * @param {...any} args mixed arguments to pass */ - - _createClass(CMS, [{ + key: "debuglog", + value: function debuglog() { + if (this.config.debug) { + var _console; + (_console = console).log.apply(_console, arguments); + } + } + + /** + * Init + * @method + * @description + * Initializes the application based on the configuration. Sets up up config object, + * hash change event listener for router, and loads the content. + */ + }, { key: "init", value: function init() { var _this = this; - // create message container element if debug mode is enabled if (this.config.debug) { createMessageContainer(this.config.messageClassName); } - if (this.config.elementId) { // setup container this.config.container = document.getElementById(this.config.elementId); - + this.view.addEventListener('click', function (e) { + if (e.target && e.target.nodeName === 'A') { + _this.listenerLinkClick(e); + } + }); if (this.config.container) { // setup file collections this.initFileCollections(function () { // check for hash changes - _this.view.addEventListener('hashchange', _this.route.bind(_this), false); // start router by manually triggering hash change - - - _this.view.dispatchEvent(new HashChangeEvent('hashchange')); // register plugins and run onload events - - + _this.view.addEventListener('hashchange', _this.route.bind(_this), false); + // AND check for location.history changes (for SEO reasons) + _this.view.addEventListener('popstate', function (event) { + console.log('popping', event); + _this.route(); + }); + // start router by manually triggering hash change + //this.view.dispatchEvent(new HashChangeEvent('hashchange')); + _this.route(); + // register plugins and run onload events _this.ready = true; - _this.registerPlugins(); - - _this.config.onload(); + _this.onload(); }); } else { handleMessage(this.config.debug, messages['ELEMENT_ID_ERROR']); @@ -869,64 +1013,186 @@ var CMS = (function () { handleMessage(this.config.debug, messages['ELEMENT_ID_ERROR']); } } + + /** + * Handle processing links clicked, will re-route to the history for applicable links. + * + * @param {Event} e Click event from user + */ + }, { + key: "listenerLinkClick", + value: function listenerLinkClick(e) { + var _this2 = this; + var targetHref = e.target.href; + + // Scan if this link was a link to one of the articles, + // we don't want to intercept non-page links. + this.config.types.forEach(function (type) { + if (targetHref.indexOf(window.location.origin + _this2.config.webpath + type.name + '/') === 0 && targetHref.substring(targetHref.length - 5) === '.html') { + // Target link is a page within a registered type path + _this2.historyPushState(targetHref); + e.preventDefault(); + return false; + } + if (targetHref.indexOf(window.location.origin + _this2.config.webpath + type.name + '.html') === 0) { + // Target link is a listing page for a registered type path + _this2.historyPushState(targetHref); + e.preventDefault(); + return false; + } + }); + if (targetHref === window.location.origin + this.config.webpath) { + // Target link is the homepage, this one can be handled too + this.historyPushState(targetHref); + e.preventDefault(); + return false; + } + console.log(targetHref); + e.preventDefault(); + } + + /** + * Function called automatically upon initialization + * by default will just call config.onload to preserve backwards compatibility + * + * @method + */ + }, { + key: "onload", + value: function onload() { + this.config.onload(); + } + + /** + * Function called when routing to a new page + * by default will just call config.onroute to preserve backwards compatibility + * + * @method + */ + }, { + key: "onroute", + value: function onroute() { + this.config.onroute(); + } + /** * Initialize file collections * @method * @async */ - }, { key: "initFileCollections", value: function initFileCollections(callback) { - var _this2 = this; - + var _this3 = this; var promises = []; - var types = []; // setup collections and routes + var types = []; + // setup collections and routes this.config.types.forEach(function (type) { - _this2.collections[type.name] = new FileCollection(type.name, type.layout, _this2.config); + _this3.collections[type.name] = new FileCollection(type.name, type.layout, _this3.config); types.push(type.name); - }); // init collections + }); + // init collections types.forEach(function (type, i) { - _this2.collections[type].init(function () { - promises.push(i); // reverse order to display newest posts first for post types - + _this3.collections[type].init(function () { + _this3.debuglog('Initialized collection ' + type); + promises.push(i); + // reverse order to display newest posts first for post types if (type.indexOf('post') === 0) { - _this2.collections[type][type].reverse(); - } // Execute after all content is loaded - - + _this3.collections[type][type].reverse(); + } + // Execute after all content is loaded if (types.length == promises.length) { callback(); } }); }); } + + /** + * Retrieve the current path URL broken down into individual pieces + * @returns {array} The segments of the URL broken down by directory + */ + }, { + key: "getPathsFromURL", + value: function getPathsFromURL() { + var paths = window.location.pathname.substring(this.config.webpath.length).split('/'); + if (paths.length >= 1 && paths[0].substring(paths[0].length - 5) === '.html') { + // First node (aka type) has HTML extension, just trim that off. + // This is done because /posts needs to be browseable separately, + // so we need a way to distinguish between that and the HTML version. + paths[0] = paths[0].substring(0, paths[0].length - 5); + } + return paths; + } + + /** + * REPLACE the window location, ONLY really useful on initial pageload + * + * Use historyPushState instead for most interactions where the user may click 'back' + * @param {string} url URL to replace + */ + }, { + key: "historyReplaceState", + value: function historyReplaceState(url) { + window.history.replaceState({}, '', url); + // Immediately trigger route to switch to the new content. + this.route(); + } + }, { + key: "historyPushState", + value: function historyPushState(url) { + window.history.pushState({}, '', url); + // Immediately trigger route to switch to the new content. + this.route(); + } }, { key: "route", value: function route() { - var paths = getPathsWithoutParameters(); - var type = paths[0]; - var filename = paths[1]; - var collection = this.collections[type]; - var query = getParameterByName('query') || ''; - var tag = getParameterByName('tag') || ''; - this.state = window.location.hash.substr(1); // Default view - + var _this4 = this; + this.debuglog('Initializing routing'); + var paths = this.getPathsFromURL(), + type = paths[0], + filename = paths.splice(1).join('/'), + collection = this.collections[type], + query = getParameterByName('s') || '', + tag = getParameterByName('tag') || '', + mode = '', + file = null; + this.debuglog('Paths retrieved from URL:', { + type: type, + filename: filename, + collection: collection + }); + this.state = window.location.hash.substr(1); if (!type) { - window.location = ['#', this.config.defaultView].join('/'); - } // List and single views - else { + // Default view + this.historyReplaceState(this.config.webpath + this.config.defaultView + '.html'); + // route will be re-called immediately upon updating the state, so stop here. + return; + } else { + // List and single views + try { if (filename) { // Single view - var permalink = ['#', type, filename.trim()].join('/'); - collection.getFileByPermalink(permalink).render(); + file = collection.getFileByPermalink([type, filename.trim()].join('/')); + mode = 'single'; + file.render(function () { + _this4.onroute({ + type: type, + file: file, + mode: mode, + query: query, + tag: tag, + collection: collection + }); + }); } else if (collection) { // List view if (query) { // Check for queries - collection.search('title', query); + collection.search(query); } else if (tag) { // Check for tags collection.getByTag(tag); @@ -934,44 +1200,61 @@ var CMS = (function () { // Reset search collection.resetSearch(); } - - collection.render(); + mode = 'listing'; + collection.render(function () { + _this4.onroute({ + type: type, + file: file, + mode: mode, + query: query, + tag: tag, + collection: collection + }); + }); } else { - // Error view - renderLayout(this.config.errorLayout, this.config, {}); + throw 'Unknown request'; } - } // onroute event - - - this.config.onroute(); + } catch (e) { + console.error(e); + renderLayout(this.config.errorLayout, this.config, {}, function () { + _this4.onroute({ + type: type, + file: file, + mode: mode, + query: query, + tag: tag, + collection: collection + }); + }); + mode = 'error'; + } + } } + /** * Register plugins. * @method * @description * Set up plugins based on user configuration. */ - }, { key: "registerPlugins", value: function registerPlugins() { - var _this3 = this; - + var _this5 = this; this.config.plugins.forEach(function (plugin) { var name = getFunctionName(plugin); - - if (!_this3[name]) { - _this3[name] = plugin; + if (!_this5[name]) { + _this5[name] = plugin; } }); } + /** * Sort method for file collections. * @method * @param {string} type - Type of file collection. * @param {function} sort - Sorting function. */ - }, { key: "sort", value: function sort(type, _sort) { @@ -982,6 +1265,7 @@ var CMS = (function () { handleMessage(messages['NOT_READY_WARNING']); } } + /** * Search method for file collections. * @method @@ -989,19 +1273,12 @@ var CMS = (function () { * @param {string} attribute - File attribute to search. * @param {string} search - Search query. */ - }, { key: "search", - value: function search(type, attribute, _search) { - if (this.ready) { - this.collections[type].search(attribute, _search); - this.collections[type].render(); - } else { - handleMessage(messages['NOT_READY_WARNING']); - } + value: function search(type, _search) { + this.historyPushState(this.config.webpath + type + '.html?s=' + encodeURIComponent(_search)); } }]); - return CMS; }(); @@ -1018,4 +1295,4 @@ var CMS = (function () { return main; -}()); +})(); diff --git a/dist/cms.min.js b/dist/cms.min.js index 9d383d9..e5d70f9 100644 --- a/dist/cms.min.js +++ b/dist/cms.min.js @@ -1,2 +1,2 @@ -/*! @chrisdiana/cmsjs v2.0.1 | MIT (c) 2021 Chris Diana | https://github.com/chrisdiana/cms.js */ -var CMS=function(){"use strict";function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){for(var n=0;n/g,'";$1\noutput+="')+";return output;")(n)),t)})}function d(e,n,t){n.container.innerHTML="",m([n.layoutDirectory,"/",e,".html"].join(""),t,function(e,t){t?f(u):n.container.innerHTML=e})}var y=function(){function e(){o(this,e),this.rules=[{regex:/(#+)(.*)/g,replacement:function(e,t,n){t=t.length;return" "+n.trim()+" "}},{regex:/!\[([^[]+)\]\(([^)]+)\)/g,replacement:""},{regex:/\[([^[]+)\]\(([^)]+)\)/g,replacement:"$1"},{regex:/(\*\*|__)(.*?)\1/g,replacement:"$2"},{regex:/(\*|_)(.*?)\1/g,replacement:"$2"},{regex:/~~(.*?)~~/g,replacement:"$1"},{regex:/:"(.*?)":/g,replacement:"$1"},{regex:/```[a-z]*\n[\s\S]*?\n```/g,replacement:function(e){return""+(e=e.replace(/```/gm,"")).trim()+""}},{regex:/&&&[a-z]*\n[\s\S]*?\n&&&/g,replacement:function(e){return' - - + + + @@ -21,7 +17,7 @@+ @@ -49,9 +49,11 @@ - + CMS.js @@ -31,14 +27,18 @@
+
+ ++ + - + - +