From 5b23408af38da8d7b3559c7bc040583ffbf4115b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Fri, 6 Mar 2020 00:35:38 +0800 Subject: [PATCH] feat(parser): recognize Jira blocks (#24) * feat(parser): recognize Jira blocks * feat: stringify --- src/__tests__/__fixtures__/jira.md | 27 + .../__snapshots__/parse.spec.ts.snap | 513 ++++++++++++++++++ src/__tests__/parse.spec.ts | 110 ++++ src/__tests__/stringify.spec.ts | 15 +- src/ast-types/mdast.ts | 6 + src/parse.ts | 2 + src/plugins/jiraBlocks.ts | 45 ++ src/stringify.ts | 3 + 8 files changed, 717 insertions(+), 4 deletions(-) create mode 100644 src/__tests__/__fixtures__/jira.md create mode 100644 src/plugins/jiraBlocks.ts diff --git a/src/__tests__/__fixtures__/jira.md b/src/__tests__/__fixtures__/jira.md new file mode 100644 index 0000000..216ba9d --- /dev/null +++ b/src/__tests__/__fixtures__/jira.md @@ -0,0 +1,27 @@ +# @stoplight/markdown + +[![Maintainability](https://api.codeclimate.com/v1/badges/751d2319d7d0fd68d8c9/maintainability)](https://codeclimate.com/github/stoplightio/markdown/maintainability) + +Useful functions when working with Markdown. Leverages the Unified / Remark ecosystem under the hood. + +- Explore the interfaces: [TSDoc](https://stoplightio.github.io/markdown) +- View the changelog: [Releases](https://github.com/stoplightio/markdown/releases) + +### Installation + +Supported in modern browsers and node. + +[block:code] +yarn add @stoplight/markdown +[/block] + +```bash +# latest stable +yarn add @stoplight/markdown +``` + +[block:image] +{ + "images": {} +} +[/block] diff --git a/src/__tests__/__snapshots__/parse.spec.ts.snap b/src/__tests__/__snapshots__/parse.spec.ts.snap index 591a6eb..b05a0f3 100644 --- a/src/__tests__/__snapshots__/parse.spec.ts.snap +++ b/src/__tests__/__snapshots__/parse.spec.ts.snap @@ -1,5 +1,518 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`parse jira blocks should parse more complex document correctly 1`] = ` +Object { + "children": Array [ + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 22, + "line": 1, + "offset": 21, + }, + "indent": Array [], + "start": Object { + "column": 3, + "line": 1, + "offset": 2, + }, + }, + "type": "text", + "value": "@stoplight/markdown", + }, + ], + "depth": 1, + "position": Position { + "end": Object { + "column": 22, + "line": 1, + "offset": 21, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "heading", + }, + Object { + "children": Array [ + Object { + "children": Array [ + Object { + "alt": "Maintainability", + "position": Position { + "end": Object { + "column": 96, + "line": 3, + "offset": 118, + }, + "indent": Array [], + "start": Object { + "column": 2, + "line": 3, + "offset": 24, + }, + }, + "title": null, + "type": "image", + "url": "https://api.codeclimate.com/v1/badges/751d2319d7d0fd68d8c9/maintainability", + }, + ], + "position": Position { + "end": Object { + "column": 166, + "line": 3, + "offset": 188, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 3, + "offset": 23, + }, + }, + "title": null, + "type": "link", + "url": "https://codeclimate.com/github/stoplightio/markdown/maintainability", + }, + Object { + "position": Position { + "end": Object { + "column": 167, + "line": 3, + "offset": 189, + }, + "indent": Array [], + "start": Object { + "column": 166, + "line": 3, + "offset": 188, + }, + }, + "type": "text", + "value": " ", + }, + ], + "position": Position { + "end": Object { + "column": 167, + "line": 3, + "offset": 189, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 3, + "offset": 23, + }, + }, + "type": "paragraph", + }, + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 102, + "line": 5, + "offset": 292, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 5, + "offset": 191, + }, + }, + "type": "text", + "value": "Useful functions when working with Markdown. Leverages the Unified / Remark ecosystem under the hood.", + }, + ], + "position": Position { + "end": Object { + "column": 102, + "line": 5, + "offset": 292, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 5, + "offset": 191, + }, + }, + "type": "paragraph", + }, + Object { + "children": Array [ + Object { + "checked": null, + "children": Array [ + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 27, + "line": 7, + "offset": 320, + }, + "indent": Array [], + "start": Object { + "column": 3, + "line": 7, + "offset": 296, + }, + }, + "type": "text", + "value": "Explore the interfaces: ", + }, + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 33, + "line": 7, + "offset": 326, + }, + "indent": Array [], + "start": Object { + "column": 28, + "line": 7, + "offset": 321, + }, + }, + "type": "text", + "value": "TSDoc", + }, + ], + "position": Position { + "end": Object { + "column": 74, + "line": 7, + "offset": 367, + }, + "indent": Array [], + "start": Object { + "column": 27, + "line": 7, + "offset": 320, + }, + }, + "title": null, + "type": "link", + "url": "https://stoplightio.github.io/markdown", + }, + ], + "position": Position { + "end": Object { + "column": 74, + "line": 7, + "offset": 367, + }, + "indent": Array [], + "start": Object { + "column": 3, + "line": 7, + "offset": 296, + }, + }, + "type": "paragraph", + }, + ], + "position": Position { + "end": Object { + "column": 74, + "line": 7, + "offset": 367, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 7, + "offset": 294, + }, + }, + "spread": false, + "type": "listItem", + }, + Object { + "checked": null, + "children": Array [ + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 23, + "line": 8, + "offset": 390, + }, + "indent": Array [], + "start": Object { + "column": 3, + "line": 8, + "offset": 370, + }, + }, + "type": "text", + "value": "View the changelog: ", + }, + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 32, + "line": 8, + "offset": 399, + }, + "indent": Array [], + "start": Object { + "column": 24, + "line": 8, + "offset": 391, + }, + }, + "type": "text", + "value": "Releases", + }, + ], + "position": Position { + "end": Object { + "column": 83, + "line": 8, + "offset": 450, + }, + "indent": Array [], + "start": Object { + "column": 23, + "line": 8, + "offset": 390, + }, + }, + "title": null, + "type": "link", + "url": "https://github.com/stoplightio/markdown/releases", + }, + ], + "position": Position { + "end": Object { + "column": 83, + "line": 8, + "offset": 450, + }, + "indent": Array [], + "start": Object { + "column": 3, + "line": 8, + "offset": 370, + }, + }, + "type": "paragraph", + }, + ], + "position": Position { + "end": Object { + "column": 83, + "line": 8, + "offset": 450, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 8, + "offset": 368, + }, + }, + "spread": false, + "type": "listItem", + }, + ], + "ordered": false, + "position": Position { + "end": Object { + "column": 83, + "line": 8, + "offset": 450, + }, + "indent": Array [ + 1, + ], + "start": Object { + "column": 1, + "line": 7, + "offset": 294, + }, + }, + "spread": false, + "start": null, + "type": "list", + }, + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 17, + "line": 10, + "offset": 468, + }, + "indent": Array [], + "start": Object { + "column": 5, + "line": 10, + "offset": 456, + }, + }, + "type": "text", + "value": "Installation", + }, + ], + "depth": 3, + "position": Position { + "end": Object { + "column": 17, + "line": 10, + "offset": 468, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 10, + "offset": 452, + }, + }, + "type": "heading", + }, + Object { + "children": Array [ + Object { + "position": Position { + "end": Object { + "column": 39, + "line": 12, + "offset": 508, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 12, + "offset": 470, + }, + }, + "type": "text", + "value": "Supported in modern browsers and node.", + }, + ], + "position": Position { + "end": Object { + "column": 39, + "line": 12, + "offset": 508, + }, + "indent": Array [], + "start": Object { + "column": 1, + "line": 12, + "offset": 470, + }, + }, + "type": "paragraph", + }, + Object { + "code": "code", + "position": Position { + "end": Object { + "column": 9, + "line": 16, + "offset": 560, + }, + "indent": Array [ + 1, + 1, + ], + "start": Object { + "column": 1, + "line": 14, + "offset": 510, + }, + }, + "type": "jira", + "value": "yarn add @stoplight/markdown", + }, + Object { + "lang": "bash", + "meta": null, + "position": Position { + "end": Object { + "column": 4, + "line": 21, + "offset": 618, + }, + "indent": Array [ + 1, + 1, + 1, + ], + "start": Object { + "column": 1, + "line": 18, + "offset": 562, + }, + }, + "type": "code", + "value": "# latest stable +yarn add @stoplight/markdown", + }, + Object { + "code": "image", + "position": Position { + "end": Object { + "column": 9, + "line": 27, + "offset": 661, + }, + "indent": Array [ + 1, + 1, + 1, + 1, + ], + "start": Object { + "column": 1, + "line": 23, + "offset": 620, + }, + }, + "type": "jira", + "value": "{ + \\"images\\": {} +}", + }, + ], + "position": Object { + "end": Object { + "column": 1, + "line": 28, + "offset": 662, + }, + "start": Object { + "column": 1, + "line": 1, + "offset": 0, + }, + }, + "type": "root", +} +`; + exports[`parse should parse simple 1`] = ` Object { "children": Array [ diff --git a/src/__tests__/parse.spec.ts b/src/__tests__/parse.spec.ts index 1325d84..cebba6a 100644 --- a/src/__tests__/parse.spec.ts +++ b/src/__tests__/parse.spec.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; +import { join } from 'path'; import { parse } from '../parse'; describe('parse', () => { @@ -10,4 +12,112 @@ describe('parse', () => { expect(parse('**simple**')).toEqual(parse('**simple**')); }); + + describe('jira blocks', () => { + it('should parse simple jira block', () => { + expect( + parse(`[block:image] +{} +[/block]`), + ).toStrictEqual( + expect.objectContaining({ + children: [ + { + code: 'image', + position: expect.objectContaining({ + end: { + column: 9, // markdown lines are columns is 1-based + line: 3, + offset: 25, + }, + start: { + column: 1, + line: 1, + offset: 0, + }, + }), + type: 'jira', + value: '{}', + }, + ], + type: 'root', + }), + ); + }); + + it('should parse more complex document correctly', () => { + expect(parse(fs.readFileSync(join(__dirname, '__fixtures__/jira.md'), 'utf8'))).toMatchSnapshot(); + }); + + it('should not recognize blocks contained in fenced code', () => { + expect( + parse(`\`\`\` +[block:image] +{} +[/block] +\`\`\``), + ).toStrictEqual( + expect.objectContaining({ + type: 'root', + children: [ + expect.objectContaining({ + type: 'code', + lang: null, + meta: null, + value: '[block:image]\n{}\n[/block]', + }), + ], + }), + ); + }); + + describe('should not recognize invalid blocks', () => { + it('no new-line', () => { + expect( + parse(`[block:image] {} +[/block]`), + ).toStrictEqual( + expect.objectContaining({ + children: expect.not.arrayContaining([ + expect.objectContaining({ + type: 'jira', + }), + ]), + }), + ); + }); + + it('no code', () => { + expect( + parse(`[block] +{} +[/block]`), + ).toStrictEqual( + expect.objectContaining({ + children: expect.not.arrayContaining([ + expect.objectContaining({ + type: 'jira', + }), + ]), + }), + ); + }); + + it('no ending', () => { + expect( + parse(`[block:code] +{} +`), + ).toStrictEqual( + expect.objectContaining({ + children: expect.not.arrayContaining([ + expect.objectContaining({ + type: 'jira', + }), + ]), + }), + ); + }); + }); + }); }); diff --git a/src/__tests__/stringify.spec.ts b/src/__tests__/stringify.spec.ts index c7fcfc3..71f3032 100644 --- a/src/__tests__/stringify.spec.ts +++ b/src/__tests__/stringify.spec.ts @@ -1,19 +1,26 @@ -import fs = require('fs'); -import path = require('path'); +import * as fs from 'fs'; +import { join } from 'path'; +import { parse } from '../parse'; import { stringify } from '../stringify'; describe('stringify', () => { it('should work', () => { expect( - stringify(JSON.parse(fs.readFileSync(path.resolve(__dirname, './__fixtures__/simple/root.json'), 'utf-8'))), + stringify(JSON.parse(fs.readFileSync(join(__dirname, './__fixtures__/simple/root.json'), 'utf8'))), ).toMatchSnapshot(); }); it('should work when called multiple times in a row', () => { // This tests to make sure the processor isn't frozen: https://github.com/unifiedjs/unified/blob/7ee2c8f563f0ebe330cd76496be9ba405a1cd023/readme.md#processorfreeze - const json = JSON.parse(fs.readFileSync(path.resolve(__dirname, './__fixtures__/simple/root.json'), 'utf-8')); + const json = JSON.parse(fs.readFileSync(join(__dirname, './__fixtures__/simple/root.json'), 'utf8')); expect(stringify(json)).toBe(stringify(json)); }); + + it('should stringify markdown containing jira blocks', () => { + const document = fs.readFileSync(join(__dirname, './__fixtures__/jira.md'), 'utf8'); + + expect(stringify(parse(document))).toBe(document); + }); }); diff --git a/src/ast-types/mdast.ts b/src/ast-types/mdast.ts index 4696b30..2d3c0c7 100644 --- a/src/ast-types/mdast.ts +++ b/src/ast-types/mdast.ts @@ -142,3 +142,9 @@ export interface IElement extends Unist.Node { properties: object; children: Unist.Node[]; } + +export interface IJiraNode extends Unist.Node { + type: 'jira'; + code: string; + value: string; +} diff --git a/src/parse.ts b/src/parse.ts index 860a6da..266cc2f 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -2,6 +2,7 @@ import remarkParse, { RemarkParseOptions } from 'remark-parse'; import unified from 'unified'; import * as Unist from 'unist'; const frontmatter = require('remark-frontmatter'); +import jiraBlocks from './plugins/jiraBlocks'; const defaultOpts: Partial = { commonmark: true, @@ -10,6 +11,7 @@ const defaultOpts: Partial = { const defaultProcessor = unified() .use(remarkParse) + .use(jiraBlocks) .use(frontmatter, ['yaml']); export const parse = ( diff --git a/src/plugins/jiraBlocks.ts b/src/plugins/jiraBlocks.ts new file mode 100644 index 0000000..6c39fa0 --- /dev/null +++ b/src/plugins/jiraBlocks.ts @@ -0,0 +1,45 @@ +import * as remarkParse from 'remark-parse'; +import * as unified from 'unified'; +import { IJiraNode } from '../ast-types/mdast'; + +export default function(this: unified.Processor) { + const { Compiler, Parser } = this; + + if (Compiler !== void 0) { + Compiler.prototype.visitors.jira = compileJiraBlock; + } else if (Parser !== void 0) { + Parser.prototype.blockTokenizers.jira = tokenizeJiraBlock; + Parser.prototype.interruptParagraph.push(['jira']); + + const methods = Parser.prototype.blockMethods; + methods.splice(methods.indexOf('fencedCode') + 1, 0, 'jira'); + } +} + +const blockStart = /^\[block:([A-Za-z]+)\][^\S\n]*(?=\n)/; +const blockEnd = /\[\/block\][^\S\n]*(?=\n|$)/; + +function tokenizeJiraBlock(eat: remarkParse.Eat, value: string, silent: boolean) { + const blockStartMatch = blockStart.exec(value); + const blockEndMatch = blockEnd.exec(value); // let's naively assume block cannot be placed in any node besides content + + if (blockStartMatch !== null && blockEndMatch !== null) { + if (silent) { + return true; + } + + const node: IJiraNode = { + type: 'jira', + code: blockStartMatch[1], + value: value.slice(blockStartMatch[0].length + 1, blockEndMatch.index - 1), + }; + + return eat(value.slice(0, blockEndMatch.index + blockEndMatch[0].length))(node); + } + + return false; +} + +function compileJiraBlock(node: IJiraNode) { + return `[block:${node.code}]\n${node.value}\n[/block]`; +} diff --git a/src/stringify.ts b/src/stringify.ts index ca7aa6c..d799bc9 100644 --- a/src/stringify.ts +++ b/src/stringify.ts @@ -1,15 +1,18 @@ import remarkStringify, { RemarkStringifyOptions } from 'remark-stringify'; import unified from 'unified'; import { Node } from 'unist'; +import jiraBlocks from './plugins/jiraBlocks'; const frontmatter = require('remark-frontmatter'); const defaultOpts: Partial = { commonmark: true, gfm: true, + listItemIndent: 'mixed', // this is needed to preserve the original indentation }; const defaultProcessor = unified() .use(remarkStringify) + .use(jiraBlocks) .use(frontmatter, ['yaml']); export const stringify = (