import * as parser from './wikiparser.js';
import { readFileSync } from 'fs';
import glob from 'glob';
import { logger } from './logger.js'
/**
* Build a page.
*
* @param path standard name for page
* @returns newly built page, or undefined
*/
export function buildPage(directory: PageDirectory, page: Page) {
const result = parser.parse(directory, page.raw);
const title = result.metadata.displayTitle ?? page.standardName;
const content = `${result.metadata.notitle ? '' : `
${title}
`}${result.html}`;
page.html = content;
page.buildTime = Date.now();
page.metadata.includeInNavbar = result.metadata.primary ?? false;
page.metadata.sortOrder = result.metadata.sortOrder ?? -1;
page.metadata.showTitle = !result.metadata.notitle ?? true;
page.metadata.displayTitle = title;
}
/**
* Convert a page name to a standard name.
* A standard name is the key used by the page directory.
*
* @param name non-standard name for a page
*/
export function convertNameToStandard(name: string): string {
name = name.replace(/[^a-z0-9:]/gi, '_').toLowerCase();
if (!name.includes(':')) {
name = `main:${name}`;
}
return name;
}
/**
* Convert a standard name to a file path.
*
* @param name standard name for a page
*/
export function convertStandardToFilePath(name: string): string {
const [first, second] = name.replace('main:', '').split(':');
const [title, subpage] = ((second) ? second : first).split('.')
const namespace = (second) ? first : undefined
return `${namespace ? `${namespace}/` : ''}${title}${subpage ? `.${subpage}` : ''}.wiki`
}
export class PageDirectory {
pages: Record;
primaryPages: Page[];
pagePath: string;
lastBuild: number;
constructor(root: string) {
this.lastBuild = 0;
this.pages = {};
this.pagePath = root;
this.rebuild();
}
/**
* Build this page directory.
*
* @returns whether the directory was built
*/
rebuild(): boolean {
if (this.lastBuild + parseInt(process.env.REBUILD_COOLDOWN_MIN, 10) * 60 * 1000 > Date.now()) {
return false;
}
for (const page in this.pages) {
delete this.pages[page];
}
const pages = glob.sync(`**/*.wiki`, { cwd: this.pagePath })
// Load page content
pages.forEach(page => {
page = convertNameToStandard(page.replace('.wiki', '').replace('/', ':'));
this.pages[page] = {
standardName: page,
raw: this.loadRaw(page),
buildTime: 0,
metadata: {
dependencies: new Set(),
dependents: new Set(),
errors: []
}
}
});
const dependencyGraph: Record = {};
Object.keys(this.pages).forEach(name => dependencyGraph[name] = Array.from(parser.findDependencies(this.pages[name].raw)).map(e => convertNameToStandard(e)));
// Revursive dependency graph traversal function
function traverseGraph(dependents: string[], current: string, dependencies: string[], recursionCount: number, pages: Record) {
if (recursionCount > parseInt(process.env.PARSER_MAX_RECURSION, 10)) {
throw new RecursionError('max recursion reached');
}
dependencies?.forEach(e => {
pages[current]?.metadata.dependencies.add(e)
if (e !== current) {
pages[e]?.metadata.dependents.add(current);
}
});
dependencies?.forEach((dependency: string) => {
if (dependencyGraph[dependency]?.length != 0) {
dependents.forEach((dependent: string) => {
if (dependencyGraph[dependency]?.includes(dependent)) {
throw new DependencyError(`circular dependency between ${dependent} and ${dependency}`, [dependent, dependency]);
}
});
traverseGraph([...dependents, dependency], dependency, dependencyGraph[dependency], recursionCount + 1, pages);
}
});
}
// Catch circular dependencies and build dependency tree
Object.keys(dependencyGraph).forEach(name => {
try {
traverseGraph([name], name, dependencyGraph[name], 1, this.pages);
} catch (e) {
if (e instanceof RecursionError) {
this.pages[name].metadata.errors.push({
identifier: 'max-recursion-reached',
message: `maximum dependency depth of ${process.env.PARSER_MAX_RECURSION} reached`
})
logger.warn(`max recursion for ${name} reached`)
} else if (e instanceof DependencyError) {
if (e.pages.includes(name)) {
this.pages[name].metadata.errors.push({
identifier: 'circular-dependency',
message: e.message
})
logger.warn(`${e.pages[0]} has a circular dependency with ${e.pages[1]}`)
} else {
logger.warn(`transclusions on page ${name} may not resolve due to dependency errors in its dependency tree`)
}
} else {
throw e;
}
}
});
function recursiveBulld(pages: Record, current: Page, directory: PageDirectory, buildPage: (directory: PageDirectory, page: Page) => void) {
if (current.metadata.errors.length == 0) {
current.metadata.dependencies.forEach(dependency => {
if (pages[dependency].buildTime == 0) {
recursiveBulld(pages, pages[dependency], directory, buildPage);
}
});
buildPage(directory, current)
}
}
// Build pages in order
const primaryPages = [];
Object.keys(this.pages).forEach(name => {
recursiveBulld(this.pages, this.pages[name], this, buildPage);
if (this.pages[name].metadata.includeInNavbar) {
primaryPages.push(this.pages[name]);
}
});
// Sort primary pages
primaryPages.sort((a, b) => {
return a.metadata.sortOrder - b.metadata.sortOrder;
});
this.primaryPages = primaryPages;
this.lastBuild = Date.now();
return true;
}
/**
* Get whether a page exists with this name.
*
* @param name standard name for page
* @returns whether the page exists
*/
exists(name: string): boolean {
return !!this.pages[convertNameToStandard(name)];
}
/**
* Get a page.
*
* @param name standard name for page
* @returns page
*/
get(name: string): Page {
name = convertNameToStandard(name);
const page = this.pages[name];
if (!page) {
return undefined;
}
return page;
}
/**
* Get the raw wikitext for a page.
*
* @param name standard name for page
* @returns raw wikitext
*/
getRaw(name: string): string {
name = convertNameToStandard(name);
return this.pages[name]?.raw;
}
/**
* Purge (rebuild) a page.
*
* @param name standard name for page
* @returns whether the page was rebuilt
*/
purge(name: string): boolean {
name = convertNameToStandard(name);
const page = this.pages[name];
if (page) {
if (page.buildTime + parseInt(process.env.PURGE_COOLDOWN_MIN, 10) * 60 * 1000 > Date.now()) {
return false;
} else {
// delete this.pages[name];
// if (this.buildPage(name)) {
// return true;
// }
}
}
return false;
}
/**
* Get all pages.
*
* @returns all pages
*/
getPages(): Record {
return this.pages;
}
/**
* Get primary pages.
*
* @param current
* @returns
*/
getPrimaryPages(): Page[] {
return this.primaryPages;
}
private loadRaw(name: string): string {
name = convertNameToStandard(name);
let data: string;
try {
data = readFileSync(`${this.pagePath}/${convertStandardToFilePath(name)}`, 'utf-8');
} catch {
return undefined;
}
return data;
}
}
export type Page = {
html?: string;
raw?: string;
standardName: string;
buildTime: number;
metadata: PageMetadata;
};
export type PageMetadata = {
displayTitle?: string;
sortOrder?: number;
showTitle?: boolean;
includeInNavbar?: boolean;
dependencies: Set;
dependents: Set;
errors: PageError[];
};
export type PageError = {
identifier: string;
message: string;
}
export class DependencyError extends Error {
pages: string[]
constructor(message: string, pages: string[]) {
super(message);
this.pages = pages;
Object.setPrototypeOf(this, DependencyError.prototype);
}
}
export class RecursionError extends Error {
constructor(message: string) {
super(message);
Object.setPrototypeOf(this, RecursionError.prototype);
}
}