@qooxdoo/framework
Version:
The JS Framework for Coders
422 lines (370 loc) • 11.3 kB
JavaScript
/* ************************************************************************
qooxdoo - the new era of web development
http://qooxdoo.org
Copyright:
2011-2019 The authors
License:
MIT: https://opensource.org/licenses/MIT
See the LICENSE file in the project's top-level directory for details.
Authors:
* John Spackman (john.spackman@zenesis.com, @johnspackman)
* Christian Boulanger (info@bibliograph.org, @cboulanger)
************************************************************************ */
const fs = qx.tool.utils.Promisify.fs;
const process = require("process");
const path = require("upath");
const rimraf = require("rimraf");
const dot = require("dot");
require("jstransformer-dot");
const metalsmith = require("metalsmith");
const layouts = require("@metalsmith/layouts");
const markdown = require("@metalsmith/markdown");
//const filenames = require("metalsmith-filenames");
//var permalinks = require("metalsmith-permalinks");
/**
* @external(qx/tool/loadsass.js)
* @ignore(loadSass)
*/
/* global loadSass */
const sass = loadSass();
const chokidar = require("chokidar");
// config
dot.templateSettings.strip = false;
qx.Class.define("qx.tool.utils.Website", {
extend: qx.core.Object,
statics: {
APP_NAMESPACE: "apps"
},
construct(options = {}) {
qx.core.Object.apply(this, arguments);
const self = qx.tool.utils.Website;
let p = qx.util.ResourceManager.getInstance().toUri(
"qx/tool/website/.gitignore"
);
p = path.dirname(p);
this.initSourceDir(p);
this.initTargetDir(path.join(p, "build"));
this.initAppsNamespace(self.APP_NAMESPACE);
for (let key of Object.getOwnPropertyNames(options)) {
this.set(key, options[key]);
}
},
properties: {
appsNamespace: {
check: "String",
deferredInit: true
},
sourceDir: {
check: "String",
deferredInit: true
},
targetDir: {
check: "String",
deferredInit: true
}
},
members: {
/** @type {chokidar} watcher */
__watcher: null,
/** @type {Boolean} whether the watcher is ready yet */
__watcherReady: false,
/** @type {Integer} setTimeout timer ID for debouncing builds */
__rebuildTimer: null,
/** @type {Boolean} Whether the build is currently taking place */
__rebuilding: false,
/** @type {Boolean} Whether a rebuild is needed ASAP */
__needsRebuild: false,
/**
* Starts the watcher for files in the source directory and compiles as needed
*/
async startWatcher() {
await this.stopWatcher();
let sourceDir = await qx.tool.utils.files.Utils.correctCase(
this.getSourceDir()
);
this._watcher = chokidar.watch([sourceDir], {});
this._watcher.on("change", filename =>
this.__onFileChange("change", filename)
);
this._watcher.on("add", filename => this.__onFileChange("add", filename));
this._watcher.on("unlink", filename =>
this.__onFileChange("unlink", filename)
);
this._watcher.on("ready", async () => {
await this.triggerRebuild(true);
this.__watcherReady = true;
});
this._watcher.on("error", err => {
qx.tool.compiler.Console.print(
err.code == "ENOSPC"
? "qx.tool.cli.watch.enospcError"
: "qx.tool.cli.watch.watchError",
err
);
});
},
/**
* Stops the watcher, if its running
*/
async stopWatcher() {
if (this._watcher) {
await this._watcher.stop();
this._watcher = null;
this.__watcherReady = false;
}
},
/**
* Whether the watcher is running
*
* @return {Boolean} true if its running
*/
isWatching() {
return Boolean(this._watcher);
},
/**
* Waits for the rebuild process to complete, if it is running
*/
async waitForRebuildComplete() {
if (this.__rebuildPromise) {
await this.__rebuildPromise;
}
},
/**
* Rebuilds everything needed for the website
*/
async rebuildAll() {
await this.generateSite();
await this.compileScss();
},
/**
* Event handler for changes to the source files
*
* @param type {String} type of change, one of "change", "add", "unlink"
* @param filename {String} the file that changed
*/
__onFileChange(type, filename) {
if (this.__watcherReady) {
if (
!filename.toLowerCase().startsWith(this.getTargetDir().toLowerCase())
) {
this.triggerRebuild(false);
}
}
},
/**
* Triggers a rebuild of the website, asynchronously. Unless immediate is true,
* the rebuild will only happen after a short delay; but each time this is called,
* the delay is restarted. This is to allow multiple files to be changed without
* swamping the processor with compilations.
*
* @param immediate {Boolean?} if true, rebuild starts ASAP
*/
triggerRebuild(immediate) {
if (this.__rebuilding) {
this.__needsRebuild = true;
return;
}
let rebuilderImpl = async () => {
await this.rebuildAll();
if (this.__needsRebuild) {
this.__needsRebuild = false;
await rebuilderImpl();
}
};
let rebuilder = async () => {
this.__rebuilding = true;
try {
this.__rebuildPromise = rebuilderImpl();
await this.__rebuildPromise;
this.__rebuildPromise = null;
} finally {
this.__rebuilding = false;
}
};
if (this.__rebuildTimer) {
clearTimeout(this.__rebuildTimer);
this.__rebuildTimer = null;
}
this.__rebuildTimer = setTimeout(rebuilder, immediate ? 1 : 250);
},
/**
* Metalsmith Plugin that collates a list of pages that are to be included in the site navigation
* into the metadata, along with their URLs.
*
* If the metadata has a `sites.pages`, then it is expected to be an array of URLs which indicates
* the ordering to be applied; `sites.pages` is replaced with an array of objects, one per page,
* that contains `url` and `title` properties.
*
*/
async getPages(files, metalsmith) {
var metadata = metalsmith.metadata();
var pages = [];
var order = {};
if (metadata.site.pages) {
metadata.site.pages.forEach((url, index) =>
typeof url == "string" ? (order[url] = index) : null
);
}
var unorderedPages = [];
function addPage(url, title) {
var page = {
url: url,
title: title
};
var index = order[url];
if (index !== undefined) {
pages[index] = page;
} else {
unorderedPages.push(page);
}
}
for (let filename of Object.getOwnPropertyNames(files)) {
let file = files[filename];
if (filename === "index.html") {
addPage("/", file.title || "Home Page");
} else if (file.permalink || file.navigation) {
addPage(file.permalink || filename, file.title || "Home Page");
}
}
unorderedPages.forEach(page => pages.push(page));
metadata.site.pages = pages;
},
/**
* Metalsmith plugin that loads partials and adding them to the metadata.partials map. Each file
* is added with its filename, and if it is a .html filename is also added without the .html
* extension.
*
*/
async loadPartials(files, metalsmith) {
const metadata = metalsmith.metadata();
const partialsDir = path.join(this.getSourceDir(), "partials");
files = await fs.readdirAsync(partialsDir, "utf8");
for (let filename of files) {
let m = filename.match(/^(.+)\.([^.]+)$/);
if (!m) {
continue;
}
let [unused, name, ext] = m;
if (unused) {
// this is simply to avoid linting errors until https://github.com/qooxdoo/qooxdoo/issues/461 is fixed
}
let data = await fs.readFileAsync(
path.join(partialsDir, filename),
"utf8"
);
let fn;
try {
fn = dot.template(data);
} catch (err) {
qx.tool.compiler.Console.log(
"Failed to load partial " + filename + ": " + err
);
continue;
}
fn.name = filename;
metadata.partials[filename] = fn;
if (ext === "html") {
metadata.partials[name] = fn;
}
}
},
/**
* Generates the site with Metalsmith
* @returns {Promise}
*/
async generateSite() {
await new Promise((resolve, reject) => {
metalsmith(this.getSourceDir())
.metadata({
site: {
title: "Qooxdoo Application Server",
description: 'Mini website used by "qx serve"',
email: "info@qooxdoo.org",
twitter_username: "qooxdoo",
github_username: "qooxdoo",
pages: ["/", "/about/"]
},
baseurl: "",
url: "",
lang: "en",
partials: {}
})
.source(path.join(this.getSourceDir(), "src"))
.destination(this.getTargetDir())
.clean(true)
.use(this.loadPartials.bind(this))
.use(markdown())
.use(this.getPages.bind(this))
.use(
layouts({
engine: "dot"
})
)
.build(function (err) {
if (err) {
reject(err);
} else {
resolve();
}
});
});
},
/**
* Compiles SCSS into CSS
*
* @returns {Promise}
*/
async compileScss() {
let result = await new Promise((resolve, reject) => {
sass.render(
{
file: path.join(this.getSourceDir(), "sass", "qooxdoo.scss"),
outFile: path.join(this.getTargetDir(), "qooxdoo.css")
},
function (err, result) {
if (err) {
reject(err);
} else {
resolve(result);
}
}
);
});
await fs.writeFileAsync(
path.join(this.getTargetDir(), "qooxdoo.css"),
result.css,
"utf8"
);
},
/**
* Build the development tool apps (APIViewer, Playground, Widgetbrowser, Demobrowser)
* @return {Promise<void>}
*/
async buildDevtools() {
const namespace = this.getAppsNamespace();
process.chdir(this.getTargetDir());
let apps_path = path.join(this.getTargetDir(), namespace);
if (await fs.existsAsync(apps_path)) {
rimraf.sync(apps_path);
}
const opts = {
noninteractive: true,
namespace,
theme: "indigo",
icontheme: "Tango"
};
await new qx.tool.cli.commands.Create(opts).process();
process.chdir(apps_path);
for (let name of [
"apiviewer",
"widgetbrowser",
"playground",
"demobrowser"
]) {
await new qx.tool.cli.commands.package.Install({}).install(
"qooxdoo/qxl." + name
);
}
}
}
});