git.iko.soy/typeable/remark-validate-links
on 1.1.1 help sign in
561 lines 13 kB history raw
1/**
2 * @author Titus Wormer
3 * @copyright 2015 Titus Wormer
4 * @license MIT
5 * @module mdast:validate-links
6 * @fileoverview Validate links to headings and files in markdown.
7 */
9'use strict';
11/*
12 * Dependencies.
13 */
15var url = require('url');
16var fs = require('fs');
17var path = require('path');
18var propose = require('propose');
19var visit = require('unist-util-visit');
20var definitions = require('mdast-util-definitions');
21var gh = require('github-url-to-object');
22var urljoin = require('urljoin');
24/*
25 * Methods.
26 */
28var exists = fs.existsSync;
29var parse = url.parse;
31/**
32 * Get the `pathname` of `uri`, if applicable.
33 *
34 * @todo Externalise.
35 * @param {string} uri - Reference.
36 * @return {string?} - Pathname.
37 */
38function getPathname(uri) {
39 return parse(uri).pathname;
42/**
43 * Get the `hash` of `uri`, if applicable.
44 *
45 * @todo Externalise.
46 * @param {string} uri - Reference.
47 * @return {string?} - Hash.
48 */
49function getHash(uri) {
50 var hash = parse(uri).hash;
52 return hash ? hash.slice(1) : null;
55/**
56 * Suggest a possible similar reference.
57 *
58 * @param {string} pathname - Unfound reference.
59 * @param {Object<string, boolean>} references - All
60 * references.
61 * @return {string?} - Suggested reference.
62 */
63function getClosest(pathname, references) {
64 var hash = getHash(pathname);
65 var base = getPathname(pathname);
66 var dictionary = [];
67 var reference;
68 var subhash;
69 var subbase;
71 for (reference in references) {
72 subbase = getPathname(reference);
73 subhash = getHash(reference);
75 if (getPathname(reference) === base) {
76 if (subhash && hash) {
77 dictionary.push(subhash);
78 }
79 } else {
80 if (!subhash && !hash) {
81 dictionary.push(subbase);
82 }
83 }
84 }
86 return propose(hash ? hash : base, dictionary, {
87 'threshold': 0.7
88 });
91/**
92 * Utilitity to warn `warning` for each node in `nodes`,
93 * on `file`.
94 *
95 * @param {File} file - Virtual file.
96 * @param {Array.<Node>} nodes - Offending nodes.
97 * @param {string} warning - Message.
98 */
99function warnAll(file, nodes, warning) {
100 nodes.forEach(function (node) {
101 file.warn(warning, node);
102 });
105/**
106 * Gather references: a map of file-paths references
107 * to be one or more nodes.
108 *
109 * @example
110 * gatherReferences(new File(), {});
111 *
112 * @param {File} file - Set of virtual files.
113 * @param {Object.<string, string>} project - GitHub
114 * project, with a `user` and `repo` property
115 * (optional).
116 * @return {Object.<string, Array.<Node>>} exposed - Map of
117 * file-paths (and anchors) which are referenced.
118 */
119function gatherReferences(file, project) {
120 var cache = {};
121 var filePath = file.filePath();
122 var directory = file.directory;
123 var space = file.namespace('mdast');
124 var ast = space.tree || space.ast;
125 var getDefinition;
126 var prefix = '';
128 getDefinition = definitions(ast);
130 if (project.user && project.repo) {
131 prefix = '/' + project.user + '/' + project.repo + '/blob/';
132 }
134 /**
135 * Handle new links.
136 *
137 * @example
138 * link({
139 * href: 'foo/bar/baz.md#foo',
140 * children: [
141 * {
142 * type: 'text',
143 * value: 'foo'
144 * }
145 * ]
146 * });
147 *
148 * @param {Node} node - Link or link-reference node.
149 */
150 function onlink(node) {
151 var link = node.href;
152 var definition;
153 var index;
154 var uri;
155 var pathname;
156 var hash;
158 /*
159 * Handle link-references.
160 */
162 if (node.identifier) {
163 definition = getDefinition(node.identifier);
164 link = definition && definition.link;
165 }
167 /*
168 * Ignore definitions without link.
169 */
171 if (!link) {
172 return;
173 }
175 uri = parse(link);
177 if (!uri.hostname) {
178 /*
179 * Handle hashes, or relative files.
180 */
181 if (!uri.pathname && uri.hash) {
182 link = filePath + uri.hash;
183 uri = parse(link);
184 } else {
185 link = urljoin(directory, link) + (uri.hash || '');
186 uri = parse(link);
187 }
188 }
190 /*
191 * Handle full links.
192 * Only works with GitHub.
193 */
195 if (uri.hostname) {
196 if (!prefix) {
197 return;
198 }
200 if (
201 uri.hostname !== 'github.com' ||
202 uri.pathname.slice(0, prefix.length) !== prefix
203 ) {
204 return;
205 }
207 link = uri.pathname.slice(prefix.length) + (uri.hash || '');
209 /*
210 * Things get interesting here: branches.
211 * `foo/bar/baz` could be `baz` on the
212 * `foo/bar` branch. Or, `baz` in the `bar`
213 * directory on the `foo` branch.
214 *
215 * Currently, I’m ignoring this and just not
216 * supporting branches.
217 */
219 link = link.slice(link.indexOf('/') + 1);
221 uri = parse(link);
222 }
224 /*
225 * Handle file links, or combinations of files
226 * and hashes.
227 */
229 index = link.indexOf('#');
231 if (index === -1) {
232 pathname = link;
233 hash = null;
234 } else {
235 pathname = link.slice(0, index);
236 hash = link.slice(index + 1);
237 }
239 if (!cache[pathname]) {
240 cache[pathname] = [];
241 }
243 cache[pathname].push(node);
245 if (hash) {
246 if (!cache[link]) {
247 cache[link] = [];
248 }
250 cache[link].push(node);
251 }
252 }
254 visit(ast, 'link', onlink);
255 visit(ast, 'linkReference', onlink);
257 return cache;
260/**
261 * Factory to store all markdown files, and headings.
262 *
263 * @example
264 * var gather = gatherExposedFactory();
265 * var file = new File('# foo');
266 *
267 * gather(file).done() // {}
268 *
269 * @return {Function}
270 */
271function gatherExposedFactory() {
272 var cache = {};
273 var hasHeadings = false;
274 var hasSlugs = false;
276 /**
277 * Access found files and headings.
278 *
279 * @return {Object.<string, boolean>} - Map of
280 * file-paths, with `true` as their value.
281 * @throws {Error} - When headings are found, but no
282 * heading has a slug.
283 */
284 function done() {
285 if (hasHeadings && !hasSlugs) {
286 throw new Error(
287 'Missing slugs. Use for example `mdast-slug` to generate ' +
288 'heading IDs'
289 );
290 }
292 return cache;
293 }
295 /**
296 * Find headings in `file`.
297 *
298 * @property {Function} done - Access found links.
299 * @param {File} file - Virtual file.
300 * @returns {Function} - Itself.
301 */
302 function gather(file) {
303 var filePath = file.filePath();
304 var space = file.namespace('mdast');
305 var ast = space.tree || space.ast;
307 /*
308 * Ignore files without AST or filename.
309 */
311 if (filePath && ast) {
312 cache[filePath] = true;
314 visit(ast, 'heading', function (node) {
315 var data = node.data || {};
316 var attributes = data.htmlAttributes || {};
317 var id = attributes.id || data.id;
319 /* istanbul ignore next */
320 id = id || (node.attributes && node.attributes.id);
322 hasHeadings = true;
324 if (id) {
325 cache[filePath + '#' + id] = hasSlugs = true;
326 }
327 });
328 }
330 return gather;
331 }
333 gather.done = done;
335 return gather;
338/**
339 * Check if `file` references headings or files not in
340 * `exposed`. If `project` is given, normalizes GitHub blob
341 * URLs.
342 *
343 * @example
344 * validate({'example.md': true}, new File());
345 *
346 * @param {Object.<string, boolean?>} exposed - Map of
347 * file-paths (and anchors) which can be references to.
348 * @param {File} file - Set of virtual files.
349 * @param {Object.<string, string>} project - GitHub
350 * project, with a `user` and `repo` property
351 * (optional).
352 */
353function validate(exposed, file, project) {
354 var space = file.namespace('mdast');
355 var ast = space.tree || space.ast;
356 var references = ast ? gatherReferences(file, project) : {};
357 var filePath = file.filePath();
358 var reference;
359 var nodes;
360 var real;
361 var hash;
362 var pathname;
363 var warning;
364 var suggestion;
366 for (reference in references) {
367 nodes = references[reference];
368 real = exposed[reference];
369 hash = getHash(reference);
371 /*
372 * Check if files without `hash` can be linked to.
373 * Because there’s no need to inspect those files
374 * for headings they are not added to mdast. This
375 * is especially useful because they might be
376 * non-markdown files. Here we check if they exist.
377 */
379 if ((real === undefined || real === null) && !hash) {
380 real = references[reference] = exists(reference);
381 }
383 if (!real) {
384 if (hash) {
385 pathname = getPathname(reference);
386 warning = 'Link to unknown heading';
388 if (pathname !== filePath) {
389 warning += ' in `' + pathname + '`';
390 }
392 warning += ': `' + hash + '`';
393 } else {
394 warning = 'Link to unknown file: `' + reference + '`';
395 }
397 suggestion = getClosest(reference, exposed);
399 if (suggestion) {
400 warning += '. Did you mean `' + suggestion + '`';
401 }
403 warnAll(file, nodes, warning);
404 }
405 }
408/**
409 * Factory to create a new completer.
410 *
411 * @example
412 * var completer = completerFactory({
413 * 'user': 'foo',
414 * 'repo': 'bar'
415 * });
416 *
417 * @param {Object} project - GitHub project, if applicable.
418 * @return {Function} - `completer`, bound to `project`.
419 */
420function completerFactory(project) {
421 /**
422 * Completer.
423 *
424 * @example
425 * completer(new FileSet(), console.log)
426 *
427 * @property {*} pluginId - Unique ID so completers by
428 * this plug-in are not added multiple times to a single
429 * file-set pipeline.
430 * @param {FileSet} set - Virtual file-set.
431 * @param {function(err?)} done - Callback.
432 */
433 function completer(set, done) {
434 var gatherExposed = gatherExposedFactory();
435 var exposed;
437 set.valueOf().forEach(gatherExposed);
439 exposed = gatherExposed.done();
441 set.valueOf().forEach(function (file) {
442 validate(exposed, file, project);
443 });
445 done();
446 }
448 completer.pluginId = 'mdast-validate-links';
450 return completer;
453/**
454 * Factory to create a transformer based on the given
455 * project and set.
456 *
457 * @example
458 * transformerFactory({}, new FileSet());
459 *
460 * @param {Object.<string, string>} project - GitHub
461 * project, with a `user` and `repo` property
462 * (optional).
463 * @param {FileSet} fileSet - Set of virtual files.
464 * @return {function(ast, file)}
465 */
466function transformerFactory(project, fileSet) {
467 /**
468 * Transformer. Adds references files to the set.
469 *
470 * @example
471 * transformer({}, new File());
472 *
473 * @param {*} ast - Node.
474 * @param {File} file - Virtual file.
475 */
476 function transformer(ast, file) {
477 var references = gatherReferences(file, project);
478 var links = [];
479 var current = getPathname(file.filePath());
480 var link;
481 var pathname;
483 for (link in references) {
484 pathname = getPathname(link);
486 if (
487 pathname !== current &&
488 getHash(link) &&
489 links.indexOf(pathname) === -1
490 ) {
491 links.push(pathname);
493 fileSet.add(pathname);
494 }
495 }
496 }
498 return transformer;
501/**
502 * Attacher.
503 *
504 * @example
505 * attacher(mdast())
506 * // [Error: mdast-validate-links only works on the CLI]
507 *
508 * @param {MDAST} mdast - Processor.
509 * @param {Object?} options - Settings.
510 * @param {FileSet?} fileSet - Virtual file-set.
511 * @throws {Error} - When `fileSet` is not given (when not on
512 * the CLI).
513 */
514function attacher(mdast, options, fileSet) {
515 var repo = (options || {}).repository;
516 var pack;
518 /*
519 * Throw when not on the CLI.
520 */
522 if (!fileSet) {
523 throw new Error('mdast-validate-links only works on the CLI');
524 }
526 /*
527 * Try to get the repo from `package.json` when not
528 * given.
529 */
531 if (!repo) {
532 try {
533 pack = require(path.resolve(process.cwd(), 'package.json'));
534 } catch (exception) {
535 pack = {};
536 }
538 repo = pack.repository ? pack.repository.url || pack.repository : '';
539 }
541 repo = repo ? gh(repo) : {};
543 repo = {
544 'user': repo.user,
545 'repo': repo.repo
546 };
548 /*
549 * Attach a `completer`.
550 */
552 fileSet.use(completerFactory(repo));
554 return transformerFactory(repo, fileSet);
557/*
558 * Expose.
559 */
561module.exports = attacher;