diff --git a/index.js b/index.js index 835bb19..7b8d7b8 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,5 @@ const tags = /\{\{|\}\}/; +const endl = "\n"; const escapedChars = { "&": "&", "<": "<", @@ -18,6 +19,67 @@ const parse = (v, context = {}, vars = {}) => { return (v || "").startsWith("@") ? get(vars, v.slice(1)) : get(context, v || "."); }; +// @description tiny yaml parser +const yamlParser = (str = "") => { + const lines = str.split(endl), result = {}, levels = [{ctx: result, indent: 0}]; + let level = 0, i = 0; + while (i < lines.length) { + const line = lines[i] || ""; + if (!!line.trim() && !line.trim().startsWith("#")) { + const indent = (line.match(/^( *)/m)?.[0] || "").length; + while(level > 0 && indent < levels[level].indent) { + levels.pop(); + level = level - 1; + } + const isArrayItem = line.trim().startsWith("-"); + let [key, value] = line.trim().split(":").map(v => v.trim()); + // Check if is a new item of the array + if (isArrayItem) { + key = key.replace(/^(- *)/m, ""); + if (typeof value === "undefined") { + value = key; + } + else { + const newIndent = (line.slice(0, line.indexOf(key))).length; + levels[level].ctx.push({}); + levels.push({ctx: levels[level].ctx[levels[level].ctx.length - 1], indent: newIndent}); + level = level + 1; + } + } + // Check for empty value --> entering into a nested object or array + if (!value) { + const nextLine = lines[i + 1] || ""; + const nextIndent = (nextLine.match(/^( *)/m)?.[0] || "").length; + levels[level].ctx[key] = nextLine.trim().startsWith("-") ? [] : {}; + if (nextIndent > levels[level].indent) { + levels.push({ctx: levels[level].ctx[key], indent: nextIndent}); + level = level + 1; + } + } + else if(value && Array.isArray(levels[level].ctx)) { + levels[level].ctx.push(JSON.parse(value)); + } + else if (value) { + levels[level].ctx[key] = JSON.parse(value); + } + } + i = i + 1; + } + return result; +}; + +// @description tiny front-matter parser +const frontmatter = (str = "", parser = null) => { + let body = (str || "").trim(), data = {}; + const matches = Array.from(body.matchAll(/^(--- *)/gm)) + if (matches?.length === 2 && matches[0].index === 0) { + const front = body.substring(0 + matches[0][1].length, matches[1].index).trim(); + body = body.substring(matches[1].index + matches[1][1].length).trim(); + data = typeof parser === "function" ? parser(front) : yamlParser(front); + } + return {body, data}; +}; + const defaultHelpers = { "each": (value, opt) => { return (typeof value === "object" ? Object.entries(value || {}) : []) @@ -111,5 +173,7 @@ const mikel = (str, context = {}, opt = {}, output = []) => { mikel.escape = escape; mikel.get = get; mikel.parse = parse; +mikel.yaml = yamlParser; +mikel.frontmatter = frontmatter; export default mikel; diff --git a/test.js b/test.js index 313aeb0..a0f444e 100644 --- a/test.js +++ b/test.js @@ -307,3 +307,101 @@ describe("{{=function }}", () => { assert.equal(m(`{{=concat "Hello" "World"}}!`, {}, options), "Hello World!"); }); }); + +describe("utils", () => { + describe("yaml", () => { + const yaml = lines => m.yaml(lines.join("\n")); + + it("should parse simple key-value yaml", () => { + const json = yaml([ + `string: "hello world"`, + `boolean: true`, + `number: 123`, + ]); + assert.equal(typeof json, "object"); + assert.equal(json.string, "hello world"); + assert.equal(json.boolean, true); + assert.equal(json.number, 123); + }); + + it("should parse nested objects", () => { + const json = yaml([ + `nested:`, + ` key1: "value1"`, + ` key2: "value2"`, + ` nested:`, + ` key1: "value1"`, + `no-nested: "another value"`, + ]); + assert.equal(typeof json.nested, "object"); + assert.equal(typeof json.nested.nested, "object"); + assert.equal(json.nested.key1, "value1"); + assert.equal(json.nested.key2, "value2"); + assert.equal(json.nested.nested.key1, "value1"); + assert.equal(json["no-nested"], "another value"); + }); + + it("should parse an array of simple values", () => { + const json = yaml([ + `items:`, + ` - "item1"`, + ` - "item2"`, + `key: "value"`, + ]); + assert.equal(typeof json.items, "object"); + assert.equal(json.items.length, 2); + assert.equal(json.items[0], "item1"); + assert.equal(json.items[1], "item2"); + assert.equal(json.key, "value"); + }); + + it("should parse an array of objects", () => { + const json = yaml([ + `items:`, + ` - key1: "value1"`, + ` key2: "value2"`, + ` - key1: "value3"`, + ` - items:`, + ` - "foo"`, + ` - "bar"`, + `foo: "bar"`, + ]); + assert.equal(typeof json.items, "object"); + assert.equal(json.items.length, 3); + assert.equal(json.items[0].key1, "value1"); + assert.equal(json.items[0].key2, "value2"); + assert.equal(json.items[1].key1, "value3"); + assert.equal(json.items[2].items.length, 2); + assert.equal(json.items[2].items[0], "foo"); + assert.equal(json.items[2].items[1], "bar"); + assert.equal(json.foo, "bar"); + }); + }); + + describe("frontmatter", () => { + const frontmatter = lines => m.frontmatter(lines.join("\n")); + + it("should return empty data if no frontmatter is present", () => { + const result = frontmatter([ + `Hello world`, + ]); + assert.equal(result.body, "Hello world"); + assert.equal(Object.keys(result.data).length, 0); + }); + + it("should return parsed frontmatter", () => { + const result = frontmatter([ + `---`, + `key: "value"`, + `items:`, + ` - "foo"`, + ` - "bar"`, + `---`, + `Hello world`, + ]); + assert.equal(result.body, "Hello world"); + assert.equal(result.data.key, "value"); + assert.equal(result.data.items[1], "bar"); + }); + }); +});