UNPKG

vis-network

Version:

A dynamic, browser-based visualization library.

699 lines (620 loc) 19 kB
import Pageres from "pageres"; import cheerio from "cheerio"; import childProcess from "child_process"; import crypto from "crypto"; import fs from "fs"; import globby from "globby"; import path from "path"; import prettier from "prettier"; import util from "util"; import yargs from "yargs"; yargs .usage("node -r ./babel.mocha.js generate-example-index.ts [options]") .hide("version") .help() .option("examples-directory", { alias: "d", default: "./examples", describe: "The directory where index.html and thumbnails will be written to and examples located.", type: "string" }) .option("lint", { alias: "l", default: false, describe: "Lint examples.", type: "boolean" }) .option("index", { alias: "i", default: false, describe: "Generate index file.", type: "boolean" }) .option("screenshots", { alias: "s", default: false, describe: "Render screenshot thumbnails.", type: "boolean" }) .option("container-id", { alias: "c", default: "vis-container", describe: "The id of the elements where Vis will put canvas.", type: "string" }) .option("web-url", { alias: "w", demand: true, describe: "The URL of web presentation (for example GitHub Pages).", type: "string" }) .option("screenshot-script", { alias: "S", demand: false, describe: "The path of JavaScript file that will be executed before taking a screenshot (and before any other JavaScript in the page).", type: "string" }) .option("title", { alias: "t", default: "Examples", describe: "The title of the examples index.", type: "string" }); // Pageres uses quite a lot of listeners when invoked multiple times in // parallel. This ensures there are no warnings about it. process.setMaxListeners(40); // Resolve paths before cd. const screenshotScriptPath = typeof yargs.argv.screenshotScript === "string" ? path.resolve(yargs.argv.screenshotScript) : undefined; // Set PWD. If omitted assumes it was executed in the root of the project. const examplesRoot = yargs.argv.examplesDirectory as string; process.chdir(examplesRoot); type ExamplesRoot = { [Key: string]: Examples; }; type Examples = { [Key: string]: Examples | Example; }; type Example = { $: CheerioStatic; delay: number; html: string; path: string; selector: string; titles: string[]; }; function isExample(value: any): value is Example { return ( typeof value === "object" && value !== null && typeof value.path === "string" ); } const collator = new Intl.Collator("US"); const exec = util.promisify(childProcess.exec); const readFile = util.promisify(fs.readFile); const unlink = util.promisify(fs.unlink); const writeFile = util.promisify(fs.writeFile); const formatHTML = (html: string): string => prettier.format(html, { filepath: "index.html" }); const formatJS = (js: string): string => prettier.format(js, { filepath: "script.js" }); const formatCSS = (css: string): string => prettier.format(css, { filepath: "style.css" }); function getMeta(page: CheerioStatic, name: string, fallback: number): number; function getMeta(page: CheerioStatic, name: string, fallback: string): string; function getMeta( page: CheerioStatic, name: string, fallback: number | string ): number | string { const content = page(`meta[name="${name}"]`).attr("content"); if (typeof fallback === "number") { const nmContent = Number.parseFloat(content); return !Number.isNaN(nmContent) ? nmContent : fallback; } else { return content != null ? content : fallback; } } class ContentBuilder { private _root: Cheerio; private _screenshotTodo: Example[] = []; public constructor( private _examples: ExamplesRoot, private _projectPath: string, private _webURL: string, private _screenshotScript: string = "" ) { this._root = cheerio("<div>"); } public build({ renderScreenshots }: { renderScreenshots?: boolean } = {}): { html: Promise<Cheerio>; screenshots: Promise<void>; } { const html = (async (): Promise<Cheerio> => { // Generate index page. for (const key of Object.keys(this._examples).sort(collator.compare)) { this._root.append( await this._processGroup(this._examples[key], key, 1) ); } return this._root; })(); const screenshots = renderScreenshots ? (async (): Promise<void> => { await html; // Generate screenshots. // There is quite long delay to ensure the chart is rendered properly // so it's much faster to run a lot of them at the same time. const total = this._screenshotTodo.length; let finished = 0; await Promise.all( new Array(6).fill(null).map( async (): Promise<void> => { while (this._screenshotTodo.length) { const example = this._screenshotTodo.pop(); await this._generateScreenshot(example); ++finished; console.info( `${("" + Math.floor((finished / total) * 100)).padStart( 3, " " )}% - ${example.path}` ); } } ) ); })() : Promise.resolve(); return { html, screenshots }; } private async _processGroup( examples: Examples, title: string, level: number ): Promise<Cheerio> { const heading = cheerio(`<h${Math.max(1, Math.min(6, level))}>`); heading.text(title); const list = cheerio("<div>"); const section = cheerio("<div>"); section.append(heading, list); for (const key of Object.keys(examples).sort(collator.compare)) { const example = examples[key]; if (isExample(example)) { const header = cheerio("<div>").append( // Title cheerio("<a>") .attr("href", example.path) .text(key), // JSFiddle cheerio("<span>") .addClass("playgrounds") .append( this._generateJSFiddle(example), this._generateCodePen(example) ) ); const image = cheerio("<a>") .attr("href", example.path) .append( cheerio("<div>") .addClass("example-image") .append( cheerio("<img>") .attr("src", this._pageToScreenshotPath(example.path)) .attr("alt", key) ) ); const item = cheerio("<span>") .addClass("example-link") .append(header, image); list.append(item); this._screenshotTodo.push(example); } else { section.append(await this._processGroup(example, key, level + 1)); } } return section; } private _generatePlaygroundData( example: Example ): { code: { css: string; html: string; js: string; }; resources: { css: string[]; js: string[]; }; } { // JavaScript const eventListeners = (Object.entries( example.$("body").get(0).attribs ) as [string, string][]) .filter(([name]): boolean => /^on/.test(name)) .map(([name, value]): [string, string] => [name.slice(2), value]) .map( ([name, value]): string => `window.addEventListener("${name}", () => { ${value} });` ) .join("\n"); const js = formatJS( example .$("script") .map((_i, elem) => elem.children[0]) .get() .map((elem): string => elem.data) .join("") + "\n\n;" + eventListeners ); // Cascading Style Sheets const css = formatCSS( example .$("style") .map((_i, elem) => elem.children[0]) .get() .map((elem): string => elem.data) .join("") ); // Hypertext Markup Language const $html = cheerio.load(example.$("body").html()); $html("script").remove(); const html = formatHTML($html("body").html()); // Resources const fixPath = (rawPath: string): string => /^https?:\/\//.test(rawPath) ? rawPath : path .resolve(path.dirname(example.path) + path.sep + rawPath) .replace(this._projectPath, this._webURL); const resources = { js: example .$("script") .map((_i, elem): string => cheerio(elem).attr("src")) .get() .map(fixPath), css: example .$("link[rel='stylesheet']") .map((_i, elem): string => cheerio(elem).attr("href")) .get() .map(fixPath) }; return { code: { css, html, js }, resources }; } private _generateJSFiddle(example: Example): Cheerio { const data = this._generatePlaygroundData(example); const form = cheerio("<form>"); form.attr("action", "http://jsfiddle.net/api/post/library/pure/"); form.attr("method", "post"); form.attr("target", "_blank"); form.append( cheerio("<button>") .addClass("icon jsfiddle") .attr("alt", "JSFiddle") .attr("title", "JSFiddle") .html("&nbsp;") // No break space helps align the icon better. ); // JavaScript form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "js") .attr("value", data.code.js) ); // Cascading Style Sheets form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "css") .attr("value", data.code.css) ); // Hypertext Markup Language form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "html") .attr("value", data.code.html) ); // Resources form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "resources") .attr("value", [...data.resources.css, ...data.resources.js].join(",")) ); // Don't run JS before the DOM is ready. form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "wrap") .attr("value", "b") ); // Title form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "title") .attr("value", example.titles.join(" | ")) ); return form; } private _generateCodePen(example: Example): Cheerio { const data = this._generatePlaygroundData(example); const form = cheerio("<form>"); form.attr("action", "https://codepen.io/pen/define"); form.attr("method", "post"); form.attr("target", "_blank"); form.append( cheerio("<button>") .addClass("icon codepen") .attr("alt", "CodePen") .attr("title", "CodePen") .html("&nbsp;"), // No break space helps align the icon better. cheerio("<input>") .attr("type", "hidden") .attr("name", "data") .attr( "value", JSON.stringify({ css: data.code.css, css_external: data.resources.css.join(";"), html: data.code.html, js: data.code.js, js_external: data.resources.js.join(";"), title: example.titles.join(" | ") }) ) ); // JavaScript form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "js") .attr("value", data.code.js) ); // Cascading Style Sheets form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "css") .attr("value", data.code.css) ); // Hypertext Markup Language form.append( cheerio("<input>") .attr("type", "hidden") .attr("name", "html") .attr("value", data.code.html) ); return form; } private async _generateScreenshot(example: Example): Promise<void> { const shotPath = this._pageToScreenshotPath(example.path); const size = 400; // Prepare the page. It has to be written to the disk so that files with // relative URLs can be loaded. Pageres' script can't be used here because // it runs after the existing scripts on the page and therefore doesn't // allow to modify things prior to their invocation. const tmpPath = path.join( path.dirname(example.path), ".tmp.example.screenshot." + path.basename(example.path) ); const screenshotPage = cheerio.load(example.html); screenshotPage("head").prepend( cheerio("<script>") .attr("type", "text/javascript") .text(this._screenshotScript) ); await writeFile(tmpPath, formatHTML(screenshotPage.html())); // Render the page and take the screenshot. await new Pageres({ delay: example.delay, selector: example.selector, css: [ `${example.selector} {`, " border: none !important;", " position: relative !important;", " top: unset !important;", " left: unset !important;", " bottom: unset !important;", " right: unset !important;", ` height: ${size}px !important;`, ` max-height: ${size}px !important;`, ` max-width: ${size}px !important;`, ` min-height: ${size}px !important;`, ` min-width: ${size}px !important;`, ` width: ${size}px !important;`, "}" ].join("\n"), filename: shotPath.replace(/^.*\/([^\/]*)\.png$/, "$1"), format: "png" }) .src(tmpPath, ["1280x720"]) .dest(shotPath.replace(/\/[^\/]*$/, "")) .run(); // Remove the temporary file. await unlink(tmpPath); } private _pageToScreenshotPath(pagePath: string): string { return `./thumbnails/${crypto .createHash("sha256") .update(pagePath) .digest("hex")}.png`; } } const exampleLinter = { lint(path: string, page: CheerioStatic): boolean { let valid = true; const msgs = [`${path}:`]; if (page("#title").length !== 1) { msgs.push("Missing #title element in the body."); valid = false; } if (page("#title > *").length < 2) { msgs.push( "There have to be at least two headings (group and example name)." ); valid = false; } const headTitle = page("head > title") .text() .trim(); const bodyTitle = page("#title > *") .map((_i, elem): string => cheerio(elem).text()) .get() .join(" | ") .trim(); if (headTitle !== bodyTitle) { msgs.push( "The title in the head doesn't match the title in the body.", ` head: ${headTitle}`, ` body: ${bodyTitle}` ); valid = false; } if (msgs.length > 1) { console.warn("\n" + msgs.join("\n ")); } return valid; } }; (async (): Promise<void> => { if (!yargs.argv.index && !yargs.argv.screenshots && !yargs.argv.lint) { yargs.parse("--help"); return; } const examples: ExamplesRoot = {}; const indexTemplate = readFile( path.join(__dirname, "index.template.html"), "utf-8" ); const selector = "#" + yargs.argv.containerId; const stats = { examples: 0 }; const skipped: string[] = []; await Promise.all( (await globby("**/*.html")).map( async (pagePath): Promise<any> => { const html = await readFile(pagePath, "utf-8"); const $page = cheerio.load(html); const pageDelay = getMeta($page, "example-screenshot-delay", 5); const pageSelector = getMeta( $page, "example-screenshot-selector", selector ); // Is this an examples? if ($page(pageSelector).length === 0) { skipped.push(pagePath); return; } if (yargs.argv.lint) { exampleLinter.lint(pagePath, $page); } // Body titles. let titles = $page("#title > *") .get() .map((elem): string => $page(elem) .text() .trim() ); // Head title fallback. if (titles.length < 2) { titles = $page("head > title") .text() .split("|") .map((title): string => title.trim()); } // File path fallback. if (titles.length < 2) { titles = pagePath.split("/"); } // Just ignore it. if (titles.length < 2) { console.error("Title resolution failed. Skipping."); return; } const example: Example = titles.reduce((acc, title): any => { while (acc[title] != null && acc[title].path != null) { console.error("The following category already exists: ", titles); title += "!"; } return (acc[title] = acc[title] || {}); }, examples); if (Object.keys(example).length) { console.error( "The following example has the same name as an already existing category: ", titles ); return; } example.$ = $page; example.delay = pageDelay; example.html = html; example.path = pagePath; example.selector = pageSelector; example.titles = titles; ++stats.examples; } ) ); if (skipped.length) { process.stdout.write("\n"); console.info( [ "The following files don't look like examples (there is nothing to take a screenshot of):", ...skipped.sort() ].join("\n ") ); } if (stats.examples === 0) { console.info("No usable example files were found."); } else if (yargs.argv.index || yargs.argv.screenshots) { process.stdout.write("\n"); // Get the project and web URL for JSFiddles. const projectPath = path.resolve( (await exec("npm prefix")).stdout.slice(0, -1) ); const webURL = yargs.argv["web-url"] as string; const screenshotScript = screenshotScriptPath != null ? await readFile(screenshotScriptPath, "utf-8") : undefined; const builtData = new ContentBuilder( examples, projectPath, webURL, screenshotScript ).build({ renderScreenshots: yargs.argv.screenshots as boolean }); // Create and write the page. if (yargs.argv.index) { const page = cheerio.load(await indexTemplate); page("title").text(yargs.argv.title as string); page("body").append(await builtData.html); await writeFile("./index.html", formatHTML(page.html())); console.info(`Index file with ${stats.examples} example(s) was written.`); } // Create and write the screenshots. if (yargs.argv.screenshots) { await builtData.screenshots; console.info("All screenshot files were written."); } } })();