diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 000000000..7dbc649d8 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,32 @@ +name: Go + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + + build: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f + + - name: Set up Go + uses: actions/setup-go@37335c7bb261b353407cff977110895fa0b4f7d8 + with: + go-version: 1.16 + + - name: Vet + working-directory: claat + run: go vet ./... + + - name: Build + working-directory: claat + run: go build -v ./... + + - name: Test + working-directory: claat + run: go test -v ./... diff --git a/.travis.yml b/.travis.yml index c49145b91..1787cc268 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,8 +21,6 @@ install: - sudo unzip -o protoc-3.7.1-linux-x86_64.zip -d /usr/local bin/protoc - sudo unzip -o protoc-3.7.1-linux-x86_64.zip -d /usr/local include/* - rm -f protoc-3.7.1-linux-x86_64.zip -# Compile protobuf -- protoc tutorial.proto -I=third_party --go_out=third_party # Dynamically get rest of Golang-specific dependencies - go get -t -d ./claat/... script: diff --git a/FORMAT-GUIDE.md b/FORMAT-GUIDE.md index b60688c30..7102d99e2 100644 --- a/FORMAT-GUIDE.md +++ b/FORMAT-GUIDE.md @@ -189,7 +189,11 @@ You can also use this to target specific events, for instance: \ Having a header 2 of "What you'll learn" followed by a bullet point list creates a list of check marks. A title of "What we've covered" has the same effect. + +1. YouTube Video Embeds + + Use a video tag like so `` to embed a video uploaded to YouTube with the URL https://www.youtube.com/watch?v=DWAinkJ54AP8 ## Things to avoid -- **Footers:** Any characters included in the footer (beyond the default page number) result in parsing bugs. For this reason, page footers are not recommended. \ No newline at end of file +- **Footers:** Any characters included in the footer (beyond the default page number) result in parsing bugs. For this reason, page footers are not recommended. diff --git a/README.md b/README.md index eda1ffa43..1480428d0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Tools for authoring and serving codelabs -[![Demo](https://storage.googleapis.com/claat/demo.png)](https://storage.googleapis.com/claat/demo.mp4) - Codelabs are interactive instructional tutorials, which can be authored in Google Docs using some simple formatting conventions. You can also author codelabs using markdown syntax. This repo contains all the tools and documentation you’ll need for building and publishing diff --git a/claat/Makefile b/claat/Makefile index 2f76a97df..51b5fed66 100644 --- a/claat/Makefile +++ b/claat/Makefile @@ -48,17 +48,8 @@ release: $(RELEASES) echo $(VERSION) > $(OUTDIR)/VERSION cd $(OUTDIR) && sha1sum claat* > sha1sum.txt -test: .test -.test: $(SRCS) - go test ./... && touch .test - -lint: .lint -.lint: $(SRCS) - go vet ./... - golint ./... && touch .lint - clean: - rm -rf $(OUTDIR) render/tmpldata.go .test .lint + rm -rf $(OUTDIR) render/tmpldata.go $(OUTDIR)/claat-%: GOOS=$(firstword $(subst -, ,$*)) $(OUTDIR)/claat-%: GOARCH=$(subst .exe,,$(word 2,$(subst -, ,$*))) diff --git a/claat/README.md b/claat/README.md index 0c82a383b..05eeb1ea3 100644 --- a/claat/README.md +++ b/claat/README.md @@ -14,7 +14,7 @@ The binaries, as well as their checksums are available at the Alternatively, if you have [Go installed](https://golang.org/doc/install): - go get github.com/googlecodelabs/tools/claat + go install github.com/googlecodelabs/tools/claat@latest If none of the above works, compile the tool from source following Dev workflow instructions below. diff --git a/claat/VERSION b/claat/VERSION index ee90284c2..21bb5e156 100644 --- a/claat/VERSION +++ b/claat/VERSION @@ -1 +1 @@ -1.0.4 +2.2.5 diff --git a/claat/bin/nb2cl b/claat/bin/nb2cl new file mode 100755 index 000000000..3f2c00dbc --- /dev/null +++ b/claat/bin/nb2cl @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +USAGE="$0 [-i stepnum:file,...] notebook-file title\n +\t- the -i (insert) option specifies a comma separated list +\t of stepnum:file pairs, causing the contents of the specified +\t files be inserted before the corresponding step numbers. +\t- notebook file should end with the .ipynb extension +\t- title must be quoted if it contains whitespace +\t- example: $0 test-notebook.ipynb \"This is a test\" +\nDependencies: +\t- jupyter (https://jupyter.org/install) +\t- claat (https://github.com/googlecodelabs/tools) +" + +if [[ ! (("$1" = "-i" && "$#" == 4) || ("$#" == 2)) ]] +then + echo "ERROR: invalid number of arguments" + echo -e "$USAGE" + exit 1 +fi + +insert_list="" +if [ "$1" = "-i" ] +then + insert_list=(${2//,/ }) + shift; shift +fi + +sorted=() +while IFS= read -rd '' item; do + sorted+=("$item") +done < <(printf '%s\0' "${insert_list[@]}" | sort -z) + +for i in ${sorted[@]} +do + if [[ !($i == *":"*) ]] + then + echo "ERROR: missing colon in -i option" + echo -e "$USAGE" + exit 1 + fi + stepnum=$(echo $i | cut -d: -f1) + re='^[0-9]+$' + if ! [[ $stepnum =~ $re ]] ; then + echo "ERROR: -i option stepnum $stepnum not a number" + echo -e "$USAGE" + exit 1 + fi + file=$(echo $i | cut -d: -f2) + if [ ! -r $file ] + then + echo "ERROR: -i option file $file not readable" + echo -e "$USAGE" + exit 1 + fi + echo $stepnum:$file +done + +NB=$1 +TITLE=$2 +if [ ! -r $NB ] +then + echo "ERROR: notebook file $NB not readable" + echo -e "$USAGE" + exit 1 +fi + +NAME=$(basename $1 .ipynb) +MD=$NAME.md +HTML=$NAME.html + +jupyter nbconvert --to markdown $NB +cat <warning +""" +WARNING: DO NOT EDIT THIS FILE. +This codelab was auto-generated by a tool that converts Jupyter ipynb +files to Google Codelab markdown. If you want to modify this codelab, +update the single source of truth (the .ipynb file), not this file. +""" +! +cat warning $MD >$MD.tmp +mv $MD.tmp $MD +rm -f warning +sed -i -e "1s/^/Id: $TITLE\n/" $MD +echo 0 >count +mv $MD $MD.tmp1 +for i in ${sorted[@]} +do + stepnum=$(echo $i | cut -d: -f1) + file=$(echo $i | cut -d: -f2) + awk -v file="$file" -v stepnum="$stepnum" -v count=$(cat count) \ + '/^## / || /^##$/ {x++} x==(stepnum+count) {system("echo " count+1 " >count");system("cat " file);print("\n");x++} {print}' $MD.tmp1 >$MD.tmp2 + mv $MD.tmp2 $MD.tmp1 +done +rm -f count +mv $MD.tmp1 $MD +claat export -o - $MD >$HTML + +echo -e "markdown version: $MD\nhtml version: $HTML" diff --git a/claat/cmd/export.go b/claat/cmd/export.go index d4c10de25..652452599 100644 --- a/claat/cmd/export.go +++ b/claat/cmd/export.go @@ -27,7 +27,6 @@ import ( "time" "github.com/googlecodelabs/tools/claat/fetch" - "github.com/googlecodelabs/tools/claat/parser" "github.com/googlecodelabs/tools/claat/render" "github.com/googlecodelabs/tools/claat/types" "github.com/googlecodelabs/tools/claat/util" @@ -43,8 +42,6 @@ type CmdExportOptions struct { ExtraVars map[string]string // GlobalGA is the global Google Analytics account to use. GlobalGA string - // MDParser is the underlying Markdown parser to use. - MDParser parser.MarkdownParser // Output is the output directory, or "-" for stdout. Output string // PassMetadata are the extra metadata fields to pass along. @@ -101,11 +98,11 @@ func CmdExport(opts CmdExportOptions) int { // // An alternate http.RoundTripper may be specified if desired. Leave null for default. func ExportCodelab(src string, rt http.RoundTripper, opts CmdExportOptions) (*types.Meta, error) { - f, err := fetch.NewFetcher(opts.AuthToken, opts.PassMetadata, rt, opts.MDParser) + f, err := fetch.NewFetcher(opts.AuthToken, opts.PassMetadata, rt) if err != nil { return nil, err } - clab, err := f.SlurpCodelab(src) + clab, err := f.SlurpCodelab(src, opts.Output) if err != nil { return nil, err } @@ -114,29 +111,23 @@ func ExportCodelab(src string, rt http.RoundTripper, opts CmdExportOptions) (*ty lastmod := types.ContextTime(clab.Mod) clab.Meta.Source = src meta := &clab.Meta - ctx := &types.Context{ - Env: opts.Expenv, - Format: opts.Tmplout, - Prefix: opts.Prefix, - MainGA: opts.GlobalGA, - Updated: &lastmod, - } dir := opts.Output // output dir or stdout if !isStdout(dir) { dir = codelabDir(dir, meta) - // download or copy codelab assets to disk, and rewrite image URLs - mdir := filepath.Join(dir, util.ImgDirname) - if _, err := f.SlurpImages(src, mdir, clab.Steps); err != nil { - return nil, err - } } // write codelab and its metadata to disk - return meta, writeCodelab(dir, clab.Codelab, opts.ExtraVars, ctx) + return meta, writeCodelab(dir, clab.Codelab, opts.ExtraVars, &types.Context{ + Env: opts.Expenv, + Format: opts.Tmplout, + Prefix: opts.Prefix, + MainGA: opts.GlobalGA, + Updated: &lastmod, + }) } func ExportCodelabMemory(src io.ReadCloser, w io.Writer, opts CmdExportOptions) (*types.Meta, error) { - m := fetch.NewMemoryFetcher(opts.PassMetadata, opts.MDParser) + m := fetch.NewMemoryFetcher(opts.PassMetadata) clab, err := m.SlurpCodelab(src) if err != nil { return nil, err @@ -271,9 +262,3 @@ func writeMeta(path string, cm *types.ContextMeta) error { b = append(b, '\n') return ioutil.WriteFile(path, b, 0644) } - -// codelabDir returns codelab root directory. -// The base argument is codelab parent directory. -func codelabDir(base string, m *types.Meta) string { - return filepath.Join(base, m.ID) -} diff --git a/claat/cmd/export_test.go b/claat/cmd/export_test.go index 1bc614491..03fd3e076 100644 --- a/claat/cmd/export_test.go +++ b/claat/cmd/export_test.go @@ -63,7 +63,7 @@ func TestExportCodelabMemory(t *testing.T) { opts := cmd.CmdExportOptions{ Expenv: "web", Output: tmp, - Tmplout: "devsite", + Tmplout: "html", GlobalGA: "UA-99999999-99", } diff --git a/claat/cmd/update.go b/claat/cmd/update.go index 8c3598902..76725b839 100644 --- a/claat/cmd/update.go +++ b/claat/cmd/update.go @@ -27,7 +27,6 @@ import ( "time" "github.com/googlecodelabs/tools/claat/fetch" - "github.com/googlecodelabs/tools/claat/parser" "github.com/googlecodelabs/tools/claat/types" "github.com/googlecodelabs/tools/claat/util" ) @@ -40,8 +39,6 @@ type CmdUpdateOptions struct { ExtraVars map[string]string // GlobalGA is the global Google Analytics account to use. GlobalGA string - // MDParser is the underlying Markdown parser to use. - MDParser parser.MarkdownParser // PassMetadata are the extra metadata fields to pass along. PassMetadata map[string]bool // Prefix is a URL prefix to prepend when using HTML format. @@ -110,27 +107,21 @@ func updateCodelab(dir string, opts CmdUpdateOptions) (*types.Meta, error) { } // fetch and parse codelab source - f, err := fetch.NewFetcher(opts.AuthToken, opts.PassMetadata, nil, opts.MDParser) + f, err := fetch.NewFetcher(opts.AuthToken, opts.PassMetadata, nil) if err != nil { return nil, err } - clab, err := f.SlurpCodelab(meta.Source) + basedir := filepath.Join(dir, "..") + clab, err := f.SlurpCodelab(meta.Source, basedir) if err != nil { return nil, err } updated := types.ContextTime(clab.Mod) meta.Context.Updated = &updated - basedir := filepath.Join(dir, "..") newdir := codelabDir(basedir, &clab.Meta) imgdir := filepath.Join(newdir, util.ImgDirname) - // slurp codelab assets to disk and rewrite image URLs - imgmap, err := f.SlurpImages(meta.Source, imgdir, clab.Steps) - if err != nil { - return nil, err - } - // write codelab and its metadata if err := writeCodelab(newdir, clab.Codelab, opts.ExtraVars, &meta.Context); err != nil { return nil, err @@ -150,7 +141,7 @@ func updateCodelab(dir string, opts CmdUpdateOptions) (*types.Meta, error) { if fi.IsDir() { return filepath.SkipDir } - if _, ok := imgmap[filepath.Base(p)]; !ok { + if _, ok := clab.Imgs[filepath.Base(p)]; !ok { return os.Remove(p) } return nil diff --git a/claat/cmd/util.go b/claat/cmd/util.go index 97f800554..a93761226 100644 --- a/claat/cmd/util.go +++ b/claat/cmd/util.go @@ -20,6 +20,9 @@ package cmd import ( + "path/filepath" + + "github.com/googlecodelabs/tools/claat/types" // allow parsers to register themselves _ "github.com/googlecodelabs/tools/claat/parser/gdoc" @@ -41,3 +44,9 @@ const ( func isStdout(filename string) bool { return filename == stdout } + +// codelabDir returns codelab root directory. +// The base argument is codelab parent directory. +func codelabDir(base string, m *types.Meta) string { + return filepath.Join(base, m.ID) +} diff --git a/claat/fetch/drive/auth/auth.go b/claat/fetch/drive/auth/auth.go index a4ce5ff6f..b0a6df16f 100644 --- a/claat/fetch/drive/auth/auth.go +++ b/claat/fetch/drive/auth/auth.go @@ -18,6 +18,7 @@ import ( "fmt" "io/ioutil" "log" + "net" "net/http" "os" "path" @@ -43,7 +44,7 @@ var ( ClientID: googClient, ClientSecret: googSecret, Scopes: []string{scopeDriveReadOnly}, - RedirectURL: "urn:ietf:wg:oauth:2.0:oob", + RedirectURL: "http://localhost:8091", Endpoint: oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/auth", TokenURL: "https://accounts.google.com/o/oauth2/token", @@ -51,6 +52,25 @@ var ( } ) +// The webserver waits for an oauth code in the three-legged auth flow. +func startWebServer() (code string, err error) { + listener, err := net.Listen("tcp", "localhost:8091") + if err != nil { + return "", err + } + codeCh := make(chan string) + + go http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + codeCh <- code // send code to OAuth flow + listener.Close() + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, "Received oauth code\r\nYou can now safely close this browser window.") + })) + code = <- codeCh + return code, nil +} + type authorizationHandler func(conf *oauth2.Config) (*oauth2.Token, error) type internalOptions struct { @@ -214,10 +234,10 @@ func (c *cachedTokenSource) Token() (*oauth2.Token, error) { // authorize performs user authorization flow, asking for permissions grant. func authorize(conf *oauth2.Config) (*oauth2.Token, error) { - aurl := conf.AuthCodeURL("unused", oauth2.AccessTypeOffline) - fmt.Printf("Authorize me at following URL, please:\n\n%s\n\nCode: ", aurl) - var code string - if _, err := fmt.Scan(&code); err != nil { + aURL := conf.AuthCodeURL("unused", oauth2.AccessTypeOffline) + fmt.Printf("Authorize me at following URL, please:\n\n%s\n", aURL) + code, err := startWebServer() + if err != nil { return nil, err } return conf.Exchange(context.Background(), code) diff --git a/claat/fetch/fetch.go b/claat/fetch/fetch.go index a3c17a94d..66ca877e7 100644 --- a/claat/fetch/fetch.go +++ b/claat/fetch/fetch.go @@ -30,6 +30,7 @@ import ( "time" "github.com/googlecodelabs/tools/claat/fetch/drive/auth" + "github.com/googlecodelabs/tools/claat/nodes" "github.com/googlecodelabs/tools/claat/parser" "github.com/googlecodelabs/tools/claat/types" "github.com/googlecodelabs/tools/claat/util" @@ -45,6 +46,9 @@ const ( // driveAPI is a base URL for Drive API driveAPI = "https://www.googleapis.com/drive/v3" + + // Minimum image size in bytes for extension detection. + minImageSize = 11 ) // TODO: create an enum for use with "nometa" for readability's sake @@ -64,19 +68,18 @@ type resource struct { // and modified timestamp fields. type codelab struct { *types.Codelab - Typ srcType // source type - Mod time.Time // last modified timestamp + Typ srcType // source type + Mod time.Time // last modified timestamp + Imgs map[string]string // Slurped local image paths } type MemoryFetcher struct { passMetadata map[string]bool - mdParser parser.MarkdownParser } -func NewMemoryFetcher(pm map[string]bool, mdp parser.MarkdownParser) *MemoryFetcher { +func NewMemoryFetcher(pm map[string]bool) *MemoryFetcher { return &MemoryFetcher{ passMetadata: pm, - mdParser: mdp, } } @@ -88,7 +91,7 @@ func (m *MemoryFetcher) SlurpCodelab(rc io.ReadCloser) (*codelab, error) { } defer r.body.Close() - opts := *parser.NewOptions(m.mdParser) + opts := *parser.NewOptions() opts.PassMetadata = m.passMetadata clab, err := parser.Parse(string(r.typ), r.body, opts) @@ -107,17 +110,16 @@ type Fetcher struct { authHelper *auth.Helper authToken string crcTable *crc64.Table - mdParser parser.MarkdownParser passMetadata map[string]bool roundTripper http.RoundTripper } -func NewFetcher(at string, pm map[string]bool, rt http.RoundTripper, mdp parser.MarkdownParser) (*Fetcher, error) { +// NewFetcher creates an instance of Fetcher. +func NewFetcher(at string, pm map[string]bool, rt http.RoundTripper) (*Fetcher, error) { return &Fetcher{ authHelper: nil, authToken: at, crcTable: crc64.MakeTable(crc64.ECMA), - mdParser: mdp, passMetadata: pm, roundTripper: rt, }, nil @@ -128,8 +130,8 @@ func NewFetcher(at string, pm map[string]bool, rt http.RoundTripper, mdp parser. // It returns parsed codelab and its source type. // // The function will also fetch and parse fragments included -// with types.ImportNode. -func (f *Fetcher) SlurpCodelab(src string) (*codelab, error) { +// with nodes.ImportNode. +func (f *Fetcher) SlurpCodelab(src string, output string) (*codelab, error) { _, err := os.Stat(src) // Only setup oauth if this source is not a local file. if os.IsNotExist(err) { @@ -146,28 +148,49 @@ func (f *Fetcher) SlurpCodelab(src string) (*codelab, error) { } defer res.body.Close() - opts := *parser.NewOptions(f.mdParser) + opts := *parser.NewOptions() opts.PassMetadata = f.passMetadata clab, err := parser.Parse(string(res.typ), res.body, opts) if err != nil { return nil, err } + images := make(map[string]string) + dir := codelabDir(output, &clab.Meta) + imgDir := filepath.Join(dir, util.ImgDirname) + if !isStdout(output) { + // download or copy codelab assets to disk, and rewrite image URLs + var nodes []nodes.Node + for _, step := range clab.Steps { + nodes = append(nodes, step.Content.Nodes...) + } + err := f.SlurpImages(src, imgDir, nodes, images) + if err != nil { + return nil, err + } + } // fetch imports and parse them as fragments - var imports []*types.ImportNode + var imports []*nodes.ImportNode for _, st := range clab.Steps { - imports = append(imports, types.ImportNodes(st.Content.Nodes)...) + imports = append(imports, nodes.ImportNodes(st.Content.Nodes)...) } ch := make(chan error, len(imports)) defer close(ch) for _, imp := range imports { - go func(n *types.ImportNode) { + go func(n *nodes.ImportNode) { frag, err := f.slurpFragment(n.URL) if err != nil { ch <- fmt.Errorf("%s: %v", n.URL, err) return } + if !isStdout(output) { + // download or copy codelab assets to disk, and rewrite image URLs + err = f.SlurpImages(gdocID(n.URL), imgDir, frag, images) + if err != nil { + return + } + } n.Content.Nodes = frag ch <- nil }(imp) @@ -182,14 +205,15 @@ func (f *Fetcher) SlurpCodelab(src string) (*codelab, error) { Codelab: clab, Typ: res.typ, Mod: res.mod, + Imgs: images, } return v, nil } -func (f *Fetcher) SlurpImages(src, dir string, steps []*types.Step) (map[string]string, error) { +func (f *Fetcher) SlurpImages(src, dir string, n []nodes.Node, images map[string]string) error { // make sure img dir exists if err := os.MkdirAll(dir, 0755); err != nil { - return nil, err + return err } type res struct { @@ -200,88 +224,93 @@ func (f *Fetcher) SlurpImages(src, dir string, steps []*types.Step) (map[string] ch := make(chan *res, 100) defer close(ch) var count int - for _, st := range steps { - nodes := types.ImageNodes(st.Content.Nodes) - count += len(nodes) - for _, n := range nodes { - go func(n *types.ImageNode) { - url := n.Src - file, err := f.slurpBytes(src, dir, url) - if err == nil { - n.Src = filepath.Join(util.ImgDirname, file) - } - ch <- &res{url, file, err} - }(n) - } + imageNodes := nodes.ImageNodes(n) + count += len(imageNodes) + for _, imageNode := range imageNodes { + go func(imageNode *nodes.ImageNode) { + url := imageNode.Src + file, err := f.slurpBytes(src, dir, url, imageNode.Bytes) + if err == nil { + imageNode.Src = filepath.Join(util.ImgDirname, file) + } + ch <- &res{url, file, err} + }(imageNode) } - - imap := make(map[string]string, count) var errStr string for i := 0; i < count; i++ { r := <-ch - imap[r.file] = r.url + images[r.file] = r.url if r.err != nil { errStr += fmt.Sprintf("%s => %s: %v\n", r.url, r.file, r.err) } } if len(errStr) > 0 { - return nil, errors.New(errStr) + return errors.New(errStr) } - return imap, nil + return nil } -func (f *Fetcher) slurpBytes(codelabSrc, dir, imgURL string) (string, error) { - // images can be local in Markdown cases or remote. +func (f *Fetcher) slurpBytes(codelabSrc, dir, imgURL string, imgBytes []byte) (string, error) { + // images can be data URLs, local in Markdown cases or remote. // Only proceed a simple copy on local reference. var b []byte var ext string - u, err := url.Parse(imgURL) - if err != nil { - return "", err - } + var err error - // If the codelab source is being downloaded from the network, then we should interpret - // the image URL in the same way. - srcUrl, err := url.Parse(codelabSrc) - if err == nil && srcUrl.Host != "" { - u = srcUrl.ResolveReference(u) - } - - if u.Host == "" { - if imgURL, err = restrictPathToParent(imgURL, filepath.Dir(codelabSrc)); err != nil { - return "", err + if len(imgBytes) > 0 { + // Slurp bytes from image URL data. + b = imgBytes + if ext, err = imgExtFromBytes(b); err != nil { + return "", fmt.Errorf("Error reading image type: %v", err) } - b, err = ioutil.ReadFile(imgURL) - ext = filepath.Ext(imgURL) } else { - b, err = f.slurpRemoteBytes(u.String(), 5) - if string(b[6:10]) == "JFIF" { - ext = ".jpeg" - } else if string(b[0:3]) == "GIF" { - ext = ".gif" + // Slurp bytes from local or remote URL. + u, err := url.Parse(imgURL) + if err != nil { + return "", err + } + + // If the codelab source is being downloaded from the network, then we should interpret + // the image URL in the same way. + srcURL, err := url.Parse(codelabSrc) + if err == nil && srcURL.Host != "" { + u = srcURL.ResolveReference(u) + } + + if u.Host == "" { + if imgURL, err = restrictPathToParent(imgURL, filepath.Dir(codelabSrc)); err != nil { + return "", err + } + if b, err = ioutil.ReadFile(imgURL); err != nil { + return "", err + } + ext = filepath.Ext(imgURL) } else { - ext = ".png" + if b, err = f.slurpRemoteBytes(u.String(), 5); err != nil { + return "", fmt.Errorf("Error downloading image at %s: %v", u.String(), err) + } + if ext, err = imgExtFromBytes(b); err != nil { + return "", fmt.Errorf("Error reading image type at %s: %v", u.String(), err) + } } } - if err != nil { - return "", err - } + // Generate image file from slurped bytes. crc := crc64.Checksum(b, f.crcTable) file := fmt.Sprintf("%x%s", crc, ext) dst := filepath.Join(dir, file) return file, ioutil.WriteFile(dst, b, 0644) } -func (f *Fetcher) slurpFragment(url string) ([]types.Node, error) { +func (f *Fetcher) slurpFragment(url string) ([]nodes.Node, error) { res, err := f.fetch(url) if err != nil { return nil, err } defer res.body.Close() - opts := *parser.NewOptions(f.mdParser) + opts := *parser.NewOptions() opts.PassMetadata = f.passMetadata return parser.ParseFragment(string(res.typ), res.body, opts) @@ -463,7 +492,10 @@ func gdocID(url string) string { } func gdocExportURL(id string) string { - return fmt.Sprintf("%s/files/%s/export?mimeType=text/html", driveAPI, id) + q := url.Values{ + "mimeType": {"text/html"}, + } + return fmt.Sprintf("%s/files/%s/export?%s", driveAPI, id, q.Encode()) } // restrictPathToParent will ensure that assetPath is in parent. @@ -481,3 +513,29 @@ func restrictPathToParent(assetPath, parent string) (string, error) { } return assetPath, nil } + +// isStdout reports whether filename is stdout. +func isStdout(filename string) bool { + const stdout = "-" + return filename == stdout +} + +// codelabDir returns codelab root directory. +// The base argument is codelab parent directory. +func codelabDir(base string, m *types.Meta) string { + return filepath.Join(base, m.ID) +} + +func imgExtFromBytes(b []byte) (string, error) { + if len(b) < minImageSize { + return "", fmt.Errorf("error parsing image - response \"%s\" is too small (< %d bytes)", b, minImageSize) + } + ext := ".png" + switch { + case string(b[6:10]) == "JFIF": + ext = ".jpeg" + case string(b[0:3]) == "GIF": + ext = ".gif" + } + return ext, nil +} diff --git a/claat/fetch/fetch_test.go b/claat/fetch/fetch_test.go index dfb5d3326..0f010ceec 100644 --- a/claat/fetch/fetch_test.go +++ b/claat/fetch/fetch_test.go @@ -104,6 +104,34 @@ func TestFuzzRestrictPathToParent(t *testing.T) { } } +func TestImgExtFromBytes(t *testing.T) { + tests := []struct { + bytes []byte + + wantExt string + wantErr bool + }{ + {[]byte("012345JFIF0"), ".jpeg", false}, + {[]byte("GIF34567890"), ".gif", false}, + {[]byte("SOMETHINGELSE"), ".png", false}, + {[]byte("GIF345JFIF0"), ".jpeg", false}, + {[]byte("toosmall"), "", true}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("bytes: %s", tc.bytes), func(t *testing.T) { + ext, err := imgExtFromBytes(tc.bytes) + + if err != nil != tc.wantErr { + t.Errorf("imgExtFromBytes() error = %v, wantErr %v", err, tc.wantErr) + return + } + if ext != tc.wantExt { + t.Errorf("imgExtFromBytes() return: got %s, wanted %s", ext, tc.wantExt) + } + }) + } +} + // safeAbs compute Abs of p and fail the test if not valid. // Empty string return empty path. func safeAbs(t *testing.T, p string) string { diff --git a/claat/go.mod b/claat/go.mod new file mode 100644 index 000000000..6950a1472 --- /dev/null +++ b/claat/go.mod @@ -0,0 +1,12 @@ +module github.com/googlecodelabs/tools/claat + +go 1.16 + +require ( + github.com/google/go-cmp v0.5.6 + github.com/stoewer/go-strcase v1.2.0 // indirect + github.com/x1ddos/csslex v0.0.0-20160125172232-7894d8ab8bfe + github.com/yuin/goldmark v1.3.7 + golang.org/x/net v0.0.0-20210525063256-abc453219eb5 + golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c +) diff --git a/claat/go.sum b/claat/go.sum new file mode 100644 index 000000000..210c6f58d --- /dev/null +++ b/claat/go.sum @@ -0,0 +1,375 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/x1ddos/csslex v0.0.0-20160125172232-7894d8ab8bfe h1:SX7lFdwn40ahL78CxofAh548P+dcWjdRNpirU7+sKiE= +github.com/x1ddos/csslex v0.0.0-20160125172232-7894d8ab8bfe/go.mod h1:SwmD4V+Y0RjNqvt8hW2FpZNkQnoFVNtBF9qEnevUueU= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.7 h1:NSaHgaeJFCtWXCBkBKXw0rhgMuJ0VoE9FB5mWldcrQ4= +github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5 h1:wjuX4b5yYQnEQHzd+CBcrcC6OVR2J1CN6mUy0oSxIPo= +golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c h1:pkQiBZBvdos9qq4wBAHqlzuZHEXo07pqV06ef90u1WI= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/claat/main.go b/claat/main.go index 5211a0c38..72fb2c9b7 100644 --- a/claat/main.go +++ b/claat/main.go @@ -30,7 +30,6 @@ import ( "time" "github.com/googlecodelabs/tools/claat/cmd" - "github.com/googlecodelabs/tools/claat/parser" // allow parsers to register themselves _ "github.com/googlecodelabs/tools/claat/parser/gdoc" @@ -46,7 +45,6 @@ var ( expenv = flag.String("e", "web", "codelab environment") extra = flag.String("extra", "", "Additional arguments to pass to format templates. JSON object of string,string key values.") globalGA = flag.String("ga", "UA-49880327-14", "global Google Analytics account") - mdParser = flag.String("md_parser", "blackfriday", "Markdown parser to use. Accepted values: \"blackfriday\", \"goldmark\"") output = flag.String("o", ".", "output directory or '-' for stdout") passMetadata = flag.String("pass_metadata", "", "Metadata fields to pass through to the output. Comma-delimited list of field names.") prefix = flag.String("prefix", "https://storage.googleapis.com", "URL prefix for html format") @@ -74,16 +72,6 @@ func main() { pm := parsePassMetadata(*passMetadata) - var mdp parser.MarkdownParser - switch *mdParser { - case "blackfriday": - mdp = parser.Blackfriday - case "goldmark": - mdp = parser.Goldmark - default: - log.Fatalf("Unrecognized md_parser value %q", *mdParser) - } - exitCode := 0 switch os.Args[1] { case "export": @@ -92,7 +80,6 @@ func main() { Expenv: *expenv, ExtraVars: extraVars, GlobalGA: *globalGA, - MDParser: mdp, Output: *output, PassMetadata: pm, Prefix: *prefix, @@ -106,7 +93,6 @@ func main() { AuthToken: *authToken, ExtraVars: extraVars, GlobalGA: *globalGA, - MDParser: mdp, PassMetadata: pm, Prefix: *prefix, }) diff --git a/claat/nodes/button.go b/claat/nodes/button.go new file mode 100644 index 000000000..baf024584 --- /dev/null +++ b/claat/nodes/button.go @@ -0,0 +1,27 @@ +package nodes + +// TODO this is a long arg signature. Maybe use an options type? +// NewButtonNode creates a new button with optional content nodes n. +func NewButtonNode(raise, color, download bool, n ...Node) *ButtonNode { + return &ButtonNode{ + node: node{typ: NodeButton}, + Raise: raise, + Color: color, + Download: download, + Content: NewListNode(n...), + } +} + +// ButtonNode represents a button, e.g. "Download Zip". +type ButtonNode struct { + node + Raise bool + Color bool + Download bool + Content *ListNode +} + +// Empty returns true if its content is empty. +func (bn *ButtonNode) Empty() bool { + return bn.Content.Empty() +} diff --git a/claat/nodes/button_test.go b/claat/nodes/button_test.go new file mode 100644 index 000000000..f482bed9a --- /dev/null +++ b/claat/nodes/button_test.go @@ -0,0 +1,130 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// AllowUnexported option for cmp to make sure we can diff properly. +var cmpOptButton = cmp.AllowUnexported(ButtonNode{}, node{}, ListNode{}, TextNode{}) + +//func NewButtonNode(raise, color, download bool, n ...Node) *ButtonNode { +func TestNewButtonNode(t *testing.T) { + tests := []struct { + name string + inRaise bool + inColor bool + inDownload bool + inContent []Node + out *ButtonNode + }{ + { + name: "Empty", + out: &ButtonNode{ + node: node{typ: NodeButton}, + Content: NewListNode(), + }, + }, + { + name: "Raise", + inRaise: true, + out: &ButtonNode{ + node: node{typ: NodeButton}, + Raise: true, + Content: NewListNode(), + }, + }, + { + name: "Color", + inColor: true, + out: &ButtonNode{ + node: node{typ: NodeButton}, + Color: true, + Content: NewListNode(), + }, + }, + { + name: "Download", + inDownload: true, + out: &ButtonNode{ + node: node{typ: NodeButton}, + Download: true, + Content: NewListNode(), + }, + }, + { + name: "ContentNoSettings", + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + out: &ButtonNode{ + node: node{typ: NodeButton}, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "foo"}), NewTextNode(NewTextNodeOptions{Value: "bar"})), + }, + }, + { + name: "ContentAllSettings", + inRaise: true, + inColor: true, + inDownload: true, + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + out: &ButtonNode{ + node: node{typ: NodeButton}, + Raise: true, + Color: true, + Download: true, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "foo"}), NewTextNode(NewTextNodeOptions{Value: "bar"})), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewButtonNode(tc.inRaise, tc.inColor, tc.inDownload, tc.inContent...) + if diff := cmp.Diff(tc.out, out, cmpOptButton); diff != "" { + t.Errorf("NewButtonNode(%t, %t, %t,%+v) got diff (-want +got): %s", tc.inRaise, tc.inColor, tc.inDownload, tc.inContent, diff) + return + } + }) + } +} + +func TestButtonNodeEmpty(t *testing.T) { + tests := []struct { + name string + inRaise bool + inColor bool + inDownload bool + inContent []Node + out bool + }{ + { + name: "Empty", + inRaise: true, + inColor: true, + inDownload: true, + out: true, + }, + { + name: "NonEmpty", + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewButtonNode(tc.inRaise, tc.inColor, tc.inDownload, tc.inContent...) + out := n.Empty() + if out != tc.out { + t.Errorf("ButtonNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/code.go b/claat/nodes/code.go new file mode 100644 index 000000000..b5e2998a7 --- /dev/null +++ b/claat/nodes/code.go @@ -0,0 +1,28 @@ +package nodes + +import "strings" + +// NewCodeNode creates a new Node of type NodeCode. +// Use term argument to specify a terminal output. +func NewCodeNode(v string, term bool, lang string) *CodeNode { + return &CodeNode{ + node: node{typ: NodeCode}, + Value: v, + Term: term, + Lang: lang, + } +} + +// CodeNode is either a source code snippet or a terminal output. +// TODO is there any room to consolidate Term and Lang? +type CodeNode struct { + node + Term bool + Lang string + Value string +} + +// Empty returns true if cn.Value is zero, exluding space runes. +func (cn *CodeNode) Empty() bool { + return strings.TrimSpace(cn.Value) == "" +} diff --git a/claat/nodes/code_test.go b/claat/nodes/code_test.go new file mode 100644 index 000000000..531ff614d --- /dev/null +++ b/claat/nodes/code_test.go @@ -0,0 +1,106 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNewCodeNode(t *testing.T) { + tests := []struct { + name string + inValue string + inTerm bool + inLang string + out *CodeNode + }{ + { + name: "Empty", + out: &CodeNode{ + node: node{typ: NodeCode}, + }, + }, + { + name: "Terminal", + inValue: "sl", + inTerm: true, + out: &CodeNode{ + node: node{typ: NodeCode}, + Value: "sl", + Term: true, + }, + }, + { + name: "SourceCode", + inValue: "fmt.Println(\"foobar\")", + inLang: "go", + out: &CodeNode{ + node: node{typ: NodeCode}, + Value: "fmt.Println(\"foobar\")", + Lang: "go", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewCodeNode(tc.inValue, tc.inTerm, tc.inLang) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(CodeNode{}, node{})); diff != "" { + t.Errorf("NewCodeNode(%q, %t, %q) got diff (-want +got): %s", tc.inValue, tc.inTerm, tc.inLang, diff) + return + } + }) + } +} + +func TestCodeNodeEmpty(t *testing.T) { + tests := []struct { + name string + inValue string + inTerm bool + inLang string + out bool + }{ + { + name: "EmptyTerminal", + inTerm: true, + out: true, + }, + { + name: "EmptySourceCode", + inLang: "go", + out: true, + }, + { + name: "NonEmptyTerminal", + inTerm: true, + inValue: "sl", + }, + { + name: "NonEmptySourceCode", + inLang: "go", + inValue: "fmt.Println(\"foobar\")", + }, + { + name: "EmptyWithSpacesTerminal", + inTerm: true, + inValue: "\n \t", + out: true, + }, + { + name: "EmptyWithSpacesSourceCode", + inLang: "go", + inValue: "\n \t", + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewCodeNode(tc.inValue, tc.inTerm, tc.inLang) + out := n.Empty() + if out != tc.out { + t.Errorf("CodeNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/grid.go b/claat/nodes/grid.go new file mode 100644 index 000000000..71e20a152 --- /dev/null +++ b/claat/nodes/grid.go @@ -0,0 +1,35 @@ +package nodes + +// NewGridNode creates a new grid with optional content. +func NewGridNode(rows ...[]*GridCell) *GridNode { + return &GridNode{ + node: node{typ: NodeGrid}, + Rows: rows, + } +} + +// TODO define a convenience type for row +// GridNode is a 2d matrix. +type GridNode struct { + node + Rows [][]*GridCell +} + +// GridCell is a cell of GridNode. +type GridCell struct { + Colspan int + Rowspan int + Content *ListNode +} + +// Empty returns true when every cell has empty content. +func (gn *GridNode) Empty() bool { + for _, r := range gn.Rows { + for _, c := range r { + if !c.Content.Empty() { + return false + } + } + } + return true +} diff --git a/claat/nodes/grid_test.go b/claat/nodes/grid_test.go new file mode 100644 index 000000000..0d648f437 --- /dev/null +++ b/claat/nodes/grid_test.go @@ -0,0 +1,183 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +var cmpOptGrid = cmp.AllowUnexported(GridNode{}, node{}, ListNode{}, TextNode{}) + +func TestNewGridNode(t *testing.T) { + tests := []struct { + name string + inRows [][]*GridCell + out *GridNode + }{ + { + name: "Empty", + out: &GridNode{ + node: node{typ: NodeGrid}, + }, + }, + { + name: "OneRow", + inRows: [][]*GridCell{ + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + }, + }, + out: &GridNode{ + node: node{typ: NodeGrid}, + Rows: [][]*GridCell{ + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + }, + }, + }, + }, + { + name: "MultipleRows", + inRows: [][]*GridCell{ + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + }, + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ccc"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ddd"})), + }, + }, + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "eee"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "fff"})), + }, + }, + }, + out: &GridNode{ + node: node{typ: NodeGrid}, + Rows: [][]*GridCell{ + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + }, + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ccc"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ddd"})), + }, + }, + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "eee"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "fff"})), + }, + }, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewGridNode(tc.inRows...) + if diff := cmp.Diff(tc.out, out, cmpOptGrid); diff != "" { + t.Errorf("NewGridNode(%v) got diff (-want +got): %s", tc.inRows, diff) + return + } + }) + } +} + +func TestGridNodeEmpty(t *testing.T) { + tests := []struct { + name string + inRows [][]*GridCell + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inRows: [][]*GridCell{ + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + }, + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ccc"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ddd"})), + }, + }, + []*GridCell{ + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "eee"})), + }, + &GridCell{ + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "fff"})), + }, + }, + }, + }, + { + name: "EmptyWithRows", + inRows: [][]*GridCell{ + []*GridCell{ + &GridCell{ + Content: NewListNode(), + }, + &GridCell{ + Content: NewListNode(), + }, + }, + }, + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewGridNode(tc.inRows...) + out := n.Empty() + if out != tc.out { + t.Errorf("GridNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/header.go b/claat/nodes/header.go new file mode 100644 index 000000000..6b469525f --- /dev/null +++ b/claat/nodes/header.go @@ -0,0 +1,34 @@ +package nodes + +// NewHeaderNode creates a new HeaderNode with optional content nodes n. +func NewHeaderNode(level int, n ...Node) *HeaderNode { + return &HeaderNode{ + node: node{typ: NodeHeader}, + Level: level, + Content: NewListNode(n...), + } +} + +// HeaderNode is any regular header, a checklist header, or an FAQ header. +type HeaderNode struct { + node + Level int + Content *ListNode +} + +// Empty returns true if header content is empty. +func (hn *HeaderNode) Empty() bool { + return hn.Content.Empty() +} + +// IsHeader returns true if t is one of header types. +func IsHeader(t NodeType) bool { + return t&(NodeHeader|NodeHeaderCheck|NodeHeaderFAQ) != 0 +} + +// MutateType sets the header's node type if the given type is a header type. +func (hn *HeaderNode) MutateType(t NodeType) { + if IsHeader(t) { + hn.typ = t + } +} diff --git a/claat/nodes/header_test.go b/claat/nodes/header_test.go new file mode 100644 index 000000000..41070decf --- /dev/null +++ b/claat/nodes/header_test.go @@ -0,0 +1,153 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// AllowUnexported option for cmp to make sure we can diff properly. +var cmpOptHeader = cmp.AllowUnexported(HeaderNode{}, node{}, ListNode{}, TextNode{}) + +func TestNewHeaderNode(t *testing.T) { + tests := []struct { + name string + inLevel int + inContent []Node + out *HeaderNode + }{ + { + name: "Empty", + inLevel: 1, + out: &HeaderNode{ + node: node{typ: NodeHeader}, + Level: 1, + Content: NewListNode(), + }, + }, + { + name: "NonEmpty", + inLevel: 1, + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + out: &HeaderNode{ + node: node{typ: NodeHeader}, + Level: 1, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "foo"}), NewTextNode(NewTextNodeOptions{Value: "bar"})), + }, + }, + { + name: "ValidLevel", + inLevel: 2, + out: &HeaderNode{ + node: node{typ: NodeHeader}, + Level: 2, + Content: NewListNode(), + }, + }, + // TODO should the function accept levels that do not correspond to elements? + { + name: "ZeroLevel", + out: &HeaderNode{ + node: node{typ: NodeHeader}, + Content: NewListNode(), + }, + }, + { + name: "NegativeLevel", + inLevel: -1337, + out: &HeaderNode{ + node: node{typ: NodeHeader}, + Level: -1337, + Content: NewListNode(), + }, + }, + { + name: "VeryHighLevel", + inLevel: 1337, + out: &HeaderNode{ + node: node{typ: NodeHeader}, + Level: 1337, + Content: NewListNode(), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewHeaderNode(tc.inLevel, tc.inContent...) + if diff := cmp.Diff(tc.out, out, cmpOptHeader); diff != "" { + t.Errorf("NewHeaderNode(%d, %+v) got diff (-want +got): %s", tc.inLevel, tc.inContent, diff) + return + } + }) + } +} + +func TestHeaderNodeEmpty(t *testing.T) { + tests := []struct { + name string + inLevel int + inContent []Node + out bool + }{ + { + name: "Empty", + inLevel: 1, + out: true, + }, + { + name: "NonEmpty", + inLevel: 1, + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewHeaderNode(tc.inLevel, tc.inContent...) + out := n.Empty() + if out != tc.out { + t.Errorf("HeaderNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} + +func TestHeaderMutateType(t *testing.T) { + tests := []struct { + name string + inType NodeType + out NodeType + }{ + { + name: "Header", + inType: NodeHeader, + out: NodeHeader, + }, + { + name: "AlternateHeaderType", + inType: NodeHeaderFAQ, + out: NodeHeaderFAQ, + }, + { + name: "NotAHeader", + inType: NodeButton, + out: NodeHeader, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewHeaderNode(1) // 1 chosen arbitrarily. + n.MutateType(tc.inType) + if n.typ != tc.out { + t.Errorf("HeaderNode.typ after MutateType = %v, want %v", n.typ, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/iframe.go b/claat/nodes/iframe.go new file mode 100644 index 000000000..05fc593f0 --- /dev/null +++ b/claat/nodes/iframe.go @@ -0,0 +1,39 @@ +package nodes + +// iframe allowlist - set of domains allow to embed iframes in a codelab. +// TODO make this configurable somehow +var IframeAllowlist = []string{ + "carto.com", + "codepen.io", + "dartlang.org", + "dartpad.dev", + "demo.arcade.software", + "github.com", + "glitch.com", + "google.com", + "google.dev", + "observablehq.com", + "repl.it", + "stackblitz.com", + "vimeo.com", + "web.dev", +} + +// NewIframeNode creates a new embedded iframe. +func NewIframeNode(url string) *IframeNode { + return &IframeNode{ + node: node{typ: NodeIframe}, + URL: url, + } +} + +// IframeNode is an embeddes iframe. +type IframeNode struct { + node + URL string +} + +// Empty returns true if iframe's URL field is empty. +func (iframe *IframeNode) Empty() bool { + return iframe.URL == "" +} diff --git a/claat/nodes/iframe_test.go b/claat/nodes/iframe_test.go new file mode 100644 index 000000000..1ac6eba96 --- /dev/null +++ b/claat/nodes/iframe_test.go @@ -0,0 +1,66 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNewIframeNode(t *testing.T) { + tests := []struct { + name string + inURL string + out *IframeNode + }{ + { + name: "Empty", + out: &IframeNode{ + node: node{typ: NodeIframe}, + }, + }, + { + name: "Simple", + inURL: "google.com", + out: &IframeNode{ + node: node{typ: NodeIframe}, + URL: "google.com", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewIframeNode(tc.inURL) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(IframeNode{}, node{})); diff != "" { + t.Errorf("NewIframeNode(%q) got diff (-want +got): %s", tc.inURL, diff) + return + } + }) + } +} + +func TestIframeNodeEmpty(t *testing.T) { + tests := []struct { + name string + inURL string + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inURL: "google.com", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewIframeNode(tc.inURL) + out := n.Empty() + if out != tc.out { + t.Errorf("IframeNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/image.go b/claat/nodes/image.go new file mode 100644 index 000000000..7c966c42d --- /dev/null +++ b/claat/nodes/image.go @@ -0,0 +1,72 @@ +package nodes + +import "strings" + +type NewImageNodeOptions struct { + Src string + Width float32 + Alt string + Title string + Bytes []byte +} + +// NewImageNode creates a new ImageNode with the given options. +// TODO this API is inconsistent with button +func NewImageNode(opts NewImageNodeOptions) *ImageNode { + return &ImageNode{ + node: node{typ: NodeImage}, + Src: opts.Src, + Width: opts.Width, + Alt: opts.Alt, + Title: opts.Title, + Bytes: opts.Bytes, + } +} + +// ImageNode represents a single image. +type ImageNode struct { + node + Src string + Width float32 + Alt string + Title string + Bytes []byte +} + +// Empty returns true if its Src is zero, excluding space runes. +func (in *ImageNode) Empty() bool { + return strings.TrimSpace(in.Src) == "" && len(in.Bytes) == 0 +} + +// ImageNodes extracts everything except NodeImage nodes, recursively. +// TODO rename +func ImageNodes(nodes []Node) []*ImageNode { + var imgs []*ImageNode + for _, n := range nodes { + switch n := n.(type) { + case *ImageNode: + imgs = append(imgs, n) + case *ListNode: + imgs = append(imgs, ImageNodes(n.Nodes)...) + case *ItemsListNode: + for _, i := range n.Items { + imgs = append(imgs, ImageNodes(i.Nodes)...) + } + case *HeaderNode: + imgs = append(imgs, ImageNodes(n.Content.Nodes)...) + case *URLNode: + imgs = append(imgs, ImageNodes(n.Content.Nodes)...) + case *ButtonNode: + imgs = append(imgs, ImageNodes(n.Content.Nodes)...) + case *InfoboxNode: + imgs = append(imgs, ImageNodes(n.Content.Nodes)...) + case *GridNode: + for _, r := range n.Rows { + for _, c := range r { + imgs = append(imgs, ImageNodes(c.Content.Nodes)...) + } + } + } + } + return imgs +} diff --git a/claat/nodes/image_test.go b/claat/nodes/image_test.go new file mode 100644 index 000000000..c1665b140 --- /dev/null +++ b/claat/nodes/image_test.go @@ -0,0 +1,228 @@ +package nodes + +import ( + "encoding/base64" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var testBytes, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + +func TestNewImageNode(t *testing.T) { + tests := []struct { + name string + inOpts NewImageNodeOptions + out *ImageNode + }{ + { + name: "Empty", + out: &ImageNode{ + node: node{typ: NodeImage}, + }, + }, + { + name: "StandardURL", + inOpts: NewImageNodeOptions{ + Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png", + Width: 1.0, + Title: "foo", + Alt: "bar", + }, + out: &ImageNode{ + node: node{typ: NodeImage}, + Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png", + Width: 1.0, + Title: "foo", + Alt: "bar", + }, + }, + { + name: "DataURL", + inOpts: NewImageNodeOptions{ + Width: 1.0, + Title: "foo", + Alt: "bar", + Bytes: testBytes, + }, + out: &ImageNode{ + node: node{typ: NodeImage}, + Width: 1.0, + Title: "foo", + Alt: "bar", + Bytes: testBytes, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewImageNode(tc.inOpts) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(ImageNode{}, node{})); diff != "" { + t.Errorf("NewImageNode(%+v) got diff (-want +got): %s", tc.inOpts, diff) + return + } + }) + } +} + +func TestImageNodeEmpty(t *testing.T) { + tests := []struct { + name string + inOpts NewImageNodeOptions + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inOpts: NewImageNodeOptions{ + Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png", + }, + }, + { + name: "EmptyWithSpaces", + inOpts: NewImageNodeOptions{ + Src: "\n \t", + }, + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewImageNode(tc.inOpts) + out := n.Empty() + if out != tc.out { + t.Errorf("ImageNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} + +func TestImageNodes(t *testing.T) { + a1 := NewImageNode(NewImageNodeOptions{Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"}) + a2 := NewImageNode(NewImageNodeOptions{Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"}) + a3 := NewImageNode(NewImageNodeOptions{Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png"}) + + b1 := NewItemsListNode("", 1) + b1.Items = append(b1.Items, NewListNode(a1, a2, NewTextNode(NewTextNodeOptions{Value: "foobar"}), a3)) + + c1 := NewGridNode( + []*GridCell{ + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"}), NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(a1, NewTextNode(NewTextNodeOptions{Value: "ccc"})), + }, + }, + []*GridCell{ + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ddd"}), a3), + }, + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(a2, NewTextNode(NewTextNodeOptions{Value: "eee"})), + }, + }, + ) + + d1 := NewURLNode("google.com", NewTextNode(NewTextNodeOptions{Value: "aaa"}), a1, NewTextNode(NewTextNodeOptions{Value: "bbb"})) + d2 := NewButtonNode(true, true, true, d1) + d3 := NewURLNode("google.com", a2) + d4 := NewHeaderNode(2, d3) + d5 := NewInfoboxNode(InfoboxNegative, d4) + d6 := NewListNode(d2, d5, a3) + + tests := []struct { + name string + inNodes []Node + out []*ImageNode + }{ + { + name: "JustImage", + inNodes: []Node{a1}, + out: []*ImageNode{a1}, + }, + { + name: "Multiple", + inNodes: []Node{a1, a2, a3}, + out: []*ImageNode{a1, a2, a3}, + }, + { + name: "List", + inNodes: []Node{ + NewListNode(a1, NewTextNode(NewTextNodeOptions{Value: "foobar"}), a2, a3), + }, + out: []*ImageNode{a1, a2, a3}, + }, + { + name: "ItemsList", + inNodes: []Node{b1}, + out: []*ImageNode{a1, a2, a3}, + }, + { + name: "Header", + inNodes: []Node{ + NewHeaderNode(1, a1, NewTextNode(NewTextNodeOptions{Value: "foobar"})), + }, + out: []*ImageNode{a1}, + }, + { + name: "URL", + inNodes: []Node{ + NewURLNode("google.com", a2, NewTextNode(NewTextNodeOptions{Value: "foobar"}), a3), + }, + out: []*ImageNode{a2, a3}, + }, + { + name: "Button", + inNodes: []Node{ + NewButtonNode(true, true, true, NewTextNode(NewTextNodeOptions{Value: "foobar"}), a3, a1), + }, + out: []*ImageNode{a3, a1}, + }, + { + name: "Infobox", + inNodes: []Node{ + NewInfoboxNode(InfoboxPositive, a2, a1, NewTextNode(NewTextNodeOptions{Value: "foobar"})), + }, + out: []*ImageNode{a2, a1}, + }, + { + name: "Grid", + inNodes: []Node{c1}, + out: []*ImageNode{a1, a3, a2}, + }, + { + name: "Text", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + }, + { + name: "NontrivialStructure", + inNodes: []Node{d6}, + out: []*ImageNode{a1, a2, a3}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := ImageNodes(tc.inNodes) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(ImageNode{}, node{})); diff != "" { + t.Errorf("ImageNodes(%+v) got diff (-want +got): %s", tc.inNodes, diff) + return + } + }) + } +} diff --git a/claat/nodes/import.go b/claat/nodes/import.go new file mode 100644 index 000000000..9cf5bf21c --- /dev/null +++ b/claat/nodes/import.go @@ -0,0 +1,51 @@ +package nodes + +// NewImportNode creates a new Node of type NodeImport, +// with initialized ImportNode.Content. +func NewImportNode(url string) *ImportNode { + return &ImportNode{ + node: node{typ: NodeImport}, + Content: NewListNode(), + URL: url, + } +} + +// ImportNode indicates a remote resource available at ImportNode.URL. +type ImportNode struct { + node + URL string + Content *ListNode +} + +// Empty returns the result of in.Content.Empty method. +func (in *ImportNode) Empty() bool { + return in.Content.Empty() +} + +// MutateBlock mutates both in's block marker and that of in.Content. +func (in *ImportNode) MutateBlock(v interface{}) { + in.node.MutateBlock(v) + in.Content.MutateBlock(v) +} + +// ImportNodes extracts everything except NodeImport nodes, recursively. +func ImportNodes(nodes []Node) []*ImportNode { + var imps []*ImportNode + for _, n := range nodes { + switch n := n.(type) { + case *ImportNode: + imps = append(imps, n) + case *ListNode: + imps = append(imps, ImportNodes(n.Nodes)...) + case *InfoboxNode: + imps = append(imps, ImportNodes(n.Content.Nodes)...) + case *GridNode: + for _, r := range n.Rows { + for _, c := range r { + imps = append(imps, ImportNodes(c.Content.Nodes)...) + } + } + } + } + return imps +} diff --git a/claat/nodes/import_test.go b/claat/nodes/import_test.go new file mode 100644 index 000000000..4e23366cb --- /dev/null +++ b/claat/nodes/import_test.go @@ -0,0 +1,196 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +var cmpOptImport = cmp.AllowUnexported(ImportNode{}, node{}, ListNode{}, TextNode{}) + +func TestNewImportNode(t *testing.T) { + tests := []struct { + name string + inURL string + out *ImportNode + }{ + { + name: "Empty", + out: &ImportNode{ + node: node{typ: NodeImport}, + Content: NewListNode(), + }, + }, + { + name: "HasURL", + inURL: "google.com", + out: &ImportNode{ + node: node{typ: NodeImport}, + URL: "google.com", + Content: NewListNode(), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewImportNode(tc.inURL) + if diff := cmp.Diff(tc.out, out, cmpOptImport); diff != "" { + t.Errorf("NewImportNode(%q) got diff (-want +got): %s", tc.inURL, diff) + return + } + }) + } +} + +func TestImportNodeEmpty(t *testing.T) { + a := NewImportNode("") + a.Content.Nodes = append(a.Content.Nodes, NewTextNode(NewTextNodeOptions{Value: "a"})) + b := NewImportNode("foobar") + b.Content.Nodes = append(b.Content.Nodes, NewTextNode(NewTextNodeOptions{Value: "b"})) + c := NewImportNode("foobar") + c.Content.Nodes = append(c.Content.Nodes, NewTextNode(NewTextNodeOptions{Value: ""})) + + tests := []struct { + name string + inNode *ImportNode + out bool + }{ + { + name: "EmptyNoURL", + inNode: NewImportNode(""), + out: true, + }, + { + name: "EmptyWithURL", + inNode: NewImportNode("google.com"), + out: true, + }, + { + name: "NonEmptyNoURL", + inNode: a, + }, + { + name: "NonEmptyWithURL", + inNode: b, + }, + { + name: "EmptyWithContent", + inNode: c, + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := tc.inNode.Empty() + if out != tc.out { + t.Errorf("ImportNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} + +func TestImportNodeMutateBlock(t *testing.T) { + n := NewImportNode("") + mValue := "foobar" + + n.MutateBlock(mValue) + + if n.node.block != mValue { + t.Errorf("ImportNode.node.block = %+v, want %q", n.node.block, mValue) + } + if n.Content.node.block != mValue { + t.Errorf("ImportNode.Content.node.block = %+v, want %q", n.Content.node.block, mValue) + } +} + +func TestImportNodes(t *testing.T) { + a1 := NewImportNode("google.com") + a2 := NewImportNode("youtube.com") + a3 := NewImportNode("google.com/calendar") + + b1 := NewGridNode( + []*GridCell{ + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "aaa"}), NewTextNode(NewTextNodeOptions{Value: "bbb"})), + }, + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(a1, NewTextNode(NewTextNodeOptions{Value: "ccc"})), + }, + }, + []*GridCell{ + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "ddd"}), a3), + }, + &GridCell{ + Rowspan: 1, + Colspan: 1, + Content: NewListNode(a2, NewTextNode(NewTextNodeOptions{Value: "eee"})), + }, + }, + ) + + c1 := NewInfoboxNode(InfoboxNegative, a1, NewTextNode(NewTextNodeOptions{Value: "foobar"})) + c2 := NewListNode(a2, NewButtonNode(false, false, false, NewTextNode(NewTextNodeOptions{Value: "foobar"}))) + c3 := NewListNode(c1, c2, a3) + + tests := []struct { + name string + inNodes []Node + out []*ImportNode + }{ + { + name: "JustImport", + inNodes: []Node{a1}, + out: []*ImportNode{a1}, + }, + { + name: "Multiple", + inNodes: []Node{a1, NewTextNode(NewTextNodeOptions{Value: "foo"}), a2, NewTextNode(NewTextNodeOptions{Value: "bar"}), a3}, + out: []*ImportNode{a1, a2, a3}, + }, + { + name: "List", + inNodes: []Node{NewListNode(a1, a2, a3)}, + out: []*ImportNode{a1, a2, a3}, + }, + { + name: "Infobox", + inNodes: []Node{NewInfoboxNode(InfoboxPositive, a1, a2, NewTextNode(NewTextNodeOptions{Value: "foobar"}), a3)}, + out: []*ImportNode{a1, a2, a3}, + }, + { + name: "Grid", + inNodes: []Node{b1}, + out: []*ImportNode{a1, a3, a2}, + }, + { + name: "Button", + inNodes: []Node{NewButtonNode(true, true, true, a3, a2, a1)}, + }, + { + name: "Text", + inNodes: []Node{NewTextNode(NewTextNodeOptions{Value: "foobar"})}, + }, + { + name: "NontrivialStructure", + inNodes: []Node{c3}, + out: []*ImportNode{a1, a2, a3}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := ImportNodes(tc.inNodes) + if diff := cmp.Diff(tc.out, out, cmpOptImport); diff != "" { + t.Errorf("ImportNodes(%+v) got diff (-want +got): %s", tc.inNodes, diff) + return + } + }) + } +} diff --git a/claat/nodes/infobox.go b/claat/nodes/infobox.go new file mode 100644 index 000000000..4918adb59 --- /dev/null +++ b/claat/nodes/infobox.go @@ -0,0 +1,31 @@ +package nodes + +// InfoboxKind defines kind type for InfoboxNode. +type InfoboxKind string + +// InfoboxNode variants. +const ( + InfoboxPositive InfoboxKind = "special" + InfoboxNegative InfoboxKind = "warning" +) + +// InfoboxNode is any regular header, a checklist header, or an FAQ header. +type InfoboxNode struct { + node + Kind InfoboxKind + Content *ListNode +} + +// NewInfoboxNode creates a new infobox node with specified kind and optional content. +func NewInfoboxNode(k InfoboxKind, n ...Node) *InfoboxNode { + return &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: k, + Content: NewListNode(n...), + } +} + +// Empty returns true if ib content is empty. +func (ib *InfoboxNode) Empty() bool { + return ib.Content.Empty() +} diff --git a/claat/nodes/infobox_test.go b/claat/nodes/infobox_test.go new file mode 100644 index 000000000..32f2e4234 --- /dev/null +++ b/claat/nodes/infobox_test.go @@ -0,0 +1,156 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// AllowUnexported option for cmp to make sure we can diff properly. +var cmpOptInfobox = cmp.AllowUnexported(InfoboxNode{}, node{}, ListNode{}, TextNode{}) + +func TestNewInfoboxNode(t *testing.T) { + tests := []struct { + name string + inKind InfoboxKind + inContent []Node + out *InfoboxNode + }{ + { + name: "PositiveEmpty", + inKind: InfoboxPositive, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxPositive, + // TODO: Do we really want this to not be nil? + Content: NewListNode(), + }, + }, + { + name: "PositiveOneContent", + inKind: InfoboxPositive, + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "hello"})}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxPositive, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "hello"})), + }, + }, + { + name: "PositiveMultiContent", + inKind: InfoboxPositive, + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "orange"}), NewTextNode(NewTextNodeOptions{Value: "strawberry"}), NewTextNode(NewTextNodeOptions{Value: "pineapple"})}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxPositive, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "orange"}), NewTextNode(NewTextNodeOptions{Value: "strawberry"}), NewTextNode(NewTextNodeOptions{Value: "pineapple"})), + }, + }, + { + name: "NegativeEmpty", + inKind: InfoboxNegative, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxNegative, + // TODO: Do we really want this to not be nil? + Content: NewListNode(), + }, + }, + { + name: "NegativeOneContent", + inKind: InfoboxNegative, + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "hello"})}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxNegative, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "hello"})), + }, + }, + { + name: "NegativeMultiContent", + inKind: InfoboxNegative, + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "orange"}), NewTextNode(NewTextNodeOptions{Value: "strawberry"}), NewTextNode(NewTextNodeOptions{Value: "pineapple"})}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxNegative, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "orange"}), NewTextNode(NewTextNodeOptions{Value: "strawberry"}), NewTextNode(NewTextNodeOptions{Value: "pineapple"})), + }, + }, + { + // TODO: Should we set a default value? + name: "NoKind", + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "orange"})}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "orange"})), + }, + }, + { + // TODO: Should this return an error instead? + name: "UnsupportedKind", + inKind: "this is not a valid kind of infobox", + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "orange"})}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: "this is not a valid kind of infobox", + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "orange"})), + }, + }, + { + name: "ListOfOneList", + inKind: InfoboxPositive, + inContent: []Node{NewListNode(NewTextNode(NewTextNodeOptions{Value: "a"}), NewTextNode(NewTextNodeOptions{Value: "b"}))}, + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Kind: InfoboxPositive, + Content: NewListNode(NewListNode(NewTextNode(NewTextNodeOptions{Value: "a"}), NewTextNode(NewTextNodeOptions{Value: "b"}))), + }, + }, + { + name: "Empty", + out: &InfoboxNode{ + node: node{typ: NodeInfobox}, + Content: NewListNode(), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewInfoboxNode(tc.inKind, tc.inContent...) + if diff := cmp.Diff(tc.out, out, cmpOptInfobox); diff != "" { + t.Errorf("NewInfoboxNode(%q, %v) got diff (-want +got): %s", tc.inKind, tc.inContent, diff) + return + } + }) + } +} + +func TestInfoboxNodeEmpty(t *testing.T) { + tests := []struct { + name string + inKind InfoboxKind + inContent []Node + out bool + }{ + { + name: "Empty", + inKind: InfoboxPositive, + out: true, + }, + { + name: "NonEmpty", + inKind: InfoboxPositive, + inContent: []Node{NewTextNode(NewTextNodeOptions{Value: "a"})}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewInfoboxNode(tc.inKind, tc.inContent...) + out := n.Empty() + if out != tc.out { + t.Errorf("InfoboxNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/itemslist.go b/claat/nodes/itemslist.go new file mode 100644 index 000000000..806f10e9a --- /dev/null +++ b/claat/nodes/itemslist.go @@ -0,0 +1,54 @@ +package nodes + +// NewItemsListNode creates a new ItemsListNode of type NodeItemsList, +// which defaults to an unordered list. +// Provide a positive start to make this a numbered list. +// NodeItemsCheck and NodeItemsFAQ are always unnumbered. +func NewItemsListNode(typ string, start int) *ItemsListNode { + iln := ItemsListNode{ + node: node{typ: NodeItemsList}, + // TODO document this + ListType: typ, + Start: start, + } + iln.MutateBlock(true) + return &iln +} + +// ItemsListNode containts sets of ListNode. +// Non-zero ListType indicates an ordered list. +type ItemsListNode struct { + node + ListType string + Start int + Items []*ListNode +} + +// Empty returns true if every item has empty content. +func (il *ItemsListNode) Empty() bool { + for _, i := range il.Items { + if !i.Empty() { + return false + } + } + return true +} + +// NewItem creates a new ListNode and adds it to il.Items. +func (il *ItemsListNode) NewItem(nodes ...Node) *ListNode { + n := NewListNode(nodes...) + il.Items = append(il.Items, n) + return n +} + +// IsItemsList returns true if t is one of ItemsListNode types. +func IsItemsList(t NodeType) bool { + return t&(NodeItemsList|NodeItemsCheck|NodeItemsFAQ) != 0 +} + +// MutateType sets the items list's node type if the given type is an items list type. +func (il *ItemsListNode) MutateType(t NodeType) { + if IsItemsList(t) { + il.typ = t + } +} diff --git a/claat/nodes/itemslist_test.go b/claat/nodes/itemslist_test.go new file mode 100644 index 000000000..87b249499 --- /dev/null +++ b/claat/nodes/itemslist_test.go @@ -0,0 +1,155 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +var cmpOptItemsList = cmp.AllowUnexported(ItemsListNode{}, node{}, ListNode{}, TextNode{}) + +func TestNewItemsListNode(t *testing.T) { + // Only one code path, so this is not a tabular test. + inTyp := "foobar" + inStart := 5 + got := NewItemsListNode(inTyp, inStart) + want := &ItemsListNode{ + node: node{ + typ: NodeItemsList, + block: true, + }, + ListType: "foobar", + Start: 5, + } + + if diff := cmp.Diff(want, got, cmpOptItemsList); diff != "" { + t.Errorf("NewItemsListNode(%q, %d) got diff (-want +got): %s", inTyp, inStart, diff) + } +} + +func TestItemsListNodeEmpty(t *testing.T) { + a := NewItemsListNode("foobar", 0) + a.Items = append(a.Items, NewListNode(NewTextNode(NewTextNodeOptions{Value: ""}))) + + b := NewItemsListNode("foobar", 0) + b.Items = append(b.Items, NewListNode(NewTextNode(NewTextNodeOptions{Value: "a"}))) + + c := NewItemsListNode("foobar", 0) + c.Items = append(c.Items, NewListNode(NewTextNode(NewTextNodeOptions{Value: ""})), NewListNode(NewTextNode(NewTextNodeOptions{Value: ""})), NewListNode(NewTextNode(NewTextNodeOptions{Value: ""}))) + + d := NewItemsListNode("foobar", 0) + d.Items = append(d.Items, NewListNode(NewTextNode(NewTextNodeOptions{Value: "a"})), NewListNode(NewTextNode(NewTextNodeOptions{Value: ""})), NewListNode(NewTextNode(NewTextNodeOptions{Value: "b"}))) + + e := NewItemsListNode("foobar", 0) + e.Items = append(e.Items, NewListNode(NewTextNode(NewTextNodeOptions{Value: "a"})), NewListNode(NewTextNode(NewTextNodeOptions{Value: "b"})), NewListNode(NewTextNode(NewTextNodeOptions{Value: "c"}))) + + tests := []struct { + name string + inNode *ItemsListNode + out bool + }{ + { + name: "Zero", + inNode: NewItemsListNode("foobar", 0), + out: true, + }, + { + name: "OneEmpty", + inNode: a, + out: true, + }, + { + name: "OneNonEmpty", + inNode: b, + }, + { + name: "MultipleEmpty", + inNode: c, + out: true, + }, + { + name: "MultipleSomeNonEmpty", + inNode: d, + }, + { + name: "MultipleAllNonEmpty", + inNode: e, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := tc.inNode.Empty() + if out != tc.out { + t.Errorf("ItemsListNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} + +func TestItemsListNewItem(t *testing.T) { + // Only one code path, so this is not a tabular test. + a := NewTextNode(NewTextNodeOptions{Value: "a"}) + b := NewTextNode(NewTextNodeOptions{Value: "b"}) + c := NewTextNode(NewTextNodeOptions{Value: "c"}) + + iln := NewItemsListNode("foobar", 0) + + got := iln.NewItem(a, b, c) + want := NewListNode(a, b, c) + + if diff := cmp.Diff(want, got, cmpOptItemsList); diff != "" { + t.Errorf("ItemsListNode.NewItem() got diff (-want +got): %s", diff) + } + + wantItemsListNode := &ItemsListNode{ + node: node{ + typ: NodeItemsList, + block: true, + }, + ListType: "foobar", + Items: []*ListNode{ + &ListNode{ + node: node{typ: NodeList}, + Nodes: []Node{a, b, c}, + }, + }, + } + if diff := cmp.Diff(wantItemsListNode, iln, cmpOptItemsList); diff != "" { + t.Errorf("ItemsListNode after NewItem got diff ((-want +got): %s", diff) + } +} + +func TestItemsListMutateType(t *testing.T) { + tests := []struct { + name string + inType NodeType + out NodeType + }{ + { + name: "ItemsList", + inType: NodeItemsList, + out: NodeItemsList, + }, + { + name: "AlternateItemsListType", + inType: NodeItemsCheck, + out: NodeItemsCheck, + }, + { + name: "NotAItemsList", + inType: NodeButton, + out: NodeItemsList, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewItemsListNode("foobar", 0) // Args chosen arbitrarily. + n.MutateType(tc.inType) + if n.typ != tc.out { + t.Errorf("ItemsListNode.typ after MutateType = %v, want %v", n.typ, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/list.go b/claat/nodes/list.go new file mode 100644 index 000000000..86b405cca --- /dev/null +++ b/claat/nodes/list.go @@ -0,0 +1,25 @@ +package nodes + +// NewListNode creates a new Node of type NodeList. +func NewListNode(nodes ...Node) *ListNode { + n := &ListNode{node: node{typ: NodeList}} + n.Append(nodes...) + return n +} + +// ListNode contains other nodes. +type ListNode struct { + node + Nodes []Node +} + +// Empty returns true if all l.Nodes are empty. +func (l *ListNode) Empty() bool { + return EmptyNodes(l.Nodes) +} + +// TODO remove +// Append appends nodes n to the end of l.Nodes slice. +func (l *ListNode) Append(n ...Node) { + l.Nodes = append(l.Nodes, n...) +} diff --git a/claat/nodes/list_test.go b/claat/nodes/list_test.go new file mode 100644 index 000000000..9583ab30d --- /dev/null +++ b/claat/nodes/list_test.go @@ -0,0 +1,101 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +var cmpOptList = cmp.AllowUnexported(ListNode{}, node{}, TextNode{}) + +func TestNewListNode(t *testing.T) { + tests := []struct { + name string + inNodes []Node + out *ListNode + }{ + { + name: "Empty", + out: &ListNode{ + node: node{typ: NodeList}, + }, + }, + { + name: "One", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + }, + out: &ListNode{ + node: node{typ: NodeList}, + Nodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + }, + }, + }, + { + name: "Multiple", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + NewTextNode(NewTextNodeOptions{Value: "baz"}), + }, + out: &ListNode{ + node: node{typ: NodeList}, + Nodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + NewTextNode(NewTextNodeOptions{Value: "baz"}), + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewListNode(tc.inNodes...) + if diff := cmp.Diff(tc.out, out, cmpOptList); diff != "" { + t.Errorf("NewListNode(%v) got diff (-want +got): %s", tc.inNodes, diff) + return + } + }) + } +} + +func TestListNodeEmpty(t *testing.T) { + tests := []struct { + name string + inNodes []Node + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + NewTextNode(NewTextNodeOptions{Value: "baz"}), + }, + }, + { + name: "EmptyWithNodes", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: ""}), + NewTextNode(NewTextNodeOptions{Value: ""}), + NewTextNode(NewTextNodeOptions{Value: ""}), + }, + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewListNode(tc.inNodes...) + out := n.Empty() + if out != tc.out { + t.Errorf("ListNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/nodes.go b/claat/nodes/nodes.go new file mode 100644 index 000000000..2bcd38a51 --- /dev/null +++ b/claat/nodes/nodes.go @@ -0,0 +1,114 @@ +// Copyright 2016 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package nodes + +import ( + "sort" +) + +// NodeType is type for parsed codelab nodes tree. +type NodeType uint32 + +// Codelab node kinds. +const ( + NodeInvalid NodeType = 1 << iota + NodeList // A node which contains a list of other nodes + NodeGrid // Table + NodeText // Simple node with a string as the value + NodeCode // Source code or console (terminal) output + NodeInfobox // An aside box for notes or warnings + NodeSurvey // Sets of grouped questions + NodeURL // Represents elements such as + NodeImage // Image + NodeButton // Button + NodeItemsList // Set of NodeList items + NodeItemsCheck // Special kind of NodeItemsList, checklist + NodeItemsFAQ // Special kind of NodeItemsList, FAQ + NodeHeader // A header text node + NodeHeaderCheck // Special kind of header, checklist + NodeHeaderFAQ // Special kind of header, FAQ + NodeYouTube // YouTube video + NodeIframe // Embedded iframe + NodeImport // A node which holds content imported from another resource +) + +// Node is an interface common to all node types. +type Node interface { + // Type returns node type. + Type() NodeType + // MutateType changes node type where possible. + // Only changes within this same category are allowed. + // For instance, items list or header nodes can change their types + // to another kind of items list or header. + MutateType(NodeType) + // Block returns a source reference of the node. + Block() interface{} + // MutateBlock updates source reference of the node. + MutateBlock(interface{}) + // Empty returns true if the node has no content. + Empty() bool + // Env returns node environment + Env() []string + // MutateEnv replaces current node environment tags with env. + MutateEnv(env []string) +} + +// IsInline returns true if t is an inline node type. +func IsInline(t NodeType) bool { + return t&(NodeText|NodeURL|NodeImage|NodeButton) != 0 +} + +// EmptyNodes returns true if all of nodes are empty. +func EmptyNodes(nodes []Node) bool { + for _, n := range nodes { + if !n.Empty() { + return false + } + } + return true +} + +type node struct { + typ NodeType + block interface{} + env []string +} + +func (b *node) Type() NodeType { + return b.typ +} + +// Default implementation is a no op. +func (b *node) MutateType(t NodeType) { + return +} + +func (b *node) Block() interface{} { + return b.block +} + +func (b *node) MutateBlock(v interface{}) { + b.block = v +} + +func (b *node) Env() []string { + return b.env +} + +func (b *node) MutateEnv(e []string) { + b.env = make([]string, len(e)) + copy(b.env, e) + sort.Strings(b.env) +} diff --git a/claat/nodes/nodes_test.go b/claat/nodes/nodes_test.go new file mode 100644 index 000000000..a3c634b80 --- /dev/null +++ b/claat/nodes/nodes_test.go @@ -0,0 +1,103 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestEmptyNodes(t *testing.T) { + tests := []struct { + name string + inNodes []Node + out bool + }{ + { + name: "Zero", + out: true, + }, + { + name: "OneEmpty", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: ""}), + }, + out: true, + }, + { + name: "OneNonEmpty", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + }, + }, + { + name: "MultipleEmpty", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: ""}), + NewTextNode(NewTextNodeOptions{Value: ""}), + NewTextNode(NewTextNodeOptions{Value: ""}), + }, + out: true, + }, + { + name: "MultipleSomeNonEmpty", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: ""}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + }, + { + name: "MultipleAllNonEmpty", + inNodes: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + NewTextNode(NewTextNodeOptions{Value: "baz"}), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := EmptyNodes(tc.inNodes) + if out != tc.out { + t.Errorf("EmptyNodes(%+v) = %t, want %t", tc.inNodes, out, tc.out) + return + } + }) + } +} + +func TestMutateEnv(t *testing.T) { + tests := []struct { + name string + inEnv []string + out []string + }{ + { + name: "Sorted", + inEnv: []string{"a", "b", "c"}, + out: []string{"a", "b", "c"}, + }, + { + name: "Unsorted", + inEnv: []string{"b", "c", "a"}, + out: []string{"a", "b", "c"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewTextNode(NewTextNodeOptions{Value: "foobar"}) + n.MutateEnv(tc.inEnv) + if diff := cmp.Diff(tc.out, n.env); diff != "" { + t.Errorf("MutateEnv(%q) got diff (-want +got): %s", tc.inEnv, diff) + return + + } + + // Also test that a copy was made. + if &(tc.out) == &(n.env) { + t.Errorf("MutateEnv(%q) did not copy input", tc.inEnv) + return + } + }) + } +} diff --git a/claat/nodes/survey.go b/claat/nodes/survey.go new file mode 100644 index 000000000..6ce2c0bf2 --- /dev/null +++ b/claat/nodes/survey.go @@ -0,0 +1,37 @@ +package nodes + +// TODO general refactor? + +// SurveyNode contains groups of questions. Each group name is the Survey key. +type SurveyNode struct { + node + ID string + Groups []*SurveyGroup +} + +// SurveyGroup contains group name/question and possible answers. +type SurveyGroup struct { + Name string + Options []string +} + +// NewSurveyNode creates a new survey node with optional questions. +// If survey is nil, a new empty map will be created. +// TODO is "map" above a mistake, or should the code below contain a map? +func NewSurveyNode(id string, groups ...*SurveyGroup) *SurveyNode { + return &SurveyNode{ + node: node{typ: NodeSurvey}, + ID: id, + Groups: groups, + } +} + +// Empty returns true if each group has 0 options. +func (sn *SurveyNode) Empty() bool { + for _, g := range sn.Groups { + if len(g.Options) > 0 { + return false + } + } + return true +} diff --git a/claat/nodes/survey_test.go b/claat/nodes/survey_test.go new file mode 100644 index 000000000..4a50fee91 --- /dev/null +++ b/claat/nodes/survey_test.go @@ -0,0 +1,219 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNewSurveyNode(t *testing.T) { + tests := []struct { + name string + inID string + inGroups []*SurveyGroup + out *SurveyNode + }{ + { + name: "Empty", + out: &SurveyNode{ + node: node{typ: NodeSurvey}, + }, + }, + { + // TODO: should the absence of an ID be an error? + name: "GroupsNoID", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "pick a number", + Options: []string{"1", "2", "3"}, + }, + &SurveyGroup{ + Name: "choose an answer", + Options: []string{"yes", "no", "probably"}, + }, + }, + out: &SurveyNode{ + node: node{typ: NodeSurvey}, + Groups: []*SurveyGroup{ + &SurveyGroup{ + Name: "pick a number", + Options: []string{"1", "2", "3"}, + }, + &SurveyGroup{ + Name: "choose an answer", + Options: []string{"yes", "no", "probably"}, + }, + }, + }, + }, + { + name: "IDNoGroups", + inID: "identifier", + out: &SurveyNode{ + node: node{typ: NodeSurvey}, + ID: "identifier", + }, + }, + { + name: "Simple", + inID: "a simple example", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "pick a color", + Options: []string{"red", "blue", "yellow"}, + }, + }, + out: &SurveyNode{ + node: node{typ: NodeSurvey}, + ID: "a simple example", + Groups: []*SurveyGroup{ + &SurveyGroup{ + Name: "pick a color", + Options: []string{"red", "blue", "yellow"}, + }, + }, + }, + }, + { + name: "Multiple", + inID: "an example with multiple survey groups", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "a", + Options: []string{"a", "aa", "aaa"}, + }, + &SurveyGroup{ + Name: "b", + Options: []string{"b", "bb", "bbb"}, + }, + &SurveyGroup{ + Name: "c", + Options: []string{"c", "cc", "ccc"}, + }, + }, + out: &SurveyNode{ + node: node{typ: NodeSurvey}, + ID: "an example with multiple survey groups", + Groups: []*SurveyGroup{ + &SurveyGroup{ + Name: "a", + Options: []string{"a", "aa", "aaa"}, + }, + &SurveyGroup{ + Name: "b", + Options: []string{"b", "bb", "bbb"}, + }, + &SurveyGroup{ + Name: "c", + Options: []string{"c", "cc", "ccc"}, + }, + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewSurveyNode(tc.inID, tc.inGroups...) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(SurveyNode{}, node{})); diff != "" { + t.Errorf("NewSurveyNode(%q, %v) got diff (-want +got): %s", tc.inID, tc.inGroups, diff) + return + } + }) + } +} + +func TestSurveyNodeEmpty(t *testing.T) { + tests := []struct { + name string + inID string + inGroups []*SurveyGroup + out bool + }{ + { + name: "NoGroups", + inID: "id", + out: true, + }, + { + name: "OneGroupEmpty", + inID: "id", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "one", + }, + }, + out: true, + }, + { + name: "MultiGroupsEmpty", + inID: "id", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "one", + }, + &SurveyGroup{ + Name: "two", + }, + &SurveyGroup{ + Name: "three", + }, + }, + out: true, + }, + { + name: "OneGroupNonEmpty", + inID: "id", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "one", + Options: []string{"two", "three"}, + }, + }, + }, + { + name: "MultiGroupsNonEmpty", + inID: "id", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "one", + Options: []string{"two", "three"}, + }, + &SurveyGroup{ + Name: "four", + Options: []string{"five", "six"}, + }, + &SurveyGroup{ + Name: "seven", + Options: []string{"eight", "nine"}, + }, + }, + }, + { + name: "MultiGroupsNonEmptySomeNoOptions", + inID: "id", + inGroups: []*SurveyGroup{ + &SurveyGroup{ + Name: "one", + Options: []string{"two", "three"}, + }, + &SurveyGroup{ + Name: "four", + Options: []string{"five", "six"}, + }, + &SurveyGroup{ + Name: "seven", + }, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewSurveyNode(tc.inID, tc.inGroups...) + out := n.Empty() + if out != tc.out { + t.Errorf("SurveyNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/text.go b/claat/nodes/text.go new file mode 100644 index 000000000..2332834e1 --- /dev/null +++ b/claat/nodes/text.go @@ -0,0 +1,35 @@ +package nodes + +import "strings" + +type NewTextNodeOptions struct { + Bold bool + Italic bool + Code bool + Value string +} + +// NewTextNode creates a new Node of type NodeText. +func NewTextNode(opts NewTextNodeOptions) *TextNode { + return &TextNode{ + node: node{typ: NodeText}, + Bold: opts.Bold, + Italic: opts.Italic, + Code: opts.Code, + Value: opts.Value, + } +} + +// TextNode is a simple node containing text as a string value. +type TextNode struct { + node + Bold bool + Italic bool + Code bool + Value string +} + +// Empty returns true if tn.Value is zero, excluding space runes. +func (tn *TextNode) Empty() bool { + return strings.TrimSpace(tn.Value) == "" +} diff --git a/claat/nodes/text_test.go b/claat/nodes/text_test.go new file mode 100644 index 000000000..120899500 --- /dev/null +++ b/claat/nodes/text_test.go @@ -0,0 +1,77 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNewTextNode(t *testing.T) { + tests := []struct { + name string + inOpts NewTextNodeOptions + out *TextNode + }{ + { + name: "Empty", + out: &TextNode{ + node: node{typ: NodeText}, + }, + }, + { + name: "NonEmpty", + inOpts: NewTextNodeOptions{ + Value: "foobar", + }, + out: &TextNode{ + node: node{typ: NodeText}, + Value: "foobar", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewTextNode(tc.inOpts) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(TextNode{}, node{})); diff != "" { + t.Errorf("NewTextNode(%+v) got diff (-want +got): %s", tc.inOpts, diff) + return + } + }) + } +} + +func TestTextNodeEmpty(t *testing.T) { + tests := []struct { + name string + inOpts NewTextNodeOptions + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inOpts: NewTextNodeOptions{ + Value: "foobar", + }, + }, + { + name: "EmptyWithSpaces", + inOpts: NewTextNodeOptions{ + Value: "\n \t", + }, + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewTextNode(tc.inOpts) + out := n.Empty() + if out != tc.out { + t.Errorf("TextNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/url.go b/claat/nodes/url.go new file mode 100644 index 000000000..14b83d299 --- /dev/null +++ b/claat/nodes/url.go @@ -0,0 +1,25 @@ +package nodes + +// NewURLNode creates a new Node of type NodeURL with optional content n. +func NewURLNode(url string, n ...Node) *URLNode { + return &URLNode{ + node: node{typ: NodeURL}, + URL: url, + Target: "_blank", + Content: NewListNode(n...), + } +} + +// URLNode represents elements such as +type URLNode struct { + node + URL string + Name string + Target string + Content *ListNode +} + +// Empty returns true if un content is empty. +func (un *URLNode) Empty() bool { + return un.Content.Empty() +} diff --git a/claat/nodes/url_test.go b/claat/nodes/url_test.go new file mode 100644 index 000000000..33a77eb2f --- /dev/null +++ b/claat/nodes/url_test.go @@ -0,0 +1,83 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +// AllowUnexported option for cmp to make sure we can diff properly. +var cmpOptURL = cmp.AllowUnexported(URLNode{}, node{}, ListNode{}, TextNode{}) + +func TestNewURLNode(t *testing.T) { + tests := []struct { + name string + inURL string + inContent []Node + out *URLNode + }{ + { + name: "Empty", + out: &URLNode{ + node: node{typ: NodeURL}, + Target: "_blank", + Content: NewListNode(), + }, + }, + { + name: "NonEmpty", + inURL: "google.com", + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + out: &URLNode{ + node: node{typ: NodeURL}, + URL: "google.com", + Target: "_blank", + Content: NewListNode(NewTextNode(NewTextNodeOptions{Value: "foo"}), NewTextNode(NewTextNodeOptions{Value: "bar"})), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewURLNode(tc.inURL, tc.inContent...) + if diff := cmp.Diff(tc.out, out, cmpOptURL); diff != "" { + t.Errorf("NewURLNode(%s, %+v) got diff (-want +got): %s", tc.inURL, tc.inContent, diff) + return + } + }) + } +} + +func TestURLNodeEmpty(t *testing.T) { + tests := []struct { + name string + inURL string + inContent []Node + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inURL: "google.com", + inContent: []Node{ + NewTextNode(NewTextNodeOptions{Value: "foo"}), + NewTextNode(NewTextNodeOptions{Value: "bar"}), + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewURLNode(tc.inURL, tc.inContent...) + out := n.Empty() + if out != tc.out { + t.Errorf("URLNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/nodes/youtube.go b/claat/nodes/youtube.go new file mode 100644 index 000000000..20602daa1 --- /dev/null +++ b/claat/nodes/youtube.go @@ -0,0 +1,20 @@ +package nodes + +// NewYouTubeNode creates a new YouTube video node. +func NewYouTubeNode(vid string) *YouTubeNode { + return &YouTubeNode{ + node: node{typ: NodeYouTube}, + VideoID: vid, + } +} + +// YouTubeNode is a YouTube video. +type YouTubeNode struct { + node + VideoID string +} + +// Empty returns true if yt's VideoID field is zero. +func (yt *YouTubeNode) Empty() bool { + return yt.VideoID == "" +} diff --git a/claat/nodes/youtube_test.go b/claat/nodes/youtube_test.go new file mode 100644 index 000000000..02eca1cf3 --- /dev/null +++ b/claat/nodes/youtube_test.go @@ -0,0 +1,66 @@ +package nodes + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestNewYouTubeNode(t *testing.T) { + tests := []struct { + name string + inVideoID string + out *YouTubeNode + }{ + { + name: "Empty", + out: &YouTubeNode{ + node: node{typ: NodeYouTube}, + }, + }, + { + name: "NonEmpty", + inVideoID: "Mlk888FiI8A", + out: &YouTubeNode{ + node: node{typ: NodeYouTube}, + VideoID: "Mlk888FiI8A", + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out := NewYouTubeNode(tc.inVideoID) + if diff := cmp.Diff(tc.out, out, cmp.AllowUnexported(YouTubeNode{}, node{})); diff != "" { + t.Errorf("NewYouTubeNode(%q) got diff (-want +got): %s", tc.inVideoID, diff) + return + } + }) + } +} + +func TestYouTubeNodeEmpty(t *testing.T) { + tests := []struct { + name string + inVideoID string + out bool + }{ + { + name: "Empty", + out: true, + }, + { + name: "NonEmpty", + inVideoID: "Mlk888FiI8A", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + n := NewYouTubeNode(tc.inVideoID) + out := n.Empty() + if out != tc.out { + t.Errorf("YouTubeNode.Empty() = %t, want %t", out, tc.out) + return + } + }) + } +} diff --git a/claat/parser/gdoc/css_test.go b/claat/parser/gdoc/css_test.go new file mode 100644 index 000000000..7075335ff --- /dev/null +++ b/claat/parser/gdoc/css_test.go @@ -0,0 +1,596 @@ +package gdoc + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func nodeWithStyle(s string) *html.Node { + return nodeWithAttrs(map[string]string{"style": s}) +} + +// TODO rename +func nodeWithAttrs(attrs map[string]string) *html.Node { + n := makePNode() + for k, v := range attrs { + n.Attr = append(n.Attr, html.Attribute{Key: k, Val: v}) + } + return n +} + +// Input string is used as the text content, i.e. +func makeStyleNode(s string) *html.Node { + n := html.Node{ + Type: html.ElementNode, + DataAtom: atom.Style, + Data: "style", + } + n.AppendChild(makeTextNode(s)) + return &n +} + +func TestParseStyle(t *testing.T) { + tests := []struct { + name string + inNode *html.Node + out cssStyle + ok bool + }{ + { + name: "Simple", + inNode: makeStyleNode(`.foo { + margin-top: 1em; +}`), + out: cssStyle(map[string]map[string]string{ + ".foo": map[string]string{ + "margin-top": "1em", + }, + }), + ok: true, + }, + { + name: "MultipleClasses", + inNode: makeStyleNode(`.foo { + margin-top: 1em; + margin-left: 2em; +} + +.bar { + padding-top: 3em; + padding-left: 4em; +} +`), + out: cssStyle(map[string]map[string]string{ + ".foo": map[string]string{ + "margin-top": "1em", + "margin-left": "2em", + }, + ".bar": map[string]string{ + "padding-top": "3em", + "padding-left": "4em", + }, + }), + ok: true, + }, + { + name: "MultipleTypes", + inNode: makeStyleNode(`.foo { + margin-top: 1em; + margin-left: 2em; +} + +#bar { + padding-top: 3em; + padding-left: 4em; +} + +.baz { + color: #ff0000; +} +`), + out: cssStyle(map[string]map[string]string{ + ".foo": map[string]string{ + "margin-top": "1em", + "margin-left": "2em", + }, + ".baz": map[string]string{ + "color": "#ff0000", + }, + }), + ok: true, + }, + { + name: "PushedRandomKeys", + inNode: makeStyleNode("0jffffffff[9,uc"), + out: make(cssStyle), + ok: true, + }, + { + name: "AtRuleSimple", + inNode: makeStyleNode("@charset \"ascii\";"), + out: make(cssStyle), + ok: true, + }, + { + name: "InvalidCSS", + inNode: makeStyleNode("@media something(max-width: 1)"), + }, + { + name: "AtRuleBlock", + inNode: makeStyleNode(`@media something(max-width: 1) { + foo + bar + baz +}`), + out: make(cssStyle), + ok: true, + }, + { + name: "Capitalization", + inNode: makeStyleNode(`.foo { + color: #00FF00; + MARGIN-TOP: 3px; + margin-left: 3PX; +}`), + out: cssStyle(map[string]map[string]string{ + ".foo": map[string]string{ + "color": "#00ff00", + "margin-top": "3px", + "margin-left": "3px", + }, + }), + ok: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + out, err := parseStyle(tc.inNode) + if err != nil && tc.ok { + t.Errorf("parseStyle(%+v) = %+v, want %+v", tc.inNode, err, tc.out) + return + } + if err == nil && !tc.ok { + t.Errorf("parseStyle(%+v) = %+v, want err", tc.inNode, out) + return + } + if tc.ok { + if diff := cmp.Diff(tc.out, out); diff != "" { + t.Errorf("parseStyle(%+v) got diff (-want +got):\n%s", tc.inNode, diff) + return + } + } + }) + } +} + +func TestClassList(t *testing.T) { + tests := []struct { + name string + inNode *html.Node + out []string + }{ + { + name: "Simple", + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + out: []string{"foo"}, + }, + { + name: "MultipleClassesPresorted", + inNode: nodeWithAttrs(map[string]string{"class": "bar baz foo"}), + out: []string{"bar", "baz", "foo"}, + }, + { + name: "MultipleClassesUnsorted", + inNode: nodeWithAttrs(map[string]string{"class": "foo bar baz"}), + out: []string{"bar", "baz", "foo"}, + }, + { + name: "OtherAttrs", + inNode: nodeWithAttrs(map[string]string{"style": "margin-left: 2em", "class": "bar baz foo", "data-something": "a value"}), + out: []string{"bar", "baz", "foo"}, + }, + { + // TODO should this just return nil? + name: "NoAttrs", + inNode: makePNode(), + out: []string{""}, + }, + { + // TODO should this just return nil? + // TODO should capitalization be handled? + name: "CapitalizationKey", + inNode: nodeWithAttrs(map[string]string{"Class": "bar baz foo"}), + out: []string{""}, + }, + { + // TODO should this just return nil? + name: "NoClass", + inNode: nodeWithAttrs(map[string]string{"data-whatever": "lol"}), + out: []string{""}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, classList(tc.inNode)); diff != "" { + t.Errorf("classList(%+v) got diff (-want +got):\n%s", tc.inNode, diff) + } + }) + } +} + +func TestHasClass(t *testing.T) { + tests := []struct { + name string + inNode *html.Node + inName string + out bool + }{ + { + name: "Simple", + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inName: "foo", + out: true, + }, + { + name: "Multiple", + inNode: nodeWithAttrs(map[string]string{"class": "foo bar baz"}), + inName: "bar", + out: true, + }, + { + name: "NotFound", + inNode: nodeWithAttrs(map[string]string{"class": "foo bar baz"}), + inName: "qux", + }, + { + name: "NoClasses", + inNode: makePNode(), + inName: "foo", + }, + { + name: "CapitalizationInput", + inNode: nodeWithAttrs(map[string]string{"class": "foo bar baz"}), + inName: "Foo", + }, + { + name: "CapitalizationClass", + inNode: nodeWithAttrs(map[string]string{"class": "foo bar baZ"}), + inName: "baz", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := hasClass(tc.inNode, tc.inName); out != tc.out { + t.Errorf("hasClass(%+v, %q) = %t, want %t", tc.inNode, tc.inName, out, tc.out) + + } + }) + } +} + +func TestHasClassStyle(t *testing.T) { + testStyleNode := makeStyleNode(`.foo { + margin-top: 1em; + margin-left: 2em; +} + +.bar { + padding-top: 3em; + padding-left: 4em; +} + +#baz { + color: green; +} +`) + testStyle, err := parseStyle(testStyleNode) + if err != nil { + t.Fatalf("parseStyle(%+v) = %+v", testStyleNode, err) + return + } + + caseTestStyleNode := makeStyleNode(`.foo { + MARGIN-top: 1eM; +}`) + caseTestStyle, err := parseStyle(caseTestStyleNode) + if err != nil { + t.Fatalf("parseStyle(%+v) = %+v", caseTestStyleNode, err) + return + } + + tests := []struct { + name string + inCSS cssStyle + inNode *html.Node + inKey string + inVal string + out bool + }{ + { + name: "ClassHitNoInlineKVHit", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inKey: "margin-top", + inVal: "1em", + out: true, + }, + { + name: "ClassHitNoInlineVMiss", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inKey: "margin-top", + inVal: "10px", + }, + { + name: "ClassHitNoInlineKMiss", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inKey: "font-weight", + inVal: "10em", + }, + { + name: "ClassHitInlineMissKVHit", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo", "style": "bottom: 1px"}), + inKey: "margin-top", + inVal: "1em", + out: true, + }, + { + name: "ClassHitInlineMissVMiss", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo", "style": "bottom: 1px"}), + inKey: "margin-top", + inVal: "10px", + }, + { + name: "ClassHitInlineMissKMiss", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo", "style": "bottom: 1px"}), + inKey: "font-weight", + inVal: "10em", + }, + { + name: "ClassMissInlineHitKVHit", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "qux", "style": "bottom: 1px"}), + inKey: "bottom", + inVal: "1px", + out: true, + }, + { + name: "ClassMissInlineHitVMiss", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "qux", "style": "bottom: 1px"}), + inKey: "bottom", + inVal: "10in", + }, + { + name: "ClassMissInlineMiss", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "qux", "style": "bottom: 1px"}), + inKey: "right", + inVal: "10in", + }, + { + name: "ClassMissInlineMissIDHit", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "qux", "id": "baz", "style": "bottom: 1px"}), + inKey: "color", + inVal: "green", + }, + { + name: "ClassHitInlineHit", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "bar", "style": "padding-top: 6cm"}), + inKey: "padding-top", + inVal: "3em", + out: true, + }, + { + name: "CSSCapitalization", + inCSS: caseTestStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inKey: "margin-top", + inVal: "1em", + out: true, + }, + { + name: "KCapitalization", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inKey: "margin-TOP", + inVal: "1em", + }, + { + name: "VCapitalization", + inCSS: testStyle, + inNode: nodeWithAttrs(map[string]string{"class": "foo"}), + inKey: "margin-top", + inVal: "1Em", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := hasClassStyle(tc.inCSS, tc.inNode, tc.inKey, tc.inVal); out != tc.out { + t.Errorf("hasClassStyle(%+v, %+v, %q, %q) = %t, want %t", tc.inCSS, tc.inNode, tc.inKey, tc.inVal, out, tc.out) + return + } + }) + } +} + +func TestStyleValue(t *testing.T) { + tests := []struct { + name string + inNode *html.Node + inName string + out string + }{ + { + name: "NoName", + inNode: makePNode(), + }, + { + name: "NoStyle", + inNode: makePNode(), + inName: "foobar", + }, + { + name: "One", + inNode: nodeWithStyle("position: absolute"), + inName: "position", + out: "absolute", + }, + { + name: "CapitalizationKeyStyle", + inNode: nodeWithStyle("Position: relative"), + inName: "position", + out: "relative", + }, + { + name: "CapitalizationValueStyle", + inNode: nodeWithStyle("color: #0000FF"), + inName: "color", + out: "#0000ff", + }, + { + name: "CapitalizationKeyInput", + inNode: nodeWithStyle("position: relative"), + inName: "Position", + out: "relative", + }, + { + name: "Multiple", + inNode: nodeWithStyle("position: absolute; color: #ff00ff; font-weight: 300"), + inName: "color", + out: "#ff00ff", + }, + { + name: "NotFound", + inNode: nodeWithStyle("position: absolute; color: #FF00FF; font-weight: 300"), + inName: "margin-left", + }, + { + name: "NoKVPair", + inNode: nodeWithStyle("margin-left"), + inName: "margin-left", + }, + { + // TODO should this be the behavior? + name: "BadSyntax", + inNode: nodeWithStyle("margin-left: font-weight: #00ff00"), + inName: "margin-left", + out: "font-weight: #00ff00", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := styleValue(tc.inNode, tc.inName); out != tc.out { + t.Errorf("styleValue(%+v, %q) = %q, want %q", tc.inNode, tc.inName, out, tc.out) + + } + }) + } +} + +func TestStyleFloatValue(t *testing.T) { + tests := []struct { + name string + inNode *html.Node + inName string + out float32 + }{ + { + name: "NoName", + inNode: makePNode(), + }, + { + name: "NoStyle", + inNode: makePNode(), + inName: "foobar", + }, + { + name: "Simple", + inNode: nodeWithStyle("margin-top: 3.14em"), + inName: "margin-top", + out: 3.14, + }, + { + name: "NoDecimalPlaces", + inNode: nodeWithStyle("margin-left: 2in"), + inName: "margin-left", + out: 2, + }, + { + name: "DecimalZeroes", + inNode: nodeWithStyle("margin-right: 1.0px"), + inName: "margin-right", + out: 1, + }, + { + name: "NoUnit", + inNode: nodeWithStyle("margin-bottom: 4"), + inName: "margin-bottom", + out: 4, + }, + { + name: "Multiple", + inNode: nodeWithStyle("padding-top: 1.2; padding-left: 3.4; padding-right: 5.6"), + inName: "padding-left", + out: 3.4, + }, + { + name: "NotFound", + inNode: nodeWithStyle("border-top: 7.8; border-left: 0.9"), + inName: "border-right", + }, + { + name: "NoKVPair", + inNode: nodeWithStyle("margin-left"), + inName: "margin-left", + }, + { + name: "BadSyntax", + inNode: nodeWithStyle("margin-left: margin-top: 1.234em"), + inName: "margin-left", + out: -1, + }, + { + // TODO should this be the behavior? + name: "BadSyntaxMiddle", + inNode: nodeWithStyle("margin-left: margin-top: 1.234em"), + inName: "margin-top", + }, + { + // TODO should this be the behavior? + name: "BadValue", + inNode: nodeWithStyle("margin-left: 7jv9ue4if4.21"), + inName: "margin-left", + out: 7, + }, + { + name: "CapitalizationKeyStyle", + inNode: nodeWithStyle("Margin-Left: 2.3px"), + inName: "margin-left", + out: 2.3, + }, + { + name: "CapitalizationKeyInput", + inNode: nodeWithStyle("margin-left: 4.5px"), + inName: "Margin-Left", + out: 4.5, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := styleFloatValue(tc.inNode, tc.inName); out != tc.out { + t.Errorf("styleFloatValue(%+v, %q) = %f, want %f", tc.inNode, tc.inName, out, tc.out) + + } + }) + } +} diff --git a/claat/parser/gdoc/html.go b/claat/parser/gdoc/html.go index 1ad9842d5..4fe686c0e 100644 --- a/claat/parser/gdoc/html.go +++ b/claat/parser/gdoc/html.go @@ -213,13 +213,14 @@ func findBlockParent(hn *html.Node) *html.Node { return nil } -// nodeAttr returns node attribute value of the key name. -// Attribute keys are case insensitive. -func nodeAttr(n *html.Node, name string) string { - name = strings.ToLower(name) - for _, a := range n.Attr { - if strings.ToLower(a.Key) == name { - return a.Val +// nodeAttr checks the given node's HTML attributes for the given key. +// The corresponding value is returned, or the empty string if the key is not found. +// Keys are case insensitive. +func nodeAttr(n *html.Node, key string) string { + key = strings.ToLower(key) + for _, attr := range n.Attr { + if strings.ToLower(attr.Key) == key { + return attr.Val } } return "" diff --git a/claat/parser/gdoc/html_test.go b/claat/parser/gdoc/html_test.go new file mode 100644 index 000000000..2b48f604c --- /dev/null +++ b/claat/parser/gdoc/html_test.go @@ -0,0 +1,1510 @@ +package gdoc + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +func makeBlinkNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Blink, + Data: "blink", + } +} + +func makePNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.P, + Data: "p", + } +} + +func makeEmNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Em, + Data: "em", + } +} + +func makeMarqueeNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Marquee, + Data: "marquee", + } +} + +func makeStrongNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Strong, + Data: "strong", + } +} + +func makeCodeNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Code, + Data: "code", + } +} + +func makeBNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.B, + Data: "b", + } +} + +// , not the filesystem abstraction. +func makeINode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.I, + Data: "i", + } +} + +// data is the text in the node. +func makeTextNode(data string) *html.Node { + return &html.Node{ + Type: html.TextNode, + Data: data, + } +} + +func makeBrNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Br, + Data: "br", + } +} + +func makeSpanNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Span, + Data: "span", + } +} + +func makeANode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.A, + Data: "a", + } +} + +func makeTdNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Td, + Data: "td", + } +} + +func makeDivNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Div, + Data: "td", + } +} + +func makeOlNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Ol, + Data: "ol", + } +} + +func makeUlNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Ul, + Data: "ul", + } +} + +func makeTableNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Table, + Data: "table", + } +} + +func makeTrNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Tr, + Data: "tr", + } +} + +func TestIsHeader(t *testing.T) { + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "StepTitle", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H1, + Data: "h1", + }, + }, + { + name: "FirstLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H2, + Data: "h2", + }, + out: true, + }, + { + name: "SecondLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H3, + Data: "h3", + }, + out: true, + }, + { + name: "ThirdLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H4, + Data: "h4", + }, + out: true, + }, + { + name: "FourthLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H5, + Data: "h5", + }, + out: true, + }, + { + name: "FifthLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H6, + Data: "h6", + }, + out: true, + }, + { + name: "NotAHeader", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isHeader(tc.in); out != tc.out { + t.Errorf("isHeader(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsMeta(t *testing.T) { + metaStyleText := `.meta { + color: #b7b7b7; +}` + metaStyle, err := parseStyle(makeStyleNode(metaStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", metaStyleText, err) + return + } + + a := nodeWithAttrs(map[string]string{"class": "meta"}) + a.AppendChild(makeTextNode("foobar")) + + b := makePNode() + b.AppendChild(makeTextNode("foobar")) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "Meta", + inNode: a, + out: true, + }, + { + name: "NonMeta", + inNode: b, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isMeta(metaStyle, tc.inNode); out != tc.out { + t.Errorf("isMeta(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsBold(t *testing.T) { + boldStyleText := `.literalbold { + font-weight: bold; +} + +.weightbold { + font-weight: 700; +} +` + boldStyle, err := parseStyle(makeStyleNode(boldStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", boldStyleText, err) + return + } + + a1 := makeStrongNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + b := makeBNode() + b.AppendChild(makeTextNode("foobar")) + + c := nodeWithAttrs(map[string]string{"class": "literalbold"}) + c.AppendChild(makeTextNode("foobar")) + + d := nodeWithAttrs(map[string]string{"class": "weightbold"}) + d.AppendChild(makeTextNode("foobar")) + + e1 := makeEmNode() + e2 := makeTextNode("foobar") + e1.AppendChild(e2) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "Strong", + inNode: a1, + out: true, + }, + { + name: "B", + inNode: b, + out: true, + }, + { + name: "FontWeightBold", + inNode: c, + out: true, + }, + { + name: "FontWeight700", + inNode: d, + out: true, + }, + { + name: "TextNodeBold", + inNode: a2, + out: true, + }, + { + name: "TextNodeNonBold", + inNode: e2, + }, + { + name: "Em", + inNode: e1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isBold(boldStyle, tc.inNode); out != tc.out { + t.Errorf("isBold(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsItalic(t *testing.T) { + italicStyleText := `.literalitalic { + font-style: italic; +} +` + italicStyle, err := parseStyle(makeStyleNode(italicStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", italicStyleText, err) + return + } + + a1 := makeEmNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + b := makeINode() + b.AppendChild(makeTextNode("foobar")) + + c := nodeWithAttrs(map[string]string{"class": "literalitalic"}) + c.AppendChild(makeTextNode("foobar")) + + d1 := makeStrongNode() + d2 := makeTextNode("foobar") + d1.AppendChild(d2) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "Em", + inNode: a1, + out: true, + }, + { + name: "I", + inNode: b, + out: true, + }, + { + name: "FontStyleItalic", + inNode: c, + out: true, + }, + { + name: "TextNodeItalic", + inNode: a2, + out: true, + }, + { + name: "TextNodeNonItalic", + inNode: d2, + }, + { + name: "Strong", + inNode: d1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isItalic(italicStyle, tc.inNode); out != tc.out { + t.Errorf("isItalic(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsConsole(t *testing.T) { + consoleStyleText := `.console { + font-family: consolas; +} + +.code { + font-family: courier new; +} +` + consoleStyle, err := parseStyle(makeStyleNode(consoleStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", consoleStyleText, err) + return + } + + a1 := nodeWithAttrs(map[string]string{"class": "console"}) + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + b1 := makePNode() + b2 := makeTextNode("foobar") + b1.AppendChild(b2) + + c1 := nodeWithAttrs(map[string]string{"class": "courier new"}) + c2 := makeTextNode("foobar") + c1.AppendChild(c2) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "ConsoleNonText", + inNode: a1, + out: true, + }, + { + name: "ConsoleText", + inNode: a2, + out: true, + }, + { + name: "NonConsoleNonText", + inNode: b1, + }, + { + name: "NonConsoleText", + inNode: b2, + }, + { + name: "CodeNonText", + inNode: c1, + }, + { + name: "CodeText", + inNode: c2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isConsole(consoleStyle, tc.inNode); out != tc.out { + t.Errorf("isConsole(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsCode(t *testing.T) { + codeStyleText := `.console { + font-family: consolas; +} + +.code { + font-family: courier new; +} +` + codeStyle, err := parseStyle(makeStyleNode(codeStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", codeStyleText, err) + return + } + + a1 := nodeWithAttrs(map[string]string{"class": "console"}) + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + b1 := makePNode() + b2 := makeTextNode("foobar") + b1.AppendChild(b2) + + c1 := nodeWithAttrs(map[string]string{"class": "code"}) + c2 := makeTextNode("foobar") + c1.AppendChild(c2) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "ConsoleNonText", + inNode: a1, + }, + { + name: "ConsoleText", + inNode: a2, + }, + { + name: "NonCodeNonText", + inNode: b1, + }, + { + name: "NonCodeText", + inNode: b2, + }, + { + name: "CodeNonText", + inNode: c1, + out: true, + }, + { + name: "CodeText", + inNode: c2, + out: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isCode(codeStyle, tc.inNode); out != tc.out { + t.Errorf("isCode(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsButton(t *testing.T) { + buttonStyleText := `.button { + background-color: #6aa84f; +}` + buttonStyle, err := parseStyle(makeStyleNode(buttonStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", buttonStyleText, err) + return + } + + a := nodeWithAttrs(map[string]string{"class": "button"}) + a.AppendChild(makeTextNode("foobar")) + + b := makePNode() + b.AppendChild(makeTextNode("foobar")) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "Button", + inNode: a, + out: true, + }, + { + name: "NonButton", + inNode: b, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isButton(buttonStyle, tc.inNode); out != tc.out { + t.Errorf("isButton(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsInfobox(t *testing.T) { + infoboxStyleText := `.infoboxNegative { + background-color: #fce5cd; +} + +.infoboxPositive { + background-color: #d9ead3; +} +` + infoboxStyle, err := parseStyle(makeStyleNode(infoboxStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", infoboxStyleText, err) + return + } + + a := makeTdNode() + a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: "infoboxNegative"}) + a.AppendChild(makeTextNode("foobar")) + + b := makeTdNode() + b.AppendChild(makeTextNode("foobar")) + + c := nodeWithAttrs(map[string]string{"class": "infoboxNegative"}) + c.AppendChild(makeTextNode("foobar")) + + d := makeTdNode() + d.Attr = append(d.Attr, html.Attribute{Key: "class", Val: "infoboxPositive"}) + d.AppendChild(makeTextNode("foobar")) + + e := nodeWithAttrs(map[string]string{"class": "infoboxPositive"}) + e.AppendChild(makeTextNode("foobar")) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "TdInfoboxNegative", + inNode: a, + out: true, + }, + { + name: "TdNonInfobox", + inNode: b, + }, + { + name: "NonTdNegative", + inNode: c, + }, + { + name: "TdInfoboxPositive", + inNode: d, + out: true, + }, + { + name: "NonTdPositive", + inNode: e, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isInfobox(infoboxStyle, tc.inNode); out != tc.out { + t.Errorf("isInfobox(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsInfoboxNegative(t *testing.T) { + infoboxNegativeStyleText := `.infoboxNegative { + background-color: #fce5cd; +}` + infoboxNegativeStyle, err := parseStyle(makeStyleNode(infoboxNegativeStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", infoboxNegativeStyleText, err) + return + } + + a := makeTdNode() + a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: "infoboxNegative"}) + a.AppendChild(makeTextNode("foobar")) + + b := makeTdNode() + b.AppendChild(makeTextNode("foobar")) + + c := nodeWithAttrs(map[string]string{"class": "infoboxNegative"}) + c.AppendChild(makeTextNode("foobar")) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "TdInfoboxNegative", + inNode: a, + out: true, + }, + { + name: "TdNonInfoboxNegative", + inNode: b, + }, + { + name: "NonTd", + inNode: c, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isInfoboxNegative(infoboxNegativeStyle, tc.inNode); out != tc.out { + t.Errorf("isInfoboxNegative(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsSurvey(t *testing.T) { + surveyStyleText := `.survey { + background-color: #cfe2f3; +}` + surveyStyle, err := parseStyle(makeStyleNode(surveyStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", surveyStyleText, err) + return + } + + a := makeTdNode() + a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: "survey"}) + a.AppendChild(makeTextNode("foobar")) + + b := makeTdNode() + b.AppendChild(makeTextNode("foobar")) + + c := nodeWithAttrs(map[string]string{"class": "survey"}) + c.AppendChild(makeTextNode("foobar")) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "TdSurvey", + inNode: a, + out: true, + }, + { + name: "TdNonSurvey", + inNode: b, + }, + { + name: "NonTd", + inNode: c, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isSurvey(surveyStyle, tc.inNode); out != tc.out { + t.Errorf("isSurvey(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsComment(t *testing.T) { + commentStyleText := `.comment { + border: 1px solid black; +}` + commentStyle, err := parseStyle(makeStyleNode(commentStyleText)) + if err != nil { + t.Fatalf("parseStyle(makeStyleNode(%q)) = %+v", commentStyleText, err) + return + } + + a := makeDivNode() + a.Attr = append(a.Attr, html.Attribute{Key: "class", Val: "comment"}) + a.AppendChild(makeTextNode("foobar")) + + b := makeDivNode() + b.AppendChild(makeTextNode("foobar")) + + c := nodeWithAttrs(map[string]string{"class": "comment"}) + c.AppendChild(makeTextNode("foobar")) + + tests := []struct { + name string + inNode *html.Node + out bool + }{ + { + name: "DivComment", + inNode: a, + out: true, + }, + { + name: "DivNonComment", + inNode: b, + }, + { + name: "NonDiv", + inNode: c, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isComment(commentStyle, tc.inNode); out != tc.out { + t.Errorf("isComment(css, %+v) = %t, want %t", tc.inNode, out, tc.out) + } + }) + } +} + +func TestIsTable(t *testing.T) { + a := makeTableNode() + a.AppendChild(makeTrNode()) + a.AppendChild(makeTrNode()) + a.AppendChild(makeTdNode()) + a.AppendChild(makeTdNode()) + + b := makeTableNode() + b.AppendChild(makeTrNode()) + b.AppendChild(makeTdNode()) + b.AppendChild(makeTdNode()) + + c := makeTableNode() + c.AppendChild(makeTrNode()) + c.AppendChild(makeTrNode()) + c.AppendChild(makeTdNode()) + + d := makeTableNode() + d.AppendChild(makeTrNode()) + d.AppendChild(makeTdNode()) + + e := makeTableNode() + e.AppendChild(makeTdNode()) + + f := makeTableNode() + f.AppendChild(makeTrNode()) + + g := makeMarqueeNode() + g.AppendChild(makeTrNode()) + g.AppendChild(makeTrNode()) + g.AppendChild(makeTdNode()) + g.AppendChild(makeTdNode()) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "Table2Rows2Data", + in: a, + out: true, + }, + { + name: "Table1Row2Data", + in: b, + out: true, + }, + { + name: "Table2Rows1Data", + in: c, + out: true, + }, + { + name: "Table1Row1Data", + in: d, + }, + { + name: "Table0Rows1Data", + in: e, + }, + { + name: "Table1Row0Data", + in: f, + }, + { + name: "TableNone", + in: makeTableNode(), + }, + { + name: "NonTableAtom", + in: makeMarqueeNode(), + }, + { + name: "NonTableAtomRowsAndData", + in: g, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isTable(tc.in); out != tc.out { + t.Errorf("isTable(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsList(t *testing.T) { + a1 := makeOlNode() + a2 := makeTextNode("aaa") + a3 := makeTextNode("bbb") + a4 := makeTextNode("ccc") + // The name and input nodes should be siblings. + a1.AppendChild(a2) + a1.AppendChild(a3) + a1.AppendChild(a4) + + b1 := makeUlNode() + b2 := makeTextNode("aaa") + b3 := makeTextNode("bbb") + b4 := makeTextNode("ccc") + // The name and input nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "OrderedListWithElements", + in: a1, + out: true, + }, + { + name: "UnorderedListWithElements", + in: b1, + out: true, + }, + // TODO: Should a list of no elements be considered an error? + { + name: "OrderedListWithoutElements", + in: makeOlNode(), + out: true, + }, + { + name: "UnorderedListWithoutElements", + in: makeUlNode(), + out: true, + }, + { + name: "NotAList", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isList(tc.in); out != tc.out { + t.Errorf("isList(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestCountTwo(t *testing.T) { + a1 := makePNode() + a2 := makeBlinkNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + b1 := makePNode() + b2 := makeTextNode("foobar") + b3 := makeMarqueeNode() + // The nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + + c1 := makePNode() + c2 := makeTextNode("foobar") + c3 := makeMarqueeNode() + c4 := makeTextNode("foobar2") + c5 := makeMarqueeNode() + // The nodes should be siblings. + c1.AppendChild(c2) + c1.AppendChild(c3) + c1.AppendChild(c4) + c1.AppendChild(c5) + + d1 := makePNode() + d2 := makeTextNode("foobar") + d3 := makeMarqueeNode() + d4 := makeTextNode("foobar2") + d5 := makeMarqueeNode() + d6 := makeMarqueeNode() + d7 := makeMarqueeNode() + // The nodes should be siblings. + d1.AppendChild(d2) + d1.AppendChild(d3) + d1.AppendChild(d4) + d1.AppendChild(d5) + d1.AppendChild(d6) + d1.AppendChild(d7) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out int + }{ + { + name: "Zero", + inNode: a1, + inAtom: atom.Marquee, + out: 0, + }, + { + name: "One", + inNode: b1, + inAtom: atom.Marquee, + out: 1, + }, + { + name: "Two", + inNode: c1, + inAtom: atom.Marquee, + out: 2, + }, + { + name: "MoreThanTwo", + inNode: d1, + inAtom: atom.Marquee, + out: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := countTwo(tc.inNode, tc.inAtom); out != tc.out { + t.Errorf("countTwo(%+v, %+v) = %d, want %d", tc.inNode, tc.inAtom, out, tc.out) + } + }) + } +} + +func TestCountDirect(t *testing.T) { + a1 := makePNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + b1 := makePNode() + b2 := makeTextNode("foobar") + b3 := makeTextNode("foobar2") + b4 := makeTextNode("foobar3") + // The nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + c1 := makePNode() + c2 := makeBlinkNode() + c3 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + + tests := []struct { + name string + in *html.Node + out int + }{ + { + name: "Zero", + in: makePNode(), + out: 0, + }, + { + name: "One", + in: a1, + out: 1, + }, + { + name: "MoreThanOne", + in: b1, + out: 3, + }, + { + name: "NonRecursive", + in: c1, + out: 1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := countDirect(tc.in); out != tc.out { + t.Errorf("countDirect(%+v) = %d, want %d", tc.in, out, tc.out) + } + }) + } +} + +func TestFindAtom(t *testing.T) { + a1 := makePNode() + a2 := makeEmNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + b1 := makePNode() + b2 := makeMarqueeNode() + b3 := makeMarqueeNode() + b4 := makeBlinkNode() + // The nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + c1 := makePNode() + c2 := makeEmNode() + c3 := makeStrongNode() + c4 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + c3.AppendChild(c4) + + d1 := makeBlinkNode() + + e1 := makeEmNode() + e2 := makeStrongNode() + e3 := makeTextNode("foobar") + e1.AppendChild(e2) + e2.AppendChild(e3) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out *html.Node + }{ + { + name: "OneMatch", + inNode: a1, + inAtom: atom.Em, + out: a2, + }, + { + name: "MultipleMatches", + inNode: b1, + inAtom: atom.Marquee, + out: b2, + }, + { + name: "Recursive", + inNode: c1, + inAtom: atom.Strong, + out: c3, + }, + { + name: "Self", + inNode: d1, + inAtom: atom.Blink, + out: d1, + }, + { + name: "NoMatches", + inNode: e1, + inAtom: atom.Div, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := findAtom(tc.inNode, tc.inAtom); out != tc.out { + t.Errorf("findAtom(%+v, %+v) = %+v, want %v", tc.inNode, tc.inAtom, out, tc.out) + } + }) + } +} + +func TestFindChildAtoms(t *testing.T) { + a1 := makePNode() + a2 := makeEmNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + b1 := makePNode() + b2 := makeCodeNode() + b3 := makeEmNode() + b4 := makeStrongNode() + b5 := makeTextNode("foobar") + b1.AppendChild(b2) + b2.AppendChild(b3) + b3.AppendChild(b4) + b4.AppendChild(b5) + + c1 := makePNode() + c2 := makeCodeNode() + c3 := makeTextNode("foobar1") + c4 := makeEmNode() + c5 := makeTextNode("foobar2") + c6 := makeStrongNode() + c7 := makeCodeNode() + c8 := makeTextNode("foobar3") + //

foobar1foobar2foobar3

+ c1.AppendChild(c2) + c2.AppendChild(c3) + c1.AppendChild(c4) + c4.AppendChild(c5) + c1.AppendChild(c6) + c6.AppendChild(c7) + c7.AppendChild(c8) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out []*html.Node + }{ + { + name: "One", + inNode: a1, + inAtom: atom.Em, + out: []*html.Node{a2}, + }, + { + name: "DistantDescendant", + inNode: b1, + inAtom: atom.Strong, + out: []*html.Node{b4}, + }, + { + name: "Multi", + inNode: c1, + inAtom: atom.Code, + out: []*html.Node{c2, c7}, + }, + { + name: "None", + inNode: a1, + inAtom: atom.Marquee, + }, + { + name: "Self", + inNode: a1, + inAtom: atom.P, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, findChildAtoms(tc.inNode, tc.inAtom)); diff != "" { + t.Errorf("findChildAtoms(%+v, %+v) got diff (-want +got):\n%s", tc.inNode, tc.inAtom, diff) + } + }) + } +} + +func TestFindParent(t *testing.T) { + a1 := makePNode() + a2 := makeStrongNode() + a3 := makeEmNode() + a4 := makeCodeNode() + a5 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + a3.AppendChild(a4) + a4.AppendChild(a5) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out *html.Node + }{ + { + name: "Parent", + inNode: a4, + inAtom: atom.Em, + out: a3, + }, + { + name: "DistantAncestor", + inNode: a4, + inAtom: atom.P, + out: a1, + }, + { + name: "Self", + inNode: a4, + inAtom: atom.Code, + out: a4, + }, + { + name: "NotFound", + inNode: a4, + inAtom: atom.Blink, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, findParent(tc.inNode, tc.inAtom)); diff != "" { + t.Errorf("findParent(%+v, %+v) got diff (-want +got):\n%s", tc.inNode, tc.inAtom, diff) + } + }) + } +} + +func TestFindBlockParent(t *testing.T) { + // Choice of

from blockParents is arbitrary. + a1 := makePNode() + a2 := makeBNode() + a3 := makeINode() + a4 := makeCodeNode() + a5 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + a3.AppendChild(a4) + a4.AppendChild(a5) + + tests := []struct { + name string + in *html.Node + out *html.Node + }{ + { + name: "Parent", + in: a2, + out: a1, + }, + { + name: "DistantAncestor", + in: a5, + out: a1, + }, + { + name: "Self", + in: a1, + }, + { + name: "None", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, findBlockParent(tc.in)); diff != "" { + t.Errorf("findBlockParent(%+v) got diff (-want +got):\n%s", tc.in, diff) + } + }) + } +} + +func TestNodeAttr(t *testing.T) { + a1 := makeBlinkNode() + a1.Attr = append(a1.Attr, html.Attribute{Key: "keyone", Val: "valone"}) + a1.Attr = append(a1.Attr, html.Attribute{Key: "keytwo", Val: "valtwo"}) + a1.Attr = append(a1.Attr, html.Attribute{Key: "keythree", Val: "valthree"}) + + tests := []struct { + name string + inNode *html.Node + inKey string + out string + }{ + { + name: "Simple", + inNode: a1, + inKey: "keyone", + out: "valone", + }, + { + name: "MixedCase", + inNode: a1, + inKey: "KEytWO", + out: "valtwo", + }, + { + name: "NotFound", + inNode: a1, + inKey: "nokey", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, nodeAttr(tc.inNode, tc.inKey)); diff != "" { + t.Errorf("nodeAttr(%+v, %s) got diff (-want +got):\n%s", tc.inNode, tc.inKey, diff) + } + }) + } +} + +func TestStringifyNode(t *testing.T) { + a1 := makePNode() + a2 := makeTextNode("1") + a3 := makeBNode() + a4 := makeTextNode("2 ") + a5 := makeTextNode("3") + a6 := makeINode() + a7 := makeTextNode(" 4\n") + a1.AppendChild(a2) + a1.AppendChild(a3) + a1.AppendChild(a5) + a1.AppendChild(a6) + a3.AppendChild(a4) + a6.AppendChild(a7) + + b1 := makePNode() + b2 := makeTextNode("foo") + b3 := makeBrNode() + b4 := makeTextNode("bar") + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + c1 := makePNode() + c2 := makeTextNode("foo") + c3 := makeSpanNode() + c4 := makeTextNode("bar") + c1.AppendChild(c2) + c1.AppendChild(c3) + c1.AppendChild(c4) + + d1 := makePNode() + d2 := makeANode() + d2.Attr = append(d2.Attr, html.Attribute{Key: "href", Val: "google.com"}) + d3 := makeTextNode("foobar") + d1.AppendChild(d2) + d2.AppendChild(d3) + + e1 := makePNode() + e2 := makeANode() + e2.Attr = append(e2.Attr, html.Attribute{Key: "href", Val: "#cmnt"}) + e3 := makeTextNode("foobar") + e1.AppendChild(e2) + e2.AppendChild(e3) + + tests := []struct { + name string + inRoot *html.Node + inTrim bool + inLineBreak bool + out string + }{ + { + name: "TextRoot", + inRoot: makeTextNode(" foo bar"), + out: " foo bar", + }, + { + name: "TextRootTrim", + inRoot: makeTextNode(" foo bar"), + inTrim: true, + out: "foo bar", + }, + { + name: "StyledText", + inRoot: a1, + out: "12 3 4\n", + }, + { + name: "StyledTextTrim", + inRoot: a1, + inTrim: true, + out: "12 3 4", + }, + { + name: "BrNonRoot", + inRoot: b1, + out: "foobar", + }, + { + name: "BrNonRootLineBreak", + inRoot: b1, + inLineBreak: true, + out: "foo\nbar", + }, + { + name: "SpanNonRoot", + inRoot: c1, + out: "foobar", + }, + { + name: "SpanNonRootLineBreak", + inRoot: c1, + inLineBreak: true, + out: "foobar", + }, + { + name: "AComment", + inRoot: d1, + out: "foobar", + }, + { + name: "ANonComment", + inRoot: e1, + }, + { + name: "BrRoot", + inRoot: makeBrNode(), + }, + { + name: "BrRootTrim", + inRoot: makeBrNode(), + inTrim: true, + }, + { + name: "BrRootLineBreak", + inRoot: makeBrNode(), + inLineBreak: true, + out: "\n", + }, + { + name: "BrRootTrimLineBreak", + inRoot: makeBrNode(), + inTrim: true, + inLineBreak: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, stringifyNode(tc.inRoot, tc.inTrim, tc.inLineBreak)); diff != "" { + t.Errorf("stringifyNode(%+v, %t, %t) got diff (-want +got):\n%s", tc.inRoot, tc.inTrim, tc.inLineBreak, diff) + } + }) + } +} diff --git a/claat/parser/gdoc/parse.go b/claat/parser/gdoc/parse.go index 08993711d..53843a5b7 100644 --- a/claat/parser/gdoc/parse.go +++ b/claat/parser/gdoc/parse.go @@ -16,6 +16,7 @@ package gdoc import ( "bytes" + "encoding/base64" "fmt" "io" "net/url" @@ -28,9 +29,11 @@ import ( "golang.org/x/net/html" "golang.org/x/net/html/atom" + "github.com/googlecodelabs/tools/claat/nodes" "github.com/googlecodelabs/tools/claat/parser" "github.com/googlecodelabs/tools/claat/types" "github.com/googlecodelabs/tools/claat/util" + "github.com/stoewer/go-strcase" ) func init() { @@ -52,7 +55,7 @@ func (p *Parser) Parse(r io.Reader, opts parser.Options) (*types.Codelab, error) } // ParseFragment parses a codelab fragment exported in HTML from Google Docs. -func (p *Parser) ParseFragment(r io.Reader, opts parser.Options) ([]types.Node, error) { +func (p *Parser) ParseFragment(r io.Reader, opts parser.Options) ([]nodes.Node, error) { // TODO: use html.Tokenizer instead doc, err := html.Parse(r) if err != nil { @@ -76,6 +79,9 @@ const ( // google docs comments are links with commentPrefix. commentPrefix = "#cmnt" + + // the google.com redirector service + redirectorPrefix = "https://www.google.com/url?q=" ) var ( @@ -115,7 +121,7 @@ type docState struct { survey int // last used survey ID css cssStyle // styles of the doc step *types.Step // current codelab step - lastNode types.Node // last appended node + lastNode nodes.Node // last appended node env []string // current enviornment cur *html.Node // current HTML node flags stateFlag // current flags @@ -156,7 +162,7 @@ func (ds *docState) pop() { ds.flags = item.flags } -func (ds *docState) appendNodes(nn ...types.Node) { +func (ds *docState) appendNodes(nn ...nodes.Node) { if ds.step == nil || len(nn) == 0 { return } @@ -169,7 +175,7 @@ func (ds *docState) appendNodes(nn ...types.Node) { ds.lastNode = nn[len(nn)-1] } -func parseFragment(doc *html.Node) ([]types.Node, error) { +func parseFragment(doc *html.Node) ([]nodes.Node, error) { body := findAtom(doc, atom.Body) if body == nil { return nil, fmt.Errorf("document without a body") @@ -254,24 +260,24 @@ func finalizeStep(s *types.Step) { // TODO: find a better place for the code below // find [[directive]] instructions and act accordingly for i, n := range s.Content.Nodes { - if n.Type() != types.NodeList { + if n.Type() != nodes.NodeList { continue } - l := n.(*types.ListNode) + l := n.(*nodes.ListNode) // [[ directive ... ]] if len(l.Nodes) < 4 { continue } // first element is opening [[ - if t, ok := l.Nodes[0].(*types.TextNode); !ok || t.Value != metaTagOpen { + if t, ok := l.Nodes[0].(*nodes.TextNode); !ok || t.Value != metaTagOpen { continue } // last element is closing ]] - if t, ok := l.Nodes[len(l.Nodes)-1].(*types.TextNode); !ok || t.Value != metaTagClose { + if t, ok := l.Nodes[len(l.Nodes)-1].(*nodes.TextNode); !ok || t.Value != metaTagClose { continue } // second element is a text in bold - t, ok := l.Nodes[1].(*types.TextNode) + t, ok := l.Nodes[1].(*nodes.TextNode) if !ok || !t.Bold || t.Italic || t.Code { continue } @@ -285,13 +291,13 @@ func finalizeStep(s *types.Step) { } } -func transformNodes(name string, nodes []types.Node) types.Node { - if name == metaTagImport && len(nodes) == 1 { - u, ok := nodes[0].(*types.URLNode) +func transformNodes(name string, nodesToTransform []nodes.Node) nodes.Node { + if name == metaTagImport && len(nodesToTransform) == 1 { + u, ok := nodesToTransform[0].(*nodes.URLNode) if !ok { return nil } - return types.NewImportNode(u.URL) + return nodes.NewImportNode(u.URL) } return nil } @@ -314,8 +320,8 @@ func parseTop(ds *docState) { // parseSubtree parses children of root recursively. // It may modify ds.cur, so the caller is responsible for wrapping // this function in ds.push and ds.pop. -func parseSubtree(ds *docState) []types.Node { - var nodes []types.Node +func parseSubtree(ds *docState) []nodes.Node { + var nodes []nodes.Node for ds.cur = ds.cur.FirstChild; ds.cur != nil; ds.cur = ds.cur.NextSibling { if n, ok := parseNode(ds); ok { if n != nil { @@ -333,10 +339,10 @@ func parseSubtree(ds *docState) []types.Node { // parseNode parses html node hn if it is a recognized node construction. // It returns a bool indicating that hn has been accepted and parsed. // Some nodes result in metadata parsing, in which case the returned bool is still true, -// but resuling types.Node is nil. +// but resuling nodes.Node is nil. // // The flag argument modifies default behavour of the func. -func parseNode(ds *docState) (types.Node, bool) { +func parseNode(ds *docState) (nodes.Node, bool) { switch { case isMeta(ds.css, ds.cur): metaStep(ds) @@ -386,29 +392,26 @@ func metaTable(ds *docState) { continue } s := stringifyNode(tr.FirstChild.NextSibling, true, false) - fieldName := strings.ToLower(stringifyNode(tr.FirstChild, true, false)) + fieldName := strcase.SnakeCase(stringifyNode(tr.FirstChild, true, false)) switch fieldName { case "id", "url": ds.clab.ID = s case "author", "authors": ds.clab.Authors = s - case "badge path": - ds.clab.BadgePath = s case "summary": ds.clab.Summary = stringifyNode(tr.FirstChild.NextSibling, true, true) case "category", "categories": - ds.clab.Categories = util.Unique(stringSlice(s)) + ds.clab.Categories = util.NormalizedSplit(s) + toLowerSlice(ds.clab.Categories) case "environment", "environments", "tags": - ds.clab.Tags = stringSlice(s) - toLowerSlice(ds.clab.Tags) + ds.clab.Tags = util.NormalizedSplit(s) case "status", "state": - v := stringSlice(s) - toLowerSlice(v) + v := util.NormalizedSplit(s) sv := types.LegacyStatus(v) ds.clab.Status = &sv - case "feedback", "feedback link": + case "feedback", "feedback_link": ds.clab.Feedback = s - case "analytics", "analytics account", "google analytics": + case "analytics", "analytics_account", "google_analytics": ds.clab.GA = s default: // If not explicitly parsed, it might be a pass_metadata value. @@ -454,11 +457,11 @@ func metaStep(ds *docState) { ds.step.Duration = roundDuration(d) ds.totdur += ds.step.Duration case metaEnvironment: - ds.env = util.Unique(stringSlice(value)) + ds.env = util.NormalizedSplit(value) toLowerSlice(ds.env) ds.step.Tags = append(ds.step.Tags, ds.env...) ds.clab.Tags = append(ds.clab.Tags, ds.env...) - if ds.lastNode != nil && types.IsHeader(ds.lastNode.Type()) { + if ds.lastNode != nil && nodes.IsHeader(ds.lastNode.Type()) { ds.lastNode.MutateEnv(ds.env) } } @@ -470,29 +473,29 @@ func metaStep(ds *docState) { // // Given that headers do not belong to any block, the returned node's B // field is always nil. -func header(ds *docState) types.Node { +func header(ds *docState) nodes.Node { ds.push(nil, ds.flags|fSkipBlock|fSkipHeader|fSkipList) - nodes := parseSubtree(ds) + subtree := parseSubtree(ds) ds.pop() - if len(nodes) == 0 { + if len(subtree) == 0 { return nil } - n := types.NewHeaderNode(headerLevel[ds.cur.DataAtom], nodes...) + n := nodes.NewHeaderNode(headerLevel[ds.cur.DataAtom], subtree...) if n.Empty() { return nil } switch strings.ToLower(stringifyNode(ds.cur, true, false)) { case headerLearn, headerCover: - n.MutateType(types.NodeHeaderCheck) + n.MutateType(nodes.NodeHeaderCheck) case headerFAQ: - n.MutateType(types.NodeHeaderFAQ) + n.MutateType(nodes.NodeHeaderFAQ) } ds.env = nil return n } // infobox doesn't have a block parent. -func infobox(ds *docState) types.Node { +func infobox(ds *docState) nodes.Node { ds.push(nil, ds.flags|fSkipCode|fSkipInfobox|fSkipSurvey) nn := parseSubtree(ds) nn = parser.BlockNodes(nn) @@ -501,17 +504,17 @@ func infobox(ds *docState) types.Node { if len(nn) == 0 { return nil } - kind := types.InfoboxPositive + kind := nodes.InfoboxPositive if isInfoboxNegative(ds.css, ds.cur) { - kind = types.InfoboxNegative + kind = nodes.InfoboxNegative } - return types.NewInfoboxNode(kind, nn...) + return nodes.NewInfoboxNode(kind, nn...) } // table parses an arbitrary element and its children. // It may return other elements if the table is just a wrap. -func table(ds *docState) types.Node { - var rows [][]*types.GridCell +func table(ds *docState) nodes.Node { + var rows [][]*nodes.GridCell for _, tr := range findChildAtoms(ds.cur, atom.Tr) { ds.push(tr, ds.flags) r := tableRow(ds) @@ -521,11 +524,11 @@ func table(ds *docState) types.Node { if len(rows) == 0 { return nil } - return types.NewGridNode(rows...) + return nodes.NewGridNode(rows...) } -func tableRow(ds *docState) []*types.GridCell { - var row []*types.GridCell +func tableRow(ds *docState) []*nodes.GridCell { + var row []*nodes.GridCell for td := findAtom(ds.cur, atom.Td); td != nil; td = td.NextSibling { if td.DataAtom != atom.Td { continue @@ -543,10 +546,10 @@ func tableRow(ds *docState) []*types.GridCell { if err != nil { rs = 1 } - cell := &types.GridCell{ + cell := &nodes.GridCell{ Colspan: cs, Rowspan: rs, - Content: types.NewListNode(nn...), + Content: nodes.NewListNode(nn...), } row = append(row, cell) } @@ -554,7 +557,7 @@ func tableRow(ds *docState) []*types.GridCell { } // survey expects a header followed by 1 or more lists. -func survey(ds *docState) types.Node { +func survey(ds *docState) nodes.Node { // find direct parent of the survey elements hn := findAtom(ds.cur, atom.Ul) if hn == nil { @@ -562,7 +565,7 @@ func survey(ds *docState) types.Node { } hn = hn.Parent // parse survey elements - var gg []*types.SurveyGroup + var gg []*nodes.SurveyGroup for c := hn.FirstChild; c != nil; { if !isHeader(c) { c = c.NextSibling @@ -570,7 +573,7 @@ func survey(ds *docState) types.Node { } opt, next := surveyOpt(c.NextSibling) if len(opt) > 0 { - gg = append(gg, &types.SurveyGroup{ + gg = append(gg, &nodes.SurveyGroup{ Name: stringifyNode(c, true, false), Options: opt, }) @@ -582,7 +585,7 @@ func survey(ds *docState) types.Node { } ds.survey++ id := fmt.Sprintf("%s-%d", ds.clab.ID, ds.survey) - return types.NewSurveyNode(id, gg...) + return nodes.NewSurveyNode(id, gg...) } func surveyOpt(hn *html.Node) ([]string, *html.Node) { @@ -606,7 +609,7 @@ func surveyOpt(hn *html.Node) ([]string, *html.Node) { // code parses hn as inline or block codes. // Inline code node will be of type NodeText. -func code(ds *docState, term bool) types.Node { +func code(ds *docState, term bool) nodes.Node { td := findParent(ds.cur, atom.Td) // inline text if td == nil { @@ -623,20 +626,20 @@ func code(ds *docState, term bool) types.Node { v = "\n" + v } var lang string - n := types.NewCodeNode(v, term, lang) + n := nodes.NewCodeNode(v, term, lang) n.MutateBlock(td) return n } // list parses - + @@ -252,9 +236,9 @@ func TestMetaTablePassMetadata(t *testing.T) { ` p := &Parser{} - opts := *parser.NewOptions(parser.Blackfriday) + opts := *parser.NewOptions() opts.PassMetadata = map[string]bool{ - "extrafieldone": true, + "extra_field_one": true, } clab, err := p.Parse(markupReader(markup), opts) @@ -264,7 +248,7 @@ func TestMetaTablePassMetadata(t *testing.T) { meta := types.Meta{ Summary: "Test summary", Authors: "John Smith ", - Categories: []string{"Foo", "Bar"}, + Categories: []string{"foo", "bar"}, Theme: "foo", Status: clab.Meta.Status, // verified separately Feedback: "https://example.com/issues", @@ -273,7 +257,7 @@ func TestMetaTablePassMetadata(t *testing.T) { // TODO: move sorting to Parse of the parser package Tags: []string{"kiosk", "web"}, Extra: map[string]string{ - "extrafieldone": "11111", + "extra_field_one": "11111", }, } if !reflect.DeepEqual(clab.Meta, meta) { @@ -312,6 +296,9 @@ func TestParseDoc(t *testing.T) {

[[import shared]]

alt text +

JPEG

+

GIF

+

PNG

icon.

https://www.youtube.com/watch?v=vid

@@ -379,7 +366,7 @@ func TestParseDoc(t *testing.T) { ` p := &Parser{} - c, err := p.Parse(markupReader(markup), *parser.NewOptions(parser.Blackfriday)) + c, err := p.Parse(markupReader(markup), *parser.NewOptions()) if err != nil { t.Fatal(err) } @@ -400,9 +387,9 @@ func TestParseDoc(t *testing.T) { t.Fatal("step.Content.Nodes is empty") } want := "https://example.com/import" - in, ok := step.Content.Nodes[0].(*types.ImportNode) + in, ok := step.Content.Nodes[0].(*nodes.ImportNode) if !ok { - t.Errorf("step.Content.Nodes[0] = %+v; want types.ImportNode", step.Content.Nodes[0]) + t.Errorf("step.Content.Nodes[0] = %+v; want nodes.ImportNode", step.Content.Nodes[0]) } if ok && in.URL != want { t.Errorf("in.URL = %q; want %q", in.URL, want) @@ -411,113 +398,146 @@ func TestParseDoc(t *testing.T) { t.Errorf("in.Block = %+v (%T); want nil", in.Block(), in.Block()) } - content := types.NewListNode() + content := nodes.NewListNode() + + img := nodes.NewImageNode(nodes.NewImageNodeOptions{ + Src: "https://host/image.png", + Alt: "alt text", + Title: "title text", + }) + para := nodes.NewListNode(img) + para.MutateBlock(true) + content.Append(para) + + bytes, _ := base64.StdEncoding.DecodeString("/9j/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z") + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Bytes: bytes, + Alt: "JPEG", + }) + para = nodes.NewListNode(img) + para.MutateBlock(true) + content.Append(para) + + bytes, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Bytes: bytes, + Alt: "GIF", + }) + para = nodes.NewListNode(img) + para.MutateBlock(true) + content.Append(para) - img := types.NewImageNode("https://host/image.png") - img.Alt = "alt text" - img.Title = "title text" - para := types.NewListNode(img) + bytes, _ = base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=") + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Bytes: bytes, + Alt: "PNG", + }) + para = nodes.NewListNode(img) para.MutateBlock(true) content.Append(para) - img = types.NewImageNode("https://host/small.png") - img.Width = 25.5 - para = types.NewListNode(img, types.NewTextNode(" icon.")) + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Src: "https://host/small.png", + Width: 25.5, + }) + para = nodes.NewListNode(img, nodes.NewTextNode(nodes.NewTextNodeOptions{Value: " icon."})) para.MutateBlock(true) content.Append(para) - yt := types.NewYouTubeNode("vid") + yt := nodes.NewYouTubeNode("vid") yt.MutateBlock(true) content.Append(yt) - iframe := types.NewIframeNode("https://repl.it/?foo=bar") + iframe := nodes.NewIframeNode("https://repl.it/?foo=bar") iframe.MutateBlock(true) content.Append(iframe) - img = types.NewImageNode("https://host/image.png") - img.Alt = "The domain of the requested iframe (example.com) has not been whitelisted." - para = types.NewListNode(img) + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Src: "https://host/image.png", + Alt: "The domain of the requested iframe (example.com) has not been whitelisted.", + }) + para = nodes.NewListNode(img) para.MutateBlock(true) content.Append(para) - h := types.NewHeaderNode(3, types.NewTextNode("What you'll learn")) - h.MutateType(types.NodeHeaderCheck) + h := nodes.NewHeaderNode(3, nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "What you'll learn"})) + h.MutateType(nodes.NodeHeaderCheck) content.Append(h) - list := types.NewItemsListNode("", 0) - list.MutateType(types.NodeItemsCheck) - list.NewItem().Append(types.NewTextNode("First One")) + list := nodes.NewItemsListNode("", 0) + list.MutateType(nodes.NodeItemsCheck) + list.NewItem().Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "First One"})) item := list.NewItem() - item.Append(types.NewTextNode("Two ")) - item.Append(types.NewURLNode("http://example.com", types.NewTextNode("Link"))) - list.NewItem().Append(types.NewTextNode("Three")) + item.Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Two "})) + item.Append(nodes.NewURLNode("http://example.com", nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Link"}))) + list.NewItem().Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Three"})) content.Append(list) - para = types.NewListNode() + para = nodes.NewListNode() para.MutateBlock(true) - para.Append(types.NewTextNode("This is ")) - txt := types.NewTextNode("code") + para.Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "This is "})) + txt := nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "code"}) txt.Code = true para.Append(txt) - para.Append(types.NewTextNode(".")) + para.Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "."})) content.Append(para) - para = types.NewListNode() + para = nodes.NewListNode() para.MutateBlock(true) - para.Append(types.NewTextNode("Just a paragraph.")) + para.Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Just a paragraph."})) content.Append(para) - u := types.NewURLNode("url", types.NewTextNode("one url")) - para = types.NewListNode(u) + u := nodes.NewURLNode("url", nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "one url"})) + para = nodes.NewListNode(u) para.MutateBlock(true) content.Append(para) - btn := types.NewButtonNode(true, true, true, types.NewTextNode("Download Zip")) - dl := types.NewURLNode("http://example.com", btn) - para = types.NewListNode(dl) + btn := nodes.NewButtonNode(true, true, true, nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Download Zip"})) + dl := nodes.NewURLNode("http://example.com", btn) + para = nodes.NewListNode(dl) para.MutateBlock(true) content.Append(para) - b := types.NewTextNode("Bo ld") + b := nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Bo ld"}) b.Bold = true - i := types.NewTextNode(" italic") + i := nodes.NewTextNode(nodes.NewTextNodeOptions{Value: " italic"}) i.Italic = true - bi := types.NewTextNode("or both.") + bi := nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "or both."}) bi.Bold = true bi.Italic = true - para = types.NewListNode(b, i, types.NewTextNode(" text "), bi) + para = nodes.NewListNode(b, i, nodes.NewTextNode(nodes.NewTextNodeOptions{Value: " text "}), bi) para.MutateBlock(true) content.Append(para) - h = types.NewHeaderNode(3, types.NewURLNode( - "http://host/file.java", types.NewTextNode("a file"))) + h = nodes.NewHeaderNode(3, nodes.NewURLNode( + "http://host/file.java", nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "a file"}))) content.Append(h) var lang string code := "start func() {\n}\n\nfunc2() {\n} // comment" - cn := types.NewCodeNode(code, false, lang) + cn := nodes.NewCodeNode(code, false, lang) cn.MutateBlock(1) content.Append(cn) term := "adb shell am start -a VIEW \\\n-d \"http://host\" app" - tn := types.NewCodeNode(term, true, lang) + tn := nodes.NewCodeNode(term, true, lang) tn.MutateBlock(2) content.Append(tn) - b = types.NewTextNode("warning") + b = nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "warning"}) b.Bold = true - n1 := types.NewListNode(b) + n1 := nodes.NewListNode(b) n1.MutateBlock(true) - n2 := types.NewListNode(types.NewTextNode("negative box.")) + n2 := nodes.NewListNode(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "negative box."})) n2.MutateBlock(true) - box := types.NewInfoboxNode(types.InfoboxNegative, n1, n2) + box := nodes.NewInfoboxNode(nodes.InfoboxNegative, n1, n2) content.Append(box) - sv := types.NewSurveyNode("test-codelab-1") - sv.Groups = append(sv.Groups, &types.SurveyGroup{ + sv := nodes.NewSurveyNode("test-codelab-1") + sv.Groups = append(sv.Groups, &nodes.SurveyGroup{ Name: "How will you use it?", Options: []string{"Read it", "Read and complete"}, }) - sv.Groups = append(sv.Groups, &types.SurveyGroup{ + sv.Groups = append(sv.Groups, &nodes.SurveyGroup{ Name: "How would you rate?", Options: []string{"Novice", "Intermediate", "Proficient"}, }) @@ -547,7 +567,8 @@ func TestParseFragment(t *testing.T) {

Test Codelab

this should not be ignored

- +

+ Test redirector.

[a]Test comment.

@@ -556,31 +577,39 @@ func TestParseFragment(t *testing.T) { ` p := &Parser{} - opts := *parser.NewOptions(parser.Blackfriday) - nodes, err := p.ParseFragment(markupReader(markup), opts) + opts := *parser.NewOptions() + fragmentNodes, err := p.ParseFragment(markupReader(markup), opts) if err != nil { t.Fatal(err) } - var want []types.Node + var want []nodes.Node - para := types.NewListNode() + para := nodes.NewListNode() para.MutateBlock(true) - para.Append(types.NewTextNode("Test Codelab")) + para.Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "Test Codelab"})) want = append(want, para) - para = types.NewListNode() + para = nodes.NewListNode() para.MutateBlock(true) - para.Append(types.NewTextNode("this should not be ignored")) + para.Append(nodes.NewTextNode(nodes.NewTextNodeOptions{Value: "this should not be ignored"})) want = append(want, para) - img := types.NewImageNode("https://host/image.png") - para = types.NewListNode(img) + img := nodes.NewImageNode(nodes.NewImageNodeOptions{Src: "https://host/image.png"}) + para = nodes.NewListNode(img) + para.MutateBlock(true) + want = append(want, para) + + tn := nodes.NewTextNode(nodes.NewTextNodeOptions{ + Value: "Test redirector.", + }) + rlink := nodes.NewURLNode("https://www.example.com/+/test;l=68&sa=D", tn) + para = nodes.NewListNode(rlink) para.MutateBlock(true) want = append(want, para) var ctx render.Context - html1, _ := render.HTML(ctx, nodes...) + html1, _ := render.HTML(ctx, fragmentNodes...) html2, _ := render.HTML(ctx, want...) if html1 != html2 { t.Errorf("nodes:\n\n%s\nwant:\n\n%s", html1, html2) diff --git a/claat/parser/md/html.go b/claat/parser/md/html.go index 569e83447..c121a1236 100644 --- a/claat/parser/md/html.go +++ b/claat/parser/md/html.go @@ -40,6 +40,7 @@ func isHeader(hn *html.Node) bool { return ok } +// TODO rename, it only captures some meta. Maybe redo the meta system? func isMeta(hn *html.Node) bool { elem := strings.ToLower(hn.Data) return strings.HasPrefix(elem, metaDuration+metaSep) || strings.HasPrefix(elem, metaEnvironment+metaSep) @@ -48,6 +49,15 @@ func isMeta(hn *html.Node) bool { func isBold(hn *html.Node) bool { if hn.Type == html.TextNode { hn = hn.Parent + } else if hn.DataAtom == atom.Code { + // Look up as many as 2 levels, to handle the case of e.g. + for i := 0; i < 2; i++ { + hn = hn.Parent + if hn.DataAtom == atom.Strong || hn.DataAtom == atom.B { + return true + } + } + return false } return hn.DataAtom == atom.Strong || hn.DataAtom == atom.B @@ -56,12 +66,21 @@ func isBold(hn *html.Node) bool { func isItalic(hn *html.Node) bool { if hn.Type == html.TextNode { hn = hn.Parent + } else if hn.DataAtom == atom.Code { + // Look up as many as 2 levels, to handle the case of e.g. + for i := 0; i < 2; i++ { + hn = hn.Parent + if hn.DataAtom == atom.Em || hn.DataAtom == atom.I { + return true + } + } + return false } return hn.DataAtom == atom.Em || hn.DataAtom == atom.I } -// This is different to calling isBold and isItalic seperately as we must look +// This is different to calling isBold and isItalic separately as we must look // up an extra level in the tree func isBoldAndItalic(hn *html.Node) bool { if hn.Parent == nil || hn.Parent.Parent == nil { @@ -75,17 +94,17 @@ func isBoldAndItalic(hn *html.Node) bool { } func isConsole(hn *html.Node) bool { - if hn.Type == html.TextNode { - hn = hn.Parent - } - if (hn.DataAtom == atom.Code) { - for _, a := range hn.Attr { - if (a.Key == "class" && a.Val == "language-console") { - return true; - } - } - } - return false; + if hn.Type == html.TextNode { + hn = hn.Parent + } + if hn.DataAtom == atom.Code { + for _, a := range hn.Attr { + if a.Key == "class" && a.Val == "language-console" { + return true + } + } + } + return false } func isCode(hn *html.Node) bool { @@ -104,16 +123,15 @@ func isAside(hn *html.Node) bool { } func isNewAside(hn *html.Node) bool { - if hn.FirstChild == nil || - hn.FirstChild.NextSibling == nil || - hn.FirstChild.NextSibling.FirstChild == nil { + if hn.DataAtom != atom.Blockquote || + hn.FirstChild == nil || + hn.FirstChild.NextSibling == nil || + hn.FirstChild.NextSibling.FirstChild == nil { return false } - bq := hn.DataAtom == atom.Blockquote - apn := strings.HasPrefix(strings.ToLower(hn.FirstChild.NextSibling.FirstChild.Data), "aside positive") || - strings.HasPrefix(strings.ToLower(hn.FirstChild.NextSibling.FirstChild.Data), "aside negative") - return bq && apn + asideText := strings.ToLower(hn.FirstChild.NextSibling.FirstChild.Data) + return strings.HasPrefix(asideText, "aside positive") || strings.HasPrefix(asideText, "aside negative") } func isInfobox(hn *html.Node) bool { @@ -143,10 +161,12 @@ func isSurvey(hn *html.Node) bool { return true } +// TODO Write an explanation for why the countTwo checks are necessary. func isTable(hn *html.Node) bool { if hn.DataAtom != atom.Table { return false } + // TODO if =1 is fine, can we sub findAtom? return countTwo(hn, atom.Tr) >= 1 || countTwo(hn, atom.Td) >= 1 } @@ -205,6 +225,7 @@ func findAtom(root *html.Node, a atom.Atom) *html.Node { return nil } +// TODO reuse code with findAtom? func findChildAtoms(root *html.Node, a atom.Atom) []*html.Node { var nodes []*html.Node for hn := root.FirstChild; hn != nil; hn = hn.NextSibling { @@ -216,15 +237,23 @@ func findChildAtoms(root *html.Node, a atom.Atom) []*html.Node { return nodes } -// findParent is like findAtom but search is in the opposite direction. -// It is faster to look for parent than child lookup in findAtom. -func findParent(root *html.Node, a atom.Atom) *html.Node { - if root.DataAtom == a { - return root +type considerSelf int + +const ( + doNotConsiderSelf considerSelf = iota + doConsiderSelf +) + +// findNearestAncestor finds the nearest ancestor of the given node of any of the given atoms. +// A pointer to the ancestor is returned, or nil if none are found. +// If doConsiderSelf is passed, the given node itself counts as an ancestor for our purposes. +func findNearestAncestor(n *html.Node, atoms map[atom.Atom]struct{}, cs considerSelf) *html.Node { + if _, ok := atoms[n.DataAtom]; cs == doConsiderSelf && ok { + return n } - for c := root.Parent; c != nil; c = c.Parent { - if c.DataAtom == a { - return c + for p := n.Parent; p != nil; p = p.Parent { + if _, ok := atoms[p.DataAtom]; ok { + return p } } return nil @@ -242,30 +271,32 @@ var blockParents = map[atom.Atom]struct{}{ atom.Div: {}, } -// findBlockParent looks up nearest block parent node of hn. +// findNearestBlockAncestor finds the nearest ancestor node of a block atom. // For instance, block parent of "text" in
  • text
is
  • , // while block parent of "text" in

    text

    is

    . -func findBlockParent(hn *html.Node) *html.Node { - for p := hn.Parent; p != nil; p = p.Parent { - if _, ok := blockParents[p.DataAtom]; ok { - return p - } - } - return nil +// The node passed in itself is never considered. +// A pointer to the ancestor is returned, or nil if none are found. +func findNearestBlockAncestor(n *html.Node) *html.Node { + return findNearestAncestor(n, blockParents, doNotConsiderSelf) } -// nodeAttr returns node attribute value of the key name. -// Attribute keys are case insensitive. -func nodeAttr(n *html.Node, name string) string { - name = strings.ToLower(name) - for _, a := range n.Attr { - if strings.ToLower(a.Key) == name { - return a.Val +// nodeAttr checks the given node's HTML attributes for the given key. +// The corresponding value is returned, or the empty string if the key is not found. +// Keys are case insensitive. +func nodeAttr(n *html.Node, key string) string { + key = strings.ToLower(key) + for _, attr := range n.Attr { + if strings.ToLower(attr.Key) == key { + return attr.Val } } return "" } +// TODO divide into smaller functions +// TODO redo comment, more than just text nodes are handled and atom.A +// TODO should we really have trim? +// TODO part of why this is weird is because the processing is split across root and child nodes. could restructure // stringifyNode extracts and concatenates all text nodes starting with root. // Line breaks are inserted at
    and any non- elements. func stringifyNode(root *html.Node, trim bool) string { diff --git a/claat/parser/md/html_test.go b/claat/parser/md/html_test.go new file mode 100644 index 000000000..e08ff08fd --- /dev/null +++ b/claat/parser/md/html_test.go @@ -0,0 +1,2209 @@ +package md + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +// The utility functions for these tests are purposefully kept very simple to make it easy to understand what the tests are doing. + +func makeStrongNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Strong, + Data: "strong", + } +} + +func makeBNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.B, + Data: "b", + } +} + +func makeEmNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Em, + Data: "em", + } +} + +// , not the filesystem abstraction. +func makeINode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.I, + Data: "i", + } +} + +// data is the text in the node. +func makeTextNode(data string) *html.Node { + return &html.Node{ + Type: html.TextNode, + Data: data, + } +} + +func makeCodeNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Code, + Data: "code", + } +} + +func makePNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.P, + Data: "p", + } +} + +func makeButtonNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Button, + Data: "button", + } +} + +func makeBlinkNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Blink, + Data: "blink", + } +} + +func makeAsideNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Aside, + Data: "aside", + } +} + +func makeDtNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Dt, + Data: "dt", + } +} + +func makeFormNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Form, + Data: "form", + } +} + +func makeNameNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Name, + Data: "name", + } +} + +func makeInputNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Input, + Data: "input", + } +} + +func makeOlNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Ol, + Data: "ol", + } +} + +func makeUlNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Ul, + Data: "ul", + } +} + +func makeLiNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Li, + Data: "li", + } +} + +func makeVideoNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Video, + Data: "video", + Attr: []html.Attribute{ + html.Attribute{ + Key: "id", + Val: "Mlk888FiI8A", + }, + }, + } +} + +func makeMarqueeNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Marquee, + Data: "marquee", + } +} + +func makeTableNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Table, + Data: "table", + } +} + +func makeTrNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Tr, + Data: "tr", + } +} + +func makeTdNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Td, + Data: "td", + } +} + +func makeBlockquoteNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Blockquote, + Data: "blockquote", + } +} + +func makeBrNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Br, + Data: "br", + } +} + +func makeH3Node() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H3, + Data: "h3", + } +} + +func makeANode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.A, + Data: "a", + } +} + +func makeH1Node() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H1, + Data: "h1", + } +} + +func makeSpanNode() *html.Node { + return &html.Node{ + Type: html.ElementNode, + DataAtom: atom.Span, + Data: "span", + } +} + +func TestIsHeader(t *testing.T) { + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "LabTitle", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H1, + Data: "h1", + }, + }, + { + name: "StepTitle", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H2, + Data: "h2", + }, + }, + { + name: "FirstLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H3, + Data: "h3", + }, + out: true, + }, + { + name: "SecondLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H4, + Data: "h4", + }, + out: true, + }, + { + name: "ThirdLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H5, + Data: "h5", + }, + out: true, + }, + { + name: "FourthLevel", + in: &html.Node{ + Type: html.ElementNode, + DataAtom: atom.H6, + Data: "h6", + }, + out: true, + }, + { + name: "NotAHeader", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isHeader(tc.in); out != tc.out { + t.Errorf("isHeader(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsMeta(t *testing.T) { + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "Duration", + in: makeTextNode("duration: 60"), + out: true, + }, + { + name: "Environment", + in: makeTextNode("environment: web"), + out: true, + }, + { + name: "DurationMixedCase", + in: makeTextNode("DURAtion: 60"), + out: true, + }, + { + name: "EnvironmentMixedCase", + in: makeTextNode("ENVIROnment: web"), + out: true, + }, + { + name: "TextNonMeta", + in: makeTextNode("foobar"), + }, + { + name: "NonText", + in: makeBlinkNode(), + }, + { + name: "NoSeparator", + in: makeTextNode("duration 60"), + }, + { + name: "NoMetaKey", + in: makeTextNode(": 60"), + }, + { + name: "UnsupportedMetaKey", + in: makeTextNode("summary: foobar"), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isMeta(tc.in); out != tc.out { + t.Errorf("isMeta(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsBold(t *testing.T) { + // foobar + a1 := makeStrongNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + // foobar + b1 := makeBNode() + b2 := makeTextNode("foobar") + b1.AppendChild(b2) + + // foobar + c1 := makeStrongNode() + c2 := makeCodeNode() + c3 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + + // foobar + d1 := makeBNode() + d2 := makeCodeNode() + d3 := makeTextNode("foobar") + d1.AppendChild(d2) + d2.AppendChild(d3) + + //

    foobar

    + e1 := makePNode() + e2 := makeTextNode("foobar") + e1.AppendChild(e2) + + // foobar + f1 := makeINode() + f2 := makeTextNode("foobar") + f1.AppendChild(f2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "StrongText_Strong", + in: a1, + out: true, + }, + { + name: "StrongText_Strong", + in: a2, + out: true, + }, + { + name: "BText_B", + in: b1, + out: true, + }, + { + name: "BText_Text", + in: b2, + out: true, + }, + { + name: "StrongCodeText_Strong", + in: c1, + out: true, + }, + { + name: "StrongCodeText_Code", + in: c2, + out: true, + }, + /* + // TODO: I think this should work but it doesn't. + { + name: "StrongCodeText_Text", + in: c3, + out: true, + }, + */ + { + name: "BCodeText_B", + in: d1, + out: true, + }, + { + name: "BCodeText_Code", + in: d2, + out: true, + }, + /* + // TODO: I think this should work but it doesn't + { + name: "BCodeText_Text", + in: d3, + out: true, + }, + */ + { + name: "PText_P", + in: e1, + }, + { + name: "PText_Text", + in: e2, + }, + { + name: "IText_I", + in: f1, + }, + { + name: "IText_Text", + in: f2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isBold(tc.in); out != tc.out { + t.Errorf("isBold(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsItalic(t *testing.T) { + // foobar + a1 := makeEmNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + // foobar + b1 := makeINode() + b2 := makeTextNode("foobar") + b1.AppendChild(b2) + + // foobar + c1 := makeEmNode() + c2 := makeCodeNode() + c3 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + + // foobar + d1 := makeINode() + d2 := makeCodeNode() + d3 := makeTextNode("foobar") + d1.AppendChild(d2) + d2.AppendChild(d3) + + //

    foobar

    + e1 := makePNode() + e2 := makeTextNode("foobar") + e1.AppendChild(e2) + + // foobar + f1 := makeBNode() + f2 := makeTextNode("foobar") + f1.AppendChild(f2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "EmText_Em", + in: a1, + out: true, + }, + { + name: "EmText_Em", + in: a2, + out: true, + }, + { + name: "IText_I", + in: b1, + out: true, + }, + { + name: "IText_Text", + in: b2, + out: true, + }, + { + name: "EmCodeText_Em", + in: c1, + out: true, + }, + { + name: "EmCodeText_Code", + in: c2, + out: true, + }, + /* + // TODO: I think this should work but it doesn't. + { + name: "EmCodeText_Text", + in: c3, + out: true, + }, + */ + { + name: "ICodeText_I", + in: d1, + out: true, + }, + { + name: "ICodeText_Code", + in: d2, + out: true, + }, + /* + // TODO: I think this should work but it doesn't + { + name: "ICodeText_Text", + in: d3, + out: true, + }, + */ + { + name: "PText_P", + in: e1, + }, + { + name: "PText_Text", + in: e2, + }, + { + name: "BText_B", + in: f1, + }, + { + name: "BText_Text", + in: f2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isItalic(tc.in); out != tc.out { + t.Errorf("isItalic(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsBoldAndItalic(t *testing.T) { + // foobar + a1 := makeEmNode() + a2 := makeStrongNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + // foobar + b1 := makeINode() + b2 := makeStrongNode() + b3 := makeTextNode("foobar") + b1.AppendChild(b2) + b2.AppendChild(b3) + + // foobar + c1 := makeEmNode() + c2 := makeBNode() + c3 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + + // foobar + d1 := makeINode() + d2 := makeBNode() + d3 := makeTextNode("foobar") + d1.AppendChild(d2) + d2.AppendChild(d3) + + // foobar + e1 := makeEmNode() + e2 := makeStrongNode() + e3 := makeCodeNode() + e4 := makeTextNode("foobar") + e1.AppendChild(e2) + e2.AppendChild(e3) + e3.AppendChild(e4) + + // foobar + f1 := makeEmNode() + f2 := makeCodeNode() + f3 := makeStrongNode() + f4 := makeTextNode("foobar") + f1.AppendChild(f2) + f2.AppendChild(f3) + f3.AppendChild(f4) + + // foobar + g1 := makeStrongNode() + g2 := makeEmNode() + g3 := makeTextNode("foobar") + g1.AppendChild(g2) + g2.AppendChild(g3) + + // foobar + h1 := makeStrongNode() + h2 := makeINode() + h3 := makeTextNode("foobar") + h1.AppendChild(h2) + h2.AppendChild(h3) + + // foobar + // Skipped i and j due to widespread use of + k1 := makeBNode() + k2 := makeEmNode() + k3 := makeTextNode("foobar") + k1.AppendChild(k2) + k2.AppendChild(k3) + + // foobar + l1 := makeBNode() + l2 := makeINode() + l3 := makeTextNode("foobar") + l1.AppendChild(l2) + l2.AppendChild(l3) + + // foobar + m1 := makeStrongNode() + m2 := makeEmNode() + m3 := makeCodeNode() + m4 := makeTextNode("foobar") + m1.AppendChild(m2) + m2.AppendChild(m3) + m3.AppendChild(m4) + + // foobar + n1 := makeStrongNode() + n2 := makeCodeNode() + n3 := makeEmNode() + n4 := makeTextNode("foobar") + n1.AppendChild(n2) + n2.AppendChild(n3) + n3.AppendChild(n4) + + //

    foobar

    + o1 := makePNode() + o2 := makeTextNode("foobar") + o1.AppendChild(o2) + + // foobar + p1 := makeEmNode() + p2 := makeTextNode("foobar") + p1.AppendChild(p2) + + // foobar + q1 := makeStrongNode() + q2 := makeTextNode("foobar") + q1.AppendChild(q2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + /* + // TODO: I think this should work but it doesn't + // Specifically, without loss of generality, passing in foobar returns true, but this behaves differently + { + name: "EmStrongText_Strong", + in: a2, + out: true, + }, + */ + { + name: "EmStrongText_Text", + in: a3, + out: true, + }, + /* + // TODO: I think this should work but it doesn't + { + name: "IStrongText_Strong", + in: b2, + out: true, + }, + */ + { + name: "IStrongText_Text", + in: b3, + out: true, + }, + /* + // TODO: I think this should work but it doesn't + { + name: "EmBText_B", + in: c2, + out: true, + }, + */ + { + name: "EmBText_Text", + in: c3, + out: true, + }, + /* + // TODO: I think this should work but it doesn't + { + name: "IBText_B", + in: d2, + out: true, + }, + */ + { + name: "IBText_Text", + in: d3, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "EmStrongCodeText_Strong", + in: e2, + out: true, + }, + */ + { + name: "EmStrongCodeText_Code", + in: e3, + out: true, + }, + { + name: "EmStrongCodeText_Text", + in: e4, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "EmCodeStrongText_Code", + in: f2, + out: true, + }, + */ + { + name: "EmCodeStrongText_Strong", + in: f3, + out: true, + }, + { + name: "EmCodeStrongText_Text", + in: f4, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "StrongEmText_Em", + in: g2, + out: true, + }, + */ + { + name: "StrongEmText_Text", + in: g3, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "StrongIText_I", + in: h2, + out: true, + }, + */ + { + name: "strongIText_Text", + in: h3, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "BEmText_Em", + in: k2, + out: true, + }, + */ + { + name: "BEmText_Text", + in: k3, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "BIText_I", + in: l2, + out: true, + }, + */ + { + name: "BIText_Text", + in: l3, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "StrongEmCodeText_Em", + in: m2, + out: true, + }, + */ + { + name: "StrongEmCodeText_Code", + in: m3, + out: true, + }, + { + name: "StrongEmCodeText_Text", + in: m4, + out: true, + }, + /* + // TODO: I (maybe) think this should work but it doesn't + { + name: "StrongCodeEmText_Code", + in: m2, + out: true, + }, + */ + { + name: "StrongCodeEmText_Em", + in: n3, + out: true, + }, + { + name: "StrongCodeEmText_Text", + in: n4, + out: true, + }, + { + name: "PText_P", + in: o1, + }, + { + name: "PText_Text", + in: o2, + }, + { + name: "EmText_Em", + in: p1, + }, + { + name: "EmText_Text", + in: p2, + }, + { + name: "StrongText_Strong", + in: q1, + }, + { + name: "StrongText_Text", + in: q2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isBoldAndItalic(tc.in); out != tc.out { + t.Errorf("isBoldAndItalic(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsConsole(t *testing.T) { + // foobar + a1 := makeCodeNode() + a1.Attr = append(a1.Attr, html.Attribute{Key: "class", Val: "language-console"}) + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + // foobar + b1 := makeCodeNode() + b1.Attr = append(b1.Attr, html.Attribute{Key: "class", Val: "language-js"}) + b2 := makeTextNode("foobar") + b1.AppendChild(b2) + + // foobar + c1 := makeCodeNode() + c2 := makeTextNode("foobar") + c1.AppendChild(c2) + + //

    foobar

    + d1 := makePNode() + d2 := makeTextNode("foobar") + d1.AppendChild(d2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "ConsoleText_Console", + in: a1, + out: true, + }, + { + name: "ConsoleText_Text", + in: a2, + out: true, + }, + { + name: "JavascriptText_Javascript", + in: b1, + }, + { + name: "JavascriptText_Text", + in: b2, + }, + { + name: "CodeText_Code", + in: c1, + }, + { + name: "CodeText_Text", + in: c2, + }, + { + name: "PText_P", + in: d1, + }, + { + name: "PText_Text", + in: d2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isConsole(tc.in); out != tc.out { + t.Errorf("isConsole(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsCode(t *testing.T) { + // foobar + a1 := makeCodeNode() + a1.Attr = append(a1.Attr, html.Attribute{Key: "class", Val: "language-console"}) + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + // foobar + b1 := makeCodeNode() + b1.Attr = append(b1.Attr, html.Attribute{Key: "class", Val: "language-js"}) + b2 := makeTextNode("foobar") + b1.AppendChild(b2) + + // foobar + c1 := makeCodeNode() + c2 := makeTextNode("foobar") + c1.AppendChild(c2) + + //

    foobar

    + d1 := makePNode() + d2 := makeTextNode("foobar") + d1.AppendChild(d2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "ConsoleText_Console", + in: a1, + }, + { + name: "ConsoleText_Text", + in: a2, + }, + { + name: "JavascriptText_Javascript", + in: b1, + out: true, + }, + { + name: "JavascriptText_Text", + in: b2, + out: true, + }, + { + name: "CodeText_Code", + in: c1, + out: true, + }, + { + name: "CodeText_Text", + in: c2, + out: true, + }, + { + name: "PText_P", + in: d1, + }, + { + name: "PText_Text", + in: d2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isCode(tc.in); out != tc.out { + t.Errorf("isCode(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsButton(t *testing.T) { + a1 := makeButtonNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "Button", + in: makeButtonNode(), + out: true, + }, + { + name: "ButtonWithText", + in: a1, + out: true, + }, + { + name: "TextInButton", + in: a2, + }, + { + name: "NotAButton", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isButton(tc.in); out != tc.out { + t.Errorf("isButton(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsAside(t *testing.T) { + a1 := makeAsideNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "Aside", + in: makeAsideNode(), + out: true, + }, + { + name: "AsideWithText", + in: a1, + out: true, + }, + { + name: "TextInAside", + in: a2, + }, + { + name: "NotAnAside", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isAside(tc.in); out != tc.out { + t.Errorf("isAside(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsNewAside(t *testing.T) { + a1 := makeBlockquoteNode() + a2 := makeTextNode("\n") + a1.AppendChild(a2) + + b1 := makeBlockquoteNode() + b2 := makeTextNode("\n") + b3 := makePNode() + b1.AppendChild(b2) + b1.AppendChild(b3) + + c1 := makeBlockquoteNode() + c2 := makeTextNode("\n") + c3 := makePNode() + c4 := makeStrongNode() + c5 := makeTextNode("aside positive") + c1.AppendChild(c2) + c1.AppendChild(c3) + c3.AppendChild(c4) + c4.AppendChild(c5) + + d1 := makeBlockquoteNode() + d2 := makeTextNode("\n") + d3 := makePNode() + d4 := makeTextNode("aside positive") + d1.AppendChild(d2) + d1.AppendChild(d3) + d3.AppendChild(d4) + + e1 := makeBlockquoteNode() + e2 := makeTextNode("\n") + e3 := makePNode() + e4 := makeTextNode("aside negative") + e1.AppendChild(e2) + e1.AppendChild(e3) + e3.AppendChild(e4) + + f1 := makeMarqueeNode() + f2 := makeTextNode("\n") + f3 := makePNode() + f4 := makeTextNode("aside positive") + f1.AppendChild(f2) + f1.AppendChild(f3) + f3.AppendChild(f4) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "NoChildNodes", + in: makeBlockquoteNode(), + }, + { + name: "OnlyOneChildNode", + in: a1, + }, + { + name: "SecondChildNodeNoGrandchildNodes", + in: b1, + }, + { + name: "SecondChildNodeGrandchildNotText", + in: c1, + }, + { + name: "AsidePositive", + in: d1, + out: true, + }, + { + name: "AsideNegative", + in: e1, + out: true, + }, + { + name: "NotBlockquote", + in: f1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isNewAside(tc.in); out != tc.out { + t.Errorf("isNewAside(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } + +} + +func TestIsInfobox(t *testing.T) { + a1 := makeDtNode() + a2 := makeTextNode("positive") + a3 := makeTextNode("foobar") + // The text nodes should be siblings. + a1.AppendChild(a2) + a1.AppendChild(a3) + + b1 := makeDtNode() + b2 := makeTextNode("negative") + b3 := makeTextNode("foobar") + // The text nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + + c1 := makeDtNode() + c2 := makeTextNode("foobar") + c1.AppendChild(c2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "InfoboxPositive", + in: a1, + out: true, + }, + { + name: "TextInInfoboxPositive", + in: a3, + }, + { + name: "InfoboxNegative", + in: b1, + out: true, + }, + { + name: "TextInInfoboxNegative", + in: b3, + }, + { + name: "NotAnInfobox", + in: makeBlinkNode(), + }, + // TODO: Is this how this function should work? + { + name: "InfoboxNoKind", + in: c1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isInfobox(tc.in); out != tc.out { + t.Errorf("isInfobox(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsInfoboxNegative(t *testing.T) { + a1 := makeDtNode() + a2 := makeTextNode("positive") + a3 := makeTextNode("foobar") + // The text nodes should be siblings. + a1.AppendChild(a2) + a1.AppendChild(a3) + + b1 := makeDtNode() + b2 := makeTextNode("negative") + b3 := makeTextNode("foobar") + // The text nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "InfoboxPositive", + in: a1, + }, + { + name: "TextInInfoboxPositive", + in: a3, + }, + { + name: "InfoboxNegative", + in: b1, + out: true, + }, + { + name: "TextInInfoboxNegative", + in: b3, + }, + { + name: "NotAnInfobox", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isInfoboxNegative(tc.in); out != tc.out { + t.Errorf("isInfoboxNegative(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsSurvey(t *testing.T) { + a1 := makeFormNode() + a2 := makeNameNode() + a3 := makeInputNode() + // The name and input nodes should be siblings. + a1.AppendChild(a2) + a1.AppendChild(a3) + + b1 := makeFormNode() + b2 := makeInputNode() + b1.AppendChild(b2) + + c1 := makeFormNode() + c2 := makeNameNode() + c1.AppendChild(c2) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "ValidSurvey", + in: a1, + out: true, + }, + { + name: "NoName", + in: b1, + }, + { + name: "NoInputs", + in: c1, + }, + { + name: "NotAForm", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isSurvey(tc.in); out != tc.out { + t.Errorf("isSurvey(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsTable(t *testing.T) { + a := makeTableNode() + a.AppendChild(makeTrNode()) + a.AppendChild(makeTrNode()) + a.AppendChild(makeTdNode()) + a.AppendChild(makeTdNode()) + + b := makeTableNode() + b.AppendChild(makeTrNode()) + b.AppendChild(makeTdNode()) + b.AppendChild(makeTdNode()) + + c := makeTableNode() + c.AppendChild(makeTrNode()) + c.AppendChild(makeTrNode()) + c.AppendChild(makeTdNode()) + + d := makeTableNode() + d.AppendChild(makeTrNode()) + d.AppendChild(makeTdNode()) + + e := makeTableNode() + e.AppendChild(makeTdNode()) + + f := makeTableNode() + f.AppendChild(makeTrNode()) + + g := makeMarqueeNode() + g.AppendChild(makeTrNode()) + g.AppendChild(makeTrNode()) + g.AppendChild(makeTdNode()) + g.AppendChild(makeTdNode()) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "Table2Rows2Data", + in: a, + out: true, + }, + { + name: "Table1Row2Data", + in: b, + out: true, + }, + { + name: "Table2Rows1Data", + in: c, + out: true, + }, + { + name: "Table1Row1Data", + in: d, + out: true, + }, + { + name: "Table0Rows1Data", + in: e, + out: true, + }, + { + name: "Table1Row0Data", + in: f, + out: true, + }, + { + name: "TableNone", + in: makeTableNode(), + }, + { + name: "NonTableAtom", + in: makeMarqueeNode(), + }, + { + name: "NonTableAtomRowsAndData", + in: g, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isTable(tc.in); out != tc.out { + t.Errorf("isTable(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsList(t *testing.T) { + a1 := makeOlNode() + a2 := makeTextNode("aaa") + a3 := makeTextNode("bbb") + a4 := makeTextNode("ccc") + // The name and input nodes should be siblings. + a1.AppendChild(a2) + a1.AppendChild(a3) + a1.AppendChild(a4) + + b1 := makeUlNode() + b2 := makeTextNode("aaa") + b3 := makeTextNode("bbb") + b4 := makeTextNode("ccc") + // The name and input nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "OrderedListWithElements", + in: a1, + out: true, + }, + { + name: "UnorderedListWithElements", + in: b1, + out: true, + }, + // TODO: Should a list of no elements be considered an error? + { + name: "OrderedListWithoutElements", + in: makeOlNode(), + out: true, + }, + { + name: "UnorderedListWithoutElements", + in: makeUlNode(), + out: true, + }, + { + name: "NotAList", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isList(tc.in); out != tc.out { + t.Errorf("isList(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func testIsYoutube(t *testing.T) { + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "IsYoutube", + in: makeVideoNode(), + out: true, + }, + { + name: "IsNotYoutube", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isYoutube(tc.in); out != tc.out { + t.Errorf("isYoutube(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +func TestIsFragmentImport(t *testing.T) { + tests := []struct { + name string + in *html.Node + out bool + }{ + { + name: "FragmentImport", + in: &html.Node{ + Type: html.ElementNode, + Data: convertedImportsDataPrefix + "foobar", + }, + out: true, + }, + { + name: "NoAtomMissingPrefix", + in: &html.Node{ + Type: html.ElementNode, + Data: "foobar", + }, + }, + { + name: "HasAtom", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := isFragmentImport(tc.in); out != tc.out { + t.Errorf("isFragmentImport(%v) = %t, want %t", tc.in, out, tc.out) + } + }) + } +} + +// TODO countTwo feels unintuitive to me -- I struggle with the name and return type, and its mere existence feels like a needless optimization. +func TestCountTwo(t *testing.T) { + a1 := makePNode() + a2 := makeBlinkNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + b1 := makePNode() + b2 := makeTextNode("foobar") + b3 := makeMarqueeNode() + // The nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + + c1 := makePNode() + c2 := makeTextNode("foobar") + c3 := makeMarqueeNode() + c4 := makeTextNode("foobar2") + c5 := makeMarqueeNode() + // The nodes should be siblings. + c1.AppendChild(c2) + c1.AppendChild(c3) + c1.AppendChild(c4) + c1.AppendChild(c5) + + d1 := makePNode() + d2 := makeTextNode("foobar") + d3 := makeMarqueeNode() + d4 := makeTextNode("foobar2") + d5 := makeMarqueeNode() + d6 := makeMarqueeNode() + d7 := makeMarqueeNode() + // The nodes should be siblings. + d1.AppendChild(d2) + d1.AppendChild(d3) + d1.AppendChild(d4) + d1.AppendChild(d5) + d1.AppendChild(d6) + d1.AppendChild(d7) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out int + }{ + { + name: "Zero", + inNode: a1, + inAtom: atom.Marquee, + out: 0, + }, + { + name: "One", + inNode: b1, + inAtom: atom.Marquee, + out: 1, + }, + { + name: "Two", + inNode: c1, + inAtom: atom.Marquee, + out: 2, + }, + { + name: "MoreThanTwo", + inNode: d1, + inAtom: atom.Marquee, + out: 2, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := countTwo(tc.inNode, tc.inAtom); out != tc.out { + t.Errorf("countTwo(%+v, %+v) = %d, want %d", tc.inNode, tc.inAtom, out, tc.out) + } + }) + } +} + +// TODO rename countDirect, it doesn't make sense particularly in light of countTwo +func TestCountDirect(t *testing.T) { + a1 := makePNode() + a2 := makeTextNode("foobar") + a1.AppendChild(a2) + + b1 := makePNode() + b2 := makeTextNode("foobar") + b3 := makeTextNode("foobar2") + b4 := makeTextNode("foobar3") + // The nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + c1 := makePNode() + c2 := makeBlinkNode() + c3 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + + tests := []struct { + name string + in *html.Node + out int + }{ + { + name: "Zero", + in: makePNode(), + out: 0, + }, + { + name: "One", + in: a1, + out: 1, + }, + { + name: "MoreThanOne", + in: b1, + out: 3, + }, + { + name: "NonRecursive", + in: c1, + out: 1, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := countDirect(tc.in); out != tc.out { + t.Errorf("countDirect(%+v) = %d, want %d", tc.in, out, tc.out) + } + }) + } +} + +// TODO review name +func TestFindAtom(t *testing.T) { + a1 := makePNode() + a2 := makeEmNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + b1 := makePNode() + b2 := makeMarqueeNode() + b3 := makeMarqueeNode() + b4 := makeBlinkNode() + // The nodes should be siblings. + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b4) + + c1 := makePNode() + c2 := makeEmNode() + c3 := makeStrongNode() + c4 := makeTextNode("foobar") + c1.AppendChild(c2) + c2.AppendChild(c3) + c3.AppendChild(c4) + + d1 := makeBlinkNode() + + e1 := makeEmNode() + e2 := makeStrongNode() + e3 := makeTextNode("foobar") + e1.AppendChild(e2) + e2.AppendChild(e3) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out *html.Node + }{ + { + name: "OneMatch", + inNode: a1, + inAtom: atom.Em, + out: a2, + }, + { + name: "MultipleMatches", + inNode: b1, + inAtom: atom.Marquee, + out: b2, + }, + { + name: "Recursive", + inNode: c1, + inAtom: atom.Strong, + out: c3, + }, + { + name: "Self", + inNode: d1, + inAtom: atom.Blink, + out: d1, + }, + { + name: "NoMatches", + inNode: e1, + inAtom: atom.Div, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if out := findAtom(tc.inNode, tc.inAtom); out != tc.out { + t.Errorf("findAtom(%+v, %+v) = %+v, want %v", tc.inNode, tc.inAtom, out, tc.out) + } + }) + } +} + +// TODO rename, this function finds all descendants +func TestFindChildAtoms(t *testing.T) { + a1 := makePNode() + a2 := makeEmNode() + a3 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + + b1 := makePNode() + b2 := makeCodeNode() + b3 := makeEmNode() + b4 := makeStrongNode() + b5 := makeTextNode("foobar") + b1.AppendChild(b2) + b2.AppendChild(b3) + b3.AppendChild(b4) + b4.AppendChild(b5) + + c1 := makePNode() + c2 := makeCodeNode() + c3 := makeTextNode("foobar1") + c4 := makeEmNode() + c5 := makeTextNode("foobar2") + c6 := makeStrongNode() + c7 := makeCodeNode() + c8 := makeTextNode("foobar3") + //

    foobar1foobar2foobar3

    + c1.AppendChild(c2) + c2.AppendChild(c3) + c1.AppendChild(c4) + c4.AppendChild(c5) + c1.AppendChild(c6) + c6.AppendChild(c7) + c7.AppendChild(c8) + + tests := []struct { + name string + inNode *html.Node + inAtom atom.Atom + out []*html.Node + }{ + { + name: "One", + inNode: a1, + inAtom: atom.Em, + out: []*html.Node{a2}, + }, + { + name: "DistantDescendant", + inNode: b1, + inAtom: atom.Strong, + out: []*html.Node{b4}, + }, + { + name: "Multi", + inNode: c1, + inAtom: atom.Code, + out: []*html.Node{c2, c7}, + }, + { + name: "None", + inNode: a1, + inAtom: atom.Marquee, + }, + { + name: "Self", + inNode: a1, + inAtom: atom.P, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, findChildAtoms(tc.inNode, tc.inAtom)); diff != "" { + t.Errorf("findChildAtoms(%+v, %+v) got diff (-want +got):\n%s", tc.inNode, tc.inAtom, diff) + } + }) + } +} + +func TestFindNearestAncestor(t *testing.T) { + a1 := makePNode() + a2 := makeStrongNode() + a3 := makeEmNode() + a4 := makeCodeNode() + a5 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + a3.AppendChild(a4) + a4.AppendChild(a5) + + tests := []struct { + name string + inNode *html.Node + inAtoms map[atom.Atom]struct{} + inConsiderSelf considerSelf + out *html.Node + }{ + { + name: "Parent", + inNode: a4, + inAtoms: map[atom.Atom]struct{}{atom.Em: {}}, + out: a3, + }, + { + name: "DistantAncestor", + inNode: a4, + inAtoms: map[atom.Atom]struct{}{atom.P: {}}, + out: a1, + }, + { + name: "SelfDoConsiderSelf", + inNode: a4, + inAtoms: map[atom.Atom]struct{}{atom.Code: {}}, + inConsiderSelf: doConsiderSelf, + out: a4, + }, + { + name: "SelfDoNotConsiderSelf", + inNode: a4, + inAtoms: map[atom.Atom]struct{}{atom.Code: {}}, + }, + { + name: "NotFound", + inNode: a4, + inAtoms: map[atom.Atom]struct{}{atom.Blink: {}}, + }, + { + name: "MultipleAtomsMatch", + inNode: a4, + inAtoms: map[atom.Atom]struct{}{atom.P: {}, atom.Strong: {}, atom.Em: {}}, + out: a3, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, findNearestAncestor(tc.inNode, tc.inAtoms, tc.inConsiderSelf)); diff != "" { + t.Errorf("findNearestAncestor(%+v, %+v, %+v) got diff (-want +got):\n%s", tc.inNode, tc.inAtoms, tc.inConsiderSelf, diff) + } + }) + } +} + +func TestFindNearestBlockAncestor(t *testing.T) { + // Choice of

    from blockParents is arbitrary. + a1 := makePNode() + a2 := makeBNode() + a3 := makeINode() + a4 := makeCodeNode() + a5 := makeTextNode("foobar") + a1.AppendChild(a2) + a2.AppendChild(a3) + a3.AppendChild(a4) + a4.AppendChild(a5) + + tests := []struct { + name string + in *html.Node + out *html.Node + }{ + { + name: "Parent", + in: a2, + out: a1, + }, + { + name: "DistantAncestor", + in: a5, + out: a1, + }, + { + name: "Self", + in: a1, + }, + { + name: "None", + in: makeBlinkNode(), + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, findNearestBlockAncestor(tc.in)); diff != "" { + t.Errorf("findNearestBlockAncestor(%+v) got diff (-want +got):\n%s", tc.in, diff) + } + }) + } +} + +func TestNodeAttr(t *testing.T) { + a1 := makeBlinkNode() + a1.Attr = append(a1.Attr, html.Attribute{Key: "keyone", Val: "valone"}) + a1.Attr = append(a1.Attr, html.Attribute{Key: "keytwo", Val: "valtwo"}) + a1.Attr = append(a1.Attr, html.Attribute{Key: "keythree", Val: "valthree"}) + + tests := []struct { + name string + inNode *html.Node + inKey string + out string + }{ + { + name: "Simple", + inNode: a1, + inKey: "keyone", + out: "valone", + }, + { + name: "MixedCase", + inNode: a1, + inKey: "KEytWO", + out: "valtwo", + }, + { + name: "NotFound", + inNode: a1, + inKey: "nokey", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if diff := cmp.Diff(tc.out, nodeAttr(tc.inNode, tc.inKey)); diff != "" { + t.Errorf("nodeAttr(%+v, %s) got diff (-want +got):\n%s", tc.inNode, tc.inKey, diff) + } + }) + } +} + +func TestStringifyNode(t *testing.T) { + a1 := makeH3Node() + a2 := makeTextNode("foobar ") + a1.AppendChild(a2) + + b1 := makeButtonNode() + b2 := makeTextNode("some ") + b3 := makeEmNode() + b4 := makeTextNode("italic") + b5 := makeTextNode(" text and some ") + b6 := makeStrongNode() + b7 := makeTextNode("bold") + b8 := makeTextNode(" text. ") + // + b3.AppendChild(b4) + b6.AppendChild(b7) + b1.AppendChild(b2) + b1.AppendChild(b3) + b1.AppendChild(b5) + b1.AppendChild(b6) + b1.AppendChild(b8) + + c1 := makeButtonNode() + c2 := makeTextNode(" aaa") + c3 := makeANode() + c3.Attr = append(c3.Attr, html.Attribute{Key: "href", Val: "google.com"}) + c4 := makeTextNode("bbb") + c5 := makeTextNode("ccc") + //

  • Final
    FeedbackFeedback Link https://example.com/issues
    \n") for _, r := range n.Rows { hw.writeString("") @@ -329,60 +315,47 @@ func (hw *htmlWriter) grid(n *types.GridNode) { hw.writeString("
    ") } -func (hw *htmlWriter) infobox(n *types.InfoboxNode) { - hw.writeString(`