GitHub PHP Hyperlinks

Enhances browsing through PHP code on GitHub by linking referenced classes

  1. // ==UserScript==
  2. // @name GitHub PHP Hyperlinks
  3. // @namespace https://github.com/Koopzington
  4. // @version 0.7
  5. // @description Enhances browsing through PHP code on GitHub by linking referenced classes
  6. // @author koopzington@gmail.com
  7. // @match https://github.com/*
  8. // @grant GM_xmlhttpRequest
  9. // ==/UserScript==
  10.  
  11. (function () {
  12. 'use strict';
  13.  
  14. // Also execute this script if content is getting loaded via pjax
  15. document.addEventListener("pjax:complete", function () {
  16. start();
  17. });
  18. start();
  19.  
  20. function start() {
  21. // Check if currently viewed file is a PHP file
  22. if (window.location.href.split('.php').length == 2) {
  23. // Grab reponame
  24. var repoName = window.location.href.split('/');
  25. var status = repoName[6];
  26. repoName = repoName[3] + '/' + repoName[4];
  27. var nsRoots = [];
  28. var dependencies = [];
  29. var imports = [];
  30. var filenamespace;
  31. parseFile();
  32. }
  33.  
  34. function parseFile() {
  35. // Grab namespace of current class
  36. var namespaceXPath = "//span[@class='pl-k' and .='namespace']/following-sibling::span";
  37. filenamespace = document.evaluate(namespaceXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  38. // Check if file is a class or not
  39. var classCheck = document.evaluate("span[@class ='pl-k' and .='class']", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
  40. var useXpath;
  41. if (classCheck !== null) {
  42. useXpath = "//span[@class='pl-k' and .='use'][not(preceding::span[@class ='pl-k' and .='class'])]/following-sibling::span[not(contains(.,'')]";
  43. } else {
  44. useXpath = "//span[@class='pl-k' and .='use']/following-sibling::span[not(contains(.,'$'))]";
  45. }
  46. // Now let's grab all use statements
  47. var iterator = document.evaluate(useXpath, document, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null);
  48. var thisNode = iterator.iterateNext();
  49.  
  50. while (thisNode) {
  51. var newImport = {};
  52. newImport.name = thisNode.textContent;
  53. thisNode = iterator.iterateNext();
  54. // Check if use statement has an alias
  55. if (thisNode && thisNode.textContent == "as") {
  56. thisNode = iterator.iterateNext();
  57. newImport.alias = thisNode.textContent;
  58. thisNode = iterator.iterateNext();
  59. } else {
  60. var split = newImport.name.split('\\');
  61. newImport.alias = split[split.length - 1];
  62. }
  63. imports.push(newImport);
  64. }
  65.  
  66. // Grab composer.json from current repo
  67. GM_xmlhttpRequest({
  68. method: "GET",
  69. url: "https://api.github.com/repos/" + repoName + '/contents/composer.json?ref=' + status,
  70. onload: function (responseDetails) {
  71. if (responseDetails.status == 200) {
  72. var data = JSON.parse(atob(JSON.parse(responseDetails.responseText).content));
  73. var req;
  74. checkAutoload(data, repoName);
  75. if (data.hasOwnProperty('require')) {
  76. for (req in data.require) {
  77. dependencies.push(req);
  78. }
  79. }
  80. if (data.hasOwnProperty('require-dev')) {
  81. for (req in data['require-dev']) {
  82. dependencies.push(req);
  83. }
  84. }
  85. addExternalRoots();
  86. }
  87. }
  88. });
  89. }
  90.  
  91. function addExternalRoots() {
  92. var promises = [];
  93. for (var i = 0; i < dependencies.length; ++i) {
  94. promises.push(getComposerOf(dependencies[i]));
  95. }
  96. Promise.all(promises).then(function () {
  97. grabFilesOnSameNamespace();
  98. });
  99. }
  100.  
  101. function grabFilesOnSameNamespace() {
  102. if (filenamespace !== null) {
  103. // Find out root namespace of file
  104. var currentNamespace = filenamespace.innerHTML;
  105. var currentRoot;
  106. for (var ns in nsRoots) {
  107. if (currentNamespace.substring(0, nsRoots[ns].root.length - 1) + '\\' == nsRoots[ns].root) {
  108. currentNamespace = currentNamespace.substring(nsRoots[ns].root.length);
  109. currentRoot = nsRoots[ns];
  110. }
  111. }
  112. // Now we get all classes that are in the same namespace as our current class
  113. GM_xmlhttpRequest({
  114. method: "GET",
  115. url: "https://api.github.com/repos/" + repoName + '/contents/' + currentRoot.path + currentNamespace,
  116. onload: function (responseDetails) {
  117. if (responseDetails.status == 200) {
  118. var data = JSON.parse(responseDetails.responseText);
  119. for (var i = 0; i < data.length; ++i) {
  120. var classname = data[i].name.split('.php')[0];
  121. imports.push({
  122. name: filenamespace.innerHTML + '\\' + classname,
  123. alias: classname
  124. });
  125. }
  126. }
  127. editDOM();
  128. }
  129. });
  130. } else {
  131. editDOM();
  132. }
  133. }
  134.  
  135. function getComposerOf(repo) {
  136. return new Promise(function (resolve) {
  137. GM_xmlhttpRequest({
  138. method: "GET",
  139. url: "https://packagist.org/p/" + repo + '.json',
  140. onload: function (responseDetails) {
  141. if (responseDetails.status == 200) {
  142. var reqData = JSON.parse(responseDetails.responseText).packages[repo];
  143. if (reqData.hasOwnProperty('dev-master')) {
  144. checkAutoload(reqData['dev-master']);
  145. }
  146. }
  147. resolve();
  148. }
  149. });
  150. });
  151. }
  152.  
  153. function checkAutoload(data, repoName) {
  154. if (data.hasOwnProperty('autoload')) {
  155. var path;
  156. var repo;
  157. var root;
  158. if (repoName !== undefined) {
  159. repo = repoName;
  160. } else {
  161. repo = data.source.url.split('github.com/')[1].split('.git')[0];
  162. }
  163. if (data.autoload.hasOwnProperty('psr-4')) {
  164. for (var ns4 in data.autoload['psr-4']) {
  165. path = data.autoload['psr-4'][ns4];
  166. if (path.substring(path.length - 1) != '/') {
  167. path = path + '/';
  168. }
  169. root = ns4;
  170. if (ns4.substring(ns4.length -1) != '\\') {
  171. root = ns4 + '\\';
  172. }
  173. nsRoots.push({
  174. root: root,
  175. path: path,
  176. repo: repo
  177. });
  178. }
  179. }
  180. if (data.autoload.hasOwnProperty('psr-0')) {
  181. for (var ns0 in data.autoload['psr-0']) {
  182. path = data.autoload['psr-0'][ns0];
  183. if (path.substring(path.length - 1) != '/') {
  184. path = path + '/';
  185. }
  186. root = ns0;
  187. if (ns0.substring(ns0.length -1) != '\\') {
  188. root = ns0 + '\\';
  189. }
  190. path = path + root.substring(0, root.length - 1) + '/';
  191. path = path.replace(/\\/g, '/');
  192. nsRoots.push({
  193. root: root,
  194. path: path,
  195. repo: repo
  196. });
  197. }
  198. }
  199. }
  200. }
  201.  
  202. function editDOM() {
  203. var currentRoot;
  204. var currentNamespace;
  205. var k;
  206. var toBeModified;
  207. var currentStatus;
  208. var classXpath;
  209. var anchorStart = '<a style="color: inherit;" href="https://github.com/';
  210. var ns;
  211. var hit;
  212.  
  213. for (ns in nsRoots) {
  214. // Find all full qualified class names
  215. classXpath = "//span[(@class='pl-s1' or @class='pl-c') and contains(.,'\\" + nsRoots[ns].root + "')]";
  216. toBeModified = findElements(classXpath);
  217. for (k = 0; k < toBeModified.length; ++k) {
  218. // GitHub is splitting FQCNs into 2 spans in code while in comments they're just in one.
  219. hit = toBeModified[k].innerText.split('\\' + nsRoots[ns].root)[1].split(' ')[0].split('::')[0].split('\\');
  220. var lastPart = hit[hit.length -1];
  221. var index = hit.indexOf(hit.length -1);
  222. hit.splice(index, 1);
  223. hit = hit.join('\\');
  224. if (nsRoots[ns].repo == repoName) {
  225. currentStatus = status;
  226. } else {
  227. currentStatus = 'master';
  228. }
  229. var firstPart = '\\' + nsRoots[ns].root + hit;
  230. if (firstPart.substring(firstPart.length -1) != '\\') {
  231. firstPart = firstPart + '\\';
  232. }
  233. var n = toBeModified[k].innerHTML.lastIndexOf(firstPart);
  234. // Splitting the innerHTML so classname and path CAN be the same
  235. toBeModified[k].innerHTML = toBeModified[k].innerHTML.substring(0, n + firstPart.length) + toBeModified[k].innerHTML.substring(n + firstPart.length).replace(lastPart, anchorStart + nsRoots[ns].repo + '/blob/' + currentStatus + '/' + nsRoots[ns].path + hit + '/' + lastPart + '.php">' + lastPart + '</a>');
  236. toBeModified[k].innerHTML = toBeModified[k].innerHTML.replace(firstPart, anchorStart + nsRoots[ns].repo + '/tree/' + currentStatus + '/' + nsRoots[ns].path + hit + '">' + firstPart + '</a>');
  237. }
  238. }
  239.  
  240. for (var j = 0; j < imports.length; ++j) {
  241. currentRoot = undefined;
  242. currentNamespace = undefined;
  243. for (ns in nsRoots) {
  244. if (imports[j].name.substring(0, nsRoots[ns].root.length) == nsRoots[ns].root) {
  245. currentNamespace = imports[j].name.substring(nsRoots[ns].root.length);
  246. currentRoot = nsRoots[ns];
  247. }
  248. }
  249. if (currentRoot !== undefined) {
  250. if (currentRoot.repo == repoName) {
  251. currentStatus = status;
  252. } else {
  253. currentStatus = 'master';
  254. }
  255.  
  256. // Find all direct uses of the classes and replace the content with links (and ignore the ones withe a leading backslash
  257. classXpath = "//span[.='" + imports[j].alias + "' and not(preceding-sibling::span[@class='pl-c1' and .='\\'])]";
  258. toBeModified = findElements(classXpath);
  259. for (k = 0; k < toBeModified.length; ++k) {
  260. toBeModified[k].innerHTML = anchorStart + currentRoot.repo + '/blob/' + currentStatus + '/' + currentRoot.path + currentNamespace + '.php">' + toBeModified[k].innerHTML + '</a>';
  261. }
  262.  
  263. // Find usages inside DocBlocks
  264. classXpath = "//span[@class='pl-c' and (" +
  265. "contains(., '@throws') " +
  266. "or contains(., '@return') " +
  267. "or contains(., '@param') " +
  268. "or contains(., '@var')" +
  269. "or contains(., '@property')" +
  270. ") and (" +
  271. "contains(concat(' ', normalize-space(.), ' '), ' " + imports[j].alias + " ') " +
  272. "or contains(concat(' ', normalize-space(.), '[] '), ' " + imports[j].alias + "[] ') " +
  273. "or contains(concat(' ', normalize-space(.), '\\'), ' " + imports[j].alias + "\\')" +
  274. ")]";
  275. toBeModified = findElements(classXpath);
  276. for (k = 0; k < toBeModified.length; ++k) {
  277. // Use innerText (which strips any HTML inside, trim and split by ' ' to get the part after @something and split by '\'
  278. hit = toBeModified[k].innerText.trim().split(' ')[2].split('\\');
  279. // If hit is just the classname, generate one link, if a subnamespace is in there, generate two links
  280. if (hit.length == 1) {
  281. toBeModified[k].innerHTML = toBeModified[k].innerHTML.replace(
  282. imports[j].alias,
  283. anchorStart + currentRoot.repo + '/blob/' + currentStatus + '/' + currentRoot.path + currentNamespace + '.php">' + imports[j].alias + '</a>'
  284. );
  285. } else if (hit.length == 2) {
  286. toBeModified[k].innerHTML = toBeModified[k].innerHTML.replace(
  287. hit.join('\\'),
  288. anchorStart + currentRoot.repo + '/tree/' + currentStatus + '/' + currentRoot.path + currentNamespace + '">' + hit[0] + '\\' +
  289. anchorStart + currentRoot.repo + '/blob/' + currentStatus + '/' + currentRoot.path + hit.join('/') + '.php">' + hit[1] + '</a>'
  290. );
  291. }
  292. }
  293.  
  294. // Find all usages of classes with subnamespaces (e.g. "Foo\Bar")
  295. classXpath = "//span[@class='pl-c1' and contains(.,'" + imports[j].alias + "\\') and not(preceding-sibling::span[@class='pl-k' and .='use'])]";
  296. toBeModified = findElements(classXpath);
  297. for (k = 0; k < toBeModified.length; ++k) {
  298. hit = toBeModified[k].innerHTML;
  299. toBeModified[k].innerHTML = anchorStart + currentRoot.repo + '/tree/' + currentStatus + '/' + currentRoot.path + hit + '">' + toBeModified[k].innerHTML + '</a>';
  300. toBeModified[k].nextSibling.innerHTML = anchorStart + currentRoot.repo + '/blob/' + currentStatus + '/' + currentRoot.path + hit + toBeModified[k].nextSibling.innerHTML + '.php">' + toBeModified[k].nextSibling.innerHTML + '</a>';
  301. }
  302.  
  303. // Add a Hyperlink to the use statement
  304. classXpath = "//span[@class='pl-c1' and .='" + imports[j].name + "']";
  305. var node = document.evaluate(classXpath, document, null, XPathResult.ANY_UNORDERED_NODE_TYPE, null).singleNodeValue;
  306. if (node !== null) {
  307. // Use the amount of results of the upper search for subnamespace usages to determine if a link to a directory or to a file should be generated
  308. if (toBeModified.length > 0) {
  309. node.innerHTML = anchorStart + currentRoot.repo + '/tree/' + currentStatus + '/' + currentRoot.path + currentNamespace + '">' + node.innerHTML + '</a>';
  310. } else {
  311. node.innerHTML = anchorStart + currentRoot.repo + '/blob/' + currentStatus + '/' + currentRoot.path + currentNamespace + '.php">' + node.innerHTML + '</a>';
  312. }
  313. }
  314. }
  315. }
  316.  
  317. // Accepts a xpath query and returns a list of found nodes
  318. function findElements(queryString) {
  319. var iterator = document.evaluate(queryString, document, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null);
  320. var thisNode = iterator.iterateNext();
  321. var toBeModified = [];
  322. while (thisNode) {
  323. toBeModified.push(thisNode);
  324. thisNode = iterator.iterateNext();
  325. }
  326. return toBeModified;
  327. }
  328. }
  329. }
  330. }());