From 76ce41bfc8195c28012fdfd82801a21646a8bc00 Mon Sep 17 00:00:00 2001 From: alex Date: Fri, 22 Jan 2016 15:26:30 +0000 Subject: [PATCH] claat: initial import --- .gitignore | 1 + CONTRIBUTING.md | 26 ++ LICENSE | 202 +++++++++ claat/.gitignore | 3 + claat/Makefile | 56 +++ claat/README.md | 44 ++ claat/VERSION | 1 + claat/auth.go | 183 ++++++++ claat/export.go | 230 ++++++++++ claat/fetch.go | 271 ++++++++++++ claat/fetch_test.go | 97 +++++ claat/main.go | 180 ++++++++ claat/parser/gdoc/css.go | 164 +++++++ claat/parser/gdoc/html.go | 260 +++++++++++ claat/parser/gdoc/parse.go | 745 ++++++++++++++++++++++++++++++++ claat/parser/gdoc/parse_test.go | 332 ++++++++++++++ claat/parser/gdoc/trim.go | 218 ++++++++++ claat/parser/parse.go | 70 +++ claat/render/gen-tmpldata.go | 101 +++++ claat/render/html.go | 320 ++++++++++++++ claat/render/html_test.go | 51 +++ claat/render/template.go | 105 +++++ claat/render/template.html | 74 ++++ claat/render/template.md | 16 + claat/render/template_test.go | 39 ++ claat/render/tmpldata.go | 356 +++++++++++++++ claat/types/meta.go | 141 ++++++ claat/types/meta_test.go | 63 +++ claat/types/node.go | 420 ++++++++++++++++++ claat/update.go | 196 +++++++++ 30 files changed, 4965 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 claat/.gitignore create mode 100644 claat/Makefile create mode 100644 claat/README.md create mode 100644 claat/VERSION create mode 100644 claat/auth.go create mode 100644 claat/export.go create mode 100644 claat/fetch.go create mode 100644 claat/fetch_test.go create mode 100644 claat/main.go create mode 100644 claat/parser/gdoc/css.go create mode 100644 claat/parser/gdoc/html.go create mode 100644 claat/parser/gdoc/parse.go create mode 100644 claat/parser/gdoc/parse_test.go create mode 100644 claat/parser/gdoc/trim.go create mode 100644 claat/parser/parse.go create mode 100644 claat/render/gen-tmpldata.go create mode 100644 claat/render/html.go create mode 100644 claat/render/html_test.go create mode 100644 claat/render/template.go create mode 100644 claat/render/template.html create mode 100644 claat/render/template.md create mode 100644 claat/render/template_test.go create mode 100644 claat/render/tmpldata.go create mode 100644 claat/types/meta.go create mode 100644 claat/types/meta_test.go create mode 100644 claat/types/node.go create mode 100644 claat/update.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e43b0f988 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..e9129a85e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +Want to contribute? Great! First, read this page (including the small print at +the end). + +### Before you contribute +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement](https://cla.developers.google.com/about/google-individual) +(CLA), which you can do online. The CLA is necessary mainly because you own the +copyright to your changes, even after your contribution becomes part of our +codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things -- for instance that you'll tell us if +you know that your code infringes on other people's patents. You don't have to +sign the CLA until after you've submitted your code for review and a member has +approved it, but you must do it before we can put your code into our codebase. +Before you start working on a larger contribution, you should get in touch with +us first through the issue tracker with your idea so that we can help out and +possibly guide you. Coordinating up front makes it much easier to avoid +frustration later on. + +### Code reviews +All submissions, including submissions by project members, require review. We +use Github pull requests for this purpose. + +### The small print +Contributions made by corporations are covered by a different agreement than +the one above, the +[Software Grant and Corporate Contributor License Agreement](https://cla.developers.google.com/about/google-corporate). diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..420f8fbf3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + 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. diff --git a/claat/.gitignore b/claat/.gitignore new file mode 100644 index 000000000..910a1b747 --- /dev/null +++ b/claat/.gitignore @@ -0,0 +1,3 @@ +bin +.test +.lint diff --git a/claat/Makefile b/claat/Makefile new file mode 100644 index 000000000..70d84246c --- /dev/null +++ b/claat/Makefile @@ -0,0 +1,56 @@ +# 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. + +SHA := $(shell git rev-parse --short HEAD) +DATE := $(shell TZ=UTC date +%FT%T)Z +VERSION := $(shell cat VERSION)-$(DATE)-$(SHA) + +OUTDIR := ./bin +OUTRELEASE := gs://claat +RELEASES=$(OUTDIR)/claat-darwin-amd64 \ + $(OUTDIR)/claat-linux-amd64 \ + $(OUTDIR)/claat-linux-386 \ + $(OUTDIR)/claat-windows-amd64.exe \ + $(OUTDIR)/claat-windows-386.exe + +SRCS = $(shell find . -name '*.go') render/tmpldata.go +LDFLAGS=-ldflags "-X main.version=$(VERSION)" + +$(OUTDIR)/claat-%: GOOS=$(firstword $(subst -, ,$*)) +$(OUTDIR)/claat-%: GOARCH=$(subst .exe,,$(word 2,$(subst -, ,$*))) +$(OUTDIR)/claat-%: $(SRCS) + GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $@ $(LDFLAGS) + +install: $(OUTDIR)/claat + cp $< $(GOPATH)/bin + +$(OUTDIR)/claat: $(SRCS) + go build -o $@ $(LDFLAGS) + +%/tmpldata.go: %/gen-tmpldata.go %/template.html %/template.md + cd $* && go generate + +release: $(RELEASES) + echo $(VERSION) > $(OUTDIR)/VERSION + +test: .test +.test: $(SRCS) + go test ./... && touch .test + +lint: .lint +.lint: $(SRCS) + golint ./... && touch .lint + +clean: + rm -rf $(OUTDIR) render/tmpldata.go .test .lint diff --git a/claat/README.md b/claat/README.md new file mode 100644 index 000000000..4adca0e2a --- /dev/null +++ b/claat/README.md @@ -0,0 +1,44 @@ +# Codelabs export tool written in Go + +The program takes an input in form of a resource location, +which can either be a Google Doc ID, local file path or an arbitrary URL. +It then converts the input into a codelab format, HTML by default. + +For more info run `claat help`. + +## Install + +The easiest way is to download pre-compiled binary. +The binaries are available at + + https://claat.storage.googleapis.com/claat-darwin-amd64 + https://claat.storage.googleapis.com/claat-linux-386 + https://claat.storage.googleapis.com/claat-linux-amd64 + https://claat.storage.googleapis.com/claat-windows-386.exe + https://claat.storage.googleapis.com/claat-windows-amd64.exe + +Alternatively, if you have Go installed and `GOPATH` set properly: + + go get github.com/googlecodelabs/tools/claat + +If none of the above works, compile the tool from source following Dev workflow +instructions below. + +## Dev workflow + +**Prerequisites** + +1. Install [Go](https://golang.org/dl/) if you don't have it. +2. Make sure this directory is placed under + `$GOPATH/src/github.com/googlecodelabs/tools`. +3. Install package dependencies with `go get ./...` from this directory. + +To build the binary run `make` or `make bin/claat`. The latter creates the target binary, +while the former will also copy it to `$GOPATH/bin`. + +Testing is done with `make test` or `go test ./...` if preferred. + +Don't forget to run `make lint` or `golint ./...` before creating a new CL. + +To create cross-compiled versions for all supported OS/Arch, run `make release`. +It will place the output in `bin/claat--`. diff --git a/claat/VERSION b/claat/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/claat/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/claat/auth.go b/claat/auth.go new file mode 100644 index 000000000..3560b5d80 --- /dev/null +++ b/claat/auth.go @@ -0,0 +1,183 @@ +// 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 main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/user" + "path" + "sync" + + "golang.org/x/net/context" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +const ( + // auth scopes needed by the program + scopeDriveReadOnly = "https://www.googleapis.com/auth/drive.readonly" + + // program credentials for installed apps + googClient = "183908478743-e8rth9fbo7juk9eeivgp23asnt791g63.apps.googleusercontent.com" + googSecret = "ljELuf5jUrzcOxZGL7OQfkIC" + + // token providers + providerGoogle = "goog" +) + +var ( + // OAuth2 configs for OOB flow + authConfig = map[string]*oauth2.Config{ + providerGoogle: { + ClientID: googClient, + ClientSecret: googSecret, + Endpoint: google.Endpoint, + Scopes: []string{scopeDriveReadOnly}, + RedirectURL: "urn:ietf:wg:oauth:2.0:oob", + }, + } + + // reusable HTTP clients + clientsMu sync.Mutex // guards clients + clients map[string]*http.Client +) + +func init() { + clients = make(map[string]*http.Client) +} + +// driveClient returns an HTTP client which knows how to perform authenticated +// requests to Google Drive API. +func driveClient() (*http.Client, error) { + clientsMu.Lock() + defer clientsMu.Unlock() + if hc, ok := clients[providerGoogle]; ok { + return hc, nil + } + ts, err := tokenSource(providerGoogle) + if err != nil { + return nil, err + } + t := &oauth2.Transport{ + Source: ts, + Base: http.DefaultTransport, + } + hc := &http.Client{Transport: t} + clients[providerGoogle] = hc + return hc, nil +} + +// tokenSource creates a new oauth2.TokenSource backed by tokenRefresher, +// using previously stored user credentials if available. +func tokenSource(provider string) (oauth2.TokenSource, error) { + conf := authConfig[provider] + if conf == nil { + return nil, fmt.Errorf("no auth config for %q", provider) + } + t, err := readToken(provider) + if err != nil { + t, err = authorize(conf) + } + if err != nil { + return nil, fmt.Errorf("unable to obtain access token for %q", provider) + } + cache := &cachedTokenSource{ + src: conf.TokenSource(context.Background(), t), + provider: provider, + config: conf, + } + return oauth2.ReuseTokenSource(nil, cache), nil +} + +// 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 { + return nil, err + } + return conf.Exchange(context.Background(), code) +} + +// cachedTokenSource stores tokens returned from src on local disk. +// It is usually combined with oauth2.ReuseTokenSource. +type cachedTokenSource struct { + src oauth2.TokenSource + provider string + config *oauth2.Config +} + +func (c *cachedTokenSource) Token() (*oauth2.Token, error) { + t, err := c.src.Token() + if err != nil { + t, err = authorize(c.config) + } + if err != nil { + return nil, err + } + writeToken(c.provider, t) + return t, nil +} + +// readToken deserializes token from local disk. +func readToken(provider string) (*oauth2.Token, error) { + l, err := tokenLocation(provider) + if err != nil { + return nil, err + } + b, err := ioutil.ReadFile(l) + if err != nil { + return nil, err + } + t := &oauth2.Token{} + return t, json.Unmarshal(b, t) +} + +// writeToken serializes token tok to local disk. +func writeToken(provider string, tok *oauth2.Token) error { + l, err := tokenLocation(provider) + if err != nil { + return err + } + w, err := os.Create(l) + if err != nil { + return err + } + defer w.Close() + b, err := json.MarshalIndent(tok, "", " ") + if err != nil { + return err + } + _, err = w.Write(b) + return err +} + +// tokenLocation returns a local file path, suitable for storing user credentials. +func tokenLocation(provider string) (string, error) { + u, err := user.Current() + if err != nil { + return "", err + } + d := path.Join(u.HomeDir, ".config", "claat") + if err := os.MkdirAll(d, 0700); err != nil { + return "", err + } + return path.Join(d, provider+"-cred.json"), nil +} diff --git a/claat/export.go b/claat/export.go new file mode 100644 index 000000000..0a276e426 --- /dev/null +++ b/claat/export.go @@ -0,0 +1,230 @@ +// 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 main + +import ( + "bytes" + "crypto/md5" + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "net/http" + "os" + "path/filepath" + + "github.com/googlecodelabs/tools/claat/render" + "github.com/googlecodelabs/tools/claat/types" +) + +// cmdExport is the "claat export ..." subcommand. +func cmdExport() { + if flag.NArg() == 0 { + fatalf("Need at least one source. Try '-h' for options.") + } + type result struct { + src string + meta *types.Meta + err error + } + args := unique(flag.Args()) + ch := make(chan *result, len(args)) + for _, src := range args { + go func(src string) { + meta, err := exportCodelab(src) + ch <- &result{src, meta, err} + }(src) + } + for _ = range args { + res := <-ch + if res.err != nil { + errorf(reportErr, res.src, res.err) + } else if !isStdout(*output) { + printf(reportOk, res.meta.ID) + } + } +} + +// exportCodelab fetches codelab src from either local disk or remote, +// parses and stores the results on disk, in a dir ancestored by *output. +// +// Stored results include codelab content formatted in *tmplout, its assets +// and metadata in JSON format. +// +// There's a special case where basedir has a value of "-", in which +// nothing is stored on disk and the only output, codelab formatted content, +// is printed to stdout. +func exportCodelab(src string) (*types.Meta, error) { + clab, err := slurpCodelab(src) + if err != nil { + return nil, err + } + var client *http.Client // need for downloadImages + if clab.typ == srcGoogleDoc { + client, err = driveClient() + if err != nil { + return nil, err + } + } + + // codelab export context + lastmod := types.ContextTime(clab.mod) + meta := &clab.Meta + ctx := &types.Context{ + Source: src, + Env: *expenv, + Format: *tmplout, + Prefix: *prefix, + MainGA: *globalGA, + Updated: &lastmod, + } + + // rewritten image urls + var imap map[string]string + + dir := *output // output dir or stdout + if !isStdout(dir) { + dir = codelabDir(dir, meta) + imap = rewriteImages(clab.Steps) + } + // write codelab and its metadata to disk + if err := writeCodelab(dir, clab.Codelab, ctx); err != nil { + return nil, err + } + // slurp codelab assets to disk, if any + mdir := filepath.Join(dir, imgDirname) + return meta, downloadImages(client, mdir, imap) +} + +// writeCodelab stores codelab main content in ctx.Format and its metadata +// in JSON format on disk. +func writeCodelab(dir string, clab *types.Codelab, ctx *types.Context) error { + // render main codelab content to a tmp buffer, + // which will also verify output format is valid, + // and avoid creating empty files in case this goes wrong + data := &render.Context{ + Env: ctx.Env, + Prefix: ctx.Prefix, + GlobalGA: ctx.MainGA, + Meta: &clab.Meta, + Steps: clab.Steps, + } + var buf bytes.Buffer + if err := render.Execute(&buf, ctx.Format, data); err != nil { + return err + } + // output to stdout does not include metadata + w := os.Stdout + if !isStdout(dir) { + // make sure codelab dir exists + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + // codelab metadata + cm := &types.ContextMeta{*ctx, clab.Meta} + f := filepath.Join(dir, metaFilename) + if err := writeMeta(f, cm); err != nil { + return err + } + // main content file + f = filepath.Join(dir, contentFile(ctx.Format)) + var err error + if w, err = os.Create(f); err != nil { + return err + } + defer w.Close() + } + _, err := w.Write(buf.Bytes()) + return err +} + +// rewriteImages returns a mapping of local codelab asset file +// to its original URL. +// The local filename is MD5 hash of the original URL. +func rewriteImages(steps []*types.Step) map[string]string { + var imap = make(map[string]string) + for _, st := range steps { + nodes := imageNodes(st.Content.Nodes) + for _, n := range nodes { + file := fmt.Sprintf("%x.png", md5.Sum([]byte(n.Src))) + imap[file] = n.Src + n.Src = filepath.Join(imgDirname, file) + } + } + return imap +} + +// imageNodes filters out everything except types.NodeImage nodes, recursively. +func imageNodes(nodes []types.Node) []*types.ImageNode { + var imgs []*types.ImageNode + for _, n := range nodes { + switch n := n.(type) { + case *types.ImageNode: + imgs = append(imgs, n) + case *types.ListNode: + imgs = append(imgs, imageNodes(n.Nodes)...) + case *types.ItemsListNode: + for _, i := range n.Items { + imgs = append(imgs, imageNodes(i.Nodes)...) + } + case *types.HeaderNode: + imgs = append(imgs, imageNodes(n.Content.Nodes)...) + case *types.URLNode: + imgs = append(imgs, imageNodes(n.Content.Nodes)...) + case *types.ButtonNode: + imgs = append(imgs, imageNodes(n.Content.Nodes)...) + case *types.InfoboxNode: + imgs = append(imgs, imageNodes(n.Content.Nodes)...) + case *types.GridNode: + for _, r := range n.Rows { + for _, c := range r { + imgs = append(imgs, imageNodes(c.Content.Nodes)...) + } + } + } + } + return imgs +} + +// writeMeta writes codelab metadata to a local disk location +// specified by path. +func writeMeta(path string, cm *types.ContextMeta) error { + b, err := json.MarshalIndent(cm, "", " ") + if err != nil { + return err + } + 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) +} + +// unique de-dupes a. +// The argument a is not modified. +func unique(a []string) []string { + seen := make(map[string]struct{}, len(a)) + res := make([]string, 0, len(a)) + for _, s := range a { + if _, y := seen[s]; !y { + res = append(res, s) + seen[s] = struct{}{} + } + } + return res +} diff --git a/claat/fetch.go b/claat/fetch.go new file mode 100644 index 000000000..90827ebb4 --- /dev/null +++ b/claat/fetch.go @@ -0,0 +1,271 @@ +// 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 main + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "math" + "math/rand" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/googlecodelabs/tools/claat/parser" + "github.com/googlecodelabs/tools/claat/types" +) + +const ( + // supported codelab source types must be registered parsers + // TODO: define these in claat/parser/..., e.g. in parser/gdoc + srcInvalid srcType = "" + srcGoogleDoc srcType = "gdoc" // Google Docs doc + srcMarkdown srcType = "md" // Markdown text + + // driveAPIBase is a base URL for Drive API v2 + driveAPIBase = "https://www.googleapis.com/drive/v2" + + driveMimeDocument = "application/vnd.google-apps.document" + driveMimeFolder = "application/vnd.google-apps.folder" + driveExportMime = "text/html" +) + +// srcType is codelab source type +type srcType string + +// resource is a codelab resource, loaded from local file +// or fetched from remote location. +type resource struct { + typ srcType // source type + body io.ReadCloser // resource body + mod time.Time // last update of content +} + +// codelab wraps types.Codelab, while adding source type +// and modified timestamp fields. +type codelab struct { + *types.Codelab + typ srcType // source type + mod time.Time // last modified timestamp +} + +// slurpCodelab retrieves and parses codelab source. +// It returns parsed codelab and its source type. +func slurpCodelab(src string) (*codelab, error) { + res, err := fetchCodelab(src) + if err != nil { + return nil, err + } + defer res.body.Close() + clab, err := parser.Parse(string(res.typ), res.body) + return &codelab{ + Codelab: clab, + typ: res.typ, + mod: res.mod, + }, err +} + +// fetchCodelab retrieves codelab doc either from local disk +// or a remote location. +// The caller is responsible for closing returned stream. +func fetchCodelab(name string) (*resource, error) { + fi, err := os.Stat(name) + if os.IsNotExist(err) { + return fetchRemote(name) + } + r, err := os.Open(name) + if err != nil { + return nil, err + } + return &resource{ + body: r, + typ: srcMarkdown, + mod: fi.ModTime(), + }, nil +} + +// fetchRemote retrieves resource r from the network. +// +// If r is not a URL, i.e. does not have a host part, it is considered to be +// a Google Doc ID and fetched accordingly. Otherwise, a simple GET request +// is issued to retrieve the contents. +// +// The caller is responsible for closing returned stream. +func fetchRemote(r string) (*resource, error) { + u, err := url.Parse(r) + if err != nil { + return nil, err + } + // Google Docs are provided as IDs + fetchFn := fetchDriveFile + if u.Host != "" { + // everything else is assumed to be an arbitrary URL + fetchFn = fetchRemoteFile + } + return fetchFn(r) +} + +// fetchRemoteFile retrieves codelab resource from url. +// It is a special case of fetchRemote function. +func fetchRemoteFile(url string) (*resource, error) { + res, err := retryGet(nil, url, 3) + if err != nil { + return nil, err + } + t, err := http.ParseTime(res.Header.Get("last-modified")) + if err != nil { + t = time.Now() + } + return &resource{ + body: res.Body, + mod: t, + typ: srcMarkdown, + }, nil +} + +// fetchDriveFile uses Drive API to retrieve HTML representation of a Google Doc. +// See https://developers.google.com/drive/web/manage-downloads#downloading_google_documents +// for more details. +func fetchDriveFile(id string) (*resource, error) { + client, err := driveClient() + if err != nil { + return nil, err + } + + u := fmt.Sprintf("%s/files/%s?fields=id,mimeType,exportLinks,modifiedDate", driveAPIBase, id) + res, err := retryGet(client, u, 7) + if err != nil { + return nil, err + } + defer res.Body.Close() + meta := &struct { + ID string `json:"id"` + MimeType string `json:"mimeType"` + ExportLinks map[string]string `json:"exportLinks"` + Modified time.Time `json:"modifiedDate"` + }{} + if err := json.NewDecoder(res.Body).Decode(meta); err != nil { + return nil, err + } + if meta.MimeType != driveMimeDocument { + return nil, fmt.Errorf("%s: invalid mime type: %s", id, meta.MimeType) + } + link := meta.ExportLinks[driveExportMime] + if link == "" { + return nil, fmt.Errorf("%s: no %q export link", id, driveExportMime) + } + + if res, err = retryGet(client, link, 7); err != nil { + return nil, err + } + return &resource{ + body: res.Body, + mod: meta.Modified, + typ: srcGoogleDoc, + }, nil +} + +// downloadImages fetches imap images and stores them in dir/img directory, concurrently. +// The imap argument is expected to be a mapping of local file name to original image URL. +func downloadImages(client *http.Client, dir string, imap map[string]string) error { + if len(imap) == 0 { + return nil + } + // make sure img dir exists + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + + ch := make(chan error, len(imap)) + for name, url := range imap { + go func(name, url string) { + ch <- slurpBytes(client, filepath.Join(dir, name), url, 5) + }(name, url) + } + for _ = range imap { + if err := <-ch; err != nil { + return err + } + } + return nil +} + +// slurpBytes fetches a resource from url using retryGet and writes it to dst. +// It retries the fetch at most n times. +func slurpBytes(client *http.Client, dst, url string, n int) error { + res, err := retryGet(client, url, n) + if err != nil { + return err + } + defer res.Body.Close() + b, err := ioutil.ReadAll(res.Body) + if err != nil { + return err + } + return ioutil.WriteFile(dst, b, 0644) +} + +// retryGet tries to GET specified url up to n times. +// Default client will be used if not provided. +func retryGet(client *http.Client, url string, n int) (*http.Response, error) { + if client == nil { + client = http.DefaultClient + } + for i := 0; i <= n; i++ { + if i > 0 { + t := time.Duration((math.Pow(2, float64(i)) + rand.Float64()) * float64(time.Second)) + time.Sleep(t) + } + res, err := client.Get(url) + // return early with a good response + // the rest is error handling + if err == nil && res.StatusCode == http.StatusOK { + return res, nil + } + + // sometimes Drive API wouldn't even start a response, + // we get net/http: TLS handshake timeout instead: + // consider this a temporary failure and retry again + if err != nil { + continue + } + // otherwise, decode error response and check for "rate limit" + defer res.Body.Close() + var erres struct { + Error struct { + Errors []struct{ Reason string } + } + } + b, _ := ioutil.ReadAll(res.Body) + json.Unmarshal(b, &erres) + var rateLimit bool + for _, e := range erres.Error.Errors { + if e.Reason == "rateLimitExceeded" || e.Reason == "userRateLimitExceeded" { + rateLimit = true + break + } + } + // this is neither a rate limit error, nor a server error: + // retrying is useless + if !rateLimit && res.StatusCode < http.StatusInternalServerError { + return nil, fmt.Errorf("fetch %s: %s; %s", url, res.Status, b) + } + } + return nil, fmt.Errorf("%s: failed after %d retries", url, n) +} diff --git a/claat/fetch_test.go b/claat/fetch_test.go new file mode 100644 index 000000000..b83570d62 --- /dev/null +++ b/claat/fetch_test.go @@ -0,0 +1,97 @@ +// 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 main + +import ( + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type testTransport struct { + roundTripper func(*http.Request) (*http.Response, error) +} + +func (tt *testTransport) RoundTrip(r *http.Request) (*http.Response, error) { + return tt.roundTripper(r) +} + +func TestFetchRemote(t *testing.T) { + const f = "/file.txt" + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("r.Method = %q; want GET", r.Method) + } + if r.URL.Path != f { + t.Errorf("r.URL.Path = %q; want %q", r.URL.Path, f) + } + w.Write([]byte("test")) + })) + defer ts.Close() + + res, err := fetchRemote(ts.URL + f) + if err != nil { + t.Fatal(err) + } + defer res.body.Close() + if res.typ != srcMarkdown { + t.Errorf("typ = %q; want %q", res.typ, srcMarkdown) + } + b, _ := ioutil.ReadAll(res.body) + if s := string(b); s != "test" { + t.Errorf("res = %q; want 'test'", s) + } +} + +func TestFetchRemoteDrive(t *testing.T) { + const driveHost = "http://dummy" + rt := &testTransport{func(r *http.Request) (*http.Response, error) { + if r.Method != "GET" { + t.Errorf("r.Method = %q; want GET", r.Method) + } + // metadata request + if r.URL.Path != "/export" { + res := fmt.Sprintf(`{ + "exportLinks": {"text/html": %q}, + "mimeType": %q + }`, driveHost+"/export", driveMimeDocument) + b := ioutil.NopCloser(strings.NewReader(res)) + return &http.Response{Body: b, StatusCode: http.StatusOK}, nil + } + // export request + if strings.HasSuffix(r.URL.Path, "/doc-123") { + t.Errorf("r.URL.Path = %q; want '/doc-123' suffix", r.URL.Path) + } + b := ioutil.NopCloser(strings.NewReader("test")) + return &http.Response{Body: b, StatusCode: http.StatusOK}, nil + }} + clients[providerGoogle] = &http.Client{Transport: rt} + + res, err := fetchRemote("doc-123") + if err != nil { + t.Fatal(err) + } + defer res.body.Close() + if res.typ != srcGoogleDoc { + t.Errorf("typ = %q; want %q", res.typ, srcGoogleDoc) + } + b, _ := ioutil.ReadAll(res.body) + if s := string(b); s != "test" { + t.Errorf("res = %q; want 'test'", s) + } +} diff --git a/claat/main.go b/claat/main.go new file mode 100644 index 000000000..5dc0ddb64 --- /dev/null +++ b/claat/main.go @@ -0,0 +1,180 @@ +// 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 main + +import ( + "flag" + "fmt" + "log" + "math/rand" + "os" + "sync" + "time" + + // allow parsers to register themselves + _ "github.com/googlecodelabs/tools/claat/parser/gdoc" +) + +var ( + output = flag.String("o", ".", "output directory or '-' for stdout") + expenv = flag.String("e", "web", "codelab environment") + tmplout = flag.String("f", "html", "output format") + prefix = flag.String("prefix", "../../", "URL prefix for html format") + globalGA = flag.String("ga", "UA-49880327-14", "global Google Analytics account") + + version string // set by linker -X +) + +const ( + // imgDirname is where a codelab images are stored, + // relative to the codelab dir. + imgDirname = "img" + // contentFilename is the name of file for codelab content output, + // without the format extension. + contentFilename = "index" + // metaFilename is codelab metadata file. + metaFilename = "codelab.json" + // stdout is a special value for -o cli arg to identify stdout writer. + stdout = "-" + + // log report formats + reportErr = "err\t%s %v" + reportOk = "ok\t%s" +) + +var ( + // commands contains all valid subcommands, e.g. "claat export". + commands = map[string]func(){ + "export": cmdExport, + "update": cmdUpdate, + "help": usage, + "version": func() { fmt.Println(version) }, + } + + exitMu sync.Mutex // guards exit + exit int // program exit code +) + +// isStdout reports whether filename is stdout. +func isStdout(filename string) bool { + return filename == stdout +} + +// contentFile returns codelab main output file given the specified format. +func contentFile(format string) string { + return contentFilename + "." + format +} + +// printf prints formatted string fmt with args to stderr. +func printf(format string, args ...interface{}) { + log.Printf(format, args...) +} + +// errorf calls printf with fmt and args, and sets non-zero exit code. +func errorf(format string, args ...interface{}) { + printf(format, args...) + exitMu.Lock() + exit = 1 + exitMu.Unlock() +} + +// fatalf calls printf and exits immediatly with non-zero code. +func fatalf(format string, args ...interface{}) { + printf(format, args...) + os.Exit(1) +} + +func main() { + log.SetFlags(0) + rand.Seed(time.Now().UnixNano()) + if len(os.Args) == 1 { + fatalf("Need subcommand. Try '-h' for options.") + } + if os.Args[1] == "-h" || os.Args[1] == "--help" { + usage() + return + } + + cmd := commands[os.Args[1]] + if cmd == nil { + fatalf("Unknown subcommand. Try '-h' for options.") + } + flag.Usage = usage + flag.CommandLine.Parse(os.Args[2:]) + + cmd() + os.Exit(exit) +} + +// usage prints usageText and program arguments to stderr. +func usage() { + fmt.Fprint(os.Stderr, usageText) + flag.PrintDefaults() +} + +const usageText = `Usage: claat [export flags] src [src ...] + +Available commands are: export, update, version. + +## Export command + +Export takes one or more 'src' documents and converts them +to the format specified with -f option. + +The following formats are built-in: +- html (Polymer-based app) +- md (Markdown) +To use a custom format, specify a local file path to a Go template file. +More info on Go templates: https://golang.org/pkg/text/template/. + +Each 'src' can be either a remote HTTP resource or a local file. +Source formats currently supported are: +- Google Doc (Codelab Format, go/codelab-guide) +- Markdown + +When 'src' is a Google Doc, it must be specified as a doc ID, +omitting https://docs.google.com/... part. + +Instead of writing to an output directory, use "-o -" to specify +stdout. In this case images and metadata are not exported. +When writing to a directory, existing files will be overwritten. + +The program exits with non-zero code if at least one src could not be exported. + +## Update command + +Update scans one or more 'src' local directories for codelab.json metadata +files, recursively. A directory containing the metadata file is expected +to be a codelab previously created with the export command. + +Current directory is assumed if no 'src' argument is given. + +Each found codelab is then re-exported using parameters from the metadata file. +Unused codelab assets will be deleted, as well as the entire codelab directory, +if codelab ID has changed since last update or export. + +In the latter case, where codelab ID has changed, the new directory +will be placed alongside the old one. In other words, it will have the same ancestor +as the old one. + +While -prefix and -ga can override existing codelab metadata, the other +arguments have no effect during update. + +The program does not follow symbolic links and exits with non-zero code +if no metadata found or at least one src could not be updated. + +## Flags + +` diff --git a/claat/parser/gdoc/css.go b/claat/parser/gdoc/css.go new file mode 100644 index 000000000..a4e37fb92 --- /dev/null +++ b/claat/parser/gdoc/css.go @@ -0,0 +1,164 @@ +// 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 gdoc + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/crhym3/csslex" + + "golang.org/x/net/html" + "golang.org/x/net/html/atom" +) + +const ( + metaColor = "#b7b7b7" // step meta instruction + buttonColor = "#6aa84f" // button background color + fontCode = "courier new" // source code format in original doc + fontConsole = "consolas" // terminal text format in original doc + ibPositiveColor = "#d9ead3" // positive infobox background + ibNegativeColor = "#fce5cd" // negative infobox background + surveyColor = "#cfe2f3" // survey background color +) + +// cssStyle represents styles of an exported Google Doc. +type cssStyle map[string]map[string]string + +// parseStyle parses styles found in doc. +// The argument can be anything which is, or contains as a child, +// a + +

Test Codelab

+

Overview

+

Duration: 1:00

+ + +

icon.

+ +

What you’ll learn

+ +
  • Three
+ +

This is code.

+

Just a paragraph.

+

one url

+

Download Zip

+

+ Bo ld + italic text or both.

+ +

a file

+ + +
+

start func() {
}

+

+

func2() {
}
 // comment

+
+ + + +
+

adb shell am start -a VIEW \

+

-d "http://host" app

+
+ + + +
+

warning

+

negative box.

+
+ + + +
+

How will you use it?

+
  • Read it
+
  • Read and complete
+

+

How would you rate?

+
    +
  • Novice
  • +
  • Intermediate
  • +
  • Proficient
  • +
+

+
+

[a]Test comment.

+ + + ` + c, err := Parse(markupReader(markup)) + if err != nil { + t.Fatal(err) + } + if c.Meta.Title != "Test Codelab" { + t.Errorf("c.Meta.Title = %q; want Test Codelab", c.Meta.Title) + } + if c.Meta.ID != "test-codelab" { + t.Errorf("c.ID = %q; want test-codelab", c.Meta.ID) + } + if len(c.Steps) == 0 { + t.Fatalf("len(c.Steps) = 0") + } + step := c.Steps[0] + if step.Title != "Overview" { + t.Errorf("step.Title = %q; want Overview", step.Title) + } + + content := types.NewListNode() + + img := types.NewImageNode("https://host/image.png") + para := types.NewListNode(img) + para.MutateBlock(true) + content.Append(para) + + img = types.NewImageNode("https://host/small.png") + img.MaxWidth = 25.5 + para = types.NewListNode(img, types.NewTextNode(" icon.")) + para.MutateBlock(true) + content.Append(para) + + h := types.NewHeaderNode(3, types.NewTextNode("What you'll learn")) + h.MutateType(types.NodeHeaderCheck) + content.Append(h) + list := types.NewItemsListNode("", 0) + list.MutateType(types.NodeItemsCheck) + list.NewItem().Append(types.NewTextNode("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")) + content.Append(list) + + para = types.NewListNode() + para.MutateBlock(true) + para.Append(types.NewTextNode("This is ")) + txt := types.NewTextNode("code") + txt.Code = true + para.Append(txt) + para.Append(types.NewTextNode(".")) + content.Append(para) + + para = types.NewListNode() + para.MutateBlock(true) + para.Append(types.NewTextNode("Just a paragraph.")) + content.Append(para) + + u := types.NewURLNode("url", types.NewTextNode("one url")) + para = types.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) + para.MutateBlock(true) + content.Append(para) + + b := types.NewTextNode("Bo ld") + b.Bold = true + i := types.NewTextNode(" italic") + i.Italic = true + bi := types.NewTextNode("or both.") + bi.Bold = true + bi.Italic = true + para = types.NewListNode(b, i, types.NewTextNode(" text "), bi) + para.MutateBlock(true) + content.Append(para) + + h = types.NewHeaderNode(3, types.NewURLNode( + "http://host/file.java", types.NewTextNode("a file"))) + content.Append(h) + + code := "start func() {\n}\n\nfunc2() {\n} // comment" + cn := types.NewCodeNode(code, false) + cn.MutateBlock(1) + content.Append(cn) + + term := "adb shell am start -a VIEW \\\n-d \"http://host\" app" + tn := types.NewCodeNode(term, true) + tn.MutateBlock(2) + content.Append(tn) + + b = types.NewTextNode("warning") + b.Bold = true + n1 := types.NewListNode(b) + n1.MutateBlock(true) + n2 := types.NewListNode(types.NewTextNode("negative box.")) + n2.MutateBlock(true) + box := types.NewInfoboxNode(types.InfoboxNegative, n1, n2) + content.Append(box) + + sv := types.NewSurveyNode("test-codelab-1") + sv.Groups = append(sv.Groups, &types.SurveyGroup{ + Name: "How will you use it?", + Options: []string{"Read it", "Read and complete"}, + }) + sv.Groups = append(sv.Groups, &types.SurveyGroup{ + Name: "How would you rate?", + Options: []string{"Novice", "Intermediate", "Proficient"}, + }) + content.Append(sv) + + html1, _ := render.HTML("", step.Content) + html2, _ := render.HTML("", content) + if html1 != html2 { + t.Errorf("step.Content:\n\n%s\nwant:\n\n%s", html1, html2) + } +} diff --git a/claat/parser/gdoc/trim.go b/claat/parser/gdoc/trim.go new file mode 100644 index 000000000..2e6db7614 --- /dev/null +++ b/claat/parser/gdoc/trim.go @@ -0,0 +1,218 @@ +// 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 gdoc + +import ( + "strings" + "unicode" + + "github.com/googlecodelabs/tools/claat/types" +) + +// blockSquashable returns true if a node of type t can be squash in a block. +func blockSquashable(n types.Node) bool { + if n.Block() == nil { + return false + } + return types.IsInline(n.Type()) +} + +func squashHeadBlock(nodes []types.Node) (squash, remainder []types.Node) { + first := nodes[0] + if !blockSquashable(first) { + return nodes[:1], nodes[1:] + } + hnodes := []types.Node{first} + for _, n := range nodes[1:] { + if !blockSquashable(n) || n.Block() != first.Block() { + break + } + hnodes = append(hnodes, n) + } + next := nodes[len(hnodes):] + hnodes = trimNodes(hnodes) + if len(hnodes) == 0 { + return nil, next + } + head := types.NewListNode(hnodes...) + head.MutateBlock(true) + head.MutateEnv(first.Env()) + return []types.Node{head}, next +} + +func trimNodes(nodes []types.Node) []types.Node { + trim := make([]types.Node, 0, len(nodes)) + for i, n := range nodes { + if n.Type() == types.NodeCode && i == 0 { + cn := n.(*types.CodeNode) + cn.Value = strings.TrimLeft(cn.Value, "\n") + } + if !n.Empty() || len(trim) > 0 { + trim = append(trim, n) + continue + } + } + return trim +} + +func concatNodes(a, b types.Node) bool { + switch { + case a.Type() == types.NodeText && b.Type() == types.NodeText: + return concatText(a, b) + case a.Type() == types.NodeCode && b.Type() == types.NodeCode: + return concatCode(a, b) + case a.Type() == types.NodeCode && b.Type() == types.NodeText: + t := b.(*types.TextNode) + if strings.TrimSpace(t.Value) == "" { + return true + } + case a.Type() == types.NodeURL && b.Type() == types.NodeURL: + return concatURL(a, b) + case types.IsItemsList(a.Type()) && types.IsItemsList(b.Type()): + return concatItemsList(a, b) + } + return false +} + +func concatItemsList(a, b types.Node) bool { + l1 := a.(*types.ItemsListNode) + l2 := b.(*types.ItemsListNode) + if l1.ListType != l2.ListType { + return false + } + if l1.ListType != "" && l1.Start > 0 && l2.Start > 0 && l2.Start-len(l1.Items) != 1 { + return false + } + l1.Items = append(l1.Items, l2.Items...) + return true +} + +func concatText(a, b types.Node) bool { + t1 := a.(*types.TextNode) + t2 := b.(*types.TextNode) + + if t1.Block() != t2.Block() { + return false + } + v1, sp1 := splitSpaceRight(t1.Value) + v2, sp2 := splitSpaceLeft(t2.Value) + // + if t1.Code && !t2.Code && sp1 != "" { + t1.Value = v1 + t2.Value = sp1 + t2.Value + return false + } + // + if !t1.Code && t2.Code && sp2 != "" { + t2.Value = v2 + t1.Value += sp2 + return false + } + // + if !t1.Code && strings.TrimSpace(t2.Value) == "" { + t1.Value += t2.Value + return true + } + // different text styles: bold, italic or code + if t1.Code != t2.Code || t1.Bold != t2.Bold || t1.Italic != t2.Italic { + return false + } + // everything else can be concatenated + t1.Value += t2.Value + return true +} + +func concatCode(a, b types.Node) bool { + c1 := a.(*types.CodeNode) + c2 := b.(*types.CodeNode) + if c1.Block() != c2.Block() || c1.Term != c2.Term || c1.Lang != c2.Lang { + return false + } + c1.Value += c2.Value + return true +} + +func concatURL(a, b types.Node) bool { + u1 := a.(*types.URLNode) + u2 := b.(*types.URLNode) + if u1.Block() != u2.Block() || u1.URL != u2.URL || u1.Name != u2.Name { + return false + } + u1.Content.Append(u2.Content.Nodes...) + return true +} + +func splitSpaceLeft(s string) (v string, sp string) { + for i, r := range s { + if !unicode.IsSpace(r) { + return s[i:], s[:i] + } + } + return s, "" +} + +func splitSpaceRight(s string) (v string, sp string) { + rs := []rune(s) + for i := len(rs) - 1; i >= 0; i-- { + if !unicode.IsSpace(rs[i]) { + return s[:i+1], s[i+1:] + } + } + return s, "" +} + +// nodeBlocks encapsulates all nodes of the same block into a new ListNode +// with its B field set to true. +// Nodes which are not blockSquashable remain as is. +func blockNodes(nodes []types.Node) []types.Node { + var blocks []types.Node + for { + if len(nodes) == 0 { + break + } + var head []types.Node + head, nodes = squashHeadBlock(nodes) + blocks = append(blocks, head...) + } + return blocks +} + +// Although nodes slice is not modified, its elements are. +func compactNodes(nodes []types.Node) []types.Node { + res := make([]types.Node, 0, len(nodes)) + var last types.Node + for _, n := range nodes { + switch { + case n.Type() == types.NodeList: + l := n.(*types.ListNode) + l.Nodes = compactNodes(l.Nodes) + case types.IsItemsList(n.Type()): + l := n.(*types.ItemsListNode) + for _, it := range l.Items { + it.Nodes = compactNodes(it.Nodes) + } + } + if last == nil || !concatNodes(last, n) { + last = n + res = append(res, n) + + if n.Type() == types.NodeCode { + c := n.(*types.CodeNode) + c.Value = strings.TrimLeft(c.Value, "\n") + } + } + } + return res +} diff --git a/claat/parser/parse.go b/claat/parser/parse.go new file mode 100644 index 000000000..0a22d2d74 --- /dev/null +++ b/claat/parser/parse.go @@ -0,0 +1,70 @@ +// 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 parser + +import ( + "fmt" + "io" + "sync" + + "github.com/googlecodelabs/tools/claat/types" +) + +// ParseFunc parses source r into a Codelab for the specified environment env. +type ParseFunc func(r io.Reader) (*types.Codelab, error) + +var ( + parsersMu sync.Mutex // guards parsers + parsers = make(map[string]ParseFunc) +) + +// Register registers a new parser f under specified name. +// It panics if another parser is already registered under the same name. +func Register(name string, f ParseFunc) { + parsersMu.Lock() + defer parsersMu.Unlock() + if _, exists := parsers[name]; exists { + panic(fmt.Sprintf("parser %q already registered", name)) + } + parsers[name] = f +} + +// Parsers returns a slice of all registered parser names. +func Parsers() []string { + parsersMu.Lock() + defer parsersMu.Unlock() + p := make([]string, 0, len(parsers)) + for k := range parsers { + p = append(p, k) + } + return p +} + +// Parse parses source r into a Codelab using a parser registered with +// the specified name. +func Parse(name string, r io.Reader) (*types.Codelab, error) { + parsersMu.Lock() + p, ok := parsers[name] + parsersMu.Unlock() + if !ok { + return nil, fmt.Errorf("no parser named %q", name) + } + c, err := p(r) + if err != nil { + return nil, err + } + c.URL = c.ID + return c, err +} diff --git a/claat/render/gen-tmpldata.go b/claat/render/gen-tmpldata.go new file mode 100644 index 000000000..6db734bb2 --- /dev/null +++ b/claat/render/gen-tmpldata.go @@ -0,0 +1,101 @@ +// 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. + +// This program generates tmpldata.go +// +build ignore + +package main + +import ( + "bufio" + "fmt" + "io" + "io/ioutil" + "log" + "os" +) + +// template files to parse. +// map keys are format names. +var files = map[string]struct { + file string + html bool +}{ + "html": {"template.html", true}, + "md": {"template.md", false}, +} + +func main() { + log.SetFlags(0) + w, err := os.Create("tmpldata.go") + if err != nil { + log.Fatal(err) + } + buf := bufio.NewWriter(w) + defer func() { + buf.Flush() + w.Close() + }() + + buf.WriteString(tmpldataHead) + for f, t := range files { + b, err := ioutil.ReadFile(t.file) + if err != nil { + log.Fatal(err) + } + fmt.Fprintf(buf, "\t%q: &template{\n", f) + fmt.Fprintf(buf, "\t\thtml: %v,\n", t.html) + fmt.Fprintf(buf, "\t\tbytes: []byte{\n") + writeBytes(buf, b) + fmt.Fprintf(buf, "\t\t},\n\t},\n") + } + buf.WriteString(tmpldataFoot) +} + +func writeBytes(w io.Writer, b []byte) { + for i, x := range b { + if i%10 == 0 { + fmt.Fprint(w, "\t\t\t") + } + fmt.Fprintf(w, "%#x,", x) + if i%10 == 9 || i == len(b)-1 { + fmt.Fprint(w, "\n") + } + } +} + +const tmpldataHead = `// 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. + +// This file is auto-generated by gen-tmpldata.go. +// All modifications will be lost. + +package render + +var tmpldata = map[string]*template{ +` + +const tmpldataFoot = `} +` diff --git a/claat/render/html.go b/claat/render/html.go new file mode 100644 index 000000000..5b334091a --- /dev/null +++ b/claat/render/html.go @@ -0,0 +1,320 @@ +// 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 render + +import ( + "bytes" + "fmt" + htmlTemplate "html/template" + "io" + "sort" + "strconv" + "strings" + + "github.com/googlecodelabs/tools/claat/types" +) + +// TODO: render HTML using golang/x/net/html or template. + +var ( + doubleQuote = []byte{'"'} + lessThan = []byte{'<'} + greaterThan = []byte{'>'} + newLine = []byte{'\n'} +) + +// HTML renders nodes as the markup for the target env. +func HTML(env string, nodes ...types.Node) (htmlTemplate.HTML, error) { + var buf bytes.Buffer + if err := WriteHTML(&buf, env, nodes...); err != nil { + return "", err + } + return htmlTemplate.HTML(buf.String()), nil +} + +// WriteHTML does the same as HTML but outputs rendered markup to w. +func WriteHTML(w io.Writer, env string, nodes ...types.Node) error { + hw := htmlWriter{w: w, env: env} + return hw.write(nodes...) +} + +type htmlWriter struct { + w io.Writer // output writer + env string // target environment + err error // error during any writeXxx methods +} + +func (hw *htmlWriter) matchEnv(v []string) bool { + if len(v) == 0 || hw.env == "" { + return true + } + i := sort.SearchStrings(v, hw.env) + return i < len(v) && v[i] == hw.env +} + +func (hw *htmlWriter) write(nodes ...types.Node) error { + for _, n := range nodes { + if !hw.matchEnv(n.Env()) { + continue + } + switch n := n.(type) { + case *types.TextNode: + hw.text(n) + case *types.ImageNode: + hw.image(n) + case *types.URLNode: + hw.url(n) + case *types.ButtonNode: + hw.button(n) + case *types.CodeNode: + hw.code(n) + hw.writeBytes(newLine) + case *types.ListNode: + hw.list(n) + hw.writeBytes(newLine) + case *types.ItemsListNode: + hw.itemsList(n) + hw.writeBytes(newLine) + case *types.GridNode: + hw.grid(n) + hw.writeBytes(newLine) + case *types.InfoboxNode: + hw.infobox(n) + hw.writeBytes(newLine) + case *types.SurveyNode: + hw.survey(n) + hw.writeBytes(newLine) + case *types.HeaderNode: + hw.header(n) + hw.writeBytes(newLine) + } + if hw.err != nil { + return hw.err + } + } + return nil +} + +func (hw *htmlWriter) writeBytes(b []byte) { + if hw.err != nil { + return + } + _, hw.err = hw.w.Write(b) +} + +func (hw *htmlWriter) writeString(s string) { + hw.writeBytes([]byte(s)) +} + +func (hw *htmlWriter) writeFmt(f string, a ...interface{}) { + hw.writeString(fmt.Sprintf(f, a...)) +} + +func (hw *htmlWriter) writeEscape(s string) { + htmlTemplate.HTMLEscape(hw.w, []byte(s)) +} + +func (hw *htmlWriter) text(n *types.TextNode) { + if n.Bold { + hw.writeString("") + } + if n.Italic { + hw.writeString("") + } + if n.Code { + hw.writeString("") + } + s := htmlTemplate.HTMLEscapeString(n.Value) + hw.writeString(strings.Replace(s, "\n", "
", -1)) + if n.Code { + hw.writeString("
") + } + if n.Italic { + hw.writeString("
") + } + if n.Bold { + hw.writeString("
") + } +} + +func (hw *htmlWriter) image(n *types.ImageNode) { + hw.writeString(" 0 { + hw.writeFmt(` style="max-width: %.2fpx"`, n.MaxWidth) + } + hw.writeString(` src="`) + hw.writeString(n.Src) + hw.writeBytes(doubleQuote) + hw.writeBytes(greaterThan) +} + +func (hw *htmlWriter) url(n *types.URLNode) { + hw.writeString("") +} + +func (hw *htmlWriter) button(n *types.ButtonNode) { + hw.writeString("`) + } + hw.write(n.Content.Nodes...) + hw.writeString("") +} + +func (hw *htmlWriter) code(n *types.CodeNode) { + hw.writeString("
")
+	if !n.Term {
+		hw.writeString("")
+	}
+	hw.writeString("
") +} + +func (hw *htmlWriter) list(n *types.ListNode) { + wrap := n.Block() == true + if wrap { + hw.writeString("

") + } + hw.write(n.Nodes...) + if wrap { + hw.writeString("

") + } +} + +func (hw *htmlWriter) itemsList(n *types.ItemsListNode) { + tag := "ul" + if n.Type() == types.NodeItemsList && n.Start > 0 { + tag = "ol" + } + hw.writeBytes(lessThan) + hw.writeString(tag) + switch n.Type() { + case types.NodeItemsCheck: + hw.writeString(` class="checklist"`) + case types.NodeItemsFAQ: + hw.writeString(` class="faq"`) + default: + if n.ListType != "" { + hw.writeString(` type="`) + hw.writeString(n.ListType) + hw.writeBytes(doubleQuote) + } + if n.Start > 0 { + hw.writeFmt(` start="%d"`, n.Start) + } + } + hw.writeBytes(greaterThan) + hw.writeBytes(newLine) + + for _, i := range n.Items { + hw.writeString("
  • ") + hw.write(i.Nodes...) + hw.writeString("
  • \n") + } + + hw.writeString("\n") + for _, r := range n.Rows { + hw.writeString("") + for _, c := range r { + hw.writeFmt(``, c.Colspan, c.Rowspan) + hw.write(c.Content.Nodes...) + hw.writeString("") + } + hw.writeString("\n") + } + hw.writeString("") +} + +func (hw *htmlWriter) infobox(n *types.InfoboxNode) { + hw.writeString(`") +} + +func (hw *htmlWriter) survey(n *types.SurveyNode) { + hw.writeString(`\n") + for _, g := range n.Groups { + hw.writeString("

    ") + hw.writeEscape(g.Name) + hw.writeString("

    \n\n") + for _, o := range g.Options { + hw.writeString("") + hw.writeEscape(o) + hw.writeString("\n") + } + hw.writeString("\n") + } + hw.writeString("
    ") +} + +func (hw *htmlWriter) header(n *types.HeaderNode) { + tag := "h" + strconv.Itoa(n.Level) + hw.writeBytes(lessThan) + hw.writeString(tag) + switch n.Type() { + case types.NodeHeaderCheck: + hw.writeString(` class="checklist"`) + case types.NodeHeaderFAQ: + hw.writeString(` class="faq"`) + } + hw.writeBytes(greaterThan) + hw.write(n.Content.Nodes...) + hw.writeString(" + + + + + + + + {{.Meta.Title}} + + + + + + + + {{range .Steps}}{{if matchEnv .Tags $.Env}} + + {{.Content | renderHTML $.Env}} + + {{end}}{{end}} + + + + + + diff --git a/claat/render/template.md b/claat/render/template.md new file mode 100644 index 000000000..c64f0196f --- /dev/null +++ b/claat/render/template.md @@ -0,0 +1,16 @@ +# {{.Meta.Title}} + +[//]: # (This is the default template for 'md' output format of the tool) +[//]: # (This file is distributed under Apache-2 license; see LICENSE file at the root of this repo) + +{{if .Meta.Feedback}}[Codelab Feedback]({{.Meta.Feedback}}){{end}} + +{{range .Steps}} +## {{.Title}} + +{{if .Duration}}Duration is {{.Duration}}{{end}} + +{{range .Content.Nodes}} +{{.}} +{{end}} +{{end}} diff --git a/claat/render/template_test.go b/claat/render/template_test.go new file mode 100644 index 000000000..07454a1c7 --- /dev/null +++ b/claat/render/template_test.go @@ -0,0 +1,39 @@ +// 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 render + +import ( + "bytes" + "testing" + + "github.com/googlecodelabs/tools/claat/types" +) + +func TestExecuteBuiltin(t *testing.T) { + step := &types.Step{ + Title: "Test step", + Content: types.NewListNode(types.NewTextNode("text")), + } + ctx := &Context{ + Meta: &types.Meta{}, + Steps: []*types.Step{step}, + } + for _, f := range []string{"html", "md"} { + var buf bytes.Buffer + if err := Execute(&buf, f, ctx); err != nil { + t.Errorf("%s: %v", f, err) + } + } +} diff --git a/claat/render/tmpldata.go b/claat/render/tmpldata.go new file mode 100644 index 000000000..41ad5d2d9 --- /dev/null +++ b/claat/render/tmpldata.go @@ -0,0 +1,356 @@ +// 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. + +// This file is auto-generated by gen-tmpldata.go. +// All modifications will be lost. + +package render + +var tmpldata = map[string]*template{ + "html": &template{ + html: true, + bytes: []byte{ + 0x3c,0x21,0x2d,0x2d,0xa,0x43,0x6f,0x70,0x79,0x72, + 0x69,0x67,0x68,0x74,0x20,0x28,0x63,0x29,0x20,0x32, + 0x30,0x31,0x36,0x20,0x47,0x6f,0x6f,0x67,0x6c,0x65, + 0x20,0x49,0x6e,0x63,0x2e,0xa,0xa,0x4c,0x69,0x63, + 0x65,0x6e,0x73,0x65,0x64,0x20,0x75,0x6e,0x64,0x65, + 0x72,0x20,0x74,0x68,0x65,0x20,0x41,0x70,0x61,0x63, + 0x68,0x65,0x20,0x4c,0x69,0x63,0x65,0x6e,0x73,0x65, + 0x2c,0x20,0x56,0x65,0x72,0x73,0x69,0x6f,0x6e,0x20, + 0x32,0x2e,0x30,0x20,0x28,0x74,0x68,0x65,0x20,0x22, + 0x4c,0x69,0x63,0x65,0x6e,0x73,0x65,0x22,0x29,0x3b, + 0x20,0x79,0x6f,0x75,0x20,0x6d,0x61,0x79,0x20,0x6e, + 0x6f,0x74,0xa,0x75,0x73,0x65,0x20,0x74,0x68,0x69, + 0x73,0x20,0x66,0x69,0x6c,0x65,0x20,0x65,0x78,0x63, + 0x65,0x70,0x74,0x20,0x69,0x6e,0x20,0x63,0x6f,0x6d, + 0x70,0x6c,0x69,0x61,0x6e,0x63,0x65,0x20,0x77,0x69, + 0x74,0x68,0x20,0x74,0x68,0x65,0x20,0x4c,0x69,0x63, + 0x65,0x6e,0x73,0x65,0x2e,0x20,0x59,0x6f,0x75,0x20, + 0x6d,0x61,0x79,0x20,0x6f,0x62,0x74,0x61,0x69,0x6e, + 0x20,0x61,0x20,0x63,0x6f,0x70,0x79,0x20,0x6f,0x66, + 0xa,0x74,0x68,0x65,0x20,0x4c,0x69,0x63,0x65,0x6e, + 0x73,0x65,0x20,0x61,0x74,0xa,0xa,0x20,0x20,0x20, + 0x20,0x68,0x74,0x74,0x70,0x3a,0x2f,0x2f,0x77,0x77, + 0x77,0x2e,0x61,0x70,0x61,0x63,0x68,0x65,0x2e,0x6f, + 0x72,0x67,0x2f,0x6c,0x69,0x63,0x65,0x6e,0x73,0x65, + 0x73,0x2f,0x4c,0x49,0x43,0x45,0x4e,0x53,0x45,0x2d, + 0x32,0x2e,0x30,0xa,0xa,0x55,0x6e,0x6c,0x65,0x73, + 0x73,0x20,0x72,0x65,0x71,0x75,0x69,0x72,0x65,0x64, + 0x20,0x62,0x79,0x20,0x61,0x70,0x70,0x6c,0x69,0x63, + 0x61,0x62,0x6c,0x65,0x20,0x6c,0x61,0x77,0x20,0x6f, + 0x72,0x20,0x61,0x67,0x72,0x65,0x65,0x64,0x20,0x74, + 0x6f,0x20,0x69,0x6e,0x20,0x77,0x72,0x69,0x74,0x69, + 0x6e,0x67,0x2c,0x20,0x73,0x6f,0x66,0x74,0x77,0x61, + 0x72,0x65,0xa,0x64,0x69,0x73,0x74,0x72,0x69,0x62, + 0x75,0x74,0x65,0x64,0x20,0x75,0x6e,0x64,0x65,0x72, + 0x20,0x74,0x68,0x65,0x20,0x4c,0x69,0x63,0x65,0x6e, + 0x73,0x65,0x20,0x69,0x73,0x20,0x64,0x69,0x73,0x74, + 0x72,0x69,0x62,0x75,0x74,0x65,0x64,0x20,0x6f,0x6e, + 0x20,0x61,0x6e,0x20,0x22,0x41,0x53,0x20,0x49,0x53, + 0x22,0x20,0x42,0x41,0x53,0x49,0x53,0x2c,0x20,0x57, + 0x49,0x54,0x48,0x4f,0x55,0x54,0xa,0x57,0x41,0x52, + 0x52,0x41,0x4e,0x54,0x49,0x45,0x53,0x20,0x4f,0x52, + 0x20,0x43,0x4f,0x4e,0x44,0x49,0x54,0x49,0x4f,0x4e, + 0x53,0x20,0x4f,0x46,0x20,0x41,0x4e,0x59,0x20,0x4b, + 0x49,0x4e,0x44,0x2c,0x20,0x65,0x69,0x74,0x68,0x65, + 0x72,0x20,0x65,0x78,0x70,0x72,0x65,0x73,0x73,0x20, + 0x6f,0x72,0x20,0x69,0x6d,0x70,0x6c,0x69,0x65,0x64, + 0x2e,0x20,0x53,0x65,0x65,0x20,0x74,0x68,0x65,0xa, + 0x4c,0x69,0x63,0x65,0x6e,0x73,0x65,0x20,0x66,0x6f, + 0x72,0x20,0x74,0x68,0x65,0x20,0x73,0x70,0x65,0x63, + 0x69,0x66,0x69,0x63,0x20,0x6c,0x61,0x6e,0x67,0x75, + 0x61,0x67,0x65,0x20,0x67,0x6f,0x76,0x65,0x72,0x6e, + 0x69,0x6e,0x67,0x20,0x70,0x65,0x72,0x6d,0x69,0x73, + 0x73,0x69,0x6f,0x6e,0x73,0x20,0x61,0x6e,0x64,0x20, + 0x6c,0x69,0x6d,0x69,0x74,0x61,0x74,0x69,0x6f,0x6e, + 0x73,0x20,0x75,0x6e,0x64,0x65,0x72,0xa,0x74,0x68, + 0x65,0x20,0x4c,0x69,0x63,0x65,0x6e,0x73,0x65,0x2e, + 0xa,0x2d,0x2d,0x3e,0xa,0x3c,0x21,0x64,0x6f,0x63, + 0x74,0x79,0x70,0x65,0x20,0x68,0x74,0x6d,0x6c,0x3e, + 0xa,0x3c,0x21,0x2d,0x2d,0x20,0x54,0x68,0x69,0x73, + 0x20,0x69,0x73,0x20,0x74,0x68,0x65,0x20,0x64,0x65, + 0x66,0x61,0x75,0x6c,0x74,0x20,0x74,0x65,0x6d,0x70, + 0x6c,0x61,0x74,0x65,0x20,0x66,0x6f,0x72,0x20,0x27, + 0x68,0x74,0x6d,0x6c,0x27,0x20,0x6f,0x75,0x74,0x70, + 0x75,0x74,0x20,0x66,0x6f,0x72,0x6d,0x61,0x74,0x20, + 0x6f,0x66,0x20,0x74,0x68,0x65,0x20,0x74,0x6f,0x6f, + 0x6c,0x20,0x2d,0x2d,0x3e,0xa,0x3c,0x68,0x74,0x6d, + 0x6c,0x3e,0xa,0x3c,0x68,0x65,0x61,0x64,0x3e,0xa, + 0x20,0x20,0x3c,0x6d,0x65,0x74,0x61,0x20,0x6e,0x61, + 0x6d,0x65,0x3d,0x22,0x76,0x69,0x65,0x77,0x70,0x6f, + 0x72,0x74,0x22,0x20,0x63,0x6f,0x6e,0x74,0x65,0x6e, + 0x74,0x3d,0x22,0x77,0x69,0x64,0x74,0x68,0x3d,0x64, + 0x65,0x76,0x69,0x63,0x65,0x2d,0x77,0x69,0x64,0x74, + 0x68,0x2c,0x20,0x6d,0x69,0x6e,0x69,0x6d,0x75,0x6d, + 0x2d,0x73,0x63,0x61,0x6c,0x65,0x3d,0x31,0x2e,0x30, + 0x2c,0x20,0x69,0x6e,0x69,0x74,0x69,0x61,0x6c,0x2d, + 0x73,0x63,0x61,0x6c,0x65,0x3d,0x31,0x2e,0x30,0x2c, + 0x20,0x75,0x73,0x65,0x72,0x2d,0x73,0x63,0x61,0x6c, + 0x61,0x62,0x6c,0x65,0x3d,0x79,0x65,0x73,0x22,0x3e, + 0xa,0x20,0x20,0x3c,0x6d,0x65,0x74,0x61,0x20,0x6e, + 0x61,0x6d,0x65,0x3d,0x22,0x74,0x68,0x65,0x6d,0x65, + 0x2d,0x63,0x6f,0x6c,0x6f,0x72,0x22,0x20,0x63,0x6f, + 0x6e,0x74,0x65,0x6e,0x74,0x3d,0x22,0x23,0x34,0x46, + 0x37,0x44,0x43,0x39,0x22,0x3e,0xa,0x20,0x20,0x3c, + 0x6d,0x65,0x74,0x61,0x20,0x63,0x68,0x61,0x72,0x73, + 0x65,0x74,0x3d,0x22,0x55,0x54,0x46,0x2d,0x38,0x22, + 0x3e,0xa,0x20,0x20,0x3c,0x74,0x69,0x74,0x6c,0x65, + 0x3e,0x7b,0x7b,0x2e,0x4d,0x65,0x74,0x61,0x2e,0x54, + 0x69,0x74,0x6c,0x65,0x7d,0x7d,0x3c,0x2f,0x74,0x69, + 0x74,0x6c,0x65,0x3e,0xa,0x20,0x20,0x3c,0x73,0x63, + 0x72,0x69,0x70,0x74,0x20,0x73,0x72,0x63,0x3d,0x22, + 0x7b,0x7b,0x2e,0x50,0x72,0x65,0x66,0x69,0x78,0x7d, + 0x7d,0x62,0x6f,0x77,0x65,0x72,0x5f,0x63,0x6f,0x6d, + 0x70,0x6f,0x6e,0x65,0x6e,0x74,0x73,0x2f,0x77,0x65, + 0x62,0x63,0x6f,0x6d,0x70,0x6f,0x6e,0x65,0x6e,0x74, + 0x73,0x6a,0x73,0x2f,0x77,0x65,0x62,0x63,0x6f,0x6d, + 0x70,0x6f,0x6e,0x65,0x6e,0x74,0x73,0x2d,0x6c,0x69, + 0x74,0x65,0x2e,0x6d,0x69,0x6e,0x2e,0x6a,0x73,0x22, + 0x3e,0x3c,0x2f,0x73,0x63,0x72,0x69,0x70,0x74,0x3e, + 0xa,0x20,0x20,0x3c,0x6c,0x69,0x6e,0x6b,0x20,0x72, + 0x65,0x6c,0x3d,0x22,0x69,0x6d,0x70,0x6f,0x72,0x74, + 0x22,0x20,0x68,0x72,0x65,0x66,0x3d,0x22,0x7b,0x7b, + 0x2e,0x50,0x72,0x65,0x66,0x69,0x78,0x7d,0x7d,0x62, + 0x6f,0x77,0x65,0x72,0x5f,0x63,0x6f,0x6d,0x70,0x6f, + 0x6e,0x65,0x6e,0x74,0x73,0x2f,0x67,0x6f,0x6f,0x67, + 0x6c,0x65,0x2d,0x63,0x6f,0x64,0x65,0x6c,0x61,0x62, + 0x2d,0x65,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x73,0x2f, + 0x67,0x6f,0x6f,0x67,0x6c,0x65,0x2d,0x63,0x6f,0x64, + 0x65,0x6c,0x61,0x62,0x2d,0x65,0x6c,0x65,0x6d,0x65, + 0x6e,0x74,0x73,0x2e,0x68,0x74,0x6d,0x6c,0x22,0x3e, + 0xa,0x20,0x20,0x3c,0x21,0x2d,0x2d,0x20,0x54,0x4f, + 0x44,0x4f,0x3a,0x20,0x61,0x64,0x64,0x20,0x74,0x68, + 0x65,0x6d,0x69,0x6e,0x67,0xa,0x20,0x20,0x3c,0x6c, + 0x69,0x6e,0x6b,0x20,0x72,0x65,0x6c,0x3d,0x22,0x69, + 0x6d,0x70,0x6f,0x72,0x74,0x22,0x20,0x68,0x72,0x65, + 0x66,0x3d,0x22,0x7b,0x7b,0x2e,0x50,0x72,0x65,0x66, + 0x69,0x78,0x7d,0x7d,0x62,0x6f,0x77,0x65,0x72,0x5f, + 0x63,0x6f,0x6d,0x70,0x6f,0x6e,0x65,0x6e,0x74,0x73, + 0x2f,0x67,0x6f,0x6f,0x67,0x6c,0x65,0x2d,0x63,0x6f, + 0x64,0x65,0x6c,0x61,0x62,0x2d,0x65,0x6c,0x65,0x6d, + 0x65,0x6e,0x74,0x73,0x2f,0x74,0x68,0x65,0x6d,0x65, + 0x2d,0x7b,0x7b,0x2e,0x4d,0x65,0x74,0x61,0x2e,0x54, + 0x68,0x65,0x6d,0x65,0x7d,0x7d,0x2e,0x68,0x74,0x6d, + 0x6c,0x22,0x3e,0xa,0x20,0x20,0x3c,0x73,0x74,0x79, + 0x6c,0x65,0x20,0x69,0x73,0x3d,0x22,0x63,0x75,0x73, + 0x74,0x6f,0x6d,0x2d,0x73,0x74,0x79,0x6c,0x65,0x22, + 0x20,0x69,0x6e,0x63,0x6c,0x75,0x64,0x65,0x3d,0x22, + 0x67,0x6f,0x6f,0x67,0x6c,0x65,0x2d,0x63,0x6f,0x64, + 0x65,0x6c,0x61,0x62,0x2d,0x74,0x68,0x65,0x6d,0x65, + 0x2d,0x7b,0x7b,0x2e,0x4d,0x65,0x74,0x61,0x2e,0x54, + 0x68,0x65,0x6d,0x65,0x7d,0x7d,0x22,0x3e,0x3c,0x2f, + 0x73,0x74,0x79,0x6c,0x65,0x3e,0xa,0x20,0x20,0x2a, + 0x20,0x74,0x68,0x65,0x20,0x61,0x62,0x6f,0x76,0x65, + 0x20,0x77,0x69,0x6c,0x6c,0x20,0x72,0x65,0x70,0x6c, + 0x61,0x63,0x65,0x20,0x74,0x68,0x69,0x73,0x3a,0xa, + 0x20,0x20,0x3c,0x6c,0x69,0x6e,0x6b,0x20,0x72,0x65, + 0x6c,0x3d,0x22,0x73,0x74,0x79,0x6c,0x65,0x73,0x68, + 0x65,0x65,0x74,0x22,0x20,0x68,0x72,0x65,0x66,0x3d, + 0x22,0x7b,0x7b,0x2e,0x50,0x72,0x65,0x66,0x69,0x78, + 0x7d,0x7d,0x63,0x6f,0x64,0x65,0x6c,0x61,0x62,0x5f, + 0x63,0x6f,0x6d,0x70,0x6f,0x6e,0x65,0x6e,0x74,0x73, + 0x2f,0x67,0x6f,0x6f,0x67,0x6c,0x65,0x2d,0x63,0x6f, + 0x64,0x65,0x6c,0x61,0x62,0x2f,0x74,0x68,0x65,0x6d, + 0x65,0x73,0x2f,0x7b,0x7b,0x2e,0x4d,0x65,0x74,0x61, + 0x2e,0x54,0x68,0x65,0x6d,0x65,0x7d,0x7d,0x2e,0x63, + 0x73,0x73,0x22,0x3e,0xa,0x20,0x20,0x2d,0x2d,0x3e, + 0xa,0x3c,0x2f,0x68,0x65,0x61,0x64,0x3e,0xa,0x3c, + 0x62,0x6f,0x64,0x79,0x20,0x75,0x6e,0x72,0x65,0x73, + 0x6f,0x6c,0x76,0x65,0x64,0x20,0x63,0x6c,0x61,0x73, + 0x73,0x3d,0x22,0x66,0x75,0x6c,0x6c,0x62,0x6c,0x65, + 0x65,0x64,0x22,0x3e,0xa,0xa,0x20,0x20,0x3c,0x67, + 0x6f,0x6f,0x67,0x6c,0x65,0x2d,0x63,0x6f,0x64,0x65, + 0x6c,0x61,0x62,0x20,0x74,0x69,0x74,0x6c,0x65,0x3d, + 0x22,0x7b,0x7b,0x2e,0x4d,0x65,0x74,0x61,0x2e,0x54, + 0x69,0x74,0x6c,0x65,0x7d,0x7d,0x22,0xa,0x20,0x20, + 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, + 0x20,0x20,0x20,0x20,0x20,0x20,0x65,0x6e,0x76,0x69, + 0x72,0x6f,0x6e,0x6d,0x65,0x6e,0x74,0x3d,0x22,0x7b, + 0x7b,0x69,0x6e,0x64,0x65,0x78,0x20,0x2e,0x45,0x6e, + 0x76,0x7d,0x7d,0x22,0xa,0x20,0x20,0x20,0x20,0x20, + 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, + 0x20,0x20,0x20,0x66,0x65,0x65,0x64,0x62,0x61,0x63, + 0x6b,0x2d,0x6c,0x69,0x6e,0x6b,0x3d,0x22,0x7b,0x7b, + 0x2e,0x4d,0x65,0x74,0x61,0x2e,0x46,0x65,0x65,0x64, + 0x62,0x61,0x63,0x6b,0x7d,0x7d,0x22,0x3e,0xa,0x20, + 0x20,0x20,0x20,0x7b,0x7b,0x72,0x61,0x6e,0x67,0x65, + 0x20,0x2e,0x53,0x74,0x65,0x70,0x73,0x7d,0x7d,0x7b, + 0x7b,0x69,0x66,0x20,0x6d,0x61,0x74,0x63,0x68,0x45, + 0x6e,0x76,0x20,0x2e,0x54,0x61,0x67,0x73,0x20,0x24, + 0x2e,0x45,0x6e,0x76,0x7d,0x7d,0xa,0x20,0x20,0x20, + 0x20,0x20,0x20,0x3c,0x67,0x6f,0x6f,0x67,0x6c,0x65, + 0x2d,0x63,0x6f,0x64,0x65,0x6c,0x61,0x62,0x2d,0x73, + 0x74,0x65,0x70,0x20,0x6c,0x61,0x62,0x65,0x6c,0x3d, + 0x22,0x7b,0x7b,0x2e,0x54,0x69,0x74,0x6c,0x65,0x7d, + 0x7d,0x22,0x20,0x64,0x75,0x72,0x61,0x74,0x69,0x6f, + 0x6e,0x3d,0x22,0x7b,0x7b,0x2e,0x44,0x75,0x72,0x61, + 0x74,0x69,0x6f,0x6e,0x2e,0x4d,0x69,0x6e,0x75,0x74, + 0x65,0x73,0x7d,0x7d,0x22,0x3e,0xa,0x20,0x20,0x20, + 0x20,0x20,0x20,0x20,0x20,0x7b,0x7b,0x2e,0x43,0x6f, + 0x6e,0x74,0x65,0x6e,0x74,0x20,0x7c,0x20,0x72,0x65, + 0x6e,0x64,0x65,0x72,0x48,0x54,0x4d,0x4c,0x20,0x24, + 0x2e,0x45,0x6e,0x76,0x7d,0x7d,0xa,0x20,0x20,0x20, + 0x20,0x20,0x20,0x3c,0x2f,0x67,0x6f,0x6f,0x67,0x6c, + 0x65,0x2d,0x63,0x6f,0x64,0x65,0x6c,0x61,0x62,0x2d, + 0x73,0x74,0x65,0x70,0x3e,0xa,0x20,0x20,0x20,0x20, + 0x7b,0x7b,0x65,0x6e,0x64,0x7d,0x7d,0x7b,0x7b,0x65, + 0x6e,0x64,0x7d,0x7d,0xa,0x20,0x20,0x3c,0x2f,0x67, + 0x6f,0x6f,0x67,0x6c,0x65,0x2d,0x63,0x6f,0x64,0x65, + 0x6c,0x61,0x62,0x3e,0xa,0xa,0x20,0x20,0x3c,0x73, + 0x63,0x72,0x69,0x70,0x74,0x3e,0xa,0x20,0x20,0x20, + 0x20,0x28,0x66,0x75,0x6e,0x63,0x74,0x69,0x6f,0x6e, + 0x28,0x69,0x2c,0x73,0x2c,0x6f,0x2c,0x67,0x2c,0x72, + 0x2c,0x61,0x2c,0x6d,0x29,0x7b,0x69,0x5b,0x27,0x47, + 0x6f,0x6f,0x67,0x6c,0x65,0x41,0x6e,0x61,0x6c,0x79, + 0x74,0x69,0x63,0x73,0x4f,0x62,0x6a,0x65,0x63,0x74, + 0x27,0x5d,0x3d,0x72,0x3b,0x69,0x5b,0x72,0x5d,0x3d, + 0x69,0x5b,0x72,0x5d,0x7c,0x7c,0x66,0x75,0x6e,0x63, + 0x74,0x69,0x6f,0x6e,0x28,0x29,0x7b,0xa,0x20,0x20, + 0x20,0x20,0x28,0x69,0x5b,0x72,0x5d,0x2e,0x71,0x3d, + 0x69,0x5b,0x72,0x5d,0x2e,0x71,0x7c,0x7c,0x5b,0x5d, + 0x29,0x2e,0x70,0x75,0x73,0x68,0x28,0x61,0x72,0x67, + 0x75,0x6d,0x65,0x6e,0x74,0x73,0x29,0x7d,0x2c,0x69, + 0x5b,0x72,0x5d,0x2e,0x6c,0x3d,0x31,0x2a,0x6e,0x65, + 0x77,0x20,0x44,0x61,0x74,0x65,0x28,0x29,0x3b,0x61, + 0x3d,0x73,0x2e,0x63,0x72,0x65,0x61,0x74,0x65,0x45, + 0x6c,0x65,0x6d,0x65,0x6e,0x74,0x28,0x6f,0x29,0x2c, + 0xa,0x20,0x20,0x20,0x20,0x6d,0x3d,0x73,0x2e,0x67, + 0x65,0x74,0x45,0x6c,0x65,0x6d,0x65,0x6e,0x74,0x73, + 0x42,0x79,0x54,0x61,0x67,0x4e,0x61,0x6d,0x65,0x28, + 0x6f,0x29,0x5b,0x30,0x5d,0x3b,0x61,0x2e,0x61,0x73, + 0x79,0x6e,0x63,0x3d,0x31,0x3b,0x61,0x2e,0x73,0x72, + 0x63,0x3d,0x67,0x3b,0x6d,0x2e,0x70,0x61,0x72,0x65, + 0x6e,0x74,0x4e,0x6f,0x64,0x65,0x2e,0x69,0x6e,0x73, + 0x65,0x72,0x74,0x42,0x65,0x66,0x6f,0x72,0x65,0x28, + 0x61,0x2c,0x6d,0x29,0xa,0x20,0x20,0x20,0x20,0x7d, + 0x29,0x28,0x77,0x69,0x6e,0x64,0x6f,0x77,0x2c,0x64, + 0x6f,0x63,0x75,0x6d,0x65,0x6e,0x74,0x2c,0x27,0x73, + 0x63,0x72,0x69,0x70,0x74,0x27,0x2c,0x27,0x2f,0x2f, + 0x77,0x77,0x77,0x2e,0x67,0x6f,0x6f,0x67,0x6c,0x65, + 0x2d,0x61,0x6e,0x61,0x6c,0x79,0x74,0x69,0x63,0x73, + 0x2e,0x63,0x6f,0x6d,0x2f,0x61,0x6e,0x61,0x6c,0x79, + 0x74,0x69,0x63,0x73,0x2e,0x6a,0x73,0x27,0x2c,0x27, + 0x67,0x61,0x27,0x29,0x3b,0xa,0x20,0x20,0x20,0x20, + 0x7b,0x7b,0x69,0x66,0x20,0x2e,0x47,0x6c,0x6f,0x62, + 0x61,0x6c,0x47,0x41,0x7d,0x7d,0x67,0x61,0x28,0x27, + 0x63,0x72,0x65,0x61,0x74,0x65,0x27,0x2c,0x20,0x27, + 0x7b,0x7b,0x2e,0x47,0x6c,0x6f,0x62,0x61,0x6c,0x47, + 0x41,0x7d,0x7d,0x27,0x2c,0x20,0x27,0x61,0x75,0x74, + 0x6f,0x27,0x29,0x3b,0x7b,0x7b,0x65,0x6e,0x64,0x7d, + 0x7d,0xa,0xa,0x20,0x20,0x20,0x20,0x28,0x66,0x75, + 0x6e,0x63,0x74,0x69,0x6f,0x6e,0x28,0x29,0x20,0x7b, + 0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x76,0x61,0x72, + 0x20,0x67,0x61,0x43,0x6f,0x64,0x65,0x6c,0x61,0x62, + 0x20,0x3d,0x20,0x27,0x7b,0x7b,0x2e,0x4d,0x65,0x74, + 0x61,0x2e,0x47,0x41,0x7d,0x7d,0x27,0x3b,0xa,0x20, + 0x20,0x20,0x20,0x20,0x20,0x69,0x66,0x20,0x28,0x67, + 0x61,0x43,0x6f,0x64,0x65,0x6c,0x61,0x62,0x29,0x20, + 0x7b,0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, + 0x67,0x61,0x28,0x27,0x63,0x72,0x65,0x61,0x74,0x65, + 0x27,0x2c,0x20,0x67,0x61,0x43,0x6f,0x64,0x65,0x6c, + 0x61,0x62,0x2c,0x20,0x27,0x61,0x75,0x74,0x6f,0x27, + 0x2c,0x20,0x7b,0x6e,0x61,0x6d,0x65,0x3a,0x20,0x27, + 0x63,0x6f,0x64,0x65,0x6c,0x61,0x62,0x27,0x7d,0x29, + 0x3b,0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x7d,0xa, + 0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x76,0x61,0x72, + 0x20,0x67,0x61,0x56,0x69,0x65,0x77,0x3b,0xa,0x20, + 0x20,0x20,0x20,0x20,0x20,0x76,0x61,0x72,0x20,0x70, + 0x61,0x72,0x74,0x73,0x20,0x3d,0x20,0x6c,0x6f,0x63, + 0x61,0x74,0x69,0x6f,0x6e,0x2e,0x73,0x65,0x61,0x72, + 0x63,0x68,0x2e,0x73,0x75,0x62,0x73,0x74,0x72,0x69, + 0x6e,0x67,0x28,0x31,0x29,0x2e,0x73,0x70,0x6c,0x69, + 0x74,0x28,0x27,0x26,0x27,0x29,0x3b,0xa,0x20,0x20, + 0x20,0x20,0x20,0x20,0x66,0x6f,0x72,0x20,0x28,0x76, + 0x61,0x72,0x20,0x69,0x20,0x3d,0x20,0x30,0x3b,0x20, + 0x69,0x20,0x3c,0x20,0x70,0x61,0x72,0x74,0x73,0x2e, + 0x6c,0x65,0x6e,0x67,0x74,0x68,0x3b,0x20,0x69,0x2b, + 0x2b,0x29,0x20,0x7b,0xa,0x20,0x20,0x20,0x20,0x20, + 0x20,0x20,0x20,0x76,0x61,0x72,0x20,0x70,0x61,0x72, + 0x61,0x6d,0x20,0x3d,0x20,0x70,0x61,0x72,0x74,0x73, + 0x5b,0x69,0x5d,0x2e,0x73,0x70,0x6c,0x69,0x74,0x28, + 0x27,0x3d,0x27,0x29,0x3b,0xa,0x20,0x20,0x20,0x20, + 0x20,0x20,0x20,0x20,0x69,0x66,0x20,0x28,0x70,0x61, + 0x72,0x61,0x6d,0x5b,0x30,0x5d,0x20,0x3d,0x3d,0x3d, + 0x20,0x27,0x76,0x69,0x65,0x77,0x67,0x61,0x27,0x29, + 0x20,0x7b,0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x20, + 0x20,0x20,0x20,0x67,0x61,0x56,0x69,0x65,0x77,0x20, + 0x3d,0x20,0x70,0x61,0x72,0x61,0x6d,0x5b,0x31,0x5d, + 0x3b,0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20, + 0x20,0x20,0x62,0x72,0x65,0x61,0x6b,0x3b,0xa,0x20, + 0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x7d,0xa,0x20, + 0x20,0x20,0x20,0x20,0x20,0x7d,0xa,0x20,0x20,0x20, + 0x20,0x20,0x20,0x69,0x66,0x20,0x28,0x67,0x61,0x56, + 0x69,0x65,0x77,0x20,0x26,0x26,0x20,0x67,0x61,0x56, + 0x69,0x65,0x77,0x20,0x21,0x3d,0x3d,0x20,0x67,0x61, + 0x43,0x6f,0x64,0x65,0x6c,0x61,0x62,0x29,0x20,0x7b, + 0xa,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x67, + 0x61,0x28,0x27,0x63,0x72,0x65,0x61,0x74,0x65,0x27, + 0x2c,0x20,0x67,0x61,0x56,0x69,0x65,0x77,0x2c,0x20, + 0x27,0x61,0x75,0x74,0x6f,0x27,0x2c,0x20,0x7b,0x6e, + 0x61,0x6d,0x65,0x3a,0x20,0x27,0x76,0x69,0x65,0x77, + 0x27,0x7d,0x29,0x3b,0xa,0x20,0x20,0x20,0x20,0x20, + 0x20,0x7d,0xa,0x20,0x20,0x20,0x20,0x7d,0x29,0x28, + 0x29,0x3b,0xa,0x20,0x20,0x3c,0x2f,0x73,0x63,0x72, + 0x69,0x70,0x74,0x3e,0xa,0xa,0x3c,0x2f,0x62,0x6f, + 0x64,0x79,0x3e,0xa,0x3c,0x2f,0x68,0x74,0x6d,0x6c, + 0x3e,0xa, + }, + }, + "md": &template{ + html: false, + bytes: []byte{ + 0x23,0x20,0x7b,0x7b,0x2e,0x4d,0x65,0x74,0x61,0x2e, + 0x54,0x69,0x74,0x6c,0x65,0x7d,0x7d,0xa,0xa,0x5b, + 0x2f,0x2f,0x5d,0x3a,0x20,0x23,0x20,0x28,0x54,0x68, + 0x69,0x73,0x20,0x69,0x73,0x20,0x74,0x68,0x65,0x20, + 0x64,0x65,0x66,0x61,0x75,0x6c,0x74,0x20,0x74,0x65, + 0x6d,0x70,0x6c,0x61,0x74,0x65,0x20,0x66,0x6f,0x72, + 0x20,0x27,0x6d,0x64,0x27,0x20,0x6f,0x75,0x74,0x70, + 0x75,0x74,0x20,0x66,0x6f,0x72,0x6d,0x61,0x74,0x20, + 0x6f,0x66,0x20,0x74,0x68,0x65,0x20,0x74,0x6f,0x6f, + 0x6c,0x29,0xa,0x5b,0x2f,0x2f,0x5d,0x3a,0x20,0x23, + 0x20,0x28,0x54,0x68,0x69,0x73,0x20,0x66,0x69,0x6c, + 0x65,0x20,0x69,0x73,0x20,0x64,0x69,0x73,0x74,0x72, + 0x69,0x62,0x75,0x74,0x65,0x64,0x20,0x75,0x6e,0x64, + 0x65,0x72,0x20,0x41,0x70,0x61,0x63,0x68,0x65,0x2d, + 0x32,0x20,0x6c,0x69,0x63,0x65,0x6e,0x73,0x65,0x3b, + 0x20,0x73,0x65,0x65,0x20,0x4c,0x49,0x43,0x45,0x4e, + 0x53,0x45,0x20,0x66,0x69,0x6c,0x65,0x20,0x61,0x74, + 0x20,0x74,0x68,0x65,0x20,0x72,0x6f,0x6f,0x74,0x20, + 0x6f,0x66,0x20,0x74,0x68,0x69,0x73,0x20,0x72,0x65, + 0x70,0x6f,0x29,0xa,0xa,0x7b,0x7b,0x69,0x66,0x20, + 0x2e,0x4d,0x65,0x74,0x61,0x2e,0x46,0x65,0x65,0x64, + 0x62,0x61,0x63,0x6b,0x7d,0x7d,0x5b,0x43,0x6f,0x64, + 0x65,0x6c,0x61,0x62,0x20,0x46,0x65,0x65,0x64,0x62, + 0x61,0x63,0x6b,0x5d,0x28,0x7b,0x7b,0x2e,0x4d,0x65, + 0x74,0x61,0x2e,0x46,0x65,0x65,0x64,0x62,0x61,0x63, + 0x6b,0x7d,0x7d,0x29,0x7b,0x7b,0x65,0x6e,0x64,0x7d, + 0x7d,0xa,0xa,0x7b,0x7b,0x72,0x61,0x6e,0x67,0x65, + 0x20,0x2e,0x53,0x74,0x65,0x70,0x73,0x7d,0x7d,0xa, + 0x23,0x23,0x20,0x7b,0x7b,0x2e,0x54,0x69,0x74,0x6c, + 0x65,0x7d,0x7d,0xa,0xa,0x7b,0x7b,0x69,0x66,0x20, + 0x2e,0x44,0x75,0x72,0x61,0x74,0x69,0x6f,0x6e,0x7d, + 0x7d,0x44,0x75,0x72,0x61,0x74,0x69,0x6f,0x6e,0x20, + 0x69,0x73,0x20,0x7b,0x7b,0x2e,0x44,0x75,0x72,0x61, + 0x74,0x69,0x6f,0x6e,0x7d,0x7d,0x7b,0x7b,0x65,0x6e, + 0x64,0x7d,0x7d,0xa,0xa,0x7b,0x7b,0x72,0x61,0x6e, + 0x67,0x65,0x20,0x2e,0x43,0x6f,0x6e,0x74,0x65,0x6e, + 0x74,0x2e,0x4e,0x6f,0x64,0x65,0x73,0x7d,0x7d,0xa, + 0x7b,0x7b,0x2e,0x7d,0x7d,0xa,0x7b,0x7b,0x65,0x6e, + 0x64,0x7d,0x7d,0xa,0x7b,0x7b,0x65,0x6e,0x64,0x7d, + 0x7d,0xa, + }, + }, +} diff --git a/claat/types/meta.go b/claat/types/meta.go new file mode 100644 index 000000000..14d9c7245 --- /dev/null +++ b/claat/types/meta.go @@ -0,0 +1,141 @@ +// 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 types provide types for format-independent codelab data model. +package types + +import ( + "bytes" + "encoding/json" + "fmt" + "time" +) + +// Meta contains a single codelab metadata. +// Format, Env should not be set by a parser, as they may be overwritten +// by the parser callers. +type Meta struct { + ID string `json:"id"` // ID is also part of codelab URL + Duration int `json:"duration"` // Codelab duration in minutes + Title string `json:"title"` // Codelab title + Summary string `json:"summary"` // Short summary + Theme string `json:"theme"` // Usually first item of Categories + Status *LegacyStatus `json:"status"` // Draft, Published, Hidden, etc. + Categories []string `json:"category"` // Categories from the meta table + Tags []string `json:"tags"` // All environments supported by the codelab + Feedback string `json:"feedback,omitempty"` // Issues and bugs are sent here + GA string `json:"ga,omitempty"` // Codelab-specific GA tracking ID + + URL string `json:"url"` // Legacy ID; TODO: remove +} + +// Context is an export context. +// It is defined in this package so that it can be used by both cli and a server. +type Context struct { + Env string `json:"environment"` // Current export environment + Source string `json:"source"` // Codelab source doc + Format string `json:"format"` // Output format, e.g. "html" + Prefix string `json:"prefix,omitempty"` // Assets URL prefix for HTML-based formats + MainGA string `json:"mainga,omitempty"` // Global Google Analytics ID + Updated *ContextTime `json:"updated,omitempty"` // Last update timestamp +} + +// ContextMeta is a composition of export context and meta data. +type ContextMeta struct { + Context + Meta +} + +// Codelab is a top-level structure containing metadata and codelab steps. +type Codelab struct { + Meta + Steps []*Step +} + +// NewStep creates a new codelab step, adding it to c.Steps slice. +func (c *Codelab) NewStep(title string) *Step { + s := &Step{Title: title, Content: NewListNode()} + c.Steps = append(c.Steps, s) + return s +} + +// Step is a single codelab step, containing metadata and actual content. +type Step struct { + Title string // Step title + Tags []string // Step environments + Duration time.Duration // Duration + Content *ListNode // Root node of the step nodes tree +} + +// ContextTime is codelab metadata timestamp. +// It can be of "YYYY-MM-DD" or RFC3339 formats but marshaling +// always uses RFC3339 format. +type ContextTime time.Time + +// MarshalJSON implements Marshaler interface. +func (ct ContextTime) MarshalJSON() ([]byte, error) { + v := time.Time(ct).Format(time.RFC3339) + b := make([]byte, len(v)+2) + b[0] = '"' + b[len(b)-1] = '"' + copy(b[1:], v) + return b, nil +} + +// UnmarshalJSON implements Unmarshaler interface. +// Accepted format is "YYYY-MM-DD" or RFC3339. +func (ct *ContextTime) UnmarshalJSON(b []byte) error { + b = bytes.Trim(b, `"`) + t, err := time.Parse(time.RFC3339, string(b)) + if err != nil { + t, err = time.Parse("2006-01-02", string(b)) + } + if err != nil { + return err + } + *ct = ContextTime(t) + return nil +} + +// LegacyStatus supports legacy status values which are strings +// as opposed to an array, e.g. "['one', u'two', ...]". +type LegacyStatus []string + +// MarshalJSON implements Marshaler interface. +func (s LegacyStatus) MarshalJSON() ([]byte, error) { + if len(s) == 0 { + return []byte("[]"), nil + } + return json.Marshal([]string(s)) +} + +// UnmarshalJSON implements Unmarshaler interface. +func (s *LegacyStatus) UnmarshalJSON(b []byte) error { + if len(b) == 0 { + return nil + } + if b[0] == '"' { + // legacy status: "['s1', u's2', ...]" + // assume no status value contains single quotes + b = bytes.Trim(b, `"`) + b = bytes.Replace(b, []byte("u'"), []byte(`"`), -1) + b = bytes.Replace(b, []byte("'"), []byte(`"`), -1) + } + var v []string + if err := json.Unmarshal(b, &v); err != nil { + return fmt.Errorf("%v: %s", err, b) + } + *s = LegacyStatus(v) + return nil +} diff --git a/claat/types/meta_test.go b/claat/types/meta_test.go new file mode 100644 index 000000000..0f901eb02 --- /dev/null +++ b/claat/types/meta_test.go @@ -0,0 +1,63 @@ +// 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 types + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestLegacyStatus(t *testing.T) { + tests := []struct { + s string + v []string + }{ + {`"[]"`, []string{}}, + {`"['one']"`, []string{"one"}}, + {`"['one', u'two']"`, []string{"one", "two"}}, + {`["one", "two"]`, []string{"one", "two"}}, + } + for i, test := range tests { + var v LegacyStatus + if err := json.Unmarshal([]byte(test.s), &v); err != nil { + t.Errorf("%d: json.Unmarshal(%s): %v", i, test.s, err) + continue + } + if !reflect.DeepEqual(v, LegacyStatus(test.v)) { + t.Errorf("%d: v = %v; want %v", i, v, test.v) + } + } + + tests = []struct { + s string + v []string + }{ + {"[]", nil}, + {"[]", []string{}}, + {`["one"]`, []string{"one"}}, + } + for i, test := range tests { + s := LegacyStatus(test.v) + b, err := json.Marshal(&s) + if err != nil { + t.Errorf("%d: json.Marshal(%s): %v", i, test.v, err) + continue + } + if string(b) != test.s { + t.Errorf("%d: b = %s; want %s", i, b, test.s) + } + } +} diff --git a/claat/types/node.go b/claat/types/node.go new file mode 100644 index 000000000..f95685562 --- /dev/null +++ b/claat/types/node.go @@ -0,0 +1,420 @@ +// 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 types + +import ( + "sort" + "strings" +) + +// 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 +) + +// 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) +} + +// IsItemsList returns true if t is one of ItemsListNode types. +func IsItemsList(t NodeType) bool { + return t&(NodeItemsList|NodeItemsCheck|NodeItemsFAQ) != 0 +} + +// IsHeader returns true if t is one of header types. +func IsHeader(t NodeType) bool { + return t&(NodeHeader|NodeHeaderCheck|NodeHeaderFAQ) != 0 +} + +// 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 +} + +func (b *node) MutateType(t NodeType) { + if IsItemsList(b.typ) && IsItemsList(t) || IsHeader(b.typ) && IsHeader(t) { + b.typ = t + } +} + +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) +} + +// 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) +} + +// Append appends nodes n to the end of l.Nodes slice. +func (l *ListNode) Append(n ...Node) { + l.Nodes = append(l.Nodes, n...) +} + +// Prepend prepends nodes n at the beginning of l.Nodes slice. +func (l *ListNode) Prepend(n ...Node) { + l.Nodes = append(n, l.Nodes...) +} + +// NewGridNode creates a new grid with optional content. +func NewGridNode(rows ...[]*GridCell) *GridNode { + return &GridNode{ + node: node{typ: NodeGrid}, + Rows: rows, + } +} + +// 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 +} + +// 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 { + return &ItemsListNode{ + node: node{typ: NodeItemsList}, + ListType: typ, + Start: start, + } +} + +// 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 +} + +// NewTextNode creates a new Node of type NodeText. +func NewTextNode(v string) *TextNode { + return &TextNode{ + node: node{typ: NodeText}, + Value: v, + } +} + +// 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) == "" +} + +// NewCodeNode creates a new Node of type NodeCode. +// Use term argument to specify a terminal output. +func NewCodeNode(v string, term bool) *CodeNode { + return &CodeNode{ + node: node{typ: NodeCode}, + Value: v, + Term: term, + } +} + +// CodeNode is either a source code snippet or a terminal output. +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) == "" +} + +// 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() +} + +// NewURLNode creates a new Node of type NodeURL with optinal 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() +} + +// NewImageNode creates a new ImageNode with the give src. +func NewImageNode(src string) *ImageNode { + return &ImageNode{ + node: node{typ: NodeImage}, + Src: src, + } +} + +// ImageNode represents a single image. +type ImageNode struct { + node + Src string + MaxWidth float32 +} + +// Empty returns true if its Src is zero, excluding space runes. +func (in *ImageNode) Empty() bool { + return strings.TrimSpace(in.Src) == "" +} + +// NewButtonNode creates a new button with optional content nodes n. +func NewButtonNode(raised, colored, download bool, n ...Node) *ButtonNode { + return &ButtonNode{ + node: node{typ: NodeButton}, + Raised: raised, + Colored: colored, + Download: download, + Content: NewListNode(n...), + } +} + +// ButtonNode represents a button, e.g. "Download Zip". +type ButtonNode struct { + node + Raised bool + Colored bool + Download bool + Content *ListNode +} + +// Empty returns true if its content is empty. +func (bn *ButtonNode) Empty() bool { + return bn.Content.Empty() +} + +// NewSurveyNode creates a new survey node with optional questions. +// If survey is nil, a new empty map will be created. +func NewSurveyNode(id string, groups ...*SurveyGroup) *SurveyNode { + return &SurveyNode{ + node: node{typ: NodeSurvey}, + ID: id, + Groups: groups, + } +} + +// 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 +} + +// 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 +} + +// 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...), + } +} + +// InfoboxKind defines kind type for InfoboxNode. +type InfoboxKind string + +// InfoboxNode variations. +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 +} + +// Empty returns true if ib content is empty. +func (ib *InfoboxNode) Empty() bool { + return ib.Content.Empty() +} diff --git a/claat/update.go b/claat/update.go new file mode 100644 index 000000000..1e4815267 --- /dev/null +++ b/claat/update.go @@ -0,0 +1,196 @@ +// 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 main + +import ( + "encoding/json" + "flag" + "fmt" + "io/ioutil" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/googlecodelabs/tools/claat/types" +) + +// cmdUpdate is the "claat update ..." subcommand. +func cmdUpdate() { + roots := flag.Args() + if len(roots) == 0 { + roots = []string{"."} + } + dirs, err := scanPaths(roots) + if err != nil { + fatalf("%v", err) + } + if len(dirs) == 0 { + fatalf("no codelabs found in %s", strings.Join(roots, ", ")) + } + + type result struct { + dir string + meta *types.Meta + err error + } + ch := make(chan *result, len(dirs)) + for _, d := range dirs { + go func(d string) { + // random sleep up to 1 sec + // to reduce number of rate limit errors + time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond) + meta, err := updateCodelab(d) + ch <- &result{d, meta, err} + }(d) + } + for _ = range dirs { + res := <-ch + if res.err != nil { + errorf(reportErr, res.dir, res.err) + } else { + printf(reportOk, res.meta.ID) + } + } +} + +// updateCodelab reads metadata from a dir/codelab.json file, +// re-exports the codelab just like it normally would in exportCodelab, +// and removes assets (images) which are not longer in use. +func updateCodelab(dir string) (*types.Meta, error) { + // get stored codelab metadata and fail early if we can't + meta, err := readMeta(filepath.Join(dir, metaFilename)) + if err != nil { + return nil, err + } + // override allowed options from cli + if *prefix != "" { + meta.Prefix = *prefix + } + if *globalGA != "" { + meta.MainGA = *globalGA + } + + // fetch and parse codelab source + clab, err := slurpCodelab(meta.Source) + if err != nil { + return nil, err + } + updated := types.ContextTime(clab.mod) + meta.Context.Updated = &updated + + // update image references before writing codelab + imgmap := rewriteImages(clab.Steps) + basedir := filepath.Join(dir, "..") + newdir := codelabDir(basedir, &clab.Meta) + if err := writeCodelab(newdir, clab.Codelab, &meta.Context); err != nil { + return nil, err + } + + // slurp codelab assets to disk + var client *http.Client + if clab.typ == srcGoogleDoc { + client, err = driveClient() + if err != nil { + return nil, err + } + } + imgdir := filepath.Join(newdir, imgDirname) + if err := downloadImages(client, imgdir, imgmap); err != nil { + return nil, err + } + + // cleanup: + // - remove original dir if codelab ID has changed and so has the output dir + // - otherwise, remove images which are not in imgs + old := codelabDir(basedir, &meta.Meta) + if old != newdir { + return &meta.Meta, os.RemoveAll(old) + } + visit := func(p string, fi os.FileInfo, err error) error { + if err != nil || p == imgdir { + return err + } + if fi.IsDir() { + return filepath.SkipDir + } + if _, ok := imgmap[filepath.Base(p)]; !ok { + return os.Remove(p) + } + return nil + } + return &meta.Meta, filepath.Walk(imgdir, visit) +} + +// scanPaths looks for codelab metadata files in roots, recursively. +// The roots argument can contain overlapping directories as the return +// value is always de-duped. +func scanPaths(roots []string) ([]string, error) { + type result struct { + root string + dirs []string + err error + } + ch := make(chan *result, len(roots)) + for _, r := range roots { + go func(r string) { + dirs, err := walkPath(r) + ch <- &result{r, dirs, err} + }(r) + } + var dirs []string + for _ = range roots { + res := <-ch + if res.err != nil { + return nil, fmt.Errorf("%s: %v", res.root, res.err) + } + dirs = append(dirs, res.dirs...) + } + return unique(dirs), nil +} + +// walkPath walks root dir recursively, looking for metaFilename files. +func walkPath(root string) ([]string, error) { + var dirs []string + err := filepath.Walk(root, func(p string, fi os.FileInfo, err error) error { + if err != nil || fi.IsDir() { + return err + } + if filepath.Base(p) == metaFilename { + dirs = append(dirs, filepath.Dir(p)) + } + return nil + }) + return dirs, err +} + +// readMeta reads codelab metadata from file. +// It will convert legacy fields to the actual. +func readMeta(file string) (*types.ContextMeta, error) { + b, err := ioutil.ReadFile(file) + if err != nil { + return nil, err + } + var cm types.ContextMeta + if err := json.Unmarshal(b, &cm); err != nil { + return nil, err + } + if cm.Format == "" { + cm.Format = "html" + } + return &cm, nil +}