Merge pull request #8 from seletskiy/master

implement macros & includes
This commit is contained in:
Egor Kovetskiy 2019-08-12 12:16:47 +03:00 committed by GitHub
commit 2369954b08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 914 additions and 162 deletions

129
README.md
View File

@ -30,10 +30,135 @@ File in extended format should follow specification
<page contents> <page contents>
``` ```
There can be any number of 'X-Parent' headers, if mark can't find specified There can be any number of 'Parent' headers, if mark can't find specified
parent by title, it will be created. parent by title, it will be created.
## Usage: Also, optional following headers are supported:
```markdown
<!-- Layout: (article|plain) -->
```
* (default) article: content will be put in narrow column for ease of
reading;
* plain: content will fill all page;
Mark supports Go templates, which can be included into article by using path
to the template relative to current working dir, e.g.:
```markdown
<!-- Include: <path> -->
```
Templates may accept configuration data in YAML format which immediately
follows include tag:
```markdown
<!-- Include: <path>
<yaml-data> -->
```
Mark also supports macro definitions, which are defined as regexps which will
be replaced with specified template:
```markdown
<!-- Macro: <regexp>
Template: <path>
<yaml-data> -->
```
Capture groups can be defined in the macro's <regexp> which can be later
referenced in the `<yaml-data>` using `${<number>}` syntax, where `<number>` is
number of a capture group in regexp (`${0}` is used for entire regexp match),
for example:
```markdown
<!-- Macro: MYJIRA-\d+
Template: ac:jira:ticket
Ticket: ${0} -->
```
By default, mark provides several built-in templates and macros:
* template `ac:status` to include badge-like text, which accepts following
parameters:
- Title: text to display in the badge
- Color: color to use as background/border for badge
- Grey
- Red
- Yellow
- Green
- Blue
- Subtle: specify to fill badge with background or not
- true
- false
* template `ac:jira:ticket` to include JIRA ticket link. Parameters:
- Ticket: Jira ticket number like BUGS-123.
See: https://confluence.atlassian.com/conf59/status-macro-792499207.html
* macro `@{...}` to mention user by name specified in the braces.
## Template & Macros Usecases
### Insert Disclamer
**disclamer.md**
```markdown
**NOTE**: this document is generated, do not edit manually.
```
**article.md**
```markdown
<!-- Space: TEST -->
<!-- Title: My Article -->
<!-- Include: disclamer.md -->
This is my article.
```
### Insert Status Badge
**article.md**
```markdown
<!-- Space: TEST -->
<!-- Title: TODO List -->
<!-- Macro: :done:
Template: ac:status
Title: DONE
Color: Green -->
<!-- Macro: :todo:
Template: ac:status
Title: TODO
Color: Blue -->
* :done: Write Article
* :todo: Publish Article
```
## Insert Jira Ticket
**article.md**
```markdown
<!-- Space: TEST -->
<!-- Title: TODO List -->
<!-- Macro: MYJIRA-\d+
Template: ac:jira:ticket
Ticket: ${0} -->
See task MYJIRA-123.
```
## Usage
``` ```
mark [options] [-u <username>] [-p <password>] [-k] [-l <url>] -f <file> mark [options] [-u <username>] [-p <password>] [-k] [-l <url>] -f <file>
mark [options] [-u <username>] [-p <password>] [-k] [-n] -c <file> mark [options] [-u <username>] [-p <password>] [-k] [-n] -c <file>

View File

@ -1 +0,0 @@
package main

View File

@ -1,29 +0,0 @@
package main
import "fmt"
type MacroLayout struct {
layout string
columns [][]byte
}
func (layout MacroLayout) Render() string {
switch layout.layout {
case "plain":
return string(layout.columns[0])
case "article":
fallthrough
default:
return fmt.Sprintf(
`<ac:layout>`+
`<ac:layout-section ac:type="two_right_sidebar">`+
`<ac:layout-cell>%s</ac:layout-cell>`+
`<ac:layout-cell></ac:layout-cell>`+
`</ac:layout-section>`+
`</ac:layout>`,
string(layout.columns[0]),
)
}
}

193
main.go
View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"bytes"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"os" "os"
@ -8,10 +9,12 @@ import (
"strings" "strings"
"github.com/kovetskiy/godocs" "github.com/kovetskiy/godocs"
"github.com/kovetskiy/lorg"
"github.com/kovetskiy/mark/pkg/confluence" "github.com/kovetskiy/mark/pkg/confluence"
"github.com/kovetskiy/mark/pkg/log"
"github.com/kovetskiy/mark/pkg/mark" "github.com/kovetskiy/mark/pkg/mark"
"github.com/reconquest/cog" "github.com/kovetskiy/mark/pkg/mark/includes"
"github.com/kovetskiy/mark/pkg/mark/macro"
"github.com/kovetskiy/mark/pkg/mark/stdlib"
"github.com/reconquest/karma-go" "github.com/reconquest/karma-go"
) )
@ -48,12 +51,60 @@ parent by title, it will be created.
Also, optional following headers are supported: Also, optional following headers are supported:
* <!-- Layout: <article|plain> --> * <!-- Layout: (article|plain) -->
- (default) article: content will be put in narrow column for ease of - (default) article: content will be put in narrow column for ease of
reading; reading;
- plain: content will fill all page; - plain: content will fill all page;
Mark supports Go templates, which can be included into article by using path
to the template relative to current working dir, e.g.:
<!-- Include: <path> -->
Templates may accept configuration data in YAML format which immediately
follows include tag:
<!-- Include: <path>
<yaml-data> -->
Mark also supports macro definitions, which are defined as regexps which will
be replaced with specified template:
<!-- Macro: <regexp>
Template: <path>
<yaml-data> -->
Capture groups can be defined in the macro's <regexp> which can be later
referenced in the <yaml-data> using ${<number>} syntax, where <number> is
number of a capture group in regexp (${0} is used for entire regexp match), for
example:
<!-- Macro: MYJIRA-\d+
Template: ac:jira:ticket
Ticket: ${0} -->
By default, mark provides several built-in templates and macros:
* template 'ac:status' to include badge-like text, which accepts following
parameters:
- Title: text to display in the badge
- Color: color to use as background/border for badge
- Grey
- Yellow
- Red
- Blue
- Subtle: specify to fill badge with background or not
- true
- false
See: https://confluence.atlassian.com/conf59/status-macro-792499207.html
* template 'ac:jira:ticket' to include JIRA ticket link. Parameters:
- Ticket: Jira ticket number like BUGS-123.
* macro '@{...}' to mention user by name specified in the braces.
Usage: Usage:
mark [options] [-u <username>] [-p <token>] [-k] [-l <url>] -f <file> mark [options] [-u <username>] [-p <token>] [-k] [-l <url>] -f <file>
mark [options] [-u <username>] [-p <password>] [-k] [-b <url>] -f <file> mark [options] [-u <username>] [-p <password>] [-k] [-b <url>] -f <file>
@ -80,31 +131,6 @@ Options:
` `
) )
var (
log *cog.Logger
)
func initlog(debug, trace bool) {
stderr := lorg.NewLog()
stderr.SetIndentLines(true)
stderr.SetFormat(
lorg.NewFormat("${time} ${level:[%s]:right:short} ${prefix}%s"),
)
log = cog.NewLogger(stderr)
if debug {
log.SetLevel(lorg.LevelDebug)
}
if trace {
log.SetLevel(lorg.LevelTrace)
}
mark.SetLogger(log)
confluence.SetLogger(log)
}
func main() { func main() {
args, err := godocs.Parse(usage, "mark 1.0", godocs.UsePager) args, err := godocs.Parse(usage, "mark 1.0", godocs.UsePager)
if err != nil { if err != nil {
@ -117,28 +143,13 @@ func main() {
editLock = args["-k"].(bool) editLock = args["-k"].(bool)
) )
initlog(args["--debug"].(bool), args["--trace"].(bool)) log.Init(args["--debug"].(bool), args["--trace"].(bool))
config, err := LoadConfig(filepath.Join(os.Getenv("HOME"), ".config/mark")) config, err := LoadConfig(filepath.Join(os.Getenv("HOME"), ".config/mark"))
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
markdownData, err := ioutil.ReadFile(targetFile)
if err != nil {
log.Fatal(err)
}
meta, err := mark.ExtractMeta(markdownData)
if err != nil {
log.Fatal(err)
}
if dryRun {
fmt.Println(string(mark.CompileMarkdown(markdownData)))
os.Exit(0)
}
creds, err := GetCredentials(args, config) creds, err := GetCredentials(args, config)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@ -146,10 +157,61 @@ func main() {
api := confluence.NewAPI(creds.BaseURL, creds.Username, creds.Password) api := confluence.NewAPI(creds.BaseURL, creds.Username, creds.Password)
markdown, err := ioutil.ReadFile(targetFile)
if err != nil {
log.Fatal(err)
}
meta, markdown, err := mark.ExtractMeta(markdown)
if err != nil {
log.Fatal(err)
}
stdlib, err := stdlib.New(api)
if err != nil {
log.Fatal(err)
}
templates := stdlib.Templates
var recurse bool
for {
templates, markdown, recurse, err = includes.ProcessIncludes(
markdown,
templates,
)
if err != nil {
log.Fatal(err)
}
if !recurse {
break
}
}
macros, markdown, err := macro.ExtractMacros(markdown, templates)
if err != nil {
log.Fatal(err)
}
macros = append(macros, stdlib.Macros...)
for _, macro := range macros {
markdown, err = macro.Apply(markdown)
if err != nil {
log.Fatal(err)
}
}
if dryRun {
fmt.Println(mark.CompileMarkdown(markdown, stdlib))
os.Exit(0)
}
if creds.PageID != "" && meta != nil { if creds.PageID != "" && meta != nil {
log.Warningf( log.Warning(
nil, `specified file contains metadata, ` +
`specified file contains metadata, `+
`but it will be ignored due specified command line URL`, `but it will be ignored due specified command line URL`,
) )
@ -157,10 +219,9 @@ func main() {
} }
if creds.PageID == "" && meta == nil { if creds.PageID == "" && meta == nil {
log.Fatalf( log.Fatal(
nil, `specified file doesn't contain metadata ` +
`specified file doesn't contain metadata `+ `and URL is not specified via command line ` +
`and URL is not specified via command line `+
`or doesn't contain pageId GET-parameter`, `or doesn't contain pageId GET-parameter`,
) )
} }
@ -195,14 +256,32 @@ func main() {
log.Fatalf(err, "unable to create/update attachments") log.Fatalf(err, "unable to create/update attachments")
} }
markdownData = mark.CompileAttachmentLinks(markdownData, attaches) markdown = mark.CompileAttachmentLinks(markdown, attaches)
htmlData := mark.CompileMarkdown(markdownData) html := mark.CompileMarkdown(markdown, stdlib)
err = api.UpdatePage( {
target, var buffer bytes.Buffer
MacroLayout{meta.Layout, [][]byte{htmlData}}.Render(),
) err := stdlib.Templates.ExecuteTemplate(
&buffer,
"ac:layout",
struct {
Layout string
Body string
}{
Layout: meta.Layout,
Body: html,
},
)
if err != nil {
log.Fatal(err)
}
html = buffer.String()
}
err = api.UpdatePage(target, html)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }

View File

@ -12,8 +12,6 @@ import (
"strings" "strings"
"github.com/bndr/gopencils" "github.com/bndr/gopencils"
"github.com/kovetskiy/lorg"
"github.com/reconquest/cog"
"github.com/reconquest/karma-go" "github.com/reconquest/karma-go"
) )
@ -59,20 +57,6 @@ type AttachmentInfo struct {
} `json:"_links"` } `json:"_links"`
} }
func discarder() *lorg.Log {
stderr := lorg.NewLog()
stderr.SetOutput(ioutil.Discard)
return stderr
}
var (
log = cog.NewLogger(discarder())
)
func SetLogger(logger *cog.Logger) {
log = logger
}
type form struct { type form struct {
buffer io.Reader buffer io.Reader
writer *multipart.Writer writer *multipart.Writer
@ -471,6 +455,35 @@ func (api *API) UpdatePage(
return nil return nil
} }
func (api *API) GetUserByName(name string) (*User, error) {
var response struct {
Results []struct {
User User
}
}
_, err := api.rest.
Res("search").
Res("user", &response).
Get(map[string]string{
"cql": fmt.Sprintf("user.fullname~%q", name),
})
if err != nil {
return nil, err
}
if len(response.Results) == 0 {
return nil, karma.
Describe("name", name).
Reason(
"user with given name is not found",
)
}
return &response.Results[0].User, nil
}
func (api *API) GetCurrentUser() (*User, error) { func (api *API) GetCurrentUser() (*User, error) {
var user User var user User

100
pkg/log/log.go Normal file
View File

@ -0,0 +1,100 @@
package log
import (
"github.com/kovetskiy/lorg"
"github.com/reconquest/cog"
)
var (
log *cog.Logger
)
func Init(debug, trace bool) {
stderr := lorg.NewLog()
stderr.SetIndentLines(true)
stderr.SetFormat(
lorg.NewFormat("${time} ${level:[%s]:right:short} ${prefix}%s"),
)
log = cog.NewLogger(stderr)
if debug {
log.SetLevel(lorg.LevelDebug)
}
if trace {
log.SetLevel(lorg.LevelTrace)
}
}
func Fatalf(
reason interface{},
message string,
args ...interface{},
) {
log.Fatalf(reason, message, args...)
}
func Errorf(
reason interface{},
message string,
args ...interface{},
) {
log.Errorf(reason, message, args...)
}
func Warningf(
reason interface{},
message string,
args ...interface{},
) {
log.Warningf(reason, message, args...)
}
func Infof(
context interface{},
message string,
args ...interface{},
) {
log.Infof(context, message, args...)
}
func Debugf(
context interface{},
message string,
args ...interface{},
) {
log.Debugf(context, message, args...)
}
func Tracef(
context interface{},
message string,
args ...interface{},
) {
log.Tracef(context, message, args...)
}
func Fatal(values ...interface{}) {
log.Fatal(values...)
}
func Error(values ...interface{}) {
log.Error(values...)
}
func Warning(values ...interface{}) {
log.Warning(values...)
}
func Info(values ...interface{}) {
log.Info(values...)
}
func Debug(values ...interface{}) {
log.Debug(values...)
}
func Trace(values ...interface{}) {
log.Trace(values...)
}

View File

@ -5,7 +5,7 @@ import (
"strings" "strings"
"github.com/kovetskiy/mark/pkg/confluence" "github.com/kovetskiy/mark/pkg/confluence"
"github.com/reconquest/faces/logger" "github.com/kovetskiy/mark/pkg/log"
"github.com/reconquest/karma-go" "github.com/reconquest/karma-go"
) )
@ -56,7 +56,7 @@ func EnsureAncestry(
return parent, nil return parent, nil
} }
logger.Debugf( log.Debugf(
"empty pages under %q to be created: %s", "empty pages under %q to be created: %s",
parent.Title, parent.Title,
strings.Join(rest, ` > `), strings.Join(rest, ` > `),

View File

@ -13,6 +13,7 @@ import (
"strings" "strings"
"github.com/kovetskiy/mark/pkg/confluence" "github.com/kovetskiy/mark/pkg/confluence"
"github.com/kovetskiy/mark/pkg/log"
"github.com/reconquest/karma-go" "github.com/reconquest/karma-go"
) )

View File

@ -0,0 +1,151 @@
package includes
import (
"bytes"
"fmt"
"io/ioutil"
"path/filepath"
"regexp"
"strings"
"text/template"
"gopkg.in/yaml.v2"
"github.com/kovetskiy/mark/pkg/log"
"github.com/reconquest/karma-go"
)
var (
reIncludeDirective = regexp.MustCompile(
// <!-- Include: <template path>
// <optional yaml data> -->
`(?s)` + // dot capture newlines
/**/ `<!--\s*Include:\s*(?P<template>\S+)\s*` +
/* */ `(\n(?P<config>.*?))?-->`,
)
)
func LoadTemplate(
path string,
templates *template.Template,
) (*template.Template, error) {
var (
name = strings.TrimSuffix(path, filepath.Ext(path))
facts = karma.Describe("name", name)
)
if template := templates.Lookup(name); template != nil {
return template, nil
}
var body []byte
body, err := ioutil.ReadFile(path)
if err != nil {
err = facts.Format(
err,
"unable to read template file",
)
return nil, err
}
templates, err = templates.New(name).Parse(string(body))
if err != nil {
err = facts.Format(
err,
"unable to parse template",
)
return nil, err
}
return templates, nil
}
func ProcessIncludes(
contents []byte,
templates *template.Template,
) (*template.Template, []byte, bool, error) {
vardump := func(
facts *karma.Context,
data map[string]interface{},
) *karma.Context {
for key, value := range data {
key = "var " + key
facts = facts.Describe(
key,
strings.ReplaceAll(
fmt.Sprint(value),
"\n",
"\n"+strings.Repeat(" ", len(key)+2),
),
)
}
return facts
}
var (
recurse bool
err error
)
contents = reIncludeDirective.ReplaceAllFunc(
contents,
func(spec []byte) []byte {
if err != nil {
return nil
}
groups := reIncludeDirective.FindSubmatch(spec)
var (
path, config = string(groups[1]), groups[2]
data = map[string]interface{}{}
facts = karma.Describe("path", path)
)
err = yaml.Unmarshal(config, &data)
if err != nil {
err = facts.
Describe("config", string(config)).
Format(
err,
"unable to unmarshal template data config",
)
return nil
}
log.Tracef(vardump(facts, data), "including template %q", path)
templates, err = LoadTemplate(path, templates)
if err != nil {
err = facts.Format(err, "unable to load template")
return nil
}
var buffer bytes.Buffer
err = templates.Execute(&buffer, data)
if err != nil {
err = vardump(facts, data).Format(
err,
"unable to execute template",
)
return nil
}
recurse = true
return buffer.Bytes()
},
)
return templates, contents, recurse, err
}

172
pkg/mark/macro/macro.go Normal file
View File

@ -0,0 +1,172 @@
package macro
import (
"bytes"
"fmt"
"regexp"
"strings"
"text/template"
"github.com/kovetskiy/mark/pkg/log"
"github.com/kovetskiy/mark/pkg/mark/includes"
"github.com/reconquest/karma-go"
"github.com/reconquest/regexputil-go"
"gopkg.in/yaml.v2"
)
var reMacroDirective = regexp.MustCompile(
// <!-- Macro: <regexp>
// Template: <template path>
// <optional yaml data> -->
`(?s)` + // dot capture newlines
/**/ `<!--\s*Macro:\s*(?P<expr>[^\n]+)\n` +
/* */ `\s*Template:\s*(?P<template>\S+)\s*` +
/* */ `(\n(?P<config>.*?))?-->`,
)
type Macro struct {
Regexp *regexp.Regexp
Template *template.Template
Config map[string]interface{}
}
func (macro *Macro) Apply(
content []byte,
) ([]byte, error) {
var err error
content = macro.Regexp.ReplaceAllFunc(
content,
func(match []byte) []byte {
config := macro.configure(
macro.Config,
macro.Regexp.FindSubmatch(match),
)
var buffer bytes.Buffer
err = macro.Template.Execute(&buffer, config)
if err != nil {
err = karma.Format(
err,
"unable to execute macros template",
)
}
return buffer.Bytes()
},
)
return content, err
}
func (macro *Macro) configure(node interface{}, groups [][]byte) interface{} {
switch node := node.(type) {
case map[interface{}]interface{}:
for key, value := range node {
node[key] = macro.configure(value, groups)
}
return node
case map[string]interface{}:
for key, value := range node {
node[key] = macro.configure(value, groups)
}
return node
case []interface{}:
for key, value := range node {
node[key] = macro.configure(value, groups)
}
return node
case string:
for i, group := range groups {
node = strings.ReplaceAll(
node,
fmt.Sprintf("${%d}", i),
string(group),
)
}
return node
}
return node
}
func ExtractMacros(
contents []byte,
templates *template.Template,
) ([]Macro, []byte, error) {
var err error
var macros []Macro
contents = reMacroDirective.ReplaceAllFunc(
contents,
func(spec []byte) []byte {
if err != nil {
return spec
}
groups := reMacroDirective.FindStringSubmatch(string(spec))
var (
expr = regexputil.Subexp(reMacroDirective, groups, "expr")
template = regexputil.Subexp(reMacroDirective, groups, "template")
config = regexputil.Subexp(reMacroDirective, groups, "config")
macro Macro
)
macro.Template, err = includes.LoadTemplate(template, templates)
if err != nil {
err = karma.Format(err, "unable to load template")
return nil
}
facts := karma.
Describe("template", template).
Describe("expr", expr)
macro.Regexp, err = regexp.Compile(expr)
if err != nil {
err = facts.
Format(
err,
"unable to compile macros regexp",
)
return nil
}
err = yaml.Unmarshal([]byte(config), &macro.Config)
if err != nil {
err = facts.
Describe("config", string(config)).
Format(
err,
"unable to unmarshal template data config",
)
return nil
}
log.Tracef(
facts.Describe("config", macro.Config),
"loaded macro %q",
expr,
)
macros = append(macros, macro)
return []byte{}
},
)
return macros, contents, err
}

View File

@ -4,7 +4,7 @@ import (
"strings" "strings"
"github.com/kovetskiy/mark/pkg/confluence" "github.com/kovetskiy/mark/pkg/confluence"
"github.com/reconquest/faces/logger" "github.com/kovetskiy/mark/pkg/log"
"github.com/reconquest/karma-go" "github.com/reconquest/karma-go"
) )
@ -47,7 +47,7 @@ func ResolvePage(
path := meta.Parents path := meta.Parents
path = append(path, meta.Title) path = append(path, meta.Title)
logger.Debugf( log.Debugf(
"resolving page path: ??? > %s", "resolving page path: ??? > %s",
strings.Join(path, ` > `), strings.Join(path, ` > `),
) )
@ -74,7 +74,7 @@ func ResolvePage(
titles = append(titles, parent.Title) titles = append(titles, parent.Title)
log.Infof( log.Infof(
nil, nil,
"page will be stored under path: %s > %s", "page will be stored under path: %s > %s",
strings.Join(titles, ` > `), strings.Join(titles, ` > `),
meta.Title, meta.Title,

View File

@ -2,14 +2,17 @@ package mark
import ( import (
"bytes" "bytes"
"fmt"
"regexp" "regexp"
"github.com/kovetskiy/mark/pkg/log"
"github.com/kovetskiy/mark/pkg/mark/stdlib"
"github.com/russross/blackfriday" "github.com/russross/blackfriday"
) )
type ConfluenceRenderer struct { type ConfluenceRenderer struct {
blackfriday.Renderer blackfriday.Renderer
Stdlib *stdlib.Lib
} }
func (renderer ConfluenceRenderer) BlockCode( func (renderer ConfluenceRenderer) BlockCode(
@ -17,22 +20,16 @@ func (renderer ConfluenceRenderer) BlockCode(
text []byte, text []byte,
lang string, lang string,
) { ) {
out.WriteString(MacroCode{lang, text}.Render()) renderer.Stdlib.Templates.ExecuteTemplate(
} out,
"ac:code",
type MacroCode struct { struct {
lang string Language string
code []byte Text string
} }{
lang,
func (code MacroCode) Render() string { string(text),
return fmt.Sprintf( },
`<ac:structured-macro ac:name="code">`+
`<ac:parameter ac:name="language">%s</ac:parameter>`+
`<ac:parameter ac:name="collapse">false</ac:parameter>`+
`<ac:plain-text-body><![CDATA[%s]]></ac:plain-text-body>`+
`</ac:structured-macro>`,
code.lang, code.code,
) )
} }
@ -41,12 +38,13 @@ func (code MacroCode) Render() string {
// <a href="ac:rich-text-body">ac:rich-text-body</a> for whatever reason. // <a href="ac:rich-text-body">ac:rich-text-body</a> for whatever reason.
func CompileMarkdown( func CompileMarkdown(
markdown []byte, markdown []byte,
) []byte { stdlib *stdlib.Lib,
) string {
log.Tracef(nil, "rendering markdown:\n%s", string(markdown)) log.Tracef(nil, "rendering markdown:\n%s", string(markdown))
colon := regexp.MustCompile(`---BLACKFRIDAY-COLON---`) colon := regexp.MustCompile(`---BLACKFRIDAY-COLON---`)
tags := regexp.MustCompile(`<(/?\S+):(\S+)>`) tags := regexp.MustCompile(`<(/?\S+?):(\S+?)>`)
markdown = tags.ReplaceAll( markdown = tags.ReplaceAll(
markdown, markdown,
@ -54,7 +52,7 @@ func CompileMarkdown(
) )
renderer := ConfluenceRenderer{ renderer := ConfluenceRenderer{
blackfriday.HtmlRenderer( Renderer: blackfriday.HtmlRenderer(
blackfriday.HTML_USE_XHTML| blackfriday.HTML_USE_XHTML|
blackfriday.HTML_USE_SMARTYPANTS| blackfriday.HTML_USE_SMARTYPANTS|
blackfriday.HTML_SMARTYPANTS_FRACTIONS| blackfriday.HTML_SMARTYPANTS_FRACTIONS|
@ -62,6 +60,8 @@ func CompileMarkdown(
blackfriday.HTML_SMARTYPANTS_LATEX_DASHES, blackfriday.HTML_SMARTYPANTS_LATEX_DASHES,
"", "", "", "",
), ),
Stdlib: stdlib,
} }
html := blackfriday.MarkdownOptions( html := blackfriday.MarkdownOptions(
@ -88,5 +88,5 @@ func CompileMarkdown(
log.Tracef(nil, "rendered markdown to html:\n%s", string(html)) log.Tracef(nil, "rendered markdown to html:\n%s", string(html))
return html return string(html)
} }

View File

@ -4,28 +4,12 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"fmt" "fmt"
"io/ioutil"
"regexp" "regexp"
"strings" "strings"
"github.com/kovetskiy/lorg" "github.com/kovetskiy/mark/pkg/log"
"github.com/reconquest/cog"
) )
func discarder() *lorg.Log {
stderr := lorg.NewLog()
stderr.SetOutput(ioutil.Discard)
return stderr
}
var (
log = cog.NewLogger(discarder())
)
func SetLogger(logger *cog.Logger) {
log = logger
}
const ( const (
HeaderParent = `Parent` HeaderParent = `Parent`
HeaderSpace = `Space` HeaderSpace = `Space`
@ -42,25 +26,30 @@ type Meta struct {
Attachments []string Attachments []string
} }
func ExtractMeta(data []byte) (*Meta, error) { var (
var ( reHeaderPatternV1 = regexp.MustCompile(`\[\]:\s*#\s*\(([^:]+):\s*(.*)\)`)
headerPatternV1 = regexp.MustCompile(`\[\]:\s*#\s*\(([^:]+):\s*(.*)\)`) reHeaderPatternV2 = regexp.MustCompile(`<!--\s*([^:]+):\s*(.*)\s*-->`)
headerPatternV2 = regexp.MustCompile(`<!--\s*([^:]+):\s*(.*)\s*-->`) )
)
var meta *Meta func ExtractMeta(data []byte) (*Meta, []byte, error) {
var (
meta *Meta
offset int
)
scanner := bufio.NewScanner(bytes.NewBuffer(data)) scanner := bufio.NewScanner(bytes.NewBuffer(data))
for scanner.Scan() { for scanner.Scan() {
line := scanner.Text() line := scanner.Text()
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
return nil, err return nil, nil, err
} }
matches := headerPatternV2.FindStringSubmatch(line) offset += len(line)
matches := reHeaderPatternV2.FindStringSubmatch(line)
if matches == nil { if matches == nil {
matches = headerPatternV1.FindStringSubmatch(line) matches = reHeaderPatternV1.FindStringSubmatch(line)
if matches == nil { if matches == nil {
break break
} }
@ -113,22 +102,22 @@ func ExtractMeta(data []byte) (*Meta, error) {
} }
if meta == nil { if meta == nil {
return nil, nil return nil, data, nil
} }
if meta.Space == "" { if meta.Space == "" {
return nil, fmt.Errorf( return nil, nil, fmt.Errorf(
"space key is not set (%s header is not set)", "space key is not set (%s header is not set)",
HeaderSpace, HeaderSpace,
) )
} }
if meta.Title == "" { if meta.Title == "" {
return nil, fmt.Errorf( return nil, nil, fmt.Errorf(
"page title is not set (%s header is not set)", "page title is not set (%s header is not set)",
HeaderTitle, HeaderTitle,
) )
} }
return meta, nil return nil, data[offset+1:], nil
} }

153
pkg/mark/stdlib/stdlib.go Normal file
View File

@ -0,0 +1,153 @@
package stdlib
import (
"strings"
"text/template"
"github.com/kovetskiy/mark/pkg/confluence"
"github.com/kovetskiy/mark/pkg/log"
"github.com/kovetskiy/mark/pkg/mark/macro"
"github.com/reconquest/karma-go"
)
type Lib struct {
Macros []macro.Macro
Templates *template.Template
}
func New(api *confluence.API) (*Lib, error) {
var (
lib Lib
err error
)
lib.Templates, err = templates(api)
if err != nil {
return nil, err
}
lib.Macros, err = macros(lib.Templates)
if err != nil {
return nil, err
}
return &lib, nil
}
func macros(templates *template.Template) ([]macro.Macro, error) {
text := func(line ...string) []byte {
return []byte(strings.Join(line, "\n"))
}
macros, _, err := macro.ExtractMacros(
[]byte(text(
`<!-- Macro: @\{([^}]+)\}`,
` Template: ac:link:user`,
` Name: ${1} -->`,
// TODO(seletskiy): more macros here
)),
templates,
)
if err != nil {
return nil, err
}
return macros, nil
}
func templates(api *confluence.API) (*template.Template, error) {
text := func(line ...string) string {
return strings.Join(line, ``)
}
templates := template.New(`stdlib`).Funcs(
template.FuncMap{
"user": func(name string) *confluence.User {
user, err := api.GetUserByName(name)
if err != nil {
log.Error(err)
}
return user
},
// The only way to escape CDATA end marker ']]>' is to split it
// into two CDATA sections.
"cdata": func(data string) string {
return strings.ReplaceAll(
data,
"]]>",
"]]><![CDATA[]]]]><![CDATA[>",
)
},
},
)
var err error
for name, body := range map[string]string{
// This template is used to select whole article layout
`ac:layout`: text(
`{{ if eq .Layout "article" }}`,
/**/ `<ac:layout>`,
/**/ `<ac:layout-section ac:type="two_right_sidebar">`,
/**/ `<ac:layout-cell>{{ .Body }}</ac:layout-cell>`,
/**/ `<ac:layout-cell></ac:layout-cell>`,
/**/ `</ac:layout-section>`,
/**/ `</ac:layout>`,
`{{ else }}`,
/**/ `{{ .Body }}`,
`{{ end }}`,
),
// This template is used for rendering code in ```
`ac:code`: text(
`<ac:structured-macro ac:name="code">`,
`<ac:parameter ac:name="language">{{ .Language }}</ac:parameter>`,
`<ac:parameter ac:name="collapse">false</ac:parameter>`,
`<ac:plain-text-body><![CDATA[{{ .Text | cdata }}]]></ac:plain-text-body>`,
`</ac:structured-macro>`,
),
`ac:status`: text(
`<ac:structured-macro ac:name="status">`,
`<ac:parameter ac:name="colour">{{ or .Color "Grey" }}</ac:parameter>`,
`<ac:parameter ac:name="title">{{ or .Title .Color }}</ac:parameter>`,
`<ac:parameter ac:name="subtle">{{ or .Subtle false }}</ac:parameter>`,
`</ac:structured-macro>`,
),
`ac:link:user`: text(
`{{ with .Name | user }}`,
/**/ `<ac:link>`,
/**/ `<ri:user ri:account-id="{{ .AccountID }}"/>`,
/**/ `</ac:link>`,
`{{ else }}`,
/**/ `{{ .Name }}`,
`{{ end }}`,
),
`ac:jira:ticket`: text(
`<ac:structured-macro ac:name="jira">`,
`<ac:parameter ac:name="key">{{ .Ticket }}</ac:parameter>`,
`</ac:structured-macro>`,
),
// TODO(seletskiy): more templates here
} {
templates, err = templates.New(name).Parse(body)
if err != nil {
return nil, karma.
Describe("template", body).
Format(
err,
"unable to parse template",
)
}
}
return templates, nil
}

View File

@ -1 +0,0 @@
package main