634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | A simple library which converts **[Editor.js](https://editorjs.io)** JSON output to **Markdown** or **HTML**.
4 |
5 | ## Installation
6 |
7 | ```bash
8 | go get github.com/micheleriva/editorjs-go
9 | ```
10 |
11 | ## Usage
12 |
13 | Let's suppose that we have the following Editor.js output saved in a file called `editorjs_output.json`:
14 |
15 | ```json
16 | {
17 | "blocks": [
18 | {
19 | "type" : "header",
20 | "data" : {
21 | "text" : "Editor.js",
22 | "level" : 2
23 | }
24 | },
25 | {
26 | "type" : "paragraph",
27 | "data" : {
28 | "text" : "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text."
29 | }
30 | }
31 | ]
32 | }
33 | ```
34 |
35 | ```go
36 | package main
37 |
38 | import (
39 | "fmt"
40 | editorjs "github.com/micheleriva/editorjs-go"
41 | "io/ioutil"
42 | "log"
43 | )
44 |
45 | func main() {
46 | myJSON, err := ioutil.ReadFile("./editorjs_output.json")
47 | if err != nil {
48 | log.Fatal(err)
49 | }
50 |
51 | resultMarkdown := editorjs.Markdown(string(data))
52 | resultHTML := editorjs.HTML(string(data))
53 |
54 | fmt.Println("=== MARKDOWN ===\n")
55 | fmt.Println(resultMarkdown)
56 |
57 | fmt.Println("=== HTML ===\n")
58 | fmt.Println(resultHTML)
59 | }
60 | ```
61 |
62 | It will generate the following output:
63 |
64 | ```
65 | === MARKDOWN ==="
66 |
67 | ## Editor.js
68 |
69 | Hey. Meet the new Editor. On this page you can see it in action — try to edit this text.
70 |
71 | === HTML ===
72 |
73 | Editor.js
74 | Hey. Meet the new Editor. On this page you can see it in action — try to edit this text.
75 | ```
76 |
77 | ## License
78 | [GPLv3](/LICENSE.md)
--------------------------------------------------------------------------------
/html.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strconv"
7 | "strings"
8 | )
9 |
10 | func HTML(input string, options ...Options) string {
11 | var markdownOptions Options
12 |
13 | if len(options) > 0 {
14 | markdownOptions = options[0]
15 | }
16 |
17 | var result []string
18 | editorJSAST := ParseEditorJSON(input)
19 |
20 | for _, el := range editorJSAST.Blocks {
21 |
22 | data := el.Data
23 |
24 | switch el.Type {
25 |
26 | case "header":
27 | result = append(result, generateHTMLHeader(data))
28 |
29 | case "paragraph":
30 | result = append(result, generateHTMLParagraph(el.Data))
31 |
32 | case "list":
33 | result = append(result, generateMDList(data))
34 |
35 | case "image":
36 | result = append(result, generateHTMLImage(data, markdownOptions))
37 |
38 | case "rawTool":
39 | result = append(result, data.HTML)
40 |
41 | case "delimiter":
42 | result = append(result, "---")
43 |
44 | case "table":
45 | result = append(result, generateMDTable(data))
46 |
47 | case "caption":
48 | result = append(result, generateMDCaption(data))
49 |
50 | default:
51 | log.Fatal("Unknown data type: " + el.Type)
52 | }
53 |
54 | }
55 |
56 | return strings.Join(result[:], "\n\n")
57 | }
58 |
59 | func generateHTMLHeader(el EditorJSData) string {
60 | level := strconv.Itoa(el.Level)
61 | return fmt.Sprintf("%s", level, el.Text, level)
62 | }
63 |
64 | func generateHTMLParagraph(el EditorJSData) string {
65 | return fmt.Sprintf("%s
", el.Text)
66 | }
67 |
68 | func generateHTMLList(el EditorJSData) string {
69 | var result []string
70 |
71 | if el.Style == "unordered" {
72 | result = append(result, "")
73 |
74 | for _, el := range el.Items {
75 | result = append(result, " - "+el+"
")
76 | }
77 |
78 | result = append(result, "
")
79 | } else {
80 | result = append(result, "")
81 |
82 | for _, el := range el.Items {
83 | result = append(result, " - "+el+"
")
84 | }
85 |
86 | result = append(result, "
")
87 | }
88 |
89 | return strings.Join(result[:], "\n")
90 | }
91 |
92 | func generateHTMLImage(el EditorJSData, options Options) string {
93 | classes := options.Image.Classes
94 | withBorder := classes.WithBorder
95 | stretched := classes.Stretched
96 | withBackground := classes.WithBackground
97 |
98 | if withBorder == "" && el.WithBorder {
99 | withBorder = "editorjs-with-border"
100 | }
101 |
102 | if stretched == "" && el.Stretched {
103 | stretched = "editorjs-stretched"
104 | }
105 |
106 | if withBackground == "" && el.WithBackground {
107 | withBackground = "editorjs-withBackground"
108 | }
109 |
110 | return fmt.Sprintf(`
`, el.File.URL, options.Image.Caption, withBorder, stretched, withBackground)
111 | }
112 |
--------------------------------------------------------------------------------
/html_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGenerateHeaderHTML(t *testing.T) {
10 |
11 | // Test H1 Header
12 | input1 := EditorJSData{
13 | Text: "Level 1 Header",
14 | Level: 1,
15 | }
16 |
17 | expected1 := "Level 1 Header
"
18 | actual1 := generateHTMLHeader(input1)
19 |
20 | // Test H2 Header
21 | input2 := EditorJSData{
22 | Text: "Level 2 Header",
23 | Level: 2,
24 | }
25 |
26 | expected2 := "Level 2 Header
"
27 | actual2 := generateHTMLHeader(input2)
28 |
29 | // Test H3 Header
30 | input3 := EditorJSData{
31 | Text: "Level 3 Header",
32 | Level: 3,
33 | }
34 |
35 | expected3 := "Level 3 Header
"
36 | actual3 := generateHTMLHeader(input3)
37 |
38 | // Test H4 Header
39 | input4 := EditorJSData{
40 | Text: "Level 4 Header",
41 | Level: 4,
42 | }
43 |
44 | expected4 := "Level 4 Header
"
45 | actual4 := generateHTMLHeader(input4)
46 |
47 | assert.Equal(t, expected1, actual1)
48 | assert.Equal(t, expected2, actual2)
49 | assert.Equal(t, expected3, actual3)
50 | assert.Equal(t, expected4, actual4)
51 | }
52 |
53 | func TestGenerateHTMLParagraph(t *testing.T) {
54 | input := EditorJSData{
55 | Text: "I am a paragraph!",
56 | }
57 |
58 | expected := "I am a paragraph!
"
59 | actual := generateHTMLParagraph(input)
60 |
61 | assert.Equal(t, expected, actual)
62 | }
63 |
64 | func TestGenerateHTMLUnorderedList(t *testing.T) {
65 | input := EditorJSData{
66 | Style: "unordered",
67 | Items: []string{"first", "second", "third"},
68 | }
69 |
70 | expected := `
71 | - first
72 | - second
73 | - third
74 |
`
75 |
76 | actual := generateHTMLList(input)
77 |
78 | assert.Equal(t, expected, actual)
79 | }
80 |
81 | func TestGenerateHTMLOrderedList(t *testing.T) {
82 | input := EditorJSData{
83 | Style: "ordered",
84 | Items: []string{"first", "second", "third"},
85 | }
86 |
87 | expected := `
88 | - first
89 | - second
90 | - third
91 |
`
92 |
93 | actual := generateHTMLList(input)
94 |
95 | assert.Equal(t, expected, actual)
96 | }
97 |
98 | func TestGenerateImageWithoutOptionsHTML(t *testing.T) {
99 | input := EditorJSData{
100 | File: FileData{
101 | URL: "https://example.com/img.png",
102 | },
103 | }
104 |
105 | expected := `
`
106 | actual := generateHTMLImage(input, Options{})
107 |
108 | assert.Equal(t, expected, actual)
109 | }
110 |
111 | func TestGenerateImageWithPartialOptionsHTML(t *testing.T) {
112 | input := EditorJSData{
113 | File: FileData{
114 | URL: "https://example.com/img.png",
115 | },
116 | }
117 |
118 | options := Options{
119 | Image: ImageOptions{
120 | Caption: "My beautiful image",
121 | },
122 | }
123 |
124 | expected := `
`
125 | actual := generateHTMLImage(input, options)
126 |
127 | assert.Equal(t, expected, actual)
128 | }
129 |
130 | func TestGenerateImageWithFullOptionsHTML(t *testing.T) {
131 | input := EditorJSData{
132 | File: FileData{
133 | URL: "https://example.com/img.png",
134 | },
135 | }
136 |
137 | options := Options{
138 | Image: ImageOptions{
139 | Caption: "My beautiful image",
140 | Classes: ImageClasses{
141 | Stretched: "streched-class",
142 | WithBackground: "with-background-class",
143 | WithBorder: "with-border-class",
144 | },
145 | },
146 | }
147 |
148 | expected := `
`
149 | actual := generateHTMLImage(input, options)
150 |
151 | assert.Equal(t, expected, actual)
152 | }
153 |
--------------------------------------------------------------------------------
/json.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "log"
6 | )
7 |
8 | type EditorJS struct {
9 | Blocks []EditorJSBlock `json:"blocks"`
10 | }
11 |
12 | type EditorJSBlock struct {
13 | Type string `json:"type"`
14 | Data EditorJSData `json:"data"`
15 | }
16 |
17 | type EditorJSData struct {
18 | Text string `json:"text",omitempty`
19 | Level int `json:"level,omitempty" `
20 | Style string `json:"style,omitempty" `
21 | Items []string `json:"items,omitempty" `
22 | File FileData `json:"file,omitempty" `
23 | Caption string `json:"caption,omitempty"`
24 | WithBorder bool `json:"withBorder,omitempty"`
25 | Stretched bool `json:"stretched,omitempty"`
26 | WithBackground bool `json:"withBackground,omitempty"`
27 | HTML string `json:"html,omitempty"`
28 | Content [][]string `json:"content,omitempty"`
29 | Alignment string `json:"alignment,omitempty"`
30 | }
31 |
32 | type FileData struct {
33 | URL string `json:"url"`
34 | }
35 |
36 | func ParseEditorJSON(editorJS string) EditorJS {
37 | var result EditorJS
38 |
39 | err := json.Unmarshal([]byte(editorJS), &result)
40 | if err != nil {
41 | log.Fatal(err)
42 | }
43 |
44 | return result
45 | }
46 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | type Options struct {
4 | Image ImageOptions
5 | }
6 |
7 | type ImageOptions struct {
8 | Classes ImageClasses
9 | Caption string
10 | }
11 |
12 | type ImageClasses struct {
13 | WithBorder string
14 | Stretched string
15 | WithBackground string
16 | }
17 |
18 | func main() {
19 |
20 | }
21 |
--------------------------------------------------------------------------------
/markdown.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strconv"
7 | "strings"
8 | )
9 |
10 | func Markdown(input string, options ...Options) string {
11 | var markdownOptions Options
12 |
13 | if len(options) > 0 {
14 | markdownOptions = options[0]
15 | }
16 |
17 | var result []string
18 | editorJSAST := ParseEditorJSON(input)
19 |
20 | for _, el := range editorJSAST.Blocks {
21 |
22 | data := el.Data
23 |
24 | switch el.Type {
25 |
26 | case "header":
27 | result = append(result, generateMDHeader(data))
28 |
29 | case "paragraph":
30 | result = append(result, data.Text)
31 |
32 | case "list":
33 | result = append(result, generateMDList(data))
34 |
35 | case "image":
36 | result = append(result, generateMDImage(data, markdownOptions))
37 |
38 | case "rawTool":
39 | result = append(result, data.HTML)
40 |
41 | case "delimiter":
42 | result = append(result, "---")
43 |
44 | case "table":
45 | result = append(result, generateMDTable(data))
46 |
47 | case "caption":
48 | result = append(result, generateMDCaption(data))
49 |
50 | default:
51 | log.Fatal("Unknown data type: " + el.Type)
52 | }
53 |
54 | }
55 |
56 | return strings.Join(result[:], "\n\n")
57 | }
58 |
59 | func generateMDHeader(el EditorJSData) string {
60 | var result []string
61 |
62 | for i := 0; i < el.Level; i++ {
63 | result = append(result, "#")
64 | }
65 |
66 | result = append(result, " "+el.Text)
67 |
68 | return strings.Join(result[:], "")
69 | }
70 |
71 | func generateMDList(el EditorJSData) string {
72 | var result []string
73 |
74 | if el.Style == "unordered" {
75 | for _, el := range el.Items {
76 | result = append(result, "- "+el)
77 | }
78 | } else {
79 | for i, el := range el.Items {
80 | n := strconv.Itoa(i+1) + "."
81 | result = append(result, fmt.Sprintf("%s %s", n, el))
82 | }
83 | }
84 |
85 | return strings.Join(result[:], "\n")
86 | }
87 |
88 | func generateMDImage(el EditorJSData, options Options) string {
89 | classes := options.Image.Classes
90 | withBorder := classes.WithBorder
91 | stretched := classes.Stretched
92 | withBackground := classes.WithBackground
93 |
94 | if withBorder == "" &&
95 | stretched == "" &&
96 | withBackground == "" {
97 |
98 | return fmt.Sprintf("", options.Image.Caption, el.File.URL)
99 | }
100 |
101 | if withBorder == "" && el.WithBorder {
102 | withBorder = "editorjs-with-border"
103 | }
104 |
105 | if stretched == "" && el.Stretched {
106 | stretched = "editorjs-stretched"
107 | }
108 |
109 | if withBackground == "" && el.WithBackground {
110 | withBackground = "editorjs-withBackground"
111 | }
112 |
113 | return fmt.Sprintf(`
`, el.File.URL, options.Image.Caption, withBorder, stretched, withBackground)
114 | }
115 |
116 | func generateMDTable(el EditorJSData) string {
117 | var result []string
118 |
119 | for _, cell := range el.Content {
120 | row := strings.Join(cell, " | ")
121 | result = append(result, fmt.Sprintf("| %s |", row))
122 | }
123 |
124 | return strings.Join(result, "\n")
125 | }
126 |
127 | func generateMDCaption(el EditorJSData) string {
128 | return fmt.Sprintf("> %s", el.Text)
129 | }
130 |
--------------------------------------------------------------------------------
/markdown_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | )
8 |
9 | func TestGenerateHeaderMD(t *testing.T) {
10 |
11 | // Test H1 Header
12 | input1 := EditorJSData{
13 | Text: "Level 1 Header",
14 | Level: 1,
15 | }
16 |
17 | expected1 := "# Level 1 Header"
18 | actual1 := generateMDHeader(input1)
19 |
20 | // Test H2 Header
21 | input2 := EditorJSData{
22 | Text: "Level 2 Header",
23 | Level: 2,
24 | }
25 |
26 | expected2 := "## Level 2 Header"
27 | actual2 := generateMDHeader(input2)
28 |
29 | // Test H3 Header
30 | input3 := EditorJSData{
31 | Text: "Level 3 Header",
32 | Level: 3,
33 | }
34 |
35 | expected3 := "### Level 3 Header"
36 | actual3 := generateMDHeader(input3)
37 |
38 | // Test H4 Header
39 | input4 := EditorJSData{
40 | Text: "Level 4 Header",
41 | Level: 4,
42 | }
43 |
44 | expected4 := "#### Level 4 Header"
45 | actual4 := generateMDHeader(input4)
46 |
47 | assert.Equal(t, expected1, actual1)
48 | assert.Equal(t, expected2, actual2)
49 | assert.Equal(t, expected3, actual3)
50 | assert.Equal(t, expected4, actual4)
51 | }
52 |
53 | func TestGenerateUnorderedListMD(t *testing.T) {
54 | input := EditorJSData{
55 | Style: "unordered",
56 | Items: []string{"first", "second", "third"},
57 | }
58 |
59 | expected := `- first
60 | - second
61 | - third`
62 |
63 | actual := generateMDList(input)
64 |
65 | assert.Equal(t, expected, actual)
66 | }
67 |
68 | func TestGenerateOrderedListMD(t *testing.T) {
69 | input := EditorJSData{
70 | Style: "ordered",
71 | Items: []string{"first", "second", "third"},
72 | }
73 |
74 | expected := `1. first
75 | 2. second
76 | 3. third`
77 |
78 | actual := generateMDList(input)
79 |
80 | assert.Equal(t, expected, actual)
81 | }
82 |
83 | func TestGenerateImageWithoutOptionsMD(t *testing.T) {
84 | input := EditorJSData{
85 | File: FileData{
86 | URL: "https://example.com/img.png",
87 | },
88 | }
89 |
90 | expected := ``
91 | actual := generateMDImage(input, Options{})
92 |
93 | assert.Equal(t, expected, actual)
94 | }
95 |
96 | func TestGenerateImageWithPartialOptionsMD(t *testing.T) {
97 | input := EditorJSData{
98 | File: FileData{
99 | URL: "https://example.com/img.png",
100 | },
101 | }
102 |
103 | options := Options{
104 | Image: ImageOptions{
105 | Caption: "My beautiful image",
106 | },
107 | }
108 |
109 | expected := ``
110 | actual := generateMDImage(input, options)
111 |
112 | assert.Equal(t, expected, actual)
113 | }
114 |
115 | func TestGenerateImageWithFullOptionsMD(t *testing.T) {
116 | input := EditorJSData{
117 | File: FileData{
118 | URL: "https://example.com/img.png",
119 | },
120 | }
121 |
122 | options := Options{
123 | Image: ImageOptions{
124 | Caption: "My beautiful image",
125 | Classes: ImageClasses{
126 | Stretched: "streched-class",
127 | WithBackground: "with-background-class",
128 | WithBorder: "with-border-class",
129 | },
130 | },
131 | }
132 |
133 | expected := `
`
134 | actual := generateMDImage(input, options)
135 |
136 | assert.Equal(t, expected, actual)
137 | }
138 |
--------------------------------------------------------------------------------
/misc/cover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/micheleriva/editorjs-go/b61b1298e8b334aa66bd8f3d582db70e37eee87f/misc/cover.png
--------------------------------------------------------------------------------
/tests/mocks/mock1.json:
--------------------------------------------------------------------------------
1 | {
2 | "time" : 1599070798438,
3 | "blocks" : [
4 | {
5 | "type" : "header",
6 | "data" : {
7 | "text" : "Editor.js",
8 | "level" : 2
9 | }
10 | },
11 | {
12 | "type" : "paragraph",
13 | "data" : {
14 | "text" : "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text."
15 | }
16 | },
17 | {
18 | "type" : "image",
19 | "data" : {
20 | "file" : {
21 | "url" : "https://codex.so/public/app/img/external/codex2x.png"
22 | },
23 | "caption" : "",
24 | "withBorder" : false,
25 | "stretched" : false,
26 | "withBackground" : false
27 | }
28 | }
29 | ],
30 | "version" : "2.18.0"
31 | }
--------------------------------------------------------------------------------
/tests/mocks/mock2.json:
--------------------------------------------------------------------------------
1 | {
2 | "time" : 1599070798438,
3 | "blocks" : [
4 | {
5 | "type" : "header",
6 | "data" : {
7 | "text" : "Editor.js",
8 | "level" : 2
9 | }
10 | },
11 | {
12 | "type" : "paragraph",
13 | "data" : {
14 | "text" : "Hey. Meet the new Editor. On this page you can see it in action — try to edit this text."
15 | }
16 | },
17 | {
18 | "type" : "header",
19 | "data" : {
20 | "text" : "Key features",
21 | "level" : 3
22 | }
23 | },
24 | {
25 | "type" : "list",
26 | "data" : {
27 | "style" : "unordered",
28 | "items" : [
29 | "It is a block-styled editor",
30 | "It returns clean data output in JSON",
31 | "Designed to be extendable and pluggable with a simple API"
32 | ]
33 | }
34 | },
35 | {
36 | "type" : "header",
37 | "data" : {
38 | "text" : "What does it mean «block-styled editor»",
39 | "level" : 3
40 | }
41 | },
42 | {
43 | "type" : "paragraph",
44 | "data" : {
45 | "text" : "Workspace in classic editors is made of a single contenteditable element, used to create different HTML markups. Editor.js workspace consists of separate Blocks: paragraphs, headings, images, lists, quotes, etc. Each of them is an independent contenteditable element (or more complex structure) provided by Plugin and united by Editor's Core."
46 | }
47 | },
48 | {
49 | "type" : "paragraph",
50 | "data" : {
51 | "text" : "There are dozens of ready-to-use Blocks and the simple API for creation any Block you need. For example, you can implement Blocks for Tweets, Instagram posts, surveys and polls, CTA-buttons and even games."
52 | }
53 | },
54 | {
55 | "type" : "header",
56 | "data" : {
57 | "text" : "What does it mean clean data output",
58 | "level" : 3
59 | }
60 | },
61 | {
62 | "type" : "paragraph",
63 | "data" : {
64 | "text" : "Classic WYSIWYG-editors produce raw HTML-markup with both content data and content appearance. On the contrary, Editor.js outputs JSON object with data of each Block. You can see an example below"
65 | }
66 | },
67 | {
68 | "type" : "paragraph",
69 | "data" : {
70 | "text" : "Given data can be used as you want: render with HTML for Web clients
, render natively for mobile apps
, create markup for Facebook Instant Articles
or Google AMP
, generate an audio version
and so on."
71 | }
72 | },
73 | {
74 | "type" : "paragraph",
75 | "data" : {
76 | "text" : "Clean data is useful to sanitize, validate and process on the backend."
77 | }
78 | },
79 | {
80 | "type" : "delimiter",
81 | "data" : {}
82 | },
83 | {
84 | "type" : "paragraph",
85 | "data" : {
86 | "text" : "We have been working on this project more than three years. Several large media projects help us to test and debug the Editor, to make it's core more stable. At the same time we significantly improved the API. Now, it can be used to create any plugin for any task. Hope you enjoy. 😏"
87 | }
88 | },
89 | {
90 | "type" : "image",
91 | "data" : {
92 | "file" : {
93 | "url" : "https://codex.so/public/app/img/external/codex2x.png"
94 | },
95 | "caption" : "",
96 | "withBorder" : false,
97 | "stretched" : false,
98 | "withBackground" : false
99 | }
100 | },
101 | {
102 | "type" : "table",
103 | "data" : {
104 | "content" : [
105 | [
106 | "1",
107 | "2",
108 | "3"
109 | ],
110 | [
111 | "4",
112 | "5",
113 | "6"
114 | ],
115 | [
116 | "7",
117 | "8",
118 | "9"
119 | ],
120 | [
121 | "10",
122 | "11",
123 | "12"
124 | ],
125 | [
126 | "13",
127 | "14",
128 | "15"
129 | ],
130 | [
131 | "16",
132 | "17",
133 | "18"
134 | ]
135 | ]
136 | }
137 | }
138 | ],
139 | "version" : "2.18.0"
140 | }
--------------------------------------------------------------------------------