patternlab-node
Version:
Pattern Lab is a collection of tools to help you create atomic design systems. This is the node command line interface (CLI).
420 lines (376 loc) • 13.3 kB
JavaScript
"use strict";
const graphlib = require('graphlib');
const Graph = graphlib.Graph;
const path = require('path');
const fs = require("fs-extra");
const Pattern = require('./object_factory').Pattern;
const CompileState = require('./object_factory').CompileState;
const PatternGraphDot = require('./pattern_graph_dot');
const PatternRegistry = require('./pattern_registry');
/**
* The most recent version of the pattern graph. This is used to rebuild the graph when
* the version of a serialized graph does not match the current version.
* @type {number}
*/
const PATTERN_GRAPH_VERSION = 1;
/**
* Wrapper around a graph library to build a dependency graph of patterns.
* Each node in the graph will maintain a {@link CompileState}. This allows finding all
* changed patterns and their transitive dependencies.
*
* Internally the graph maintains a {@link PatternRegistry} to allow fast lookups of the patterns.
*
* @constructor Constructs a new PatternGraph from a JSON-style JavaScript object or an empty graph
* if no argument is given.
*
* @param {Graph} graph The graphlib graph object
* @param {int} timestamp The unix timestamp
* @param {int} version The graph version.
*
* @returns {{PatternGraph: PatternGraph}}
* @see PatternGraph#fromJson
* @see <a href="https://github.com/pattern-lab/patternlab-node/issues/540">#540</a>
*/
const PatternGraph = function (graph, timestamp, version) {
this.graph = graph || new Graph({
directed: true
});
this.graph.setDefaultEdgeLabel({});
// Allows faster lookups for patterns by name for each element in the graph
// The idea here is to make a pattern known to the graph as soon as it exists
this.patterns = new PatternRegistry();
this.timestamp = timestamp || new Date().getTime();
this.version = version || PATTERN_GRAPH_VERSION;
};
// shorthand. Use relPath as it is always unique, even with subPatternType
var nodeName =
p => p instanceof Pattern ? p.relPath : p;
PatternGraph.prototype = {
/**
* Synchronizes the graph nodes with the set of all known patterns.
* For instance when a pattern is deleted or moved, it might still have a node from the serialized
* JSON, but there is no source pattern.
*
* @see {@link https://github.com/pattern-lab/patternlab-node/issues/580|Issue #580}
*/
sync: function () {
// Remove any patterns that are in the graph data, but that haven't been discovered when
// walking all patterns iteratively
const nodesToRemove = this.nodes().filter(n => !this.patterns.has(n));
nodesToRemove.forEach(n => this.remove(n));
return nodesToRemove;
},
/**
* Creates an independent copy of the graph where nodes and edges can be modified without
* affecting the source.
*/
clone: function () {
const json = graphlib.json.write(this.graph);
const graph = graphlib.json.read(json);
return new PatternGraph(graph, this.timestamp, this.version);
},
/**
* Add a pattern to the graph and copy its {@link Pattern.compileState} to the node's data.
* If the pattern is already known, nothing is done.
*
* @param {Pattern} pattern
*/
add: function (pattern) {
const n = nodeName(pattern);
if (!this.patterns.has(n)) {
this.graph.setNode(n, {
compileState: pattern.compileState
});
this.patterns.put(pattern);
}
},
remove: function (pattern) {
const n = nodeName(pattern);
this.graph.removeNode(n);
this.patterns.remove(n);
},
/**
* Removes nodes from this graph for which the given predicate function returns false.
* @param {function} fn which takes a node name as argument
*/
filter: function (fn) {
this.graph.nodes().forEach(n => {
if (!fn(n)) {
this.remove(n);
}
});
},
/**
* Creates a directed edge in the graph which indicates pattern inclusion.
* Patterns must be {@link PatternGraph.add added} before using this method.
*
* @param {Pattern} patternFrom The pattern (subject) which includes the other pattern
* @param {Pattern} patternTo The pattern (object) that is included by the subject.
*
* @throws {Error} If the pattern is unknown
*/
link: function (patternFrom, patternTo) {
let nameFrom = nodeName(patternFrom);
let nameTo = nodeName(patternTo);
for (let name of [nameFrom, nameTo]) {
if (!this.patterns.has(name)) {
throw new Error("Pattern not known: " + name);
}
}
this.graph.setEdge(nameFrom, nameTo);
},
/**
* Determines if there is one pattern is included by another.
* @param {Pattern} patternFrom
* @param {Pattern} patternTo
*
* @return {boolean}
*/
hasLink: function (patternFrom, patternTo) {
let nameFrom = nodeName(patternFrom);
let nameTo = nodeName(patternTo);
return this.graph.hasEdge(nameFrom, nameTo);
},
/**
* Determines the order in which all changed patterns and there transitive predecessors must
* be rebuild.
*
* This first finds all patterns that must be rebuilt, second marks any patterns that transitively
* include these patterns for rebuilding and finally applies topological sorting to the graph.
*
* @return {Array} An Array of {@link Pattern}s in the order by which the changed patters must be
* compiled.
*/
compileOrder: function () {
const compileStateFilter = function (patterns, n) {
const node = patterns.get(n);
return node.compileState !== CompileState.CLEAN;
};
/**
* This graph only contains those nodes that need recompilation
* Edges are added in reverse order for topological sorting(e.g. atom -> molecule -> organism,
* where "->" means "included by").
*/
let compileGraph = new Graph({
directed: true
});
let nodes = this.graph.nodes();
let changedNodes = nodes.filter(n => compileStateFilter(this.patterns, n));
this.nodes2patterns(changedNodes).forEach(pattern => {
let patternNode = nodeName(pattern);
if (!compileGraph.hasNode(patternNode)) {
compileGraph.setNode(patternNode);
}
this.applyReverse(pattern, (from, to) => {
from.compileState = CompileState.NEEDS_REBUILD;
let fromName = nodeName(from);
let toName = nodeName(to);
for (let name of [fromName, toName]) {
if (!compileGraph.hasNode(name)) {
compileGraph.setNode(name);
}
}
if (!compileGraph.hasNode(toName)) {
compileGraph.setNode(toName);
}
// reverse!
compileGraph.setEdge({v:toName, w:fromName});
});
});
// Apply topological sorting, Start at the leafs of the graphs (e.g. atoms) and go further
// up in the hierarchy
const o = graphlib.alg.topsort(compileGraph);
return this.nodes2patterns(o);
},
/**
* Given a node and its predecessor, allows exchanging states between nodes.
* @param pattern
* @param fn A function that takes the currently viewed pattern and node data. Allows synching data
* between patterns and node metadata.
*/
applyReverse: function (pattern, fn) {
for (let p of this.lineageR(pattern)) {
fn(p, pattern);
this.applyReverse(p, fn);
}
},
/**
* Find the node fro a pattern
*
* @param {Pattern} pattern
*
* @return [null|Pattern]
*/
node: function (pattern) {
return this.graph.node(nodeName(pattern));
},
/**
*
* @param nodes {Array}
* @return {Array} An Array of Patterns
*/
nodes2patterns: function (nodes) {
return nodes.map(n => this.patterns.get(n));
},
// TODO cache result in a Map[String, Array]?
// We trade the pattern.lineage array - O(pattern.lineage.length << |V|) - vs. O(|V|) of the graph.
// As long as no edges are added or removed, we can cache the result in a Map and just return it.
/**
* Finds all immediate successors of a pattern, i.e. all patterns which the given pattern includes.
* @param pattern
* @return {*|Array}
*/
lineage: function (pattern) {
const nodes = this.graph.successors(nodeName(pattern));
return this.nodes2patterns(nodes);
},
/**
* Returns all patterns that include the given pattern
* @param {Pattern} pattern
* @return {*|Array}
*/
lineageR: function (pattern) {
const nodes = this.graph.predecessors(nodeName(pattern));
return this.nodes2patterns(nodes);
},
/**
* Given a {Pattern}, return all partial names of {Pattern} objects included in this the given pattern
* @param {Pattern} pattern
*
* @see {@link PatternGraph.lineage(pattern)}
*/
lineageIndex: function (pattern) {
const lineage = this.lineage(pattern);
return lineage.map(p => p.patternPartial);
},
/**
* Given a {Pattern}, return all partial names of {Pattern} objects which include the given pattern
* @param {Pattern} pattern
*
* @return {Array}
*
* @see {@link PatternGraph.lineageRIndex(pattern)}
*/
lineageRIndex: function (pattern) {
const lineageR = this.lineageR(pattern);
return lineageR.map(p => p.patternPartial);
},
/**
* Creates an object representing the graph and meta data.
* @returns {{timestamp: number, graph}}
*/
toJson: function () {
return {
version: this.version,
timestamp: this.timestamp,
graph: graphlib.json.write(this.graph)
};
},
/**
* @return {Array} An array of all node names.
*/
nodes: function () {
return this.graph.nodes();
},
/**
* Updates the version to the most recent one
*/
upgradeVersion: function () {
this.version = PATTERN_GRAPH_VERSION;
}
};
/**
* Creates an empty graph with a unix timestamp of 0 as last compilation date.
* @param {int} [version=PATTERN_GRAPH_VERSION]
* @return {PatternGraph}
*/
PatternGraph.empty = function (version) {
return new PatternGraph(null, 0, version || PATTERN_GRAPH_VERSION);
};
/**
* Checks if the version of
* @param {PatternGraph|Object} graphOrJson
* @return {boolean}
*/
PatternGraph.checkVersion = function (graphOrJson) {
return graphOrJson.version === PATTERN_GRAPH_VERSION;
};
/**
* Error that is thrown if the given version does not match the current graph version.
*
* @param oldVersion
* @constructor
*/
function VersionMismatch(oldVersion) {
this.message = `Version of graph on disk ${oldVersion} != current version ${PATTERN_GRAPH_VERSION}. Please clean your patterns output directory.`;
this.name = "VersionMismatch";
}
/**
* Parse the graph from a JSON object.
* @param {object} o The JSON object to read from
* @return {PatternGraph}
*/
PatternGraph.fromJson = function (o) {
if (!PatternGraph.checkVersion(o)) {
throw new VersionMismatch(o.version);
}
const graph = graphlib.json.read(o.graph);
return new PatternGraph(graph, o.timestamp, o.version);
};
/**
* Resolve the path to the file containing the serialized graph
* @param {object} patternlab
* @param {string} [file='dependencyGraph.json'] Path to the graph file
* @return {string}
*/
PatternGraph.resolveJsonGraphFile = function (patternlab, file) {
return path.resolve(patternlab.config.paths.public.root, file || 'dependencyGraph.json');
};
/**
* Loads a graph from the file. Does not add any patterns from the patternlab object,
* i.e. graph.patterns will be still empty until all patterns have been processed.
*
* @param {object} patternlab
* @param {string} [file] Optional path to the graph json file
*
* @see {@link PatternGraph.fromJson}
* @see {@link PatternGraph.resolveJsonGraphFile}
*/
PatternGraph.loadFromFile = function (patternlab, file) {
const jsonGraphFile = this.resolveJsonGraphFile(patternlab, file);
// File is fresh, so simply construct an empty graph in memory
if (!fs.existsSync(jsonGraphFile)) {
return PatternGraph.empty();
}
const obj = fs.readJSONSync(jsonGraphFile);
if (!PatternGraph.checkVersion(obj)) {
return PatternGraph.empty(obj.version);
}
return this.fromJson(obj);
};
/**
* Serializes the graph to a file.
* @param patternlab
* @param {string} [file] For unit testing only.
*
* @see {@link PatternGraph.resolveJsonGraphFile}
*/
PatternGraph.storeToFile = function (patternlab, file) {
const jsonGraphFile = this.resolveJsonGraphFile(patternlab, file);
patternlab.graph.timestamp = new Date().getTime();
fs.writeJSONSync(jsonGraphFile, patternlab.graph.toJson());
};
/**
* Exports this graph to a GraphViz file.
* @param patternlab
@ @param {string} file Output file
*/
PatternGraph.exportToDot = function (patternlab, file) {
const dotFile = this.resolveJsonGraphFile(patternlab, file);
const g = PatternGraphDot.generate(patternlab.graph);
fs.outputFileSync(dotFile, g);
};
module.exports = {
PatternGraph: PatternGraph,
PATTERN_GRAPH_VERSION: PATTERN_GRAPH_VERSION
};