Skip to content

Commit b0e80ac

Browse files
committed
Added command componentHierarchyDgml to generate a directed graph of the Angular project structure.
1 parent a3b537b commit b0e80ac

File tree

6 files changed

+344
-3
lines changed

6 files changed

+344
-3
lines changed

package-lock.json

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"Other"
1111
],
1212
"activationEvents": [
13+
"onCommand:angulartools.componentHierarchyDgml",
1314
"onCommand:angulartools.listAllImports",
1415
"onCommand:angulartools.projectDirectoryStructure",
1516
"onCommand:angulartools.packageJsonToMarkdown"
@@ -28,6 +29,10 @@
2829
{
2930
"command": "angulartools.packageJsonToMarkdown",
3031
"title": "AngularTools: Generate Markdown file from package.json files in the workspace."
32+
},
33+
{
34+
"command": "angulartools.componentHierarchyDgml",
35+
"title": "AngularTools: Generate a Directed Graph file (dgml) showing the components in the current project."
3136
}
3237
]
3338
},
@@ -53,6 +58,9 @@
5358
"vscode-test": "^1.4.0"
5459
},
5560
"dependencies": {
56-
"node-fetch": "^2.6.1"
61+
"@types/xmldom": "^0.1.30",
62+
"node-fetch": "^2.6.1",
63+
"xmldom": "^0.3.0",
64+
"xmlserializer": "^0.6.1"
5765
}
5866
}
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import * as vscode from 'vscode';
2+
import * as fs from 'fs';
3+
import * as path from 'path';
4+
import { FileSystemUtils } from "../filesystemUtils";
5+
import * as xmldom from 'xmldom';
6+
const prettifyXml = require('prettify-xml');
7+
const xmlSerializer = require('xmlserializer');
8+
9+
class Component {
10+
11+
constructor(tsFilename: string, templateFilename: string, selector: string, subComponents: Component[], isRoot: boolean) {
12+
this.tsFilename = tsFilename;
13+
this.templateFilename = templateFilename;
14+
this.selector = selector;
15+
this.subComponents = subComponents;
16+
this.isRoot = isRoot;
17+
}
18+
public tsFilename: string;
19+
public templateFilename: string;
20+
public selector: string;
21+
public subComponents: Component[];
22+
public isRoot: boolean;
23+
}
24+
25+
export class ComponentHierarchyDgml {
26+
27+
public static get commandName(): string { return 'componentHierarchyDgml'; }
28+
29+
public execute() {
30+
const fsUtils = new FileSystemUtils();
31+
var directoryPath: string = fsUtils.getWorkspaceFolder();
32+
const excludeDirectories = ['bin', 'obj', 'node_modules', 'dist', 'packages', '.git', '.vs', '.github'];
33+
const componentFilenames = fsUtils.listFiles(directoryPath, excludeDirectories, this.isComponentFile);
34+
const components = this.findComponents(componentFilenames);
35+
this.scanComponentTemplates(components);
36+
37+
const graphFilename = 'ReadMe-ProjectStructure.dgml';
38+
const domImpl = new xmldom.DOMImplementation();
39+
const documentParser = new xmldom.DOMParser();
40+
let xmlDocument: Document;
41+
let root: Element;
42+
43+
try {
44+
// if the graph file already exists, then read it and parse it into a xml document object
45+
if (fs.existsSync(graphFilename)) {
46+
try {
47+
const content = fs.readFileSync(graphFilename).toString();
48+
xmlDocument = documentParser.parseFromString(content, 'text/xml');
49+
} catch {
50+
xmlDocument = this.createNewDirectedGraph(domImpl);
51+
}
52+
} else {
53+
xmlDocument = this.createNewDirectedGraph(domImpl);
54+
}
55+
this.addNodesAndLinks(xmlDocument, components);
56+
this.addCategories(xmlDocument);
57+
this.addProperties(xmlDocument);
58+
this.addStyles(xmlDocument);
59+
60+
// Serialize the xml into a string
61+
const xmlAsString = xmlSerializer.serializeToString(xmlDocument.documentElement);
62+
let fileContent = prettifyXml(xmlAsString);
63+
fileContent = fileContent.replace('HasCategory('RootComponent')', "HasCategory('RootComponent')");
64+
65+
// Write the prettified xml string to the ReadMe-ProjectStructure.dgml file.
66+
const fsUtils = new FileSystemUtils();
67+
fsUtils.writeFile(path.join(directoryPath, graphFilename), fileContent, () => {
68+
const angularToolsOutput = vscode.window.createOutputChannel("Angular Tools");
69+
angularToolsOutput.clear();
70+
angularToolsOutput.appendLine(`The project structure has been analyzed and a Directed Graph Markup Language (dgml) file has been created\n`);
71+
angularToolsOutput.appendLine('The ReadMe-ProjectStructure.dgml file can now be viewed in Visual Studio\n');
72+
angularToolsOutput.show();
73+
});
74+
} catch (ex) {
75+
console.log('exception:' + ex);
76+
}
77+
}
78+
79+
private isComponentFile(filename: string): boolean {
80+
return filename.endsWith('.component.ts');
81+
}
82+
83+
private findComponents(componentFilenames: string[]) {
84+
const compHash: { [selector: string]: Component; } = {};
85+
const componentRegex = /@Component\({/ig;
86+
const templateUrlRegex = /.*templateUrl:.+\/(.+)\'/i;
87+
const selectorRegex = /.*selector:.+\'(.+)\'/i;
88+
const endBracketRegex = /}\)/i;
89+
componentFilenames.forEach((componentFilename) => {
90+
let componentDefinitionFound = false;
91+
let currentComponent = new Component(componentFilename, "", "", [], true);
92+
const content = fs.readFileSync(componentFilename, 'utf8');
93+
const lines: string[] = content.split('\n');
94+
for (let i: number = 0; i < lines.length; i++) {
95+
let line = lines[i];
96+
let match = componentRegex.exec(line);
97+
if (match) {
98+
componentDefinitionFound = true;
99+
}
100+
if (componentDefinitionFound) {
101+
match = templateUrlRegex.exec(line);
102+
if (match) {
103+
currentComponent.templateFilename = path.join(path.dirname(componentFilename), match[1]);
104+
}
105+
match = selectorRegex.exec(line);
106+
if (match) {
107+
let currentSelector = match[1];
108+
currentSelector = currentSelector.replace("[", "");
109+
currentSelector = currentSelector.replace("]", "");
110+
currentComponent.selector = currentSelector;
111+
}
112+
match = endBracketRegex.exec(line);
113+
if (match) {
114+
break;
115+
}
116+
}
117+
}
118+
compHash[currentComponent.selector] = currentComponent;
119+
});
120+
return compHash;
121+
}
122+
123+
private scanComponentTemplates(componentHash: { [selector: string]: Component; }) {
124+
for (let selector1 in componentHash) {
125+
if (fs.existsSync(componentHash[selector1].templateFilename)) {
126+
const template = fs.readFileSync(componentHash[selector1].templateFilename); // We read the entire template file
127+
for (let selector2 in componentHash) { // then we check if the template contains each of the selectors we found in the components
128+
let pattern = `</${selector2}>`;
129+
let index = template.indexOf(pattern);
130+
if (index >= 0) {
131+
componentHash[selector1].subComponents = componentHash[selector1].subComponents.concat(componentHash[selector2]);
132+
// If selector2 has been found in a template then it is not root
133+
componentHash[selector2].isRoot = false;
134+
}
135+
else {
136+
pattern = ` ${selector2}`;
137+
index = template.indexOf(pattern);
138+
if (index >= 0) {
139+
componentHash[selector1].subComponents = componentHash[selector1].subComponents.concat(componentHash[selector2]);
140+
// If selector2 has been found in a template then it is not root
141+
componentHash[selector2].isRoot = false;
142+
}
143+
}
144+
}
145+
}
146+
}
147+
}
148+
149+
private createNewDirectedGraph(domImpl: DOMImplementation) {
150+
let xmlDoc: Document = domImpl.createDocument('', null, null);
151+
const root = xmlDoc.createElement("DirectedGraph");
152+
root.setAttribute("GraphDirection", "LeftToRight");
153+
root.setAttribute("Layout", "Sugiyama");
154+
root.setAttribute("ZoomLevel", "-1");
155+
root.setAttribute("xmlns", "http://schemas.microsoft.com/vs/2009/dgml");
156+
xmlDoc.appendChild(root);
157+
return xmlDoc;
158+
}
159+
160+
private addNodeToRoot(xmlDoc: Document, tagName: string): Element | null {
161+
const root = xmlDoc.documentElement;
162+
const elements = root.getElementsByTagName(tagName);
163+
let nodeElement: Element;
164+
if (elements.length === 0) {
165+
nodeElement = xmlDoc.createElement(tagName);
166+
root.appendChild(nodeElement);
167+
return nodeElement;
168+
}
169+
else {
170+
const exitingNode = elements.item(0);
171+
return exitingNode;
172+
}
173+
}
174+
175+
private addNode(element: Element | null, nodeElement: Element, attribute: string = 'Id') {
176+
if (element !== null) {
177+
let nodeAlreadyAdded = false;
178+
if (element.childNodes.length > 0) {
179+
for (let i = 0; i < element.childNodes.length; i++) {
180+
let node = element.childNodes[i];
181+
if (node.nodeType === 1 && (node as Element).hasAttribute(attribute) &&
182+
(node as Element).getAttribute(attribute)?.toLowerCase() === nodeElement.getAttribute(attribute)?.toLowerCase()) {
183+
nodeAlreadyAdded = true;
184+
}
185+
};
186+
}
187+
if (!nodeAlreadyAdded) {
188+
element.appendChild(nodeElement);
189+
}
190+
}
191+
}
192+
193+
private addLinkNode(xmlDoc: Document, element: Element | null, source: string, target: string) {
194+
if (element !== null) {
195+
let nodeAlreadyAdded = false;
196+
if (element.childNodes.length > 0) {
197+
for (let i = 0; i < element.childNodes.length; i++) {
198+
let node = element.childNodes[i];
199+
if (node.nodeType === 1 &&
200+
(node as Element).hasAttribute("Source") &&
201+
(node as Element).hasAttribute("Target") &&
202+
(node as Element).getAttribute("Source")?.toLowerCase() === source.toLowerCase() &&
203+
(node as Element).getAttribute("Target")?.toLowerCase() === target.toLowerCase()) {
204+
nodeAlreadyAdded = true;
205+
}
206+
}
207+
}
208+
if (!nodeAlreadyAdded) {
209+
const linkElement = xmlDoc.createElement("Link");
210+
linkElement.setAttribute("Source", source);
211+
linkElement.setAttribute("Target", target);
212+
element.appendChild(linkElement);
213+
}
214+
}
215+
}
216+
217+
private addNodesAndLinks(xmlDoc: Document, componentHash: { [selector: string]: Component; }) {
218+
const nodesElement = this.addNodeToRoot(xmlDoc, "Nodes");
219+
const linksElement = this.addNodeToRoot(xmlDoc, "Links");
220+
for (let selector in componentHash) {
221+
const component = componentHash[selector];
222+
if (component.isRoot) {
223+
this.generateDirectedGraphNodesXml(xmlDoc, component.subComponents, component, true, nodesElement);
224+
this.generateDirectedGraphLinksXml(xmlDoc, component.subComponents, selector, "", linksElement);
225+
}
226+
}
227+
}
228+
229+
private generateDirectedGraphNodesXml(xmlDoc: Document, components: Component[], component: Component, isRoot: boolean, nodesElement: Element | null) {
230+
const nodeElement = xmlDoc.createElement("Node");
231+
nodeElement.setAttribute("ComponentFilename", component.tsFilename);
232+
nodeElement.setAttribute("Label", component.selector);
233+
nodeElement.setAttribute("Id", component.selector);
234+
if (isRoot) {
235+
nodeElement.setAttribute("Category", "RootComponent");
236+
}
237+
const componentType = component.isRoot ? 'root ' : '';
238+
this.addNode(nodesElement, nodeElement);
239+
if (components.length > 0) {
240+
components.forEach((subComponent) => {
241+
this.generateDirectedGraphNodesXml(xmlDoc, subComponent.subComponents, subComponent, subComponent.isRoot, nodesElement);
242+
});
243+
}
244+
}
245+
246+
private generateDirectedGraphLinksXml(xmlDoc: Document, subComponents: Component[], displayName: string, parentDisplayName: string, linksElement: Element | null) {
247+
if (parentDisplayName.length > 0) {
248+
this.addLinkNode(xmlDoc, linksElement, parentDisplayName, displayName);
249+
}
250+
if (subComponents.length > 0) {
251+
subComponents.forEach((subComponent) => {
252+
this.generateDirectedGraphLinksXml(xmlDoc, subComponent.subComponents, subComponent.selector, displayName, linksElement);
253+
});
254+
}
255+
}
256+
257+
private addCategories(xmlDoc: Document) {
258+
const categoriesElement = this.addNodeToRoot(xmlDoc, "Categories");
259+
const categoryElement = xmlDoc.createElement("Category");
260+
categoryElement.setAttribute("Id", "RootComponent");
261+
categoryElement.setAttribute("Label", "Root component");
262+
categoryElement.setAttribute("Background", "#FF00AA00");
263+
categoryElement.setAttribute("IsTag", "True");
264+
this.addNode(categoriesElement, categoryElement);
265+
}
266+
267+
private addProperties(xmlDoc: Document) {
268+
const propertiesElement = this.addNodeToRoot(xmlDoc, "Properties");
269+
this.addProperty(xmlDoc, propertiesElement, "ComponentFilename", "System.String");
270+
this.addProperty(xmlDoc, propertiesElement, "Background", "System.Windows.Media.Brush");
271+
this.addProperty(xmlDoc, propertiesElement, "GraphDirection", "Microsoft.VisualStudio.Diagrams.Layout.LayoutOrientation");
272+
this.addProperty(xmlDoc, propertiesElement, "GroupLabel", "System.String");
273+
this.addProperty(xmlDoc, propertiesElement, "IsTag", "System.Boolean");
274+
this.addProperty(xmlDoc, propertiesElement, "Label", "System.String");
275+
this.addProperty(xmlDoc, propertiesElement, "Layout", "System.String");
276+
this.addProperty(xmlDoc, propertiesElement, "TargetType", "System.String");
277+
this.addProperty(xmlDoc, propertiesElement, "ValueLabel", "System.String");
278+
this.addProperty(xmlDoc, propertiesElement, "ZoomLevel", "System.String");
279+
this.addProperty(xmlDoc, propertiesElement, "Expression", "System.String");
280+
}
281+
282+
private addProperty(xmlDoc: Document, propertiesElement: Element | null, idValue: string, datatypeValue: string) {
283+
const propertyElement = xmlDoc.createElement("Property");
284+
propertyElement.setAttribute("Id", idValue);
285+
propertyElement.setAttribute("DataType", datatypeValue);
286+
this.addNode(propertiesElement, propertyElement);
287+
}
288+
289+
private addStyles(xmlDoc: Document) {
290+
const stylesElement = this.addNodeToRoot(xmlDoc, "Styles");
291+
const styleElement = xmlDoc.createElement("Style");
292+
styleElement.setAttribute("TargetType", "Node");
293+
styleElement.setAttribute("GroupLabel", "Root component");
294+
styleElement.setAttribute("ValueLabel", "Has category");
295+
const conditionElement = xmlDoc.createElement("Condition");
296+
conditionElement.setAttribute("Expression", "HasCategory('RootComponent')");
297+
styleElement.appendChild(conditionElement);
298+
const setterElement = xmlDoc.createElement("Setter");
299+
setterElement.setAttribute("Property", "Background");
300+
setterElement.setAttribute("Property", "#FF00AA00");
301+
styleElement.appendChild(setterElement);
302+
this.addNode(stylesElement, styleElement, "GroupLabel");
303+
}
304+
}

src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './componentHierarchyDgml';
12
export * from './listAllImports';
23
export * from './packageJsonToMarkdown';
34
export * from './projectDirectoryStructure';

src/extension.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Import the module and reference it with the alias vscode in your code below
33
import * as vscode from 'vscode';
44
import {
5+
ComponentHierarchyDgml,
56
ListAllImports,
67
PackageJsonToMarkdown,
78
ProjectDirectoryStructure
@@ -28,6 +29,12 @@ export function activate(context: vscode.ExtensionContext) {
2829
command.execute();
2930
});
3031
context.subscriptions.push(packageJsonToMarkdownDisposable);
32+
33+
const componentHierarchyDgmlDisposable = vscode.commands.registerCommand(`${cmdPrefix}.${ComponentHierarchyDgml.commandName}`, () => {
34+
const command = new ComponentHierarchyDgml();
35+
command.execute();
36+
});
37+
context.subscriptions.push(componentHierarchyDgmlDisposable);
3138
}
3239

3340
// this method is called when your extension is deactivated

0 commit comments

Comments
 (0)