UNPKG

@akashacms/plugins-blog-podcast

Version:

A plugin for AkashaCMS to create blogs & podcasts within a site

712 lines (610 loc) 28 kB
/** * Copyright 2015-2025 David Herron * * This file is part of AkashaCMS-embeddables (http://akashacms.com/). * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { promises as fsp } from 'node:fs'; import path from 'node:path'; import util from 'node:util'; import url from 'node:url'; import akasha, { Configuration, CustomElement, Munger, PageProcessor } from 'akasharender'; const mahabhuta = akasha.mahabhuta; const pluginName = "@akashacms/plugins-blog-podcast"; const __dirname = import.meta.dirname; export class BlogPodcastPlugin extends akasha.Plugin { #config; constructor() { super(pluginName); } configure(config, options) { this.#config = config; // this.config = config; this.akasha = config.akasha; this.options = options ? options : {}; this.options.config = config; config.addPartialsDir(path.join(__dirname, 'partials')); config.addMahabhuta(mahabhutaArray(options, config, this.akasha, this)); if (!options.bloglist) options.bloglist = []; } get config() { return this.#config; } blogcfg(tag) { return this.options.bloglist[tag]; } isBlogtag(tag) { let type = typeof this.options.bloglist[tag]; return type !== 'undefined' && type === 'object'; } addBlogPodcast(config, name, blogPodcast) { this.options.bloglist[name] = blogPodcast; return this.config; } isLegitLocalHref(config, href) { // console.log(`isLegitLocalHref ${util.inspect(this.options.bloglist)} === ${href}?`); for (var blogkey in this.options.bloglist) { var blogcfg = this.options.bloglist[blogkey]; // console.log(`isLegitLocalHref ${blogcfg.rssurl} === ${href}?`); if (blogcfg.rssurl === href) { return true; } } return false; } get cacheIndexes() { return { documents: { docMetadata: { blogtag: 1 } }, assets: undefined, layouts: undefined, partials: undefined, }; } findBlogForVPInfo(vpinfo) { for (var blogkey in this.options.bloglist) { var blogcfg = this.options.bloglist[blogkey]; // console.log(`findBlogForVPInfo ${vpinfo.vpath} in ${blogkey}`); if (this.isVPathInBlog(blogcfg, vpinfo)) { // console.log(`YES findBlogForVPInfo ${vpinfo.vpath} in ${blogkey}`); return { blogkey, blogcfg }; } } // console.log(`findBlogForVPInfo ${vpinfo.vpath} no blog`); return undefined; } isVPathInBlog(cfg, info) { if (!info.renderPath.match(/\.html$/)) return false; if (cfg.matchers) { if (cfg.matchers.layouts && Array.isArray(cfg.matchers.layouts) && cfg.matchers.layouts.length > 0) { // console.log(`isVPathInBlog ${info.vpath} layouts `, cfg.matchers.layouts); if (info.docMetadata && info.docMetadata.layout) { // console.log(`isVPathInBlog ${info.vpath} is ${info.docMetadata.layout} in `, cfg.matchers.layouts); let matchedLayout = false; for (let layout of cfg.matchers.layouts) { if (layout === info.docMetadata.layout) { matchedLayout = true; } } if (!matchedLayout) return false; } }/* else { console.log(`isVPathInBlog ${info.vpath} NO LAYOUT MATCHERS`); } */ if (cfg.matchers.renderpath) { if (!info.renderPath.match(cfg.matchers.renderpath)) return false; } if (cfg.matchers.path) { if (!info.vpath.match(cfg.matchers.path)) return false; } if (cfg.matchers.glob) { if (!info.vpath.match(cfg.matchers.glob)) return false; } if (cfg.rootPath) { if (!info.renderPath.startsWith(cfg.rootPath)) return false; } }/* else { console.log(`isVPathInBlog ${info.vpath} NO MATCHERS`); } */ return true; } async onSiteRendered(config) { const plugin = this; const tasks = []; for (var blogkey in this.options.bloglist) { if (!this.options.bloglist.hasOwnProperty(blogkey)) { continue; } var blogcfg = this.options.bloglist[blogkey]; tasks.push({ blogkey, blogcfg }); } await Promise.all(tasks.map(async data => { const blogkey = data.blogkey; const blogcfg = data.blogcfg; // console.log(`blog-podcast blogcfg ${util.inspect(blogcfg)}`); const taskStart = new Date(); var documents = await plugin.findBlogDocs(config, blogcfg, blogkey); var count = 0; var documents2 = documents.filter(doc => { if (typeof blogcfg.maxEntries === "undefined" || (typeof blogcfg.maxEntries !== "undefined" && count++ < blogcfg.maxEntries)) { return true; } else return false; }); // console.log('blog-news-river documents2 '+ util.inspect(documents2)); var rssitems = documents2.map(doc => { let u = new URL(config.root_url); // Accommodate when root_url is something like // http://example.com/foo/bar/ // This generates a URL for the blog entry that includes the // domain for the website. But in some cases the generated // content lands in a subdirectory. u.pathname = path.normalize( path.join('/', doc.renderPath)); // console.log(`rss item ${config.root_url} ${doc.renderpath} ==> ${u.toString()}`); return { title: doc.docMetadata.title, description: doc.docMetadata.teaser ? doc.docMetadata.teaser : "", url: u.toString(), date: doc.docMetadata.publicationDate ? doc.docMetadata.publicationDate : doc.mtimeMs }; }); var maxItems; if (typeof blogcfg.maxItems === 'undefined') { maxItems = 60; } else if (blogcfg.maxItems <= 0) { maxItems = undefined; } else { maxItems = blogcfg.maxItems; } if (maxItems) { let rssitems2 = []; let count = 0; for (let item of rssitems) { if (count < maxItems) { rssitems2.push(item); // console.log(`${blogkey} PUSH ITEM ${count} ${util.inspect(item)}`); } count++; } rssitems = rssitems2; } // console.log(`GENERATE RSS rssitems # ${rssitems.length} maxItems ${maxItems} ${util.inspect(blogcfg)} `); // console.log(`GENERATE RSS ${config.renderDestination + blogcfg.rssurl} ${util.inspect(rssitems)}`); let feed_url = new URL(config.root_url); feed_url.pathname = path.normalize( path.join(feed_url.pathname, blogcfg.rssurl)); // console.log(`generateRSS ${config.root_url} ${blogcfg.rssurl} ==> ${feed_url.toString()}`); await akasha.generateRSS(config, blogcfg, { feed_url: feed_url.toString(), pubDate: new Date() }, rssitems, blogcfg.rssurl); const taskEnd = new Date(); console.log(`GENERATED RSS ${feed_url.toString()} rssitems # ${rssitems.length} in ${(taskEnd - taskStart) / 1000}`) })); } /** * blogPodcast: { "news": { rss: { title: "AkashaCMS News", description: "Announcements and news about the AkashaCMS content management system", site_url: "http://akashacms.com/news/index.html", image_url: "http://akashacms.com/logo.gif", managingEditor: 'David Herron', webMaster: 'David Herron', copyright: '2015 David Herron', language: 'en', categories: [ "Node.js", "Content Management System", "HTML5", "Static website generator" ] }, rssurl: "/news/rss.xml", matchers: { layouts: [ "blog.html.ejs" ], path: /^news\// } }, "howto": { rss: { title: "AkashaCMS Tutorials", description: "Tutorials about using the AkashaCMS content management system", site_url: "http://akashacms.com/howto/index.html", image_url: "http://akashacms.com/logo.gif", managingEditor: 'David Herron', webMaster: 'David Herron', copyright: '2015 David Herron', language: 'en', categories: [ "Node.js", "Content Management System", "HTML5", "HTML5", "Static website generator" ] }, rssurl: "/howto/rss.xml", matchers: { layouts: [ "blog.html.ejs" ], path: /^howto\// } } }, * */ async findBlogDocs(config, blogcfg, blogtag) { if (!this.isBlogtag(blogtag)) { throw new Error(`findBlogDocs given invalid blogtag ${blogtag}`); } if (akasha !== config.akasha) { console.error(`findBlogDocs akasha !== config.akasha`); } // Performance testing // const _start = new Date(); if (!blogcfg || !blogcfg.matchers) { throw new Error(`findBlogDocs no blogcfg`); } const selector = {}; selector.rendersToHTML = true; selector.blogtag = blogtag; // Support matching more than one blogtag if (blogcfg.matchers && blogcfg.matchers.blogtags && Array.isArray(blogcfg.matchers.blogtags) && blogcfg.matchers.blogtags.length >= 1 ) { selector.blogtags = blogcfg.matchers.blogtags; } if (blogcfg.matchers && blogcfg.matchers.path) { selector.pathmatch = blogcfg.matchers.path; } if (blogcfg.matchers && blogcfg.matchers.renderpath) { selector.renderpathmatch = blogcfg.matchers.renderpath; } // The rootPath option is used as an alternative for selecting // blog entries within a directory hierarchy. One use is // to select the blog posts under a given location within // a blog hierarchy. For example, an index page a couple levels // down within the blog should only list the items in that directory // and below. // // The point of `rootPath` versus `renderpath` is // the difference between an SQL LIKE versus using // regular expressions. RootPath with the SQLITE3 cache // is matched using `renderPath LIKE 'rootPath%'` wheres // the others are matched with regular expressions. if (blogcfg.rootPath) { selector.rootPath = blogcfg.rootPath; } // Also support rootPath in the matchers if (blogcfg.matchers.rootPath) { selector.rootPath = blogcfg.matchers.rootPath; } if (blogcfg.matchers && blogcfg.matchers.layouts) { if (Array.isArray(blogcfg.matchers.layouts)) { selector.layouts = blogcfg.matchers.layouts; } else if (typeof blogcfg.matchers.layouts === 'string') { selector.layouts = [ blogcfg.matchers.layouts ]; } else { throw new Error(`Incorrect setting for blogcfg.matchers.layouts ${util.inspect(blogcfg.matchers.layouts)}`); } } // This is solely about filtering for blogtag. // This functionality is now handled as // a search option. // selector.filterfunc = (config, options, doc) => { // if (doc.docMetadata // && doc.docMetadata.blogtag) { // // This could possibly be in a blog, but not in this blog // // console.log(`blog podcast filterfunc ${doc.vpath} ${util.inspect(options.blogtag)} ${util.inspect(doc?.docMetadata?.blogtag)}`); // if (Array.isArray(options.blogtags) // && !options.blogtags.includes(doc.docMetadata.blogtag)) { // // console.log(`findBlogDocs filterfunc ${doc.metaData.blogtag} not in ${util.inspect(options.blogtags)} ${doc.vpath}`); // return false; // } else if (typeof options.blogtags === 'string' // && doc.docMetadata.blogtag !== options.blogtags) { // // console.log(`findBlogDocs filterfunc ${doc.metaData.blogtag} not in ${options.blogtags} ${doc.vpath}`); // return false; // } // } else if (!doc.docMetadata || !doc.docMetadata.blogtag) { // // This cannot be in any blog // // console.log(`findBlogDocs filterfunc NOT IN ANY BLOG ${doc.vpath}`) // return false; // } // return true; // }; let dateErrors = []; /* selector.sortFunc = async (a, b) => { let aPublicationTime = new Date(a.publicationDate).getTime(); if (isNaN(aPublicationTime)) { dateErrors.push(`findBlogDocs ${a.vpath} BAD DATE ${aPublicationTime}`); } let bPublicationTime = new Date(b.publicationDate).getTime(); console.log(`findBlogDocs ${a.vpath} ${aPublicationTime} ${b.vpath} ${bPublicationTime}`); if (isNaN(bPublicationTime)) { dateErrors.push(`findBlogDocs ${b.vpath} BAD DATE ${bPublicationTime}`); } if (aPublicationTime < bPublicationTime) return -1; else if (aPublicationTime === bPublicationTime) return 0; else return 1; }; */ selector.sortBy = 'publicationTime'; selector.sortByDescending = true; selector.reverse = true; if (typeof blogcfg.maxEntries === 'number' && blogcfg.maxEntries > 0) { selector.limit = blogcfg.maxEntries; } if (typeof blogcfg.startAt === 'number' && blogcfg.startAt >= 0) { selector.offset = blogcfg.startAt; } // console.log(`findBlogDocs`, selector); // console.log(filecache); // console.log(await config.documentsCache()); let documents = await akasha.filecache.documentsCache.search(selector); if (dateErrors.length >= 1) { throw dateErrors; } // Performance testing // console.log(`findBlogDocs ${blogtag} options setup ${(new Date() - _start) / 1000} seconds`); // Performance testing // console.log(`findBlogDocs ${blogtag} after searching ${_documents.length} documents ${(new Date() - _start) / 1000} seconds`); return documents; } /** * This seems to be tasked with finding the * index pages (index.html.EXT) in a blog. But, * for what purpose? And is this being used? * There is no Mahafunc referring to this. * * Actually - BlogNewsIndexElement - blog-news-index, * which corresponds to blog-news-indexes.html.njk * and blog-news-indexes.html.ejs. * * @param {*} config * @param {*} blogcfg * @returns */ async findBlogIndexes(config, blogcfg) { if (!blogcfg.indexmatchers) return []; const documents = this.akasha.filecache.documentsCache; return documents.search({ rendersToHTML: true, sortBy: 'publicationTime', sortByDescending: true, limit: blogcfg.maxEntries ? blogcfg.maxEntries : undefined, // reverse: true, pathmatch: blogcfg.indexmatchers.path ? blogcfg.indexmatchers.path : undefined, // glob: '**/*.html', layouts: blogcfg.indexmatchers.layouts ? blogcfg.indexmatchers.layouts : undefined, rootPath: blogcfg.rootPath ? blogcfg.rootPath : undefined, }); } } export function mahabhutaArray( options, config, // ?: Configuration, akasha, // ?: any, plugin // ?: Plugin ) { let ret = new mahabhuta.MahafuncArray(pluginName, options); ret.addMahafunc(new BlogNewsRiverElement(config, akasha, plugin)); ret.addMahafunc(new BlogRSSIconElement(config, akasha, plugin)); ret.addMahafunc(new BlogRSSLinkElement(config, akasha, plugin)); ret.addMahafunc(new BlogRSSListElement(config, akasha, plugin)); ret.addMahafunc(new BlogNextPrevElement(config, akasha, plugin)); ret.addMahafunc(new BlogNewsIndexElement(config, akasha, plugin)); return ret; }; class BlogNewsRiverElement extends CustomElement { get elementName() { return "blog-news-river"; } async process($element, metadata, dirty) { // const _start = new Date(); let blogtag = $element.attr("blogtag"); if (!blogtag) { blogtag = metadata.blogtag; } if (!blogtag) {// no blog tag, skip? error? console.error("NO BLOG TAG in blog-news-river"+ metadata.document.path); throw new Error("NO BLOG TAG in blog-news-river"+ metadata.document.path); } // log('blog-news-river '+ blogtag +' '+ metadata.document.path); let blogcfg = this.options.bloglist[blogtag]; if (!blogcfg) throw new Error('No blog configuration found for blogtag '+ blogtag); // console.log(`BlogNewsRiverElement found blogcfg ${(new Date() - _start) / 1000} seconds`); let _blogcfg = structuredClone(blogcfg); let maxEntries = $element.attr('maxentries'); if (maxEntries) { _blogcfg.maxEntries = Number.parseInt(maxEntries); } let template = $element.attr("template"); if (!template) template = "blog-news-river.html.njk"; let rootPath = $element.attr('root-path'); if (rootPath) { _blogcfg.matchers.rootPath = rootPath; } let docRootPath = $element.attr('doc-root-path'); if (docRootPath) { _blogcfg.matchers.rootPath = path.dirname(docRootPath); } // console.log(`BlogNewsRiverElement duplicate blogcfg ${(new Date() - _start) / 1000} seconds`); // console.log(`blog-news-river rootPath ${rootPath} docRootPath ${docRootPath} computed blogcfg`, _blogcfg); let documents = await this.config.plugin(pluginName) .findBlogDocs(this.config, _blogcfg, blogtag); // console.log(`blog-news-river ${blogtag} ${util.inspect(_blogcfg)} `, documents.map(d => { // return { // vpath: d.vpath, // renderPath: d.renderPath, // date: d.docMetadata.publicationDate // }; // })); // let documents = await this.array.options.config.plugin(pluginName) // .NEWfindBlogDocs(this.array.options.config, _blogcfg, blogtag, docRootPath); // console.log(`BlogNewsRiverElement findBlogDocs ${documents.length} entries ${(new Date() - _start) / 1000} seconds`); if (!documents) { throw new Error(`BlogNewsRiverElement NO blog docs found for ${blogtag}`); } // for (let item of documents) { // console.log(`NEWS RIVER ITEM ${blogtag} ${metadata.document.path} ${item.vpath} ${item.renderPath} ${item.docMetadata.publicationDate}`); // } let ret = await this.akasha.partial(this.config, template, { documents: documents, feedUrl: _blogcfg.rssurl }); // console.log(`NEWS RIVER RENDERED TO `, ret); // console.log(`BlogNewsRiverElement rendered ${(new Date() - _start) / 1000} seconds`); return ret; } } class BlogNewsIndexElement extends CustomElement { get elementName() { return "blog-news-index"; } async process($element, metadata, dirty) { var blogtag = $element.attr("blogtag"); if (!blogtag) { blogtag = metadata.blogtag; } if (!blogtag) {// no blog tag, skip? error? console.error("NO BLOG TAG in blog-news-index"+ metadata.document.path); throw new Error("NO BLOG TAG in blog-news-index "+ metadata.document.path); } var blogcfg = this.options.bloglist[blogtag]; if (!blogcfg) throw new Error('No blog configuration found for blogtag '+ blogtag); let _blogcfg = {}; for (let key in blogcfg) { _blogcfg[key] = blogcfg[key]; } let maxEntries = $element.attr('maxentries'); if (maxEntries) { _blogcfg.maxEntries = Number.parseInt(maxEntries); } var template = $element.attr("template"); if (!template) template = "blog-news-indexes.html.ejs"; let indexDocuments = await this.config.plugin(pluginName) .findBlogIndexes(this.config, _blogcfg); return akasha.partial(this.config, template, { indexDocuments }); } } class BlogRSSIconElement extends CustomElement { get elementName() { return "blog-rss-icon"; } process($element, metadata, dirty) { var blogtag = $element.attr("blogtag"); if (!blogtag) { blogtag = metadata.blogtag; } if (!blogtag) {// no blog tag, skip? error? console.error("NO BLOG TAG in blog-rss-icon"+ metadata.document.path); throw new Error("NO BLOG TAG in blog-rss-icon"+ metadata.document.path); } var title = $element.attr("title"); var blogcfg = this.options.bloglist[blogtag]; if (!blogcfg) throw new Error('No blog configuration found for blogtag '+ blogtag); var template = $element.attr("template"); if (!template) template = "blog-rss-icon.html.ejs"; return this.akasha.partial(this.config, template, { feedUrl: blogcfg.rssurl, title: title }); } } class BlogRSSLinkElement extends CustomElement { get elementName() { return "blog-rss-link"; } process($element, metadata, dirty) { var blogtag = $element.attr("blogtag"); if (!blogtag) { blogtag = metadata.blogtag; } if (!blogtag) {// no blog tag, skip? error? console.error("NO BLOG TAG in blog-rss-link"+ metadata.document.path); throw new Error("NO BLOG TAG in blog-rss-link"+ metadata.document.path); } var blogcfg = this.options.bloglist[blogtag]; if (!blogcfg) throw new Error('No blog configuration found for blogtag '+ blogtag); var template = $element.attr("template"); if (!template) template = "blog-rss-link.html.ejs"; return this.akasha.partial(this.config, template, { feedUrl: blogcfg.rssurl }); } } class BlogRSSListElement extends CustomElement { get elementName() { return "blog-feeds-all"; } process($element, metadata, dirty) { const template = $element.attr('template') ? $element.attr('template') : "blog-feeds-all.html.ejs"; const id = $element.attr('id'); const additionalClasses = $element.attr('additional-classes') dirty(); return this.akasha.partial(this.config, template, { id, additionalClasses, bloglist: this.options.bloglist }); } } class BlogNextPrevElement extends CustomElement { get elementName() { return "blog-next-prev"; } async process($element, metadata, dirty) { // const _start = new Date(); if (! metadata.blogtag) { return; } let blogcfg = this.options.bloglist[metadata.blogtag]; if (!blogcfg) throw new Error(`No blog configuration found for blogtag ${metadata.blogtag} in ${metadata.document.path}`); let docpathNoSlash = metadata.document.path.startsWith('/') ? metadata.document.path.substring(1) : metadata.document.path; let documents = await this.config .plugin(pluginName) .findBlogDocs(this.config, blogcfg, metadata.blogtag); // let documents = await this.array.options.config.plugin(pluginName) // .NEWfindBlogDocs(this.array.options.config, blogcfg, metadata.blogtag); // console.log(`BlogNextPrevElement findBlogDocs found ${documents.length} items ${(new Date() - _start)/1000} seconds`); let docIndex = -1; let j = 0; for (let j = 0; j < documents.length; j++) { let document = documents[j]; // console.log(`blog-next-prev findBlogDocs blogtag ${util.inspect(metadata.blogtag)} found ${document.basedir} ${document.docpath} ${document.docfullpath} ${document.renderpath} MATCHES? ${docpathNoSlash} ${metadata.document.path}`); // console.log(`BlogNextPrevElement ${path.normalize(document.vpath)} === ${path.normalize(docpathNoSlash)}`); // console.log(`BlogNextPrevElement ${path.normalize(document.vpath)}`); if (path.normalize(document.vpath) === path.normalize(docpathNoSlash)) { docIndex = j; } } // console.log(`BlogNextPrevElement docIndex ${docIndex}`); if (docIndex >= 0) { let prevDoc = docIndex === 0 ? documents[documents.length - 1] : documents[docIndex - 1]; let thisDoc = documents[docIndex]; let nextDoc = docIndex === documents.length - 1 ? documents[0] : documents[docIndex + 1]; // console.log(`prevDoc ${docIndex} ${prevDoc.renderPath} ${prevDoc.docMetadata.title}`); // console.log(`thisDoc ${docIndex} ${thisDoc.renderPath} ${thisDoc.docMetadata.title}`); // console.log(`nextDoc ${docIndex} ${nextDoc.renderPath} ${nextDoc.docMetadata.title}`); let html = await this.akasha.partial(this.options.config, 'blog-next-prev.html.ejs', { prevDoc, nextDoc }); // console.log(`BlogNextPrevElement findBlogDocs FINISH ${(new Date() - _start)/1000} seconds`); return html; } else { console.error(`blog-next-prev did not find document ${docpathNoSlash} ${metadata.document.path} in blog`); throw new Error(`did not find document ${docpathNoSlash} ${metadata.document.path} in blog ${metadata.blogtag}`); } } }