diff --git a/README.md b/README.md index abbe941..69216a9 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,9 @@ be excluded from the resulting uri. With the `type` parameter set to `entry` and an undefined `id` parameter, this template would expand to the uri `http://example.com/entry/`. -To include curly brackets in the url, without replacement, escape the opening -bracket: `?brackets=\\{}`. (Remember double escape characters to escape the -escape character in JavaScript. :) +To include curly brackets in the url, without replacement, simply escape them: +`?brackets=\\{\\}`. (Remember double escape characters to escape the +escape character in JavaScript.) ### Query parameters diff --git a/lib/compile-test.js b/lib/compile-test.js index 0a5682d..96f1f26 100644 --- a/lib/compile-test.js +++ b/lib/compile-test.js @@ -100,7 +100,7 @@ test('should compile optional query component', (t) => { test('should compile query continuation', (t) => { const template = 'http://example.com/?id=ent1{&first,max}' - const expected = ['http://example.com/?id=ent1', ['&', {param: 'first'}, {param: 'max'}]] + const expected = ['http://example.com/', '?id=ent1', ['&', {param: 'first'}, {param: 'max'}]] const ret = compile(template) @@ -270,3 +270,11 @@ test('should compile filter function with equal sign in argument', (t) => { t.deepEqual(ret, expected) }) + +test('should throw for unclosed parameter', (t) => { + const template = 'http://example.com/{type' + + t.throws(() => { + compile(template) + }) +}) diff --git a/lib/compile.js b/lib/compile.js index 2ba747a..b59c5a0 100644 --- a/lib/compile.js +++ b/lib/compile.js @@ -1,44 +1,24 @@ const filters = require('./filters') +const splitTemplate = require('./utils/splitTemplate') +const splitComponent = require('./utils/splitComponent') -const split = (template) => { - const array = [] - const delimiters = /[,()]/g - let paran = false - let from = 0 - let match - - while ((match = delimiters.exec(template)) !== null) { - switch (match[0]) { - case '(': - paran = true - break - case ')': - paran = false - break - case ',': - if (!paran && template[delimiters.lastIndex - 2] !== '\\') { - array.push(template.substring(from, delimiters.lastIndex - 1).trim()) - from = delimiters.lastIndex - } - } +const fixEscapedCommas = (args, arg, index, array) => { + if (arg.substr(arg.length - 1) === '\\' && array.length > index) { + const next = array.splice(index + 1, 1) + return args.concat(arg.substr(0, arg.length - 1) + ',' + next[0]) } - - array.push(template.substring(from).trim()) - return array + return args.concat(arg) } -const compileParam = (explode) => (segment) => { - const {length} = segment - - // Optional or not - const obj = (segment.substr(length - 1) === '?') - ? { - param: segment.substr(0, length - 1), - optional: true - } - : {param: segment} +const setOptional = (obj) => { + const {length} = obj.param + if (obj.param.substr(length - 1) === '?') { + obj.param = obj.param.substr(0, length - 1) + obj.optional = true + } +} - // Filter functions +const setFilterFunctions = (obj) => { const filterParts = obj.param.split('|') if (filterParts.length > 1) { obj.param = filterParts[0] @@ -47,27 +27,46 @@ const compileParam = (explode) => (segment) => { const match = /^(\w+)\(([^)]*)\)$/.exec(filter) const obj = {function: filters[(match && match.length > 1) ? match[1] : filter]} if (match && match[2]) { - obj.args = split(match[2]).map((filter) => filter.replace('\\,', ',')) + obj.args = match[2].split(',') + .reduce(fixEscapedCommas, []) + .map((arg) => arg.trim()) } return obj }) } +} - // Prefix modifier - const prefixParts = obj.param.split(':') - if (prefixParts.length > 1) { - obj.param = prefixParts[0] - obj.filters = [{function: filters.max, args: [prefixParts[1]]}].concat(obj.filters || []) +const setPrefixFunction = (obj) => { + const index = obj.param.indexOf(':') + if (index > -1) { + const key = obj.param.substr(index + 1) + obj.filters = [{function: filters.max, args: [key]}] + .concat(obj.filters || []) + obj.param = obj.param.substr(0, index) } +} - // Specified key +const setKey = (obj) => { const keyParts = obj.param.split('=') if (keyParts.length > 1) { obj.key = keyParts[0] obj.param = keyParts[1] } +} + +const compileParam = (explode = false) => (param, index) => { + if (index === 0) { + // Skip modifier + return param + } + + const obj = {param} + + setOptional(obj) + setFilterFunctions(obj) + setPrefixFunction(obj) + setKey(obj) - // Explode if (explode) { obj.explode = true } @@ -75,38 +74,30 @@ const compileParam = (explode) => (segment) => { return obj } -const compileComponent = (segment) => { - const modifier = (/^[?&/#.;]/.test(segment)) ? segment.substr(0, 1) : null - const list = split(segment.substr(modifier ? 1 : 0)) - const explode = (modifier === '/' || modifier === '.') - if (modifier || list.length > 1) { - return [modifier].concat(list.map(compileParam(explode))) +const compileComponent = (component) => { + const parts = splitComponent(component) + if (parts.length < 2) { + throw new TypeError(`Can't compile template with parameter '${component}'`) } - return compileParam(explode)(segment) -} - -const prepareSegment = (segment) => segment.replace(/\\([{}])/g, '$1') + const modifier = parts[0] -const compileSegment = (template, open = true) => { - let index = -1 - do { - index = template.indexOf((open) ? '{' : '}', index + 1) - } while (template[index - 1] === '\\') - - if (index === -1) { - return (template) ? [prepareSegment(template)] : [] + if (modifier === null && parts.length === 2) { + return compileParam()(parts[1]) } - let element = prepareSegment(template.substr(0, index)) - if (!open) { - element = compileComponent(element) - } + const explode = (modifier === '/' || modifier === '.') + return parts.map(compileParam(explode)) +} + +const compileSegment = (segment) => { + const isComponent = segment[0] === '{' + segment = segment.replace(/\\([{}])/g, '$1') - const rest = compileSegment(template.substr(index + 1), !open) - if (element !== '') { - return [element, ...rest] + if (isComponent) { + return compileComponent(segment) + } else { + return segment } - return rest } /** @@ -115,7 +106,8 @@ const compileSegment = (template, open = true) => { * @returns {array} An array of template segments/components */ function compile (template) { - return compileSegment(template) + const segments = splitTemplate(template) + return segments.map(compileSegment) } module.exports = compile diff --git a/lib/utils/splitComponent-test.js b/lib/utils/splitComponent-test.js new file mode 100644 index 0000000..b0e814d --- /dev/null +++ b/lib/utils/splitComponent-test.js @@ -0,0 +1,115 @@ +import test from 'ava' + +import splitComponent from './splitComponent' + +test('should exist', (t) => { + t.is(typeof splitComponent, 'function') +}) + +test('should return empty array when no component', (t) => { + const component = null + const expected = [] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should return empty array when no parameters', (t) => { + const component = '{}' + const expected = [] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split component into modifier and params', (t) => { + const component = '{id}' + const expected = [null, 'id'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split several params', (t) => { + const component = '{first,max}' + const expected = [null, 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with query modifier', (t) => { + const component = '{?first,max}' + const expected = ['?', 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with query continuation modifier', (t) => { + const component = '{&first,max}' + const expected = ['&', 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with path modifier', (t) => { + const component = '{/first,max}' + const expected = ['/', 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with fragment modifier', (t) => { + const component = '{#first,max}' + const expected = ['#', 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with dot modifier', (t) => { + const component = '{.first,max}' + const expected = ['.', 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with path-style modifier', (t) => { + const component = '{;first,max}' + const expected = [';', 'first', 'max'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with filter function', (t) => { + const component = '{/section|max(3),user}' + const expected = ['/', 'section|max(3)', 'user'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) + +test('should split with filter function several args', (t) => { + const component = '{/section,type|map(entry=entries,article=articles)}' + const expected = ['/', 'section', 'type|map(entry=entries,article=articles)'] + + const ret = splitComponent(component) + + t.deepEqual(ret, expected) +}) diff --git a/lib/utils/splitComponent.js b/lib/utils/splitComponent.js new file mode 100644 index 0000000..2d728d4 --- /dev/null +++ b/lib/utils/splitComponent.js @@ -0,0 +1,40 @@ +const split = (component) => { + const array = [] + const regex = /[,()]/g + let paran = false + let from = 0 + let match + + while ((match = regex.exec(component)) !== null) { + switch (match[0]) { + case '(': + paran = true + break + case ')': + paran = false + break + case ',': + if (!paran) { + array.push(component.substring(from, regex.lastIndex - 1).trim()) + from = regex.lastIndex + } + } + } + + array.push(component.substring(from).trim()) + + return array +} + +function splitComponent (component) { + const match = /^\{([?&/#.;]?).+\}$/.exec(component) + if (!match) { + return [] + } + const modifier = match[1] || null + component = component.substring((modifier) ? 2 : 1, component.length - 1) + + return [modifier, ...split(component)] +} + +module.exports = splitComponent diff --git a/lib/utils/splitTemplate-test.js b/lib/utils/splitTemplate-test.js new file mode 100644 index 0000000..437a56e --- /dev/null +++ b/lib/utils/splitTemplate-test.js @@ -0,0 +1,145 @@ +import test from 'ava' + +import splitTemplate from './splitTemplate' + +test('should exist', (t) => { + t.is(typeof splitTemplate, 'function') +}) + +test('should return empty array when no template', (t) => { + const template = null + const expected = [] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template', (t) => { + const template = 'http://example.com/{type}/all{?first,max}' + const expected = [ + 'http://example.com/', + '{type}', + '/all', + '{?first,max}' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template starting with param', (t) => { + const template = '{host}/all' + const expected = [ + '{host}', + '/all' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template with escaped brackets', (t) => { + const template = 'http://example.com/\\{weird\\}/all' + const expected = [ + 'http://example.com/\\{weird\\}/all' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template with escaped brackets and param', (t) => { + const template = 'http://example.com/\\{weird\\}/{type}' + const expected = [ + 'http://example.com/\\{weird\\}/', + '{type}' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template with filter functions', (t) => { + const template = 'http://example.com/{type|lower|map(entry=entries,article=articles)}/{id}' + const expected = [ + 'http://example.com/', + '{type|lower|map(entry=entries,article=articles)}', + '/', + '{id}' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template with filter functions and escaped brackets', (t) => { + const template = 'http://example.com/{keys=ids|wrap([, ", ", \\,\\{\\}])}' + const expected = [ + 'http://example.com/', + '{keys=ids|wrap([, ", ", \\,\\{\\}])}' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template at query string', (t) => { + const template = 'http://example.com/{type}/all?first=0' + const expected = [ + 'http://example.com/', + '{type}', + '/all', + '?first=0' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template when only query string', (t) => { + const template = '?first=0' + const expected = [ + '?first=0' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template at hash', (t) => { + const template = 'http://example.com/{type}/all#content' + const expected = [ + 'http://example.com/', + '{type}', + '/all', + '#content' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) + +test('should split template at query string and hash', (t) => { + const template = 'http://example.com/{type}/all?first=20#content' + const expected = [ + 'http://example.com/', + '{type}', + '/all', + '?first=20', + '#content' + ] + + const ret = splitTemplate(template) + + t.deepEqual(ret, expected) +}) diff --git a/lib/utils/splitTemplate.js b/lib/utils/splitTemplate.js new file mode 100644 index 0000000..755e8eb --- /dev/null +++ b/lib/utils/splitTemplate.js @@ -0,0 +1,41 @@ +const returnSegment = (segment, rest = []) => { + let parts = [segment] + + if (segment[0] !== '{') { + const match = segment.match(/^([^?#]+)?(\?[^#]+)?(#.+)?$/) + parts = match.slice(1) + } + + return [...parts, ...rest] +} + +const split = (template, open = true) => { + let index = -1 + do { + index = template.indexOf((open) ? '{' : '}', index + 1) + } while (index !== -1 && template[index - 1] === '\\') + + if (index === -1) { + return returnSegment(template) + } + + const splitAt = (open) ? index : index + 1 + const segment = template.substr(0, splitAt) + const rest = split(template.substr(splitAt), !open) + + return returnSegment(segment, rest) +} + +/** + * Split the given template into segments. + * @param {string} template - The template to split + * @returns {string[]} An array of string segments + */ +function splitTemplate (template) { + if (!template) { + return [] + } + return split(template).filter((seg) => seg !== '' && seg !== undefined) +} + +module.exports = splitTemplate