akasharender
Version:
Rendering support for generating static HTML websites or EPUB eBooks
1,626 lines (1,409 loc) • 82.7 kB
text/typescript
/**
*
* Copyright 2014-2025 David Herron
*
* This file is part of AkashaCMS (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 { DirsWatcher, dirToWatch, VPathData } from '@akashacms/stacked-dirs';
import path from 'node:path';
import util from 'node:util';
import url from 'node:url';
import { promises as fs } from 'fs';
import FS from 'fs';
import EventEmitter from 'events';
import micromatch from 'micromatch';
import {
field,
FieldOpts,
fk,
id,
index,
table,
TableOpts,
SqlDatabase,
schema,
BaseDAO,
Filter,
Where
} from 'sqlite3orm';
import { sqdb } from '../sqdb.js';
import { Configuration, dirToMount } from '../index.js';
import fastq from 'fastq';
///////////// Assets table
@table({
name: 'ASSETS',
withoutRowId: true,
} as TableOpts)
export class Asset {
// Primary key
@id({
name: 'vpath', dbtype: 'TEXT'
})
@index('asset_vpath')
vpath: string;
@field({
name: 'mime', dbtype: 'TEXT'
})
mime: string;
@field({
name: 'mounted', dbtype: 'TEXT'
})
@index('asset_mounted')
mounted: string;
@field({
name: 'mountPoint', dbtype: 'TEXT'
})
@index('asset_mountPoint')
mountPoint: string;
@field({
name: 'pathInMounted', dbtype: 'TEXT'
})
@index('asset_pathInMounted')
pathInMounted: string;
@field({
name: 'fspath', dbtype: 'TEXT'
})
@index('asset_fspath')
fspath: string;
@field({
name: 'renderPath', dbtype: 'TEXT'
})
@index('asset_renderPath')
renderPath: string;
@field({
name: 'mtimeMs',
dbtype: "TEXT DEFAULT(datetime('now') || 'Z')"
})
mtimeMs: string;
@field({
name: 'info', dbtype: 'TEXT', isJson: true
})
info: any;
}
await schema().createTable(sqdb, 'ASSETS');
type TassetsDAO = BaseDAO<Asset>;
export const assetsDAO: TassetsDAO
= new BaseDAO<Asset>(Asset, sqdb);
await assetsDAO.createIndex('asset_vpath');
await assetsDAO.createIndex('asset_mounted');
await assetsDAO.createIndex('asset_mountPoint');
await assetsDAO.createIndex('asset_pathInMounted');
await assetsDAO.createIndex('asset_fspath');
await assetsDAO.createIndex('asset_renderPath');
//////////// Partials Table
@table({
name: 'PARTIALS',
withoutRowId: true,
})
export class Partial {
// Primary key
@id({
name: 'vpath', dbtype: 'TEXT'
})
@index('partial_vpath')
vpath: string;
@field({
name: 'mime', dbtype: 'TEXT'
})
mime: string;
@field({
name: 'mounted', dbtype: 'TEXT'
})
@index('partial_mounted')
mounted: string;
@field({
name: 'mountPoint', dbtype: 'TEXT'
})
@index('partial_mountPoint')
mountPoint: string;
@field({
name: 'pathInMounted', dbtype: 'TEXT'
})
@index('partial_pathInMounted')
pathInMounted: string;
@field({
name: 'fspath', dbtype: 'TEXT'
})
@index('partial_fspath')
fspath: string;
@field({
name: 'renderPath', dbtype: 'TEXT'
})
@index('partial_renderPath')
renderPath: string;
@field({
name: 'mtimeMs',
dbtype: "TEXT DEFAULT(datetime('now') || 'Z')"
})
mtimeMs: string;
@field({
name: 'docMetadata', dbtype: 'TEXT', isJson: true
})
docMetadata: any;
@field({
name: 'docContent', dbtype: 'TEXT', isJson: true
})
docContent: any;
@field({
name: 'docBody', dbtype: 'TEXT', isJson: true
})
docBody: any;
@field({
name: 'metadata', dbtype: 'TEXT', isJson: true
})
metadata: any;
@field({
name: 'info', dbtype: 'TEXT', isJson: true
})
info: any;
}
await schema().createTable(sqdb, 'PARTIALS');
type TpartialsDAO = BaseDAO<Partial>;
export const partialsDAO
= new BaseDAO<Partial>(Partial, sqdb);
await partialsDAO.createIndex('partial_vpath');
await partialsDAO.createIndex('partial_mounted');
await partialsDAO.createIndex('partial_mountPoint');
await partialsDAO.createIndex('partial_pathInMounted');
await partialsDAO.createIndex('partial_fspath');
await partialsDAO.createIndex('partial_renderPath');
///////////////// Layouts Table
@table({
name: 'LAYOUTS',
withoutRowId: true,
})
export class Layout {
// Primary key
@id({
name: 'vpath', dbtype: 'TEXT'
})
@index('layout_vpath')
vpath: string;
@field({
name: 'mime', dbtype: 'TEXT'
})
mime: string;
@field({
name: 'mounted', dbtype: 'TEXT'
})
@index('layout_mounted')
mounted: string;
@field({
name: 'mountPoint', dbtype: 'TEXT'
})
@index('layout_mountPoint')
mountPoint: string;
@field({
name: 'pathInMounted', dbtype: 'TEXT'
})
@index('layout_pathInMounted')
pathInMounted: string;
@field({
name: 'fspath', dbtype: 'TEXT'
})
@index('layout_fspath')
fspath: string;
@field({
name: 'renderPath', dbtype: 'TEXT'
})
@index('layout_renderPath')
renderPath: string;
@field({
name: 'mtimeMs',
dbtype: "TEXT DEFAULT(datetime('now') || 'Z')"
})
mtimeMs: string;
@field({
name: 'docMetadata', dbtype: 'TEXT', isJson: true
})
docMetadata: any;
@field({
name: 'docContent', dbtype: 'TEXT', isJson: true
})
docContent: any;
@field({
name: 'docBody', dbtype: 'TEXT', isJson: true
})
docBody: any;
@field({
name: 'metadata', dbtype: 'TEXT', isJson: true
})
metadata: any;
@field({
name: 'info', dbtype: 'TEXT', isJson: true
})
info: any;
}
await schema().createTable(sqdb, 'LAYOUTS');
type TlayoutsDAO = BaseDAO<Layout>;
export const layoutsDAO
= new BaseDAO<Layout>(Layout, sqdb);
await layoutsDAO.createIndex('layout_vpath');
await layoutsDAO.createIndex('layout_mounted');
await layoutsDAO.createIndex('layout_mountPoint');
await layoutsDAO.createIndex('layout_pathInMounted');
await layoutsDAO.createIndex('layout_fspath');
await layoutsDAO.createIndex('layout_renderPath');
/////////////// Documents Table
@table({
name: 'DOCUMENTS',
withoutRowId: true,
})
export class Document {
// Primary key
@id({
name: 'vpath', dbtype: 'TEXT'
})
@index('docs_vpath')
vpath: string;
@field({
name: 'mime', dbtype: 'TEXT'
})
mime: string;
@field({
name: 'mounted', dbtype: 'TEXT'
})
@index('docs_mounted')
mounted: string;
@field({
name: 'mountPoint', dbtype: 'TEXT'
})
@index('docs_mountPoint')
mountPoint: string;
@field({
name: 'pathInMounted', dbtype: 'TEXT'
})
@index('docs_pathInMounted')
pathInMounted: string;
@field({
name: 'fspath', dbtype: 'TEXT'
})
@index('docs_fspath')
fspath: string;
@field({
name: 'renderPath', dbtype: 'TEXT'
})
@index('docs_renderPath')
renderPath: string;
@field({
name: 'rendersToHTML', dbtype: 'INTEGER'
})
@index('docs_rendersToHTML')
rendersToHTML: boolean;
@field({
name: 'dirname', dbtype: 'TEXT'
})
@index('docs_dirname')
dirname: string;
@field({
name: 'parentDir', dbtype: 'TEXT'
})
@index('docs_parentDir')
parentDir: string;
@field({
name: 'mtimeMs',
dbtype: "TEXT DEFAULT(datetime('now') || 'Z')"
})
mtimeMs: string;
@field({
name: 'docMetadata', dbtype: 'TEXT', isJson: true
})
docMetadata: any;
@field({
name: 'docContent', dbtype: 'TEXT', isJson: false
})
docContent: string;
@field({
name: 'docBody', dbtype: 'TEXT', isJson: false
})
docBody: string;
@field({
name: 'metadata', dbtype: 'TEXT', isJson: true
})
metadata: any;
@field({
name: 'tags', dbtype: 'TEXT', isJson: true
})
tags: any;
@field({
name: 'layout', dbtype: 'TEXT', isJson: false
})
@index('docs_layout')
layout: string;
@field({
name: 'blogtag', dbtype: 'TEXT', isJson: false
})
@index('docs_blogtag')
blogtag: string;
@field({
name: 'info', dbtype: 'TEXT', isJson: true
})
info: any;
}
await schema().createTable(sqdb, 'DOCUMENTS');
type TdocumentssDAO = BaseDAO<Document>;
export const documentsDAO
= new BaseDAO<Document>(Document, sqdb);
await documentsDAO.createIndex('docs_vpath');
await documentsDAO.createIndex('docs_mounted');
await documentsDAO.createIndex('docs_mountPoint');
await documentsDAO.createIndex('docs_pathInMounted');
await documentsDAO.createIndex('docs_fspath');
await documentsDAO.createIndex('docs_renderPath');
await documentsDAO.createIndex('docs_rendersToHTML');
await documentsDAO.createIndex('docs_dirname');
await documentsDAO.createIndex('docs_parentDir');
await documentsDAO.createIndex('docs_blogtag');
@table({ name: 'TAGGLUE' })
class TagGlue {
@field({ name: 'docvpath', dbtype: 'TEXT' })
// @fk('tag_docvpath', 'DOCUMENTS', 'vpath')
@index('tagglue_vpath')
docvpath: string;
@field({ name: 'tagName', dbtype: 'TEXT' })
// @fk('tag_slug', 'TAGS', 'slug')
@index('tagglue_name')
tagName: string;
}
await schema().createTable(sqdb, 'TAGGLUE');
export const tagGlueDAO = new BaseDAO<TagGlue>(TagGlue, sqdb);
await tagGlueDAO.createIndex('tagglue_vpath');
await tagGlueDAO.createIndex('tagglue_name');
// @table({ name: 'TAGS' })
// class Tag {
// @field({
// name: 'tagname',
// dbtype: 'TEXT'
// })
// tagname: string;
// @id({
// name: 'slug', dbtype: 'TEXT'
// })
// @index('tag_slug')
// slug: string;
// @field({
// name: 'description', dbtype: 'TEXT'
// })
// description?: string;
// }
// await schema().createTable(sqdb, 'TAGS');
// const tagsDAO = new BaseDAO<Tag>(Tag, sqdb);
// Convert AkashaCMS mount points into the mountpoint
// used by DirsWatcher
const remapdirs = (dirz: dirToMount[]): dirToWatch[] => {
return dirz.map(dir => {
// console.log('document dir ', dir);
if (typeof dir === 'string') {
return {
mounted: dir,
mountPoint: '/',
baseMetadata: {}
};
} else {
if (!dir.dest) {
throw new Error(`remapdirs invalid mount specification ${util.inspect(dir)}`);
}
return {
mounted: dir.src,
mountPoint: dir.dest,
baseMetadata: dir.baseMetadata,
ignore: dir.ignore
};
}
});
};
/**
* Type for return from paths method. The fields here
* are whats in the Asset/Layout/Partial classes above
* plus a couple fields that older code expected
* from the paths method.
*/
export type PathsReturnType = {
vpath: string,
mime: string,
mounted: string,
mountPoint: string,
pathInMounted: string,
mtimeMs: string,
info: any,
// These will be computed in BaseFileCache
// They were returned in previous versions.
fspath: string,
renderPath: string
};
export class BaseFileCache<
T extends Asset | Layout | Partial | Document,
Tdao extends BaseDAO<T>
> extends EventEmitter {
#config?: Configuration;
#name?: string;
#dirs?: dirToMount[];
#is_ready: boolean = false;
#cache_content: boolean;
#map_renderpath: boolean;
#dao: Tdao; // BaseDAO<T>;
/**
* @param config AkashaRender Configuration object
* @param dirs array of directories and mount points to watch
* @param name string giving the name for this watcher name
* @param dao The SQLITE3ORM DAO instance to use
*/
constructor(
config: Configuration,
name: string,
dirs: dirToMount[],
dao: Tdao // BaseDAO<T>
) {
super();
// console.log(`BaseFileCache ${name} constructor dirs=${util.inspect(dirs)}`);
this.#config = config;
this.#name = name;
this.#dirs = dirs;
this.#is_ready = false;
this.#cache_content = false;
this.#map_renderpath = false;
this.#dao = dao;
}
get config() { return this.#config; }
get name() { return this.#name; }
get dirs() { return this.#dirs; }
set cacheContent(doit) { this.#cache_content = doit; }
get gacheContent() { return this.#cache_content; }
set mapRenderPath(doit) { this.#map_renderpath = doit; }
get mapRenderPath() { return this.#map_renderpath; }
get dao(): Tdao { return this.#dao; }
// SKIP: getDynamicView
#watcher: DirsWatcher;
#queue;
async close() {
if (this.#queue) {
this.#queue.killAndDrain();
this.#queue = undefined;
}
if (this.#watcher) {
// console.log(`CLOSING ${this.name}`);
await this.#watcher.close();
this.#watcher = undefined;
}
this.removeAllListeners('changed');
this.removeAllListeners('added');
this.removeAllListeners('unlinked');
this.removeAllListeners('ready');
await sqdb.close();
}
/**
* Set up receiving events from DirsWatcher, and dispatching to
* the handler methods.
*/
async setup() {
const fcache = this;
if (this.#watcher) {
await this.#watcher.close();
}
this.#queue = fastq.promise(async function (event) {
if (event.code === 'changed') {
try {
// console.log(`change ${event.name} ${event.info.vpath}`);
await fcache.handleChanged(event.name, event.info);
fcache.emit('change', event.name, event.info);
} catch (e) {
fcache.emit('error', {
code: event.code,
name: event.name,
vpath: event.info.vpath,
error: e
});
}
} else if (event.code === 'added') {
try {
// console.log(`add ${event.name} ${event.info.vpath}`);
await fcache.handleAdded(event.name, event.info);
fcache.emit('add', event.name, event.info);
} catch (e) {
fcache.emit('error', {
code: event.code,
name: event.name,
vpath: event.info.vpath,
error: e
});
}
} else if (event.code === 'unlinked') {
try {
// console.log(`unlink ${event.name} ${event.info.vpath}`, event.info);
await fcache.handleUnlinked(event.name, event.info);
fcache.emit('unlink', event.name, event.info);
} catch (e) {
fcache.emit('error', {
code: event.code,
name: event.name,
vpath: event.info.vpath,
error: e
});
}
/* } else if (event.code === 'error') {
await fcache.handleError(event.name) */
} else if (event.code === 'ready') {
await fcache.handleReady(event.name);
fcache.emit('ready', event.name);
}
}, 10);
this.#watcher = new DirsWatcher(this.name);
this.#watcher.on('change', async (name: string, info: VPathData) => {
// console.log(`${name} changed ${info.mountPoint} ${info.vpath}`);
try {
if (!this.ignoreFile(info)) {
// console.log(`PUSH ${name} changed ${info.mountPoint} ${info.vpath}`);
this.#queue.push({
code: 'changed',
name, info
});
} else {
console.log(`Ignored 'change' for ${info.vpath}`);
}
} catch (err) {
console.error(`FAIL change ${info.vpath} because ${err.stack}`);
}
})
.on('add', async (name: string, info: VPathData) => {
try {
// console.log(`${name} add ${info.mountPoint} ${info.vpath}`);
if (!this.ignoreFile(info)) {
// console.log(`PUSH ${name} add ${info.mountPoint} ${info.vpath}`);
this.#queue.push({
code: 'added',
name, info
});
} else {
console.log(`Ignored 'add' for ${info.vpath}`);
}
} catch (err) {
console.error(`FAIL add ${info.vpath} because ${err.stack}`);
}
})
.on('unlink', async (name: string, info: VPathData) => {
// console.log(`unlink ${name} ${info.vpath}`);
try {
if (!this.ignoreFile(info)) {
this.#queue.push({
code: 'unlinked',
name, info
});
} else {
console.log(`Ignored 'unlink' for ${info.vpath}`);
}
} catch (err) {
console.error(`FAIL unlink ${info.vpath} because ${err.stack}`);
}
})
.on('ready', async (name: string) => {
// console.log(`${name} ready`);
this.#queue.push({
code: 'ready',
name
});
});
const mapped = remapdirs(this.dirs);
// console.log(`setup ${this.#name} watch ${util.inspect(this.#dirs)} ==> ${util.inspect(mapped)}`);
await this.#watcher.watch(mapped);
// console.log(`DAO ${this.dao.table.name} ${util.inspect(this.dao.table.fields)}`);
}
gatherInfoData(info: T) {
// Placeholder which some subclasses
// are expected to override
info.renderPath = info.vpath;
}
async handleChanged(name, info) {
// console.log(`PROCESS ${name} handleChanged`, info.vpath);
if (this.ignoreFile(info)) {
// console.log(`OOOOOOOOGA!!! Received a file that should be ingored `, info);
return;
}
if (name !== this.name) {
throw new Error(`handleChanged event for wrong name; got ${name}, expected ${this.name}`);
}
// console.log(`handleChanged ${info.vpath} ${info.metadata && info.metadata.publicationDate ? info.metadata.publicationDate : '???'}`);
this.gatherInfoData(info);
info.stack = undefined;
const result = await this.dao.selectAll({
vpath: { eq: info.vpath },
mounted: { eq: info.mounted }
} as Filter<T>);
if (
!Array.isArray(result)
|| result.length <= 0
) {
// It wasn't found in the database. Hence
// we should add it.
return this.handleAdded(name, info);
}
info.stack = undefined;
await this.updateDocInDB(info);
await this.config.hookFileChanged(name, info);
}
protected async updateDocInDB(info) {
await this.#dao.update({
vpath: info.vpath,
mime: info.mime,
mounted: info.mounted,
mountPoint: info.mountPoint,
pathInMounted: info.pathInMounted,
mtimeMs: new Date(info.statsMtime).toISOString(),
fspath: path.join(info.mounted, info.pathInMounted),
renderPath: info.renderPath,
rendersToHTML: info.rendersToHTML,
dirname: path.dirname(info.renderPath),
docMetadata: info.docMetadata,
// docContent: info.docContent,
// docBody: info.docBody,
metadata: info.metadata,
info,
} as T);
}
/**
* We receive this:
*
* {
* fspath: fspath,
* vpath: vpath,
* mime: mime.getType(fspath),
* mounted: dir.mounted,
* mountPoint: dir.mountPoint,
* pathInMounted: computed relative path
* stack: [ array of these instances ]
* }
*
* Need to add:
* renderPath
* And for HTML render files, add the baseMetadata and docMetadata
*
* Should remove the stack, since it's likely not useful to us.
*/
async handleAdded(name, info) {
// console.log(`PROCESS ${name} handleAdded`, info.vpath);
if (this.ignoreFile(info)) {
// console.log(`OOOOOOOOGA!!! Received a file that should be ingored `, info);
return;
}
if (name !== this.name) {
throw new Error(`handleAdded event for wrong name; got ${name}, expected ${this.name}`);
}
this.gatherInfoData(info);
info.stack = undefined;
await this.insertDocToDB(info);
await this.config.hookFileAdded(name, info);
}
protected async insertDocToDB(info) {
await this.#dao.insert({
vpath: info.vpath,
mime: info.mime,
mounted: info.mounted,
mountPoint: info.mountPoint,
pathInMounted: info.pathInMounted,
mtimeMs: new Date(info.statsMtime).toISOString(),
fspath: path.join(info.mounted, info.pathInMounted),
renderPath: info.renderPath,
rendersToHTML: info.rendersToHTML,
dirname: path.dirname(info.renderPath),
docMetadata: info.docMetadata,
// docContent: info.docContent,
// docBody: info.docBody,
metadata: info.metadata,
info,
} as T);
}
async handleUnlinked(name, info) {
// console.log(`PROCESS ${name} handleUnlinked`, info.vpath);
if (name !== this.name) {
throw new Error(`handleUnlinked event for wrong name; got ${name}, expected ${this.name}`);
}
await this.config.hookFileUnlinked(name, info);
await this.#dao.deleteAll({
vpath: { eq: info.vpath },
mounted: { eq: info.mounted }
} as Where<T>);
}
async handleReady(name) {
// console.log(`PROCESS ${name} handleReady`);
if (name !== this.name) {
throw new Error(`handleReady event for wrong name; got ${name}, expected ${this.name}`);
}
this.#is_ready = true;
this.emit('ready', name);
}
/**
* Find the directory mount corresponding to the file.
*
* @param {*} info
* @returns
*/
fileDirMount(info) {
const mapped = remapdirs(this.dirs);
for (const dir of mapped) {
// console.log(`dirMount for ${info.vpath} -- ${util.inspect(info)} === ${util.inspect(dir)}`);
if (info.mountPoint === dir.mountPoint) {
return dir;
}
}
return undefined;
}
/**
* Should this file be ignored, based on the `ignore` field
* in the matching `dir` mount entry.
*
* @param {*} info
* @returns
*/
ignoreFile(info) {
// console.log(`ignoreFile ${info.vpath}`);
const dirMount = this.fileDirMount(info);
// console.log(`ignoreFile ${info.vpath} dirMount ${util.inspect(dirMount)}`);
let ignore = false;
if (dirMount) {
let ignores;
if (typeof dirMount.ignore === 'string') {
ignores = [ dirMount.ignore ];
} else if (Array.isArray(dirMount.ignore)) {
ignores = dirMount.ignore;
} else {
ignores = [];
}
for (const i of ignores) {
if (micromatch.isMatch(info.vpath, i)) ignore = true;
// console.log(`dirMount.ignore ${fspath} ${i} => ${ignore}`);
}
// if (ignore) console.log(`MUST ignore File ${info.vpath}`);
// console.log(`ignoreFile for ${info.vpath} ==> ${ignore}`);
return ignore;
} else {
// no mount? that means something strange
console.error(`No dirMount found for ${info.vpath} / ${info.dirMountedOn}`);
return true;
}
}
/**
* Allow a caller to wait until the <em>ready</em> event has
* been sent from the DirsWatcher instance. This event means the
* initial indexing has happened.
*/
async isReady() {
// If there's no directories, there won't be any files
// to load, and no need to wait
while (this.#dirs.length > 0 && !this.#is_ready) {
// This does a 100ms pause
// That lets us check is_ready every 100ms
// at very little cost
// console.log(`!isReady ${this.name} ${this[_symb_dirs].length} ${this[_symb_is_ready]}`);
await new Promise((resolve, reject) => {
setTimeout(() => {
resolve(undefined);
}, 100);
});
}
return true;
}
async paths(rootPath?: string)
: Promise<Array<PathsReturnType>>
{
const fcache = this;
let rootP = rootPath?.startsWith('/')
? rootPath?.substring(1)
: rootPath;
// This is copied from the older version
// (LokiJS version) of this function. It
// seems meant to eliminate duplicates.
const vpathsSeen = new Set();
const selector = {
order: { mtimeMs: true }
} as any;
if (typeof rootP === 'string'
&& rootP.length >= 1) {
selector.renderPath = {
isLike: `${rootP}%`
// sql: ` renderPath regexp '^${rootP}' `
};
}
// console.log(`paths ${util.inspect(selector)}`);
const result = await this.dao.selectAll(selector);
const result2 = result.filter(item => {
// console.log(`paths ?ignore? ${item.vpath}`);
if (fcache.ignoreFile(item)) {
return false;
}
if (vpathsSeen.has((item as Asset).vpath)) {
return false;
} else {
vpathsSeen.add((item as Asset).vpath);
return true;
}
});
// const result3 = result2.sort((a, b) => {
// // We need these to be one of the concrete
// // types so that the mtimeMs field is
// // recognized by TypeScript. The Asset
// // class is a good substitute for the base
// // class of cached files.
// const aa = <Asset>a;
// const bb = <Asset>b;
// if (aa.mtimeMs < bb.mtimeMs) return 1;
// if (aa.mtimeMs === bb.mtimeMs) return 0;
// if (aa.mtimeMs > bb.mtimeMs) return -1;
// });
// This stage converts the items
// received by this function into
// what is required from
// the paths method.
// const result4
// = new Array<PathsReturnType>();
// for (const item of result3) {
// result4.push(<PathsReturnType>{
// vpath: item.vpath,
// mime: item.mime,
// mounted: item.mounted,
// mountPoint: item.mountPoint,
// pathInMounted: item.pathInMounted,
// mtimeMs: item.mtimeMs,
// info: item.info,
// fspath: path.join(item.mounted, item.pathInMounted),
// renderPath: item.vpath
// });
// }
// console.log(result2/*.map(item => {
// return {
// vpath: item.vpath,
// mtimeMs: item.mtimeMs
// };
// }) */);
return result2;
}
/**
* Find the file within the cache.
*
* @param _fpath The vpath or renderPath to look for
* @returns boolean true if found, false otherwise
*/
async find(_fpath): Promise<T> {
if (typeof _fpath !== 'string') {
throw new Error(`find parameter not string ${typeof _fpath}`);
}
const fpath = _fpath.startsWith('/')
? _fpath.substring(1)
: _fpath;
const fcache = this;
const result1 = await this.dao.selectAll({
or: [
{ vpath: { eq: fpath }},
{ renderPath: { eq: fpath }}
]
} as Filter<T>);
// console.log(`find ${_fpath} ${fpath} ==> result1 ${util.inspect(result1)} `);
const result2 = result1.filter(item => {
return !(fcache.ignoreFile(item));
});
// console.log(`find ${_fpath} ${fpath} ==> result2 ${util.inspect(result2)} `);
let ret;
if (Array.isArray(result2) && result2.length > 0) {
ret = result2[0];
} else if (Array.isArray(result2) && result2.length <= 0) {
ret = undefined;
} else {
ret = result2;
}
return ret;
}
#fExistsInDir(fpath, dir) {
// console.log(`#fExistsInDir ${fpath} ${util.inspect(dir)}`);
if (dir.mountPoint === '/') {
const fspath = path.join(
dir.mounted, fpath
);
let fsexists = FS.existsSync(fspath);
if (fsexists) {
let stats = FS.statSync(fspath);
return <VPathData> {
vpath: fpath,
renderPath: fpath,
fspath: fspath,
mime: undefined,
mounted: dir.mounted,
mountPoint: dir.mountPoint,
pathInMounted: fpath,
statsMtime: stats.mtimeMs
};
} else {
return undefined;
}
}
let mp = dir.mountPoint.startsWith('/')
? dir.mountPoint.substring(1)
: dir.mountPoint;
mp = mp.endsWith('/')
? mp
: (mp+'/');
if (fpath.startsWith(mp)) {
let pathInMounted
= fpath.replace(dir.mountPoint, '');
let fspath = path.join(
dir.mounted, pathInMounted);
// console.log(`Checking exist for ${dir.mountPoint} ${dir.mounted} ${pathInMounted} ${fspath}`);
let fsexists = FS.existsSync(fspath);
if (fsexists) {
let stats = FS.statSync(fspath);
return <VPathData> {
vpath: fpath,
renderPath: fpath,
fspath: fspath,
mime: undefined,
mounted: dir.mounted,
mountPoint: dir.mountPoint,
pathInMounted: pathInMounted,
statsMtime: stats.mtimeMs
};
}
}
return undefined;
}
/**
* Fulfills the "find" operation not by
* looking in the database, but by scanning
* the filesystem using synchronous calls.
*
* @param _fpath
* @returns
*/
findSync(_fpath): VPathData | undefined {
if (typeof _fpath !== 'string') {
throw new Error(`find parameter not string ${typeof _fpath}`);
}
const fpath = _fpath.startsWith('/')
? _fpath.substring(1)
: _fpath;
const fcache = this;
const mapped = remapdirs(this.dirs);
// console.log(`findSync looking for ${fpath} in ${util.inspect(mapped)}`);
for (const dir of mapped) {
if (!(dir?.mountPoint)) {
console.warn(`findSync bad dirs in ${util.inspect(this.dirs)}`);
}
const found = this.#fExistsInDir(fpath, dir);
if (found) {
// console.log(`findSync ${fpath} found`, found);
return found;
}
}
return undefined;
}
async findAll() {
const fcache = this;
const result1 = await this.dao.selectAll({
} as Filter<T>);
const result2 = result1.filter(item => {
// console.log(`findAll ?ignore? ${item.vpath}`);
return !(fcache.ignoreFile(item));
});
return result2;
}
}
export class TemplatesFileCache<
T extends Layout | Partial,
Tdao extends BaseDAO<T>>
extends BaseFileCache<T, Tdao> {
constructor(
config: Configuration,
name: string,
dirs: dirToMount[],
dao: Tdao
) {
super(config, name, dirs, dao);
}
/**
* Gather the additional data suitable
* for Partial and Layout templates. The
* full data set required for Documents is
* not suitable for the templates.
*
* @param info
*/
gatherInfoData(info) {
info.renderPath = info.vpath;
info.dirname = path.dirname(info.vpath);
if (info.dirname === '.') info.dirname = '/';
let renderer = this.config.findRendererPath(info.vpath);
info.renderer = renderer;
if (renderer) {
if (renderer.parseMetadata) {
// Using <any> here covers over
// that parseMetadata requires
// a RenderingContext which
// in turn requires a
// metadata object.
const rc = renderer.parseMetadata(<any>{
fspath: info.fspath,
content: FS.readFileSync(info.fspath, 'utf-8')
});
// docMetadata is the unmodified metadata/frontmatter
// in the document
info.docMetadata = rc.metadata;
// docContent is the unparsed original content
// including any frontmatter
info.docContent = rc.content;
// docBody is the parsed body -- e.g. following the frontmatter
info.docBody = rc.body;
// This is the computed metadata that includes data from
// several sources
info.metadata = { };
if (!info.docMetadata) info.docMetadata = {};
for (let yprop in info.baseMetadata) {
// console.log(`initMetadata ${basedir} ${fpath} baseMetadata ${baseMetadata[yprop]}`);
info.metadata[yprop] = info.baseMetadata[yprop];
}
}
}
// console.log(`TemplatesFileCache after gatherInfoData `, info);
}
protected async updateDocInDB(info) {
await this.dao.update(({
vpath: info.vpath,
mime: info.mime,
mounted: info.mounted,
mountPoint: info.mountPoint,
pathInMounted: info.pathInMounted,
mtimeMs: new Date(info.statsMtime).toISOString(),
fspath: path.join(info.mounted, info.pathInMounted),
renderPath: info.renderPath,
rendersToHTML: info.rendersToHTML,
dirname: path.dirname(info.renderPath),
docMetadata: info.docMetadata,
docContent: info.docContent,
docBody: info.docBody,
metadata: info.metadata,
info,
} as unknown) as T);
}
protected async insertDocToDB(info: any) {
await this.dao.insert(({
vpath: info.vpath,
mime: info.mime,
mounted: info.mounted,
mountPoint: info.mountPoint,
pathInMounted: info.pathInMounted,
mtimeMs: new Date(info.statsMtime).toISOString(),
fspath: path.join(info.mounted, info.pathInMounted),
renderPath: info.renderPath,
rendersToHTML: info.rendersToHTML,
dirname: path.dirname(info.renderPath),
docMetadata: info.docMetadata,
docContent: info.docContent,
docBody: info.docBody,
metadata: info.metadata,
info,
} as unknown) as T);
}
}
export class DocumentsFileCache
extends BaseFileCache<Document, TdocumentssDAO> {
constructor(
config: Configuration,
name: string,
dirs: dirToMount[]
) {
super(config, name, dirs, documentsDAO);
}
gatherInfoData(info) {
info.renderPath = info.vpath;
info.dirname = path.dirname(info.vpath);
if (info.dirname === '.') info.dirname = '/';
info.parentDir = path.dirname(info.dirname);
// find the mounted directory,
// get the baseMetadata
for (let dir of remapdirs(this.dirs)) {
if (dir.mounted === info.mounted) {
if (dir.baseMetadata) {
info.baseMetadata = dir.baseMetadata;
}
break;
}
}
// set publicationDate somehow
let renderer = this.config.findRendererPath(info.vpath);
info.renderer = renderer;
if (renderer) {
info.renderPath
= renderer.filePath(info.vpath);
// This was in the LokiJS code, but
// was not in use.
// info.rendername = path.basename(
// info.renderPath
// );
info.rendersToHTML = micromatch.isMatch(
info.renderPath,
'**/*.html')
? true : false;
if (renderer.parseMetadata) {
// Using <any> here covers over
// that parseMetadata requires
// a RenderingContext which
// in turn requires a
// metadata object.
const rc = renderer.parseMetadata(<any>{
fspath: info.fspath,
content: FS.readFileSync(info.fspath, 'utf-8')
});
// docMetadata is the unmodified metadata/frontmatter
// in the document
info.docMetadata = rc.metadata;
// docContent is the unparsed original content
// including any frontmatter
info.docContent = rc.content;
// docBody is the parsed body -- e.g. following the frontmatter
info.docBody = rc.body;
// This is the computed metadata that includes data from
// several sources
info.metadata = { };
if (!info.docMetadata) info.docMetadata = {};
// The rest of this is adapted from the old function
// HTMLRenderer.newInitMetadata
// For starters the metadata is collected from several sources.
// 1) the metadata specified in the directory mount where
// this document was found
// 2) metadata in the project configuration
// 3) the metadata in the document, as captured in docMetadata
for (let yprop in info.baseMetadata) {
// console.log(`initMetadata ${basedir} ${fpath} baseMetadata ${baseMetadata[yprop]}`);
info.metadata[yprop] = info.baseMetadata[yprop];
}
for (let yprop in this.config.metadata) {
info.metadata[yprop] = this.config.metadata[yprop];
}
let fmmcount = 0;
for (let yprop in info.docMetadata) {
info.metadata[yprop] = info.docMetadata[yprop];
fmmcount++;
}
// The rendered version of the content lands here
info.metadata.content = "";
// The document object has been useful for
// communicating the file path and other data.
info.metadata.document = {};
info.metadata.document.basedir = info.mountPoint;
info.metadata.document.relpath = info.pathInMounted;
info.metadata.document.relrender = renderer.filePath(info.pathInMounted);
info.metadata.document.path = info.vpath;
info.metadata.document.renderTo = info.renderPath;
// Ensure the <em>tags</em> field is an array
if (!(info.metadata.tags)) {
info.metadata.tags = [];
} else if (typeof (info.metadata.tags) === 'string') {
let taglist = [];
const re = /\s*,\s*/;
info.metadata.tags.split(re).forEach(tag => {
taglist.push(tag.trim());
});
info.metadata.tags = taglist;
} else if (!Array.isArray(info.metadata.tags)) {
throw new Error(
`FORMAT ERROR - ${info.vpath} has badly formatted tags `,
info.metadata.tags);
}
info.docMetadata.tags = info.metadata.tags;
// The root URL for the project
info.metadata.root_url = this.config.root_url;
// Compute the URL this document will render to
if (this.config.root_url) {
let uRootUrl = new URL(this.config.root_url, 'http://example.com');
uRootUrl.pathname = path.normalize(
path.join(uRootUrl.pathname, info.metadata.document.renderTo)
);
info.metadata.rendered_url = uRootUrl.toString();
} else {
info.metadata.rendered_url = info.metadata.document.renderTo;
}
// info.metadata.rendered_date = info.stats.mtime;
const parsePublDate = (date) => {
const parsed = Date.parse(date);
if (! isNaN(parsed)) {
info.metadata.publicationDate = new Date(parsed);
info.publicationDate = info.metadata.publicationDate;
info.publicationTime = info.publicationDate.getTime();
}
};
if (info.docMetadata
&& typeof info.docMetadata.publDate === 'string') {
parsePublDate(info.docMetadata.publDate);
}
if (info.docMetadata
&& typeof info.docMetadata.publicationDate === 'string') {
parsePublDate(info.docMetadata.publicationDate);
}
if (!info.metadata.publicationDate) {
var dateSet = false;
if (info.docMetadata
&& info.docMetadata.publDate) {
parsePublDate(info.docMetadata.publDate);
dateSet = true;
}
if (info.docMetadata
&& typeof info.docMetadata.publicationDate === 'string') {
parsePublDate(info.docMetadata.publicationDate);
dateSet = true;
}
if (! dateSet && info.mtimeMs) {
info.metadata.publicationDate = new Date(info.mtimeMs);
info.publicationDate = info.metadata.publicationDate;
info.publicationTime = info.publicationDate.getTime();
// console.log(`${info.vpath} metadata.publicationDate ${info.metadata.publicationDate} set from stats.mtime`);
}
if (!info.metadata.publicationDate) {
info.metadata.publicationDate = new Date();
info.publicationDate = info.metadata.publicationDate;
info.publicationTime = info.publicationDate.getTime();
// console.log(`${info.vpath} metadata.publicationDate ${info.metadata.publicationDate} set from current time`);
}
}
}
}
}
protected async deleteDocTagGlue(vpath) {
try {
await tagGlueDAO.deleteAll({
docvpath: vpath
} as Where<TagGlue>);
} catch (err) {
// ignore
// This can throw an error like:
// documentsCache ERROR {
// code: 'changed',
// name: 'documents',
// vpath: '_mermaid/render3356739382.mermaid',
// error: Error: delete from 'TAGGLUE' failed: nothing changed
// ... stack trace
// }
// In such a case there is no tagGlue for the document.
// This "error" is spurious.
//
// TODO Is there another query to run that will
// not throw an error if nothing was changed?
// In other words, this could hide a legitimate
// error.
}
}
protected async addDocTagGlue(vpath, tags) {
for (const tag of tags) {
const glue = await tagGlueDAO.insert({
docvpath: vpath,
tagName: tag
});
// console.log('addDocTagGlue', glue);
}
}
protected async updateDocInDB(info) {
const docInfo = <Document>{
vpath: info.vpath,
mime: info.mime,
mounted: info.mounted,
mountPoint: info.mountPoint,
pathInMounted: info.pathInMounted,
mtimeMs: new Date(info.statsMtime).toISOString(),
fspath: path.join(info.mounted, info.pathInMounted),
renderPath: info.renderPath,
rendersToHTML: info.rendersToHTML,
dirname: path.dirname(info.renderPath),
parentDir: info.parentDir,
docMetadata: info.docMetadata,
docContent: info.docContent,
docBody: info.docBody,
metadata: info.metadata,
tags: Array.isArray(info.metadata?.tags)
? info.metadata.tags
: [],
layout: info.metadata?.layout,
blogtag: typeof info.metadata?.blogtag === 'string'
? info.metadata?.blogtag
: undefined,
info,
};
await this.dao.update(docInfo);
await this.deleteDocTagGlue(docInfo.vpath);
await this.addDocTagGlue(
docInfo.vpath, docInfo.tags
);
}
protected async insertDocToDB(info: any) {
const docInfo = <Document>{
vpath: info.vpath,
mime: info.mime,
mounted: info.mounted,
mountPoint: info.mountPoint,
pathInMounted: info.pathInMounted,
mtimeMs: new Date(info.statsMtime).toISOString(),
fspath: path.join(info.mounted, info.pathInMounted),
renderPath: info.renderPath,
rendersToHTML: info.rendersToHTML,
dirname: path.dirname(info.renderPath),
parentDir: info.parentDir,
docMetadata: info.docMetadata,
docContent: info.docContent,
docBody: info.docBody,
metadata: info.metadata,
tags: Array.isArray(info.metadata?.tags)
? info.metadata.tags
: [],
layout: info.metadata?.layout,
blogtag: typeof info.metadata?.blogtag === 'string'
? info.metadata?.blogtag
: undefined,
info,
};
await this.dao.insert(docInfo);
await this.addDocTagGlue(
docInfo.vpath, docInfo.tags
);
}
async handleUnlinked(name: any, info: any): Promise<void> {
await super.handleUnlinked(name, info);
await this.deleteDocTagGlue(info.vpath);
}
async indexChain(_fpath) {
const fpath = _fpath.startsWith('/')
? _fpath.substring(1)
: _fpath;
const parsed = path.parse(fpath);
const filez: Document[] = [];
const self = await this.dao.selectAll({
'or': [
{ vpath: { eq: fpath } },
{ renderPath: { eq: fpath } }
]
});
let fileName = fpath;
if (Array.isArray(self) && self.length >= 1) {
filez.push(self[0]);
fileName = self[0].renderPath;
}
let parentDir;
let dirName = path.dirname(fpath);
let done = false;
while (!(dirName === '.' || dirName === parsed.root)) {
if (path.basename(fileName) === 'index.html') {
parentDir = path.dirname(path.dirname(fileName));
} else {
parentDir = path.dirname(fileName);
}
let lookFor = path.join(parentDir, "index.html");
const index = await this.dao.selectAll({
'or': [
{ vpath: { eq: lookFor } },
{ renderPath: { eq: lookFor } }
]
}