UNPKG

@haxtheweb/haxcms-nodejs

Version:

HAXcms single and multisite nodejs server, api, and administration

1,210 lines (1,203 loc) 96.8 kB
"use strict"; const fs = require('fs-extra'); const path = require('path'); const crypto = require('crypto'); const JWT = require('jsonwebtoken'); const { v4: uuidv4 } = require('uuid'); const GitPlus = require('./GitPlus.js'); const JSONOutlineSchema = require('./JSONOutlineSchema.js'); const { discoverConfigPath } = require('./discoverConfigPath.js'); const filter_var = require('./filter_var.js'); const explode = require('locutus/php/strings/explode'); // may need to change as we get into CLI integration const HAXCMS_ROOT = process.env.HAXCMS_ROOT || path.join(process.cwd(), "/"); const HAXCMS_DEFAULT_THEME = 'clean-two'; const HAXCMS_FALLBACK_HEX = '#3f51b5'; const SITE_FILE_NAME = 'site.json'; // HAXCMSSite which overlaps heavily and is referenced here often const utf8 = require('utf8'); const JSONOutlineSchemaItem = require('./JSONOutlineSchemaItem.js'); const FeedMe = require('./RSS.js'); const array_search = require('locutus/php/array/array_search'); const array_unshift = require('locutus/php/array/array_unshift'); const implode = require('locutus/php/strings/implode'); const array_unique = require("locutus/php/array/array_unique"); const json_encode = require('locutus/php/json/json_encode'); const strtr = require('locutus/php/strings/strtr'); const usort = require('locutus/php/array/usort'); const sharp = require('sharp'); const util = require('node:util'); const child_process = require('child_process'); const exec = util.promisify(child_process.exec); // a site object class HAXCMSSite { constructor() { this.name; this.manifest; this.directory; this.basePath = '/'; this.language = 'en-us'; this.siteDirectory; } /** * Load a site based on directory and name. Assumes multi-site mode */ async load(directory, siteBasePath, name) { this.name = name; let tmpname = decodeURIComponent(name); tmpname = HAXCMS.cleanTitle(tmpname, false); this.basePath = siteBasePath; this.directory = directory; this.manifest = new JSONOutlineSchema(); await this.manifest.load(this.directory + '/' + tmpname + '/site.json'); this.siteDirectory = path.join(this.directory, tmpname); } /** * Load a single site from a directory directly. Assumes single-site mode */ async loadSingle(directory) { this.name = path.basename(directory); this.basePath = '/'; this.directory = directory; this.manifest = new JSONOutlineSchema(); await this.manifest.load(path.join(directory, SITE_FILE_NAME)); // set additional value to ensure things requiring root find it since it is the root this.siteDirectory = path.dirname(path.join(directory, SITE_FILE_NAME)); } /** * Initialize a new site with a single page to start the outline * @var directory string file system path * @var siteBasePath string web based url / base_path * @var name string name of the site * @var gitDetails git details * @var domain domain information * * @return HAXCMSSite object */ async newSite(directory, siteBasePath, name, gitDetails = null, domain = null, build = null) { // calls must set basePath internally to avoid page association issues this.basePath = siteBasePath; this.directory = directory; this.name = name; // clean up name so it can be in a URL / published let tmpname = decodeURIComponent(name); tmpname = HAXCMS.cleanTitle(tmpname, false); let loop = 0; let newName = tmpname; if (fs.existsSync(directory + "/" + newName)) { while (fs.existsSync(directory + "/" + newName)) { loop++; newName = tmpname + '-' + loop; } } tmpname = newName; // siteDirectory set so we can discover this and work with it while it's being built this.siteDirectory = path.join(this.directory, tmpname); // attempt to shift it on the file system await HAXCMS.recurseCopy(HAXCMS.boilerplatePath + 'site', directory + '/' + tmpname); try { // create symlink to make it easier to resolve things to single built asset buckets await fs.symlink('../../build', directory + '/' + tmpname + '/build'); // symlink to do local development if needed await fs.symlink('../../dist', directory + '/' + tmpname + '/dist'); // symlink to do project development if needed if (fs.pathExistsSync(HAXCMS.HAXCMS_ROOT + 'node_modules') && (fs.lstatSync(HAXCMS.HAXCMS_ROOT + 'node_modules').isSymbolicLink() || fs.lstatSync(HAXCMS.HAXCMS_ROOT + 'node_modules').isDirectory())) { await fs.symlink('../../node_modules', directory + '/' + tmpname + '/node_modules'); } // links babel files so that unification is easier await fs.symlink('../../wc-registry.json', directory + '/' + tmpname + '/wc-registry.json'); await fs.symlink('../../../babel/babel-top.js', directory + '/' + tmpname + '/assets/babel-top.js'); await fs.symlink('../../../babel/babel-bottom.js', directory + '/' + tmpname + '/assets/babel-bottom.js'); // default support is for gh-pages if (domain == null && gitDetails != null && gitDetails.user) { domain = 'https://' + gitDetails.user + '.github.io/' + tmpname; } else if (domain != null) { // put domain into CNAME not the github.io address if that exists await fs.writeFileSync(directory + '/' + tmpname + '/CNAME', domain); } } catch (e) {} // load what we just created this.manifest = new JSONOutlineSchema(); // where to save it to this.manifest.file = directory + '/' + tmpname + '/site.json'; // start updating the schema to match this new item we got this.manifest.title = name; this.manifest.location = this.basePath + tmpname + '/index.html'; this.manifest.metadata = {}; this.manifest.metadata.author = {}; this.manifest.metadata.site = {}; this.manifest.metadata.site.settings = {}; this.manifest.metadata.site.settings.lang = 'en'; this.manifest.metadata.site.settings.canonical = true; this.manifest.metadata.site.name = tmpname; this.manifest.metadata.site.domain = domain; this.manifest.metadata.site.created = Math.floor(Date.now() / 1000); this.manifest.metadata.site.updated = Math.floor(Date.now() / 1000); this.manifest.metadata.theme = {}; this.manifest.metadata.theme.variables = {}; this.manifest.metadata.node = {}; this.manifest.metadata.node.fields = {}; this.manifest.items = []; // create an initial page to make sense of what's there // this will double as saving our location and other updated data // accept a schema which can generate an array of pages to start if (build == null) { await this.addPage(null, 'Welcome', 'init', 'welcome'); } else { let pageSchema = []; switch (build.structure) { case 'import': // implies we had a backend service process much of what we are to build for an import if (build.items) { for (let i = 0; i < build.items.length; i++) { pageSchema.push({ "parent": build.items[i]['parent'], "title": build.items[i]['title'], "template": "html", "slug": build.items[i]['slug'], "id": build.items[i]['id'], "indent": build.items[i]['indent'], "contents": build.items[i]['contents'], "order": build.items[i]['order'], "metadata": build.items[i]['metadata'] ? build.items[i]['metadata'] : null }); } } for (let i = 0; i < pageSchema.length; i++) { if (pageSchema[i]['template'] == 'html') { await this.addPage(pageSchema[i]['parent'], pageSchema[i]['title'], pageSchema[i]['template'], pageSchema[i]['slug'], pageSchema[i]['id'], pageSchema[i]['indent'], pageSchema[i]['contents'], pageSchema[i]['order'], pageSchema[i]['metadata']); } else { await this.addPage(pageSchema[i]['parent'], pageSchema[i]['title'], pageSchema[i]['template'], pageSchema[i]['slug']); } } break; case 'course': pageSchema = [{ "parent": null, "title": "Welcome to " + name, "template": "course", "slug": "welcome" }]; switch (build.type) { case 'docx import': // ensure we have items if (build.items) { for (let i = 0; i < build.items.length; i++) { pageSchema.push({ "parent": build.items[i]['parent'], "title": build.items[i]['title'], "template": "html", "slug": build.items[i]['slug'], "id": build.items[i]['id'], "indent": build.items[i]['indent'], "contents": build.items[i]['contents'], "order": build.items[i]['order'], "metadata": build.items[i]['metadata'] ? build.items[i]['metadata'] : null }); } } break; case '6w': for (let i = 0; i < 6; i++) { pageSchema.push({ "parent": null, "title": "Lesson " + (i + 1), "template": "lesson", "slug": "lesson-" + (i + 1) }); } break; case '15w': for (let i = 0; i < 15; i++) { pageSchema.push({ "parent": null, "title": "Lesson " + (i + 1), "template": "lesson", "slug": "lesson-" + (i + 1) }); } break; default: /*pageSchema.push({ "parent" : null, "title" : "Lessons", "template" : "default", "slug" : "lessons" });*/ break; } /*pageSchema.push({ "parent" : null, "title" : "Glossary", "template" : "glossary", "slug" : "glossary" });*/ for (let i = 0; i < pageSchema.length; i++) { if (pageSchema[i]['template'] == 'html') { await this.addPage(pageSchema[i]['parent'], pageSchema[i]['title'], pageSchema[i]['template'], pageSchema[i]['slug'], pageSchema[i]['id'], pageSchema[i]['indent'], pageSchema[i]['contents'], pageSchema[i]['order'], pageSchema[i]['metadata']); } else { await this.addPage(pageSchema[i]['parent'], pageSchema[i]['title'], pageSchema[i]['template'], pageSchema[i]['slug']); } } break; case 'blog': await this.addPage(null, 'Article 1', 'init', 'article-1'); await this.addPage(null, 'Article 2', 'init', 'article-2'); await this.addPage(null, 'Meet the author', 'init', 'meet-the-author'); break; case 'website': switch (build.type) { default: await this.addPage(null, 'Home', 'init', 'home'); break; } break; case 'collection': await this.addPage(null, 'Home', 'collection', 'home'); break; case 'training': await this.addPage(null, 'Start', 'init', 'start'); break; case 'portfolio': switch (build.type) { case 'art': await this.addPage(null, 'Gallery 1', 'init', 'gallery-1'); await this.addPage(null, 'Gallery 2', 'init', 'gallery-2'); await this.addPage(null, 'Meet the artist', 'init', 'meet-the-artist'); break; case 'business': case 'technology': default: await this.addPage(null, 'Article 1', 'init', 'article-1'); await this.addPage(null, 'Article 2', 'init', 'article-2'); await this.addPage(null, 'Meet the author', 'init', 'meet-the-author'); break; } break; } } try { // write the managed files to ensure we get happy copies await this.rebuildManagedFiles(); } catch (e) {} try { // put this in version control :) :) :) const git = new GitPlus({ dir: directory + '/' + tmpname, cliVersion: await this.gitTest() }); // initalize git repo await git.init(); await git.add(); await git.commit('A new journey begins: ' + this.manifest.title + ' (' + this.manifest.id + ')'); if (!(this.manifest.metadata.site && this.manifest.metadata.site.git && this.manifest.metadata.site.git.url) && gitDetails != null && gitDetails.url) { await this.gitSetRemote(gitDetails); } } catch (e) { console.warn(e); } return this; } /** * Return the forceUpgrade status which is whether to force end users to upgrade their browser * @return string status of forced upgrade, string as boolean since it'll get written into a JS file */ getForceUpgrade() { if (this.manifest.metadata.site.settings.forceUpgrade) { return "true"; } return "false"; } /** * Return the sw status * @return string status of forced upgrade, string as boolean since it'll get written into a JS file */ getServiceWorkerStatus() { if (this.manifest.metadata.site.settings.sw && this.manifest.metadata.site.settings.sw) { return true; } return false; } async gitTest() { try { const { stdout, stderr } = await exec('git --version'); return stdout; } catch (e) { return null; } } /** * Return an array of files we care about rebuilding on managed file operations * @return array keyed array of files we wish to pull from the boilerplate and keep in sync */ getManagedTemplateFiles() { return { // HAX core / application / PWA requirements 'htaccess': '.htaccess', 'build': 'build.js', 'buildlegacy': 'assets/build-legacy.js', 'buildpolyfills': 'assets/build-polyfills.js', 'buildhaxcms': 'build-haxcms.js', 'outdated': 'assets/upgrade-browser.html', 'index': 'index.html', // static published fallback 'ghpages': 'ghpages.html', // github pages publishing for custom theme work '404': '404.html', // github / static published redirect appropriately // seo / performance 'push': 'push-manifest.json', 'robots': 'robots.txt', // pwa related files 'msbc': 'browserconfig.xml', 'manifest': 'manifest.json', 'sw': 'service-worker.js', 'offline': 'offline.html', // pwa offline page // local development tooling 'webdevserverhaxcmsconfigcjs': 'web-dev-server.haxcms.config.cjs', 'package': 'package.json', 'polymer': 'polymer.json', // SCORM 1.2 'imsmdrootv1p2p1': 'imsmd_rootv1p2p1.xsd', 'imscprootv1p1p2': 'imscp_rootv1p1p2.xsd', 'adlcprootv1p2': 'adlcp_rootv1p2.xsd', 'imsxml': 'ims_xml.xsd', 'imsmanifest': 'imsmanifest.xml' }; } /** * Reprocess the files that twig helps set in their static * form that the user is not in control of. */ async rebuildManagedFiles() { let templates = this.getManagedTemplateFiles(); // this can't be there by default since it's a dynamic file and we only // want to update this when we are refreshing the managed files directly // not the case w/ non php backends but still fine for consistency templates['indexphp'] = 'index.php'; for (var key in templates) { await fs.copySync(HAXCMS.boilerplatePath + "/site/" + templates[key], this.siteDirectory + '/' + templates[key]); } let licenseData = this.getLicenseData('all'); let licenseLink = ''; let licenseName = ''; if (this.manifest.license && licenseData[this.manifest.license]) { licenseLink = licenseData[this.manifest.license]['link']; licenseName = 'License: ' + licenseData[this.manifest.license]['name']; } let templateVars = { 'hexCode': HAXCMS.HAXCMS_FALLBACK_HEX, 'version': await HAXCMS.getHAXCMSVersion(), 'basePath': this.basePath + this.manifest.metadata.site.name + '/', 'title': this.manifest.title, 'short': this.manifest.metadata.site.name, 'privateSite': this.manifest.metadata.site.settings.private, 'description': this.manifest.description, 'forceUpgrade': this.getForceUpgrade(), 'swhash': [], 'ghPagesURLParamCount': 0, 'licenseLink': licenseLink, 'licenseName': licenseName, 'serviceWorkerScript': this.getServiceWorkerScript(this.basePath + this.manifest.metadata.site.name + '/'), 'bodyAttrs': this.getSitePageAttributes(), 'metadata': await this.getSiteMetadata(), 'logo512x512': await this.getLogoSize('512', '512'), 'logo256x256': await this.getLogoSize('256', '256'), 'logo192x192': await this.getLogoSize('192', '192'), 'logo144x144': await this.getLogoSize('144', '144'), 'logo96x96': await this.getLogoSize('96', '96'), 'logo72x72': await this.getLogoSize('72', '72'), 'logo48x48': await this.getLogoSize('48', '48'), 'favicon': await this.getLogoSize('32', '32') }; let swItems = [...this.manifest.items]; // the core files you need in every SW manifest let coreFiles = ['index.html', await this.getLogoSize('512', '512'), await this.getLogoSize('256', '256'), await this.getLogoSize('192', '192'), await this.getLogoSize('144', '144'), await this.getLogoSize('96', '96'), await this.getLogoSize('72', '72'), await this.getLogoSize('48', '48'), 'manifest.json', 'site.json', '404.html']; let handle; // loop through files directory so we can cache those things too if (handle = fs.readdirSync(this.siteDirectory + '/files')) { handle.forEach(file => { if (file != "." && file != ".." && file != '.gitkeep' && file != '.DS_Store') { // ensure this is a file if (fs.lstatSync(this.siteDirectory + '/files/' + file).isFile()) { coreFiles.push('files/' + file); } else { // @todo maybe step into directories? } } }); } for (var key in coreFiles) { let coreItem = {}; coreItem.location = coreFiles[key]; swItems.push(coreItem); } // generate a legit hash value that's the same for each file name + file size for (var key in swItems) { let filesize; let item = swItems[key]; if (item.location === '' || item.location === templateVars['basePath']) { filesize = await fs.statSync(this.siteDirectory + '/index.html').size; } else if (fs.pathExistsSync(this.siteDirectory + '/' + item.location) && fs.lstatSync(this.siteDirectory + '/' + item.location).isFile()) { filesize = await fs.statSync(this.siteDirectory + '/' + item.location).size; } else { // ?? file referenced but doesn't exist filesize = 0; } if (filesize !== 0) { templateVars['swhash'].push([item.location, strtr(HAXCMS.hmacBase64(item.location + filesize, 'haxcmsswhash'), { '+': '', '/': '', '=': '', '-': '' })]); } } if (this.manifest.metadata.theme.variables.hexCode) { templateVars['hexCode'] = this.manifest.metadata.theme.variables.hexCode; } // put the twig written output into the file var Twig = require('twig'); for (var key in templates) { // ensure files exist before going to write them if (await fs.lstatSync(this.siteDirectory + '/' + templates[key]).isFile()) { try { let fileData = await fs.readFileSync(this.siteDirectory + '/' + templates[key], { encoding: 'utf8', flag: 'r' }, 'utf8'); let template = await Twig.twig({ data: fileData, async: false }); let templatedHTML = template.render(templateVars); await fs.writeFileSync(this.siteDirectory + '/' + templates[key], templatedHTML); } catch (e) {} } } } /** * Rename a page from one location to another * This ensures that folders are moved but not the final index.html involved * It also helps secure the sites by ensuring movement is only within * their folder tree */ async renamePageLocation(oldItem, newItem) { oldItem = oldItem.replace('./', '').replace('../', ''); newItem = newItem.replace('./', '').replace('../', ''); // ensure the path to the new folder is valid if ((await fs.pathExistsSync(this.siteDirectory + '/' + oldItem)) && (await fs.lstatSync(this.siteDirectory + '/' + oldItem).isFile())) { await fs.moveSync(this.siteDirectory + '/' + oldItem.replace('/index.html', ''), this.siteDirectory + '/' + newItem.replace('/index.html', '')); await fs.unlinkSync(this.siteDirectory + '/' + oldItem); } } /** * Basic wrapper to commit current changes to version control of the site */ async gitCommit(msg = 'Committed changes') { try { // commit, true flag will attempt to make this a git repo if it currently isn't const git = new GitPlus({ dir: this.siteDirectory, cliVersion: await this.gitTest() }); await git.add(); await git.commit(msg); // commit should execute the automatic push flag if it's on if (this.manifest.metadata.site.git.autoPush && this.manifest.metadata.site.git.autoPush && this.manifest.metadata.site.git.branch) { await git.checkout(this.manifest.metadata.site.git.branch); await git.push(); } } catch (e) {} return true; } /** * Basic wrapper to revert top commit of the site */ async gitRevert(count = 1) { try { const git = new GitPlus({ dir: this.siteDirectory, cliVersion: await this.gitTest() }); await git.revert(count); } catch (e) {} return true; } /** * Basic wrapper to commit current changes to version control of the site */ async gitPush() { try { const git = new GitPlus({ dir: this.siteDirectory, cliVersion: await this.gitTest() }); await git.add(); await git.commit("commit forced"); await git.push(); } catch (e) {} return true; } /** * Basic wrapper to commit current changes to version control of the site * * @var git a stdClass containing repo details */ async gitSetRemote(gitDetails) { try { const git = new GitPlus({ dir: this.siteDirectory, cliVersion: await this.gitTest() }); await repo.setRemote("origin", gitDetails.url); } catch (e) {} return true; } /** * Add a page to the site's file system and reflect it in the outine schema. * * @var parent JSONOutlineSchemaItem representing a parent to add this page under * @var title title of the new page to create * @var template string which boilerplate page template / directory to load * * @return page repesented as JSONOutlineSchemaItem */ async addPage(parent = null, title = 'New page', template = "default", slug = 'welcome', id = null, indent = null, html = '<p></p>', order = null, metadata = null) { // draft an outline schema item let page = new JSONOutlineSchemaItem(); // support direct ID setting, useful for parent associations calculated ahead of time if (id) { page.id = id; } // set a crappy default title page.title = title; if (parent == null) { page.parent = null; page.indent = 0; } else if (typeof parent === 'string' || parent instanceof String) { // set to the parent id page.parent = parent; // move it one indentation below the parent; this can be changed later if desired page.indent = indent; } else { // set to the parent id page.parent = parent.id; // move it one indentation below the parent; this can be changed later if desired page.indent = parent.indent + 1; } // set order to the page's count for default add to end ordering if (order) { page.order = order; } else { page.order = this.manifest.items.length; } // location is the html file we just copied and renamed page.location = 'pages/' + page.id + '/index.html'; // sanitize slug but dont trust it was anything if (slug == '') { slug = title; } page.slug = this.getUniqueSlugName(HAXCMS.cleanTitle(slug)); // support presetting multiple metadata attributes like tags, pageType, etc if (metadata) { for (const key in metadata) { let value = metadata[key]; page.metadata[key] = value; } } page.metadata.created = Math.floor(Date.now() / 1000); page.metadata.updated = Math.floor(Date.now() / 1000); let location = path.join(this.siteDirectory, 'pages', page.id); // copy the page we use for simplicity (or later complexity if we want) switch (template) { case 'course': case 'glossary': case 'collection': case 'init': case 'lesson': case 'default': await HAXCMS.recurseCopy(HAXCMS.boilerplatePath + 'page/' + template, location); break; // didn't understand it, just go default default: await HAXCMS.recurseCopy(HAXCMS.boilerplatePath + 'page/default', location); break; } this.manifest.addItem(page); this.manifest.metadata.site.updated = Math.floor(Date.now() / 1000); await this.manifest.save(); // support direct HTML setting if (template == 'html') { // now this should exist if it didn't a minute ago let bytes = page.writeLocation(html, this.siteDirectory); } this.updateAlternateFormats(); return page; } /** * Save the site, though this basically is just a mapping to the manifest site.json saving */ async save(reorder = true) { await this.manifest.save(reorder); } /** * Update RSS, Atom feeds, site map, legacy outline, search index * which are physical files and need rebuilt on chnages to data structure */ async updateAlternateFormats(format = null) { if (format == null || format == 'rss') { // rip changes to feed urls let rss = new FeedMe(); fs.writeFileSync(this.siteDirectory + '/rss.xml', rss.getRSSFeed(this)); fs.writeFileSync(this.siteDirectory + '/atom.xml', rss.getAtomFeed(this)); } // build a sitemap if we have a domain, kinda required... /* if (format == null || format == 'sitemap') { // @todo sitemap generator needs an equivalent if ((this.manifest.metadata.site.domain)) { let domain = this.manifest.metadata.site.domain; //generator = new \Icamys\SitemapGenerator\SitemapGenerator( // domain, // this.siteDirectory //); let generator = {}; // will create also compressed (gzipped) sitemap generator.createGZipFile = true; // determine how many urls should be put into one file // according to standard protocol 50000 is maximum value (see http://www.sitemaps.org/protocol.html) generator.maxURLsPerSitemap = 50000; // sitemap file name generator.sitemapFileName = "sitemap.xml"; // sitemap index file name generator.sitemapIndexFileName = "sitemap-index.xml"; // adding url `loc`, `lastmodified`, `changefreq`, `priority` for (var key in this.manifest.items) { let item = this.manifest.items[key]; if (item.parent == null) { priority = '1.0'; } else if (item.indent == 2) { priority = '0.7'; } else { priority = '0.5'; } let updatedTime = Math.floor(Date.now() / 1000); updatedTime.setTimestamp(item.metadata.updated); let d = new Date(); updatedTime.format(d.toISOString()); generator.addUrl( domain + '/' + item.location.replace('pages/', '').replace('/index.html', ''), updatedTime, 'daily', priority ); } // generating internally a sitemap generator.createSitemap(); // writing early generated sitemap to file generator.writeSitemap(); } }*/ if (format == null || format == 'search') { // now generate the search index await fs.writeFileSync(this.siteDirectory + '/lunrSearchIndex.json', json_encode(await this.lunrSearchIndex(this.manifest.items))); } } /** * Create Lunr.js style search index */ async lunrSearchIndex(items) { let data = []; let textData; for (var key in items) { let item = items[key]; let created = Math.floor(Date.now() / 1000); if (item.metadata && item.metadata.created) { created = item.metadata.created; } textData = ''; try { textData = await fs.readFileSync(path.join(this.siteDirectory, item.location), { encoding: 'utf8', flag: 'r' }); textData = this.cleanSearchData(textData); // may seem silly but IDs in lunr have a size limit for some reason in our context.. data.push({ "id": item.id.replace('-', '').replace('item-', '').substring(0, 29), "title": item.title, "created": created, "location": item.location.replace('pages/', '').replace('/index.html', ''), "description": item.description, "text": textData }); } catch (e) {} } return data; } /** * Clean up data from a file and make it easy for us to index on the front end */ cleanSearchData(text) { if (text == '' || text == null || !text) { return ''; } // clean up initial, small, trim, replace end lines, utf8 no tags text = utf8.encode(text.replace(/(<([^>]+)>)/ig, "").replace("\n", ' ').toLowerCase().trim()); // all weird chars text = text.replace('/[^a-z0-9\']/', ' '); text = text.replace("'", ''); // all words 1 to 4 letters long text = text.replace('~\b[a-z]{1,4}\b\s*~', ''); // all excess white space text = text.replace('/\s+/', ' '); // crush string to array and back to make an unique index text = implode(' ', array_unique(explode(' ', text))); return text; } /** * Sort items by a certain key value. Must be in the included list for safety of the sort * @var string key - the key name to sort on, only some supported * @var string dir - direction to sort, ASC default or DESC to reverse * @return array items - sorted items based on the key used */ sortItems(key, dir = 'ASC') { let items = [...this.manifest.items]; switch (key) { case 'created': case 'updated': case 'readtime': this.__compareItemKey = key; this.__compareItemDir = dir; usort(items, function (a, b) { let key = this.__compareItemKey; let dir = this.__compareItemDir; if (a.metadata[key]) { if (dir == 'DESC') { return a.metadata[key] > b.metadata[key]; } else { return a.metadata[key] < b.metadata[key]; } } }); break; case 'id': case 'title': case 'indent': case 'location': case 'order': case 'parent': case 'description': usort(items, function (a, b) { if (dir == 'ASC') { return a[key] > b[key]; } else { return a[key] < b[key]; } }); break; } return items; } /** * Build a JOS into a tree of links recursively */ treeToNodes(current, rendered = [], html = '') { let loc = ''; for (var key in current) { let item = this.manifest.items[key]; if (!array_search(item.id, rendered)) { loc += `<li><a href="${item.location}" target="content">${item.title}</a>`; rendered.push(item.id); let children = []; for (var key2 in this.manifest.items) { let child = this.manifest.items[key2]; if (child.parent == item.id) { children.push(child); } } // sort the kids usort(children, function (a, b) { return a.order > b.order; }); // only walk deeper if there were children for this page if (children.length > 0) { loc += this.treeToNodes(children, rendered); } loc += '</li>'; } } // make sure we aren't empty here before wrapping if (loc != '') { loc = '<ul>' + loc + '</ul>'; } return html + loc; } /** * Load node by unique id */ loadNode(uuid) { for (var key in this.manifest.items) { let item = this.manifest.items[key]; if (item.id == uuid) { return item; } } return false; } /** * Get a social sharing image based on context of page or site having media * @var string page page to mine the image from or attempt to * @return string full URL to an image */ getSocialShareImage(page = null) { // resolve a JOS Item vs null let id = null; if (page != null) { id = page.id; } let fileName; if (!fileName) { if (page == null) { page = this.loadNodeByLocation(); } if (page.metadata.files) { for (var key in page.manifest.files) { let file = page.manifest.items[key]; if (file.type == 'image/jpeg') { fileName = file.fullUrl; } } } // look for the theme banner if (this.manifest.metadata.theme.variables.image) { fileName = this.manifest.metadata.theme.variables.image; } } return fileName; } /** * Return attributes for the site * @todo make this mirror the drupal get attributes method * @return string eventually, array of data keyed by type of information */ getSitePageAttributes() { return 'vocab="http://schema.org/" prefix="oer:http://oerschema.org cc:http://creativecommons.org/ns dc:http://purl.org/dc/terms/"'; } /** * Return the base tag accurately which helps with the PWA / SW side of things * @return string HTML blob for hte <base> tag */ getBaseTag() { return '<base href="' + this.basePath + this.name + '/" />'; } /** * Return a standard service worker that takes into account * the context of the page it's been placed on. * @todo this will need additional vetting based on the context applied * @return string <script> tag that will be a rather standard service worker */ getServiceWorkerScript(basePath = null, ignoreDevMode = false, addSW = true) { // because this can screw with caching, let's make sure we // can throttle it locally for developers as needed if (!addSW || HAXCMS.developerMode && !ignoreDevMode) { return "\n <!-- Service worker disabled via settings -.\n"; } // support dynamic calculation if (basePath == null) { basePath = this.basePath + this.name + '/'; } return ` <script> if ('serviceWorker' in navigator) { var sitePath = '{basePath}'; // discover this path downstream of the root of the domain var swScope = window.location.pathname.substring(0, window.location.pathname.indexOf(sitePath)) + sitePath; if (swScope != document.head.getElementsByTagName('base')[0].href) { document.head.getElementsByTagName('base')[0].href = swScope; } window.addEventListener('load', function () { navigator.serviceWorker.register('service-worker.js', { scope: swScope }).then(function (registration) { registration.onupdatefound = function () { // The updatefound event implies that registration.installing is set; see // https://slightlyoff.github.io/ServiceWorker/spec/service_worker/index.html#service-worker-container-updatefound-event var installingWorker = registration.installing; installingWorker.onstatechange = function () { switch (installingWorker.state) { case 'installed': if (!navigator.serviceWorker.controller) { window.dispatchEvent(new CustomEvent('haxcms-toast-show', { bubbles: true, cancelable: false, detail: { text: 'Pages you view are cached for offline use.', duration: 5000 } })); } break; case 'redundant': throw Error('The installing service worker became redundant.'); break; } }; }; }).catch(function (e) { console.warn('Service worker registration failed:', e); }); // Check to see if the service worker controlling the page at initial load // has become redundant, since this implies there's a new service worker with fresh content. if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.onstatechange = function(event) { if (event.target.state === 'redundant') { var b = document.createElement('paper-button'); b.appendChild(document.createTextNode('Reload')); b.raised = true; b.addEventListener('click', function(e){ window.location.reload(true); }); window.dispatchEvent(new CustomEvent('haxcms-toast-show', { bubbles: true, cancelable: false, detail: { text: 'A site update is available. Reload for latest content.', duration: 8000, slot: b, clone: false } })); } }; } }); } </script>`; } /** * Load content of this page * @var JSONOutlineSchemaItem page - a loaded page object * @return string HTML / contents of the page object */ async getPageContent(page) { if (page.location && page.location != '') { return filter_var(await fs.readFileSync(path.join(this.siteDirectory, page.location), { encoding: 'utf8', flag: 'r' })); } } /** * Generate the stub of a well formed site.json item * based on parameters */ itemFromParams(params) { // get a new item prototype let item = HAXCMS.outlineSchema.newItem(); let cleanTitle = ''; // set the title item.title = params['node']['title'].replace("\n", ''); if (params['node']['id'] && params['node']['id'] != '' && params['node']['id'] != null) { item.id = params['node']['id']; } item.location = 'pages/' + item.id + '/index.html'; if (params['indent'] && params['indent'] != '' && params['indent'] != null) { item.indent = params['indent']; } if (params['order'] && params['order'] != '' && params['order'] != null) { item.order = params['order']; } if (params['parent'] && params['parent'] != '' && params['parent'] != null) { item.parent = params['parent']; } else { item.parent = null; } if (params['description'] && params['description'] != '' && params['description'] != null) { item.description = params['description'].replace("\n", ''); } if (params['metadata'] && params['metadata'] != '' && params['metadata'] != null) { item.metadata = params['metadata']; } if (typeof params['node']['location'] !== 'undefined' && params['node']['location'] != '' && params['node']['location'] != null) { cleanTitle = HAXCMS.cleanTitle(params['node']['location']); item.slug = this.getUniqueSlugName(cleanTitle); } else { cleanTitle = HAXCMS.cleanTitle(item.title); item.slug = this.getUniqueSlugName(cleanTitle, item, true); } item.metadata.created = Math.floor(Date.now() / 1000); item.metadata.updated = Math.floor(Date.now() / 1000); return item; } /** * Return accurate, rendered site metadata * @var JSONOutlineSchemaItem page - a loaded page object, most likely whats active * @return string an html chunk of tags for the head section * @todo move this to a render function / section / engine */ async getSiteMetadata(page = null, domain = null, cdn = '') { if (page == null) { page = new JSONOutlineSchemaItem(); } // domain's need to inject their own full path for OG metadata (which is edge case) // most of the time this is the actual usecase so use the active path if (domain == null) { domain = HAXCMS.getURI(); } // support preconnecting CDNs, sets us up for dynamic CDN switching too let preconnect = ''; let base = './'; if (cdn == '' && HAXCMS.cdn != './') { preconnect = `<link rel="preconnect" crossorigin href="${HAXCMS.cdn}" />`; cdn = HAXCMS.cdn; } if (cdn != '') { // preconnect for faster DNS lookup preconnect = `<link rel="preconnect" crossorigin href="${cdn}" />`; // preload rewrite correctly base = cdn; } let title = page.title; let siteTitle = this.manifest.title + ' | ' + page.title; let description = page.description; let hexCode = HAXCMS.HAXCMS_FALLBACK_HEX; let robots; let canonical; if (description == '') { description = this.manifest.description; } if (title == '' || title == 'New item') { title = this.manifest.title; siteTitle = this.manifest.title; } if (this.manifest.metadata.theme.variables.hexCode) { hexCode = this.manifest.metadata.theme.variables.hexCode; } // if we have a privacy flag, then tell robots not to index this were it to be found // which in HAXiam this isn't possible if (this.manifest.metadata.site.settings.private) { robots = '<meta name="robots" content="none" />'; } else { robots = '<meta name="robots" content="index, follow" />'; } // canonical flag, if set we use the domain field if (this.manifest.metadata.site.settings.canonical) { if (this.manifest.metadata.site.domain && this.manifest.metadata.site.domain != '') { canonical = ' <link name="canonical" href="' + filter_var(this.manifest.metadata.site.domain + '/' + page.slug, "FILTER_SANITIZE_URL") + '" />' + "\n"; } else { canonical = ' <link name="canonical" href="' + filter_var(domain, "FILTER_SANITIZE_URL") + '" />' + "\n"; } } else { canonical = ''; } let prevResource = ''; let nextResource = ''; // if we have a place in the array bc it's a page, then we can get next / prev if (page.id && this.manifest.getItemKeyById(page.id) !== false) { let currentId = this.manifest.getItemKeyById(page.id); if (currentId > 0 && this.manifest.items[currentId - 1] && this.manifest.items[currentId - 1].slug) { prevResource = ' <link rel="prev" href="' + this.manifest.items[currentId - 1].slug + '" />' + "\n"; } if (currentId < this.manifest.items.length - 1 && this.manifest.items[currentId + 1] && this.manifest.items[currentId + 1].slug) { nextResource = ' <link rel="next" href="' + this.manifest.items[currentId + 1].slug + '" />' + "\n"; } } let metadata = `<meta charset="utf-8" /> ${preconnect} <link rel="preconnect" crossorigin href="https://fonts.googleapis.com"> <link rel="preconnect" crossorigin href="https://cdnjs.cloudflare.com"> <link rel="preconnect" crossorigin href="https://i.creativecommons.org"> <link rel="preconnect" crossorigin href="https://licensebuttons.net"> <link rel="preload" href="${base}build/es6/node_modules/mobx/dist/mobx.esm.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="${base}build/es6/node_modules/@haxtheweb/haxcms-elements/lib/core/haxcms-site-builder.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="${base}build/es6/node_modules/@haxtheweb/haxcms-elements/lib/core/haxcms-site-store.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="${base}build/es6/dist/my-custom-elements.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="${base}build/es6/node_modules/@haxtheweb/haxcms-elements/lib/base.css" as="style" /> <link rel="preload" href="./custom/build/custom.es6.js" as="script" crossorigin="anonymous" /> <link rel="preload" href="./theme/theme.css" as="style" /> <meta name="generator" content="HAXcms"> ${canonical}${prevResource}${nextResource} <link rel="manifest" href="manifest.json" /> <meta name="viewport" content="width=device-width, minimum-scale=1, initial-scale=1, user-scalable=yes"> <title>${siteTitle}</title> <link rel="icon" href="${await this.getLogoSize('16', '16')}"> <meta name="theme-color" content="${hexCode}"> ${robots} <meta name="mobile-web-app-capable" content="yes"> <meta name="application-name" content="${title}"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="${title}"> <link rel="apple-touch-icon" sizes="48x48" href="${await this.getLogoSize('48', '48')}"> <link rel="apple-touch-icon" sizes="72x72" href="${await this.getLogoSize('72', '72')}"> <link rel="apple-touch-icon" sizes="96x96" href="${await this.getLogoSize('96', '96')}"> <link rel="apple-touch-icon" sizes="144x144" href="${await this.getLogoSize('144', '144')}"> <link rel="apple-touch-icon" sizes="192x192" href="${await this.getLogoSize('192', '192')}"> <meta name="msapplication-TileImage" content="${await this.getLogoSize('144', '144')}"> <meta name="msapplication-TileColor" content="${hexCode}"> <meta name="msapplication-tap-highlight" content="no"> <meta name="description" content="${description}" /> <meta name="og:sitename" property="og:sitename" content="${this.manifest.title}" /> <meta name="og:title" property="og:title" content="${title}" /> <meta name="og:type" property="og:type" content="article" /> <meta name="og:url" property="og:url" content="${domain}" /> <meta name="og:description" property="og:description" content="${description}" /> <meta name="og:image" property="og:image" content="${this.getSocialShareImage(page)}" /> <meta name="twitter:card" property="twitter:card" content="summary_large_image" /> <meta name="twitter:site" property="twitter:site" content="${domain}" /> <meta name="twitter:title" property="twitter:title" content="${title}" /> <meta name="twitter:description" property="twitter:description" content="${description}" /> <meta name="twitter:image" property="twitter:image" content="${this.getSocialShareImage(page)}" />`; // mix in license metadata if we have it let licenseData = this.getLicenseData('all'); if (this.manifest.license && licenseData[this.manifest.license]) { metadata += "\n" + ' <meta rel="cc:license" href="' + licenseData[this.manifest.license]['link'] + '" content="License: ' + licenseData[this.manifest.license]['name'] + '"/>' + "\n"; } // add in X link if they provided one if (this.manifest.metadata.author.socialLink && (this.manifest.metadata.author.socialLink.indexOf('https://twitter.com/') === 0 || this.manifest.metadata.author.socialLink.indexOf('https://x.com/') === 0)) { metadata += "\n" + ' <meta name="twitter:creator" content="' + this.manifest.metadata.author.socialLink.replace('https://twitter.com/', '@').replace('https://x.com/', '@') + '" />'; } return metadata; } /** * Load a node based on a path * @var path the path to try loading based on or search for the active from address * @return new JSONOutlineSchemaItem() a blank JOS item */ loadNodeByLocation(path = null) { // load from the active address if we have one if (path == null) { path = path.resolve(__dirname).replace('/' + HAXCMS.sitesDirectory + '/' + this.name + '/', ''); } path += "/index.html"; // failsafe in case someone had closing / path = 'pages/' + path.replace('//', '/'); for (var key in this.manifest.files) { let item = this.manifest.items[key]; if (item.location == path) { return item; } } return new JSONOutlineSchemaItem(); } /** * Generate or load the path to variations on the logo * @var string height height of the icon as a string * @var string width width of the icon as a string * @return string path to the image (web visible) that was created or pulled together */ async getLogoSize(height, width) { let fileName; if (!fileName) { // if no logo, just bail with an easy standard one if (!this.manifest.metadata.site.logo || this.manifest.metadata.site && (this.manifest.metadata.site.logo == '' || this.manifest.metadata.site.logo == null || this.manifest.metadata.site.logo == "null")) { fileName = 'assets/icon-' + height + 'x' + width + '.png'; } else { // ensure this path exists otherwis