const assert = require('assert'); const frontMatter = require('front-matter'); const fs = require('fs'); const handlebars = require('handlebars'); const marked = require('marked'); const path = require('path'); const pkg = require('../../package.json'); const promisify = require('util').promisify; const RawSource = require('webpack-sources').RawSource; const readFile = promisify(fs.readFile); const isCssRegEx = /\.css(\?.*)?$/; const isJsRegEx = /\.js(\?.*)?$/; const importRegEx = /^import .* from '(.*)';$/; const isTemplateJs = /\/(jquery(-\d+\.\d+\.\d+)?|(bootstrap(\.bundle)?))(\.min)?\.js(\?.*)?$/; const isTemplateCss = /\/bootstrap(\.min)?\.css(\?.*)?$/; handlebars.registerHelper( 'md', (str) => new handlebars.SafeString(marked(str)) ); /** * Used to doube-escape the title when stored as data-* attribute. */ handlebars.registerHelper('escape', (text) => { return handlebars.Utils.escapeExpression(text); }); handlebars.registerHelper('indent', (text, options) => { if (!text) { return text; } const count = options.hash.spaces || 2; const spaces = new Array(count + 1).join(' '); return text .split('\n') .map((line) => (line ? spaces + line : '')) .join('\n'); }); /** * Returns the object with the keys inserted in alphabetic order. * When exporting with `JSON.stringify(obj)` the keys are sorted. * @param {Object} obj Any object * @return {Object} New object */ function sortObjectByKey(obj) { return Object.keys(obj) .sort() // sort twice to get predictable, case insensitve order .sort((a, b) => a.localeCompare(b, 'en', {sensitivity: 'base'})) .reduce((idx, tag) => { idx[tag] = obj[tag]; return idx; }, {}); } /** * Create an index of tags belonging to examples * @param {Array} exampleData Array of example data objects. * @return {Object} Word index. */ function createTagIndex(exampleData) { const index = {}; exampleData.forEach((data, i) => { data.tags.forEach((tag) => { tag = tag.toLowerCase(); let tagIndex = index[tag]; if (!tagIndex) { tagIndex = []; index[tag] = tagIndex; } tagIndex.push(i); }); }); return index; } /** * Create an inverted index of keywords from examples. Property names are * lowercased words. Property values are objects mapping example index to word * count. * @param {Array} exampleData Array of example data objects. * @return {Object} Word index. */ function createWordIndex(exampleData) { const index = {}; const keys = ['shortdesc', 'title', 'tags']; exampleData.forEach((data, i) => { keys.forEach((key) => { let text = data[key] || ''; if (Array.isArray(text)) { text = text.join(' '); } const words = text.toLowerCase().split(/\W+/); words.forEach((word) => { if (word) { let counts = index[word]; if (!counts) { counts = {}; index[word] = counts; } counts[i] = (counts[i] || 0) + 1; } }); }); }); return index; } /** * Gets the source for the chunk that matches the jsPath * @param {Object} chunk Chunk. * @param {string} jsName Name of the file. * @return {string} The source. */ function getJsSource(chunk, jsName) { let jsSource; for (let i = 0, ii = chunk.modules.length; i < ii; ++i) { const module = chunk.modules[i]; if (module.modules) { jsSource = getJsSource(module, jsName); if (jsSource) { return jsSource; } } if (module.identifier.endsWith(jsName) && module.source) { return module.source; } } } /** * Gets dependencies from the js source. * @param {string} jsSource Source. * @return {Object} dependencies */ function getDependencies(jsSource) { const lines = jsSource.split('\n'); const dependencies = { ol: pkg.version, }; for (let i = 0, ii = lines.length; i < ii; ++i) { const line = lines[i]; const importMatch = line.match(importRegEx); if (importMatch) { const imp = importMatch[1]; if (!imp.startsWith('ol/') && imp != 'ol') { const parts = imp.split('/'); let dep; if (imp.startsWith('@')) { dep = parts.slice(0, 2).join('/'); } else { dep = parts[0]; } if (dep in pkg.devDependencies) { dependencies[dep] = pkg.devDependencies[dep]; } } } } return dependencies; } class ExampleBuilder { /** * A webpack plugin that builds the html files for our examples. * @param {Object} config Plugin configuration. Requires a `templates` property * with the path to templates and a `common` property with the name of the * common chunk. */ constructor(config) { this.templates = config.templates; this.common = config.common; } /** * Called by webpack. * @param {Object} compiler The webpack compiler. */ apply(compiler) { compiler.hooks.emit.tapPromise('ExampleBuilder', async (compilation) => { const chunks = compilation .getStats() .toJson() .chunks.filter((chunk) => chunk.names[0] !== this.common); const exampleData = []; await Promise.all( chunks.map(async (chunk) => { const data = await this.readHtml(compiler.context, chunk); exampleData.push(data); }) ); const examples = exampleData.map((data) => { return { link: data.filename, title: data.title, shortdesc: data.shortdesc, tags: data.tags, }; }); examples.sort((a, b) => a.title.localeCompare(b.title, 'en', {sensitivity: 'base'}) ); const tagIndex = createTagIndex(examples); const info = { examples: examples, // Tags for main page... TODO: implement index tag links // tagIndex: sortObjectByKey(tagIndex), wordIndex: sortObjectByKey(createWordIndex(examples)), }; exampleData.forEach((data) => { data.tags = data.tags.map((tag) => { const tagExamples = tagIndex[tag.toLowerCase()]; return { tag: tag, examples: tagExamples.map((exampleIdx) => { const example = examples[exampleIdx]; return { link: example.link, title: example.title, isCurrent: data.filename === example.link, }; }), }; }); }); await Promise.all( exampleData.map(async (data) => { const assets = await this.render(data, data.chunk); for (const file in assets) { compilation.assets[file] = new RawSource(assets[file]); } }) ); const indexSource = `const info = ${JSON.stringify(info)};`; compilation.assets['examples-info.js'] = new RawSource(indexSource); }); } async readHtml(dir, chunk) { const name = chunk.names[0]; const htmlName = `${name}.html`; const htmlPath = path.join(dir, htmlName); const htmlSource = await readFile(htmlPath, {encoding: 'utf8'}); const {attributes, body} = frontMatter(htmlSource); assert(!!attributes.layout, `missing layout in ${htmlPath}`); const data = Object.assign(attributes, {contents: body}); data.olVersion = pkg.version; data.filename = htmlName; data.dir = dir; data.chunk = chunk; // process tags if (data.tags) { data.tags = data.tags.replace(/[\s"]+/g, '').split(','); } else { data.tags = []; } return data; } async render(data, chunk) { const name = chunk.names[0]; const assets = {}; const readOptions = {encoding: 'utf8'}; // add in script tag const jsName = `${name}.js`; let jsSource = getJsSource(chunk, path.join('.', jsName)); if (!jsSource) { throw new Error(`No .js source for ${jsName}`); } // remove "../src/" prefix and ".js" to have the same import syntax as the documentation jsSource = jsSource.replace(/'\.\.\/src\//g, "'"); jsSource = jsSource.replace(/\.js';/g, "';"); if (data.cloak) { for (const entry of data.cloak) { jsSource = jsSource.replace(new RegExp(entry.key, 'g'), entry.value); } } // Remove worker loader import and modify `new Worker()` to add source jsSource = jsSource.replace( /import Worker from 'worker-loader![^\n]*\n/g, '' ); jsSource = jsSource.replace('new Worker()', "new Worker('./worker.js')"); data.js = { tag: ` `, source: jsSource, }; // check for worker js const workerName = `${name}.worker.js`; const workerPath = path.join(data.dir, workerName); let workerSource; try { workerSource = await readFile(workerPath, readOptions); } catch (err) { // pass } if (workerSource) { // remove "../src/" prefix and ".js" to have the same import syntax as the documentation workerSource = workerSource.replace(/'\.\.\/src\//g, "'"); workerSource = workerSource.replace(/\.js';/g, "';"); if (data.cloak) { for (const entry of data.cloak) { workerSource = workerSource.replace( new RegExp(entry.key, 'g'), entry.value ); } } data.worker = { source: workerSource, }; assets[workerName] = workerSource; } data.pkgJson = JSON.stringify( { name: name, dependencies: getDependencies( jsSource + (workerSource ? `\n${workerSource}` : '') ), devDependencies: { parcel: '1.11.0', }, scripts: { start: 'parcel index.html', build: 'parcel build --experimental-scope-hoisting --public-url . index.html', }, }, null, 2 ); // check for example css const cssName = `${name}.css`; const cssPath = path.join(data.dir, cssName); let cssSource; try { cssSource = await readFile(cssPath, readOptions); } catch (err) { // pass } if (cssSource) { data.css = { tag: ``, source: cssSource, }; assets[cssName] = cssSource; } // add additional resources if (data.resources) { const localResources = []; const remoteResources = []; data.resources.forEach((resource) => { const remoteResource = /^https?:\/\//.test(resource) ? resource : `https://openlayers.org/en/v${pkg.version}/examples/${resource}`; if (isJsRegEx.test(resource)) { if (!isTemplateJs.test(resource)) { localResources.push(``); } remoteResources.push(``); } else if (isCssRegEx.test(resource)) { if (!isTemplateCss.test(resource)) { localResources.push(``); } remoteResources.push( `` ); } else { throw new Error( `Invalid resource: '${resource}' is not .js or .css: ${data.filename}` ); } }); data.extraHead = { local: localResources.join('\n'), remote: remoteResources.join('\n'), }; } const templatePath = path.join(this.templates, data.layout); const templateSource = await readFile(templatePath, readOptions); assets[data.filename] = handlebars.compile(templateSource)(data); return assets; } } module.exports = ExampleBuilder;