From 1616cae5cf4e728d44178405f2b7f818b2d4855a Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Sat, 25 Mar 2017 21:07:34 +0000 Subject: [PATCH] build(aio): add renderExamples processor This processor will eventually replace the `{@example}` inline tags because it provides a cleaner approach that also supports tabbed examples straight out of the box. The idea is that authors will simply add a `path` and (optionally) a `region` attribute to `` or `` elements in their docs. This indicates to dgeni that the relevant example needs to be injected into the content of this element. For example, assume that there is an example file `toh-pt1/index.hml` with a region called `title`, which looks like: ```

Tour of Heroes

``` Then the document author could get this to appear in the docs as a standalone example: ``` ``` Or as part of a tabbed group: ``` ``` If no `path` attribute is provided then the element is ignored, which enables authors to provide inline code instead: ``` Some <html> escaped code ``` Also all attributes other than `path` and `region` are ignored and passed through to the final rendered output allowing the author to provide styling hints: ``` ``` --- aio/transforms/examples-package/index.js | 1 + .../inline-tag-defs/example.js | 3 +- .../processors/render-examples.js | 33 +++++++ .../processors/render-examples.spec.js | 85 ++++++++++++++++++ aio/transforms/examples-package/utils.js | 86 +++++++++++++++++++ aio/transforms/examples-package/utils.spec.js | 76 ++++++++++++++++ 6 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 aio/transforms/examples-package/processors/render-examples.js create mode 100644 aio/transforms/examples-package/processors/render-examples.spec.js create mode 100644 aio/transforms/examples-package/utils.spec.js diff --git a/aio/transforms/examples-package/index.js b/aio/transforms/examples-package/index.js index a35ec22a03..ccad58cc33 100644 --- a/aio/transforms/examples-package/index.js +++ b/aio/transforms/examples-package/index.js @@ -13,6 +13,7 @@ module.exports = .factory(require('./services/getExampleRegion')) .processor(require('./processors/collect-examples')) + .processor(require('./processors/render-examples')) .config(function(readFilesProcessor, exampleFileReader) { readFilesProcessor.fileReaders.push(exampleFileReader); diff --git a/aio/transforms/examples-package/inline-tag-defs/example.js b/aio/transforms/examples-package/inline-tag-defs/example.js index b31553bd50..aa3e896f30 100644 --- a/aio/transforms/examples-package/inline-tag-defs/example.js +++ b/aio/transforms/examples-package/inline-tag-defs/example.js @@ -13,8 +13,7 @@ var entities = require('entities'); * {@example core/application_spec.ts -region=hello-app -title='Sample component' } * @kind function */ -module.exports = function exampleInlineTagDef( - parseArgString, exampleMap, createDocMessage, log, collectExamples, getExampleRegion) { +module.exports = function exampleInlineTagDef(parseArgString, createDocMessage, getExampleRegion) { return { name: 'example', description: diff --git a/aio/transforms/examples-package/processors/render-examples.js b/aio/transforms/examples-package/processors/render-examples.js new file mode 100644 index 0000000000..f2c2d0bba5 --- /dev/null +++ b/aio/transforms/examples-package/processors/render-examples.js @@ -0,0 +1,33 @@ +const { parseAttributes } = require('../utils'); + +/** + * Search the renderedContent looking for code examples that have a path (and optionally a region) attribute. + * When they are found replace their content with the appropriate doc-region parsed previously from an example file. + */ +module.exports = function renderExamples(getExampleRegion) { + return { + $runAfter: ['docs-rendered'], + $runBefore: ['writing-files'], + $process: function(docs) { + docs.forEach(doc => { + if (doc.renderedContent) { + // We match either `code-example` or `code-pane` elements that have a path attribute + doc.renderedContent = doc.renderedContent.replace(/<(code-example|code-pane)([^>]*)>[^<]*<\/\1>/, (original, element, attributes) => { + const attrMap = parseAttributes(attributes); + if (attrMap.path) { + // We found a path attribute so look up the example and rebuild the HTML + const exampleContent = getExampleRegion(doc, attrMap.path, attrMap.region); + delete attrMap.path; + delete attrMap.region; + attributes = Object.keys(attrMap).map(key => ` ${key}="${attrMap[key].replace(/"/g, '"')}"`).join(''); + return `<${element}${attributes}>\n${exampleContent}\n`; + } + // No path attribute so just ignore this one + return original; + }); + } + }); + } + }; +}; + diff --git a/aio/transforms/examples-package/processors/render-examples.spec.js b/aio/transforms/examples-package/processors/render-examples.spec.js new file mode 100644 index 0000000000..0b956f7593 --- /dev/null +++ b/aio/transforms/examples-package/processors/render-examples.spec.js @@ -0,0 +1,85 @@ +var testPackage = require('../../helpers/test-package'); +var Dgeni = require('dgeni'); +var path = require('path'); + +describe('renderExamples processor', () => { + var injector, processor, exampleMap, regionParser, collectExamples, exampleMap; + + beforeEach(function() { + const dgeni = new Dgeni([testPackage('examples-package', true)]); + injector = dgeni.configureInjector(); + + exampleMap = injector.get('exampleMap'); + processor = injector.get('renderExamples'); + collectExamples = injector.get('collectExamples'); + exampleMap = injector.get('exampleMap'); + + collectExamples.exampleFolders = ['examples']; + exampleMap['examples'] = { + 'test/url': { regions: { + '': { renderedContent: 'whole file' }, + 'region-1': { renderedContent: 'region 1 contents' } + } } + }; + }); + + it('should run before the correct processor', () => { + expect(processor.$runBefore).toEqual(['writing-files']) + }); + + it('should run after the correct processor', () => { + expect(processor.$runAfter).toEqual(['docs-rendered']); + }); + + ['code-example', 'code-pane'].forEach(CODE_TAG => + describe(CODE_TAG, () => { + it(`should ignore a <${CODE_TAG}> tags with no path attribute`, () => { + const docs = [ + { renderedContent: `Some text\n<${CODE_TAG}>Some code\n<${CODE_TAG} class="anti-pattern" title="Bad Code">do not do this` } + ]; + processor.$process(docs); + expect(docs[0].renderedContent).toEqual(`Some text\n<${CODE_TAG}>Some code\n<${CODE_TAG} class="anti-pattern" title="Bad Code">do not do this`); + }); + + it(`should replace the content of the <${CODE_TAG}> tag with the whole contents from an example file if a path is provided`, () => { + const docs = [ + { renderedContent: `<${CODE_TAG} path="test/url">Some code`} + ]; + processor.$process(docs); + expect(docs[0].renderedContent).toEqual(`<${CODE_TAG}>\nwhole file\n`); + }); + + it('should contain the region contents from the example file if a region is specified', () => { + const docs = [ + { renderedContent: `<${CODE_TAG} path="test/url" region="region-1">Some code` } + ]; + processor.$process(docs); + expect(docs[0].renderedContent).toEqual(`<${CODE_TAG}>\nregion 1 contents\n`); + }); + + it(`should replace the content of the <${CODE_TAG}> tag with the whole contents from an example file if the region is empty`, () => { + const docs = [ + { renderedContent: `<${CODE_TAG} path="test/url" region="">Some code` } + ]; + processor.$process(docs); + expect(docs[0].renderedContent).toEqual(`<${CODE_TAG}>\nwhole file\n`); + }); + + it('should remove the path and region attributes but leave the other attributes alone', () => { + const docs = [ + { renderedContent: `<${CODE_TAG} class="special" path="test/url" linenums="15" region="region-1" id="some-id">Some code` } + ]; + processor.$process(docs); + expect(docs[0].renderedContent).toEqual(`<${CODE_TAG} class="special" linenums="15" id="some-id">\nregion 1 contents\n`); + }); + + it('should cope with spaces and double quotes inside attribute values', () => { + const docs = [ + { renderedContent: `<${CODE_TAG} title='a "quoted" value' path="test/url">`} + ]; + processor.$process(docs); + expect(docs[0].renderedContent).toEqual(`<${CODE_TAG} title="a "quoted" value">\nwhole file\n`); + }); + }) + ); +}); \ No newline at end of file diff --git a/aio/transforms/examples-package/utils.js b/aio/transforms/examples-package/utils.js index d0580917a0..f4dbb85bec 100644 --- a/aio/transforms/examples-package/utils.js +++ b/aio/transforms/examples-package/utils.js @@ -1,7 +1,93 @@ module.exports = { + /** + * Transform the values of an object via a mapper function + * @param {Object} obj + * @param {Function} mapper + */ mapObject(obj, mapper) { const mappedObj = {}; Object.keys(obj).forEach(key => { mappedObj[key] = mapper(key, obj[key]); }); return mappedObj; + }, + + /** + * Parses the attributes from a string taken from an HTML element start tag + * E.g. ` a="one" b="two" ` + * @param {string} str + */ + parseAttributes(str) { + const attrMap = {}; + let index = 0, key, value; + + skipSpace(); + + while(index < str.length) { + takeAttribute(); + skipSpace(); + } + + function takeAttribute() { + const key = takeKey(); + skipSpace(); + if (tryEquals()) { + skipSpace(); + const quote = tryQuote(); + attrMap[key] = takeValue(quote); + // skip the closing quote or whitespace + index++; + } else { + attrMap[key] = true; + } + } + + + function skipSpace() { + while(index < str.length && /\s/.test(str[index])) { + index++; + } + } + + function tryEquals() { + if (str[index] === '=') { + index++; + return true; + } + } + + function takeKey() { + let startIndex = index; + while(index < str.length && /[^\s=]/.test(str[index])) { + index++; + } + return str.substring(startIndex, index); + } + + function tryQuote() { + const quote = str[index]; + if (['"', "'"].indexOf(quote) !== -1) { + index++; + return quote; + } + } + + function takeValue(quote) { + let startIndex = index; + + if (quote) { + while(index < str.length && str[index] !== quote) { + index++; + } + if (index >= str.length) { + throw new Error(`Unterminated quoted attribute value in \`${str}\`. Starting at ${startIndex}. Expected a ${quote} but got "end of string".`); + } + } else { + while(index < str.length && /\S/.test(str[index])) { + index++; + } + } + return str.substring(startIndex, index); + } + + return attrMap; } }; \ No newline at end of file diff --git a/aio/transforms/examples-package/utils.spec.js b/aio/transforms/examples-package/utils.spec.js new file mode 100644 index 0000000000..61b079d7c7 --- /dev/null +++ b/aio/transforms/examples-package/utils.spec.js @@ -0,0 +1,76 @@ +const { mapObject, parseAttributes } = require('./utils'); + +fdescribe('utils', () => { + describe('mapObject', () => { + it('creates a new object', () => { + const testObj = { a: 1 }; + const mappedObj = mapObject(testObj, (key, value) => value); + expect(mappedObj).toEqual(testObj); + expect(mappedObj).not.toBe(testObj); + }); + + it('maps the values via the mapper function', () => { + const testObj = { a: 1, b: 2 }; + const mappedObj = mapObject(testObj, (key, value) => value * 2); + expect(mappedObj).toEqual({ a: 2, b: 4 }); + }); + }); + + describe('parseAttributes', () => { + it('should parse empty string', () => { + const attrs = parseAttributes(''); + expect(attrs).toEqual({ }); + }); + + it('should parse blank string', () => { + const attrs = parseAttributes(' '); + expect(attrs).toEqual({ }); + }); + + it('should parse double quoted attributes', () => { + const attrs = parseAttributes('a="one" b="two"'); + expect(attrs).toEqual({ a: 'one', b: 'two' }); + }); + + it('should parse empty quoted attributes', () => { + const attrs = parseAttributes('a="" b="two"'); + expect(attrs).toEqual({ a: '', b: 'two' }); + }); + + it('should parse single quoted attributes', () => { + const attrs = parseAttributes('a=\'one\' b=\'two\''); + expect(attrs).toEqual({ a: 'one', b: 'two' }); + }); + + it('should ignore whitespace', () => { + const attrs = parseAttributes(' a = "one" b = "two" '); + expect(attrs).toEqual({ a: 'one', b: 'two' }); + }); + + it('should parse attributes with quotes within quotes', () => { + const attrs = parseAttributes('a=\'o"n"e\' b="t\'w\'o"'); + expect(attrs).toEqual({ a: 'o"n"e', b: 't\'w\'o' }); + }); + + it('should parse attributes with spaces in their values', () => { + const attrs = parseAttributes('a="one and two" b="three and four"'); + expect(attrs).toEqual({ a: 'one and two', b: 'three and four' }); + }); + + it('should parse empty attributes', () => { + const attrs = parseAttributes('a b="two"'); + expect(attrs).toEqual({ a: true, b: 'two' }); + }); + + it('should parse unquoted attributes', () => { + const attrs = parseAttributes('a=one b=two'); + expect(attrs).toEqual({ a: 'one', b: 'two' }); + }); + + it('should complain if a quoted attribute is not closed', () => { + expect(() => parseAttributes('a="" b="two')).toThrowError( + 'Unterminated quoted attribute value in `a="" b="two`. Starting at 8. Expected a " but got "end of string".' + ); + }) + }); +}); \ No newline at end of file