Source: router.js

/**
 * @module router
 */

import fse from "fs-extra";

import * as http from "node:http";
import {Site, File} from "./types.js";
import {
  isBinaryPath,
  default404,
  matchFiles,
  getPathFn,
  getContentType,
  putSite,
  delSite,
  getFullSrcPath,
  getFullDocPath,
  parseFrontMatter
} from "./utils.js";

/**
 * @description Core module that handling file routing.
 */
class Router {
  /**
   * @param {Logger} logger
   * @param {Renderer} renderer
   * @param {Processor} processor
   * @param {Generator} generator
   * @param {Decorator} decorator
   * @param {Helper} helper
   * @param {Translator} translator
   * @param {Site} site
   * @param {Watcher} [watcher]
   * @return {Router}
   */
  constructor(
    logger,
    renderer,
    processor,
    generator,
    decorator,
    helper,
    translator,
    site,
    watcher = null
  ) {
    this.logger = logger;
    this.renderer = renderer;
    this.processor = processor;
    this.generator = generator;
    this.decorator = decorator;
    this.helper = helper;
    this.translator = translator;
    this.site = site;
    this._ = new Map();
    this.server = null;
    this.ip = null;
    this.port = null;
    this.listening = false;
    this.watcher = watcher;
    this.queuedFlush = false;
    this.getPath = getPathFn(this.site["siteConfig"]["rootDir"]);
  }

  /**
   * @private
   * @description Get decorated content with context.
   * @param {File} file
   * @return {Promise<String>}
   */
  async getDecoratedContent(file) {
    // Running helper and decorator for file without layout is meaningless,
    // because layout is used for decorator to select template.
    if (!file["binary"] && file["layout"] != null) {
      return this.decorator.decorate(
        file, await this.helper.getContext(this.site, file)
      );
    }
    return file["content"];
  }

  /**
   * @private
   * @description Read file content.
   * @param {String} filepath
   * @return {Promise<Buffer>}
   */
  read(filepath) {
    return fse.readFile(filepath);
  }

  /**
   * @private
   * @description Write or copy file to docDir.
   * @param {File} file
   * @param {(Buffer|String)} content This is for decorated content, if the file
   * is not decorated, leave this empty and file's content property is used.
   */
  write(file, content) {
    return file["binary"]
      ? fse.copy(getFullSrcPath(file), getFullDocPath(file))
      : fse.outputFile(getFullDocPath(file), content || file["content"]);
  }

  /**
   * @private
   * @description Load files into site data via parsing front-matter.
   * @param {File} file
   */
  async loadFile(file) {
    this.logger.debug(`Hikaru is reading \`${
      this.logger.cyan(getFullSrcPath(file))
    }\`...`);
    // Binary files are not supposed to be handled by SSGs. We can copy or pipe
    // it to save memory.
    file["binary"] = isBinaryPath(getFullSrcPath(file));
    if (!file["binary"]) {
      file["raw"] = await this.read(getFullSrcPath(file));
      file["text"] = file["raw"].toString("utf8");
      file = parseFrontMatter(file);
    }
    const results = await this.renderer.render(file);
    for (const result of results) {
      if (result["layout"] === "post") {
        putSite(this.site, "posts", result);
      } else if (result["layout"] != null) {
        putSite(this.site, "pages", result);
      } else {
        putSite(this.site, "assets", result);
      }
    }
  }

  /**
   * @private
   * @description Save file via layout.
   * @param {File} file
   */
  async saveFile(file) {
    const content = await this.getDecoratedContent(file);
    this.logger.debug(`Hikaru is writing \`${
      this.logger.cyan(getFullDocPath(file))
    }\`...`);
    this.write(file, content);
  }

  /**
   * @private
   * @description Match all src files.
   * @return {Promise<File[]>}
   */
  async matchAll() {
    // Globs must not contain windows spearators.
    return (await matchFiles("**/*", {
      "ignoreHidden": false,
      "workDir": this.site["siteConfig"]["themeSrcDir"]
    })).map((srcPath) => {
      return new File({
        "docDir": this.site["siteConfig"]["docDir"],
        "srcDir": this.site["siteConfig"]["themeSrcDir"],
        "srcPath": srcPath
      });
    }).concat((await matchFiles("**/*", {
      "ignoreHidden": false,
      "workDir": this.site["siteConfig"]["srcDir"]
    })).map((srcPath) => {
      return new File({
        "docDir": this.site["siteConfig"]["docDir"],
        "srcDir": this.site["siteConfig"]["srcDir"],
        "srcPath": srcPath
      });
    }));
  }

  /**
   * @private
   * @description Build routes for all built files to serve.
   * @param {File[]} allFiles All built files.
   */
  buildServerRoutes(allFiles) {
    this._.clear();
    for (const f of allFiles) {
      const key = this.getPath(f["docPath"]);
      this.logger.debug(`Hikaru is serving \`${this.logger.cyan(key)}\`...`);
      this._.set(key, f);
    }
  }

  /**
   * @private
   * @description Queue operations and handle only once.
   */
  flush() {
    if (!this.queuedFlush) {
      this.queuedFlush = true;
      setImmediate(async () => {
        this.queuedFlush = false;
        await this.handle();
        this.buildServerRoutes(
          this.site["assets"]
            .concat(this.site["posts"])
            .concat(this.site["pages"])
            .concat(this.site["files"])
        );
      });
    }
  }

  /**
   * @private
   * @description Watch all src files.
   */
  watchAll() {
    if (this.watcher == null) {
      return;
    }
    this.watcher.register(
      [
        this.site["siteConfig"]["themeSrcDir"],
        this.site["siteConfig"]["srcDir"]
      ],
      async (srcDir, srcPaths) => {
        const {added, changed, removed} = srcPaths;
        for (const srcPath of removed) {
          const file = new File({
            "docDir": this.site["siteConfig"]["docDir"],
            "srcDir": srcDir,
            "srcPath": srcPath
          });
          for (const key of Site.arrayKeys) {
            delSite(this.site, key, file);
          }
        }
        await Promise.all(added.concat(changed).map((srcPath) => {
          const newFile = new File({
            "docDir": this.site["siteConfig"]["docDir"],
            "srcDir": srcDir,
            "srcPath": srcPath
          });
          return this.loadFile(newFile);
        }));
        this.flush();
      }
    );
  }

  /**
   * @private
   * @description Unwatch all src files.
   */
  unwatchAll() {
    if (this.watcher == null) {
      return;
    }
    this.watcher.unregister([
      this.site["siteConfig"]["themeSrcDir"],
      this.site["siteConfig"]["srcDir"]
    ]);
  }

  /**
   * @private
   * @description Start a listening server.
   * @param {String} ip
   * @param {Number} port
   */
  listen(ip, port) {
    this.ip = ip;
    this.port = port;
    this.server = http.createServer(async (request, response) => {
      // Remove query string.
      const pathname = request["url"].split(/[?#]/)[0];
      const code = this._.has(pathname) ? 200 : 404;
      // Use custom 404 file if available.
      const real404File = this._.get(this.getPath("404.html")) || new File({
        "docDir": "docs",
        "docPath": this.getPath("404.html"),
        "content": default404
      });
      const file = this._.get(pathname) || real404File;
      this.logger.log(`${
        code === 200 ? this.logger.blue(code) : this.logger.yellow(code)
      } ${this.logger.cyan(pathname)}`);
      response.writeHead(code, {
        "Content-Type": getContentType(file["docPath"])
      });
      if (!file["binary"]) {
        const content = await this.getDecoratedContent(file);
        this.logger.debug(
          `Hikaru is sending \`${this.logger.cyan(getFullDocPath(file))}\`...`
        );
        response.write(content);
        response.end();
      } else {
        // Pipe a binary instead of send.
        this.logger.debug(
          `Hikaru is piping \`${this.logger.cyan(getFullDocPath(file))}\`...`
        );
        fse.createReadStream(getFullSrcPath(file)).pipe(response);
      }
    });
    this.logger.log(`Hikaru is starting to listen on \`${
      this.logger.cyan(`http://${this.ip}:${this.port}${this.getPath()}`)
    }\`...`);
    if (!this.listening) {
      if (this.ip !== "localhost") {
        this.server.listen(this.port, this.ip);
      } else {
        this.server.listen(this.port);
      }
      this.listening = true;
      this.watchAll();
    }
  }

  /**
   * @private
   * @description Close the listening server.
   */
  close() {
    if (this.listening) {
      this.server.close();
      this.listening = false;
      this.unwatchAll();
      this.logger.log(`Hikaru is stopping to listen on \`${
        this.logger.cyan(`http://${this.ip}:${this.port}${this.getPath()}`)
      }\`...`);
      this.server = null;
    }
  }

  /**
   * @private
   * @description Handle all processor and generator.
   */
  async handle() {
    await this.processor.process(this.site);
    this.site["files"] = await this.generator.generate(this.site);
  }

  /**
   * @description Build all site docs.
   */
  async build() {
    await Promise.all((await this.matchAll()).map(this.loadFile.bind(this)));
    await this.handle();
    this.site["assets"]
      .concat(this.site["posts"])
      .concat(this.site["pages"])
      .concat(this.site["files"])
      .map(this.saveFile.bind(this));
  }

  /**
   * @description Serve all site docs.
   */
  async serve(ip, port) {
    await Promise.all((await this.matchAll()).map(this.loadFile.bind(this)));
    await this.handle();
    this.buildServerRoutes(
      this.site["assets"]
        .concat(this.site["posts"])
        .concat(this.site["pages"])
        .concat(this.site["files"])
    );
    this.listen(ip, port);
  }
}

export default Router;