inliner.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. var fs = require('fs');
  2. var path = require('path');
  3. var http = require('http');
  4. var https = require('https');
  5. var url = require('url');
  6. var rewriteUrls = require('../urls/rewrite');
  7. var split = require('../utils/split');
  8. var override = require('../utils/object.js').override;
  9. var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
  10. var REMOTE_RESOURCE = /^(https?:)?\/\//;
  11. var NO_PROTOCOL_RESOURCE = /^\/\//;
  12. function ImportInliner (context) {
  13. this.outerContext = context;
  14. }
  15. ImportInliner.prototype.process = function (data, context) {
  16. var root = this.outerContext.options.root;
  17. context = override(context, {
  18. baseRelativeTo: this.outerContext.options.relativeTo || root,
  19. debug: this.outerContext.options.debug,
  20. done: [],
  21. errors: this.outerContext.errors,
  22. left: [],
  23. inliner: this.outerContext.options.inliner,
  24. rebase: this.outerContext.options.rebase,
  25. relativeTo: this.outerContext.options.relativeTo || root,
  26. root: root,
  27. sourceReader: this.outerContext.sourceReader,
  28. sourceTracker: this.outerContext.sourceTracker,
  29. warnings: this.outerContext.warnings,
  30. visited: []
  31. });
  32. return importFrom(data, context);
  33. };
  34. function importFrom(data, context) {
  35. if (context.shallow) {
  36. context.shallow = false;
  37. context.done.push(data);
  38. return processNext(context);
  39. }
  40. var nextStart = 0;
  41. var nextEnd = 0;
  42. var cursor = 0;
  43. var isComment = commentScanner(data);
  44. for (; nextEnd < data.length;) {
  45. nextStart = nextImportAt(data, cursor);
  46. if (nextStart == -1)
  47. break;
  48. if (isComment(nextStart)) {
  49. cursor = nextStart + 1;
  50. continue;
  51. }
  52. nextEnd = data.indexOf(';', nextStart);
  53. if (nextEnd == -1) {
  54. cursor = data.length;
  55. data = '';
  56. break;
  57. }
  58. var noImportPart = data.substring(0, nextStart);
  59. context.done.push(noImportPart);
  60. context.left.unshift([data.substring(nextEnd + 1), override(context, { shallow: false })]);
  61. context.afterContent = hasContent(noImportPart);
  62. return inline(data, nextStart, nextEnd, context);
  63. }
  64. // no @import matched in current data
  65. context.done.push(data);
  66. return processNext(context);
  67. }
  68. function rebaseMap(data, source) {
  69. return data.replace(MAP_MARKER, function (match, sourceMapUrl) {
  70. return REMOTE_RESOURCE.test(sourceMapUrl) ?
  71. match :
  72. match.replace(sourceMapUrl, url.resolve(source, sourceMapUrl));
  73. });
  74. }
  75. function nextImportAt(data, cursor) {
  76. var nextLowerCase = data.indexOf('@import', cursor);
  77. var nextUpperCase = data.indexOf('@IMPORT', cursor);
  78. if (nextLowerCase > -1 && nextUpperCase == -1)
  79. return nextLowerCase;
  80. else if (nextLowerCase == -1 && nextUpperCase > -1)
  81. return nextUpperCase;
  82. else
  83. return Math.min(nextLowerCase, nextUpperCase);
  84. }
  85. function processNext(context) {
  86. return context.left.length > 0 ?
  87. importFrom.apply(null, context.left.shift()) :
  88. context.whenDone(context.done.join(''));
  89. }
  90. function commentScanner(data) {
  91. var commentRegex = /(\/\*(?!\*\/)[\s\S]*?\*\/)/;
  92. var lastStartIndex = 0;
  93. var lastEndIndex = 0;
  94. var noComments = false;
  95. // test whether an index is located within a comment
  96. return function scanner(idx) {
  97. var comment;
  98. var localStartIndex = 0;
  99. var localEndIndex = 0;
  100. var globalStartIndex = 0;
  101. var globalEndIndex = 0;
  102. // return if we know there are no more comments
  103. if (noComments)
  104. return false;
  105. do {
  106. // idx can be still within last matched comment (many @import statements inside one comment)
  107. if (idx > lastStartIndex && idx < lastEndIndex)
  108. return true;
  109. comment = data.match(commentRegex);
  110. if (!comment) {
  111. noComments = true;
  112. return false;
  113. }
  114. // get the indexes relative to the current data chunk
  115. lastStartIndex = localStartIndex = comment.index;
  116. localEndIndex = localStartIndex + comment[0].length;
  117. // calculate the indexes relative to the full original data
  118. globalEndIndex = localEndIndex + lastEndIndex;
  119. globalStartIndex = globalEndIndex - comment[0].length;
  120. // chop off data up to and including current comment block
  121. data = data.substring(localEndIndex);
  122. lastEndIndex = globalEndIndex;
  123. } while (globalEndIndex < idx);
  124. return globalEndIndex > idx && idx > globalStartIndex;
  125. };
  126. }
  127. function hasContent(data) {
  128. var isComment = commentScanner(data);
  129. var firstContentIdx = -1;
  130. while (true) {
  131. firstContentIdx = data.indexOf('{', firstContentIdx + 1);
  132. if (firstContentIdx == -1 || !isComment(firstContentIdx))
  133. break;
  134. }
  135. return firstContentIdx > -1;
  136. }
  137. function inline(data, nextStart, nextEnd, context) {
  138. context.shallow = data.indexOf('@shallow') > 0;
  139. var importDeclaration = data
  140. .substring(nextImportAt(data, nextStart) + '@import'.length + 1, nextEnd)
  141. .replace(/@shallow\)$/, ')')
  142. .trim();
  143. var viaUrl = importDeclaration.indexOf('url(') === 0;
  144. var urlStartsAt = viaUrl ? 4 : 0;
  145. var isQuoted = /^['"]/.exec(importDeclaration.substring(urlStartsAt, urlStartsAt + 2));
  146. var urlEndsAt = isQuoted ?
  147. importDeclaration.indexOf(isQuoted[0], urlStartsAt + 1) :
  148. split(importDeclaration, ' ')[0].length - (viaUrl ? 1 : 0);
  149. var importedFile = importDeclaration
  150. .substring(urlStartsAt, urlEndsAt)
  151. .replace(/['"]/g, '')
  152. .replace(/\)$/, '')
  153. .trim();
  154. var mediaQuery = importDeclaration
  155. .substring(urlEndsAt + 1)
  156. .replace(/^\)/, '')
  157. .trim();
  158. var isRemote = context.isRemote || REMOTE_RESOURCE.test(importedFile);
  159. if (isRemote && (context.localOnly || !allowedResource(importedFile, true, context.imports))) {
  160. if (context.afterContent || hasContent(context.done.join('')))
  161. context.warnings.push('Ignoring remote @import of "' + importedFile + '" as no callback given.');
  162. else
  163. restoreImport(importedFile, mediaQuery, context);
  164. return processNext(context);
  165. }
  166. if (!isRemote && !allowedResource(importedFile, false, context.imports)) {
  167. if (context.afterImport)
  168. context.warnings.push('Ignoring local @import of "' + importedFile + '" as after other inlined content.');
  169. else
  170. restoreImport(importedFile, mediaQuery, context);
  171. return processNext(context);
  172. }
  173. if (!isRemote && context.afterContent) {
  174. context.warnings.push('Ignoring local @import of "' + importedFile + '" as after other CSS content.');
  175. return processNext(context);
  176. }
  177. var method = isRemote ? inlineRemoteResource : inlineLocalResource;
  178. return method(importedFile, mediaQuery, context);
  179. }
  180. function allowedResource(importedFile, isRemote, rules) {
  181. if (rules.length === 0)
  182. return false;
  183. if (isRemote && NO_PROTOCOL_RESOURCE.test(importedFile))
  184. importedFile = 'http:' + importedFile;
  185. var match = isRemote ?
  186. url.parse(importedFile).host :
  187. importedFile;
  188. var allowed = true;
  189. for (var i = 0; i < rules.length; i++) {
  190. var rule = rules[i];
  191. if (rule == 'all')
  192. allowed = true;
  193. else if (isRemote && rule == 'local')
  194. allowed = false;
  195. else if (isRemote && rule == 'remote')
  196. allowed = true;
  197. else if (!isRemote && rule == 'remote')
  198. allowed = false;
  199. else if (!isRemote && rule == 'local')
  200. allowed = true;
  201. else if (rule[0] == '!' && rule.substring(1) === match)
  202. allowed = false;
  203. }
  204. return allowed;
  205. }
  206. function inlineRemoteResource(importedFile, mediaQuery, context) {
  207. var importedUrl = REMOTE_RESOURCE.test(importedFile) ?
  208. importedFile :
  209. url.resolve(context.relativeTo, importedFile);
  210. var originalUrl = importedUrl;
  211. if (NO_PROTOCOL_RESOURCE.test(importedUrl))
  212. importedUrl = 'http:' + importedUrl;
  213. if (context.visited.indexOf(importedUrl) > -1)
  214. return processNext(context);
  215. if (context.debug)
  216. console.error('Inlining remote stylesheet: ' + importedUrl);
  217. context.visited.push(importedUrl);
  218. var proxyProtocol = context.inliner.request.protocol || context.inliner.request.hostname;
  219. var get =
  220. ((proxyProtocol && proxyProtocol.indexOf('https://') !== 0 ) ||
  221. importedUrl.indexOf('http://') === 0) ?
  222. http.get :
  223. https.get;
  224. var errorHandled = false;
  225. function handleError(message) {
  226. if (errorHandled)
  227. return;
  228. errorHandled = true;
  229. context.errors.push('Broken @import declaration of "' + importedUrl + '" - ' + message);
  230. restoreImport(importedUrl, mediaQuery, context);
  231. process.nextTick(function () {
  232. processNext(context);
  233. });
  234. }
  235. var requestOptions = override(url.parse(importedUrl), context.inliner.request);
  236. if (context.inliner.request.hostname !== undefined) {
  237. //overwrite as we always expect a http proxy currently
  238. requestOptions.protocol = context.inliner.request.protocol || 'http:';
  239. requestOptions.path = requestOptions.href;
  240. }
  241. get(requestOptions, function (res) {
  242. if (res.statusCode < 200 || res.statusCode > 399) {
  243. return handleError('error ' + res.statusCode);
  244. } else if (res.statusCode > 299) {
  245. var movedUrl = url.resolve(importedUrl, res.headers.location);
  246. return inlineRemoteResource(movedUrl, mediaQuery, context);
  247. }
  248. var chunks = [];
  249. var parsedUrl = url.parse(importedUrl);
  250. res.on('data', function (chunk) {
  251. chunks.push(chunk.toString());
  252. });
  253. res.on('end', function () {
  254. var importedData = chunks.join('');
  255. if (context.rebase)
  256. importedData = rewriteUrls(importedData, { toBase: originalUrl }, context);
  257. context.sourceReader.trackSource(importedUrl, importedData);
  258. importedData = context.sourceTracker.store(importedUrl, importedData);
  259. importedData = rebaseMap(importedData, importedUrl);
  260. if (mediaQuery.length > 0)
  261. importedData = '@media ' + mediaQuery + '{' + importedData + '}';
  262. context.afterImport = true;
  263. var newContext = override(context, {
  264. isRemote: true,
  265. relativeTo: parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname
  266. });
  267. process.nextTick(function () {
  268. importFrom(importedData, newContext);
  269. });
  270. });
  271. })
  272. .on('error', function (res) {
  273. handleError(res.message);
  274. })
  275. .on('timeout', function () {
  276. handleError('timeout');
  277. })
  278. .setTimeout(context.inliner.timeout);
  279. }
  280. function inlineLocalResource(importedFile, mediaQuery, context) {
  281. var relativeTo = importedFile[0] == '/' ?
  282. context.root :
  283. context.relativeTo;
  284. var fullPath = path.resolve(path.join(relativeTo, importedFile));
  285. if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
  286. context.errors.push('Broken @import declaration of "' + importedFile + '"');
  287. return processNext(context);
  288. }
  289. if (context.visited.indexOf(fullPath) > -1)
  290. return processNext(context);
  291. if (context.debug)
  292. console.error('Inlining local stylesheet: ' + fullPath);
  293. context.visited.push(fullPath);
  294. var importRelativeTo = path.dirname(fullPath);
  295. var importedData = fs.readFileSync(fullPath, 'utf8');
  296. if (context.rebase) {
  297. var rewriteOptions = {
  298. relative: true,
  299. fromBase: importRelativeTo,
  300. toBase: context.baseRelativeTo
  301. };
  302. importedData = rewriteUrls(importedData, rewriteOptions, context);
  303. }
  304. var relativePath = path.relative(context.root, fullPath);
  305. context.sourceReader.trackSource(relativePath, importedData);
  306. importedData = context.sourceTracker.store(relativePath, importedData);
  307. if (mediaQuery.length > 0)
  308. importedData = '@media ' + mediaQuery + '{' + importedData + '}';
  309. context.afterImport = true;
  310. var newContext = override(context, {
  311. relativeTo: importRelativeTo
  312. });
  313. return importFrom(importedData, newContext);
  314. }
  315. function restoreImport(importedUrl, mediaQuery, context) {
  316. var restoredImport = '@import url(' + importedUrl + ')' + (mediaQuery.length > 0 ? ' ' + mediaQuery : '') + ';';
  317. context.done.push(restoredImport);
  318. }
  319. module.exports = ImportInliner;