diff --git a/README.md b/README.md index eda1ffa43..1480428d0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Tools for authoring and serving codelabs -[![Demo](https://storage.googleapis.com/claat/demo.png)](https://storage.googleapis.com/claat/demo.mp4) - Codelabs are interactive instructional tutorials, which can be authored in Google Docs using some simple formatting conventions. You can also author codelabs using markdown syntax. This repo contains all the tools and documentation you’ll need for building and publishing diff --git a/claat/README.md b/claat/README.md index 0c82a383b..05eeb1ea3 100644 --- a/claat/README.md +++ b/claat/README.md @@ -14,7 +14,7 @@ The binaries, as well as their checksums are available at the Alternatively, if you have [Go installed](https://golang.org/doc/install): - go get github.com/googlecodelabs/tools/claat + go install github.com/googlecodelabs/tools/claat@latest If none of the above works, compile the tool from source following Dev workflow instructions below. diff --git a/claat/VERSION b/claat/VERSION index ee90284c2..21bb5e156 100644 --- a/claat/VERSION +++ b/claat/VERSION @@ -1 +1 @@ -1.0.4 +2.2.5 diff --git a/claat/cmd/export.go b/claat/cmd/export.go index 209cc8f56..652452599 100644 --- a/claat/cmd/export.go +++ b/claat/cmd/export.go @@ -111,20 +111,19 @@ func ExportCodelab(src string, rt http.RoundTripper, opts CmdExportOptions) (*ty lastmod := types.ContextTime(clab.Mod) clab.Meta.Source = src meta := &clab.Meta - ctx := &types.Context{ - Env: opts.Expenv, - Format: opts.Tmplout, - Prefix: opts.Prefix, - MainGA: opts.GlobalGA, - Updated: &lastmod, - } dir := opts.Output // output dir or stdout if !isStdout(dir) { dir = codelabDir(dir, meta) } // write codelab and its metadata to disk - return meta, writeCodelab(dir, clab.Codelab, opts.ExtraVars, ctx) + return meta, writeCodelab(dir, clab.Codelab, opts.ExtraVars, &types.Context{ + Env: opts.Expenv, + Format: opts.Tmplout, + Prefix: opts.Prefix, + MainGA: opts.GlobalGA, + Updated: &lastmod, + }) } func ExportCodelabMemory(src io.ReadCloser, w io.Writer, opts CmdExportOptions) (*types.Meta, error) { diff --git a/claat/fetch/drive/auth/auth.go b/claat/fetch/drive/auth/auth.go index a4ce5ff6f..b0a6df16f 100644 --- a/claat/fetch/drive/auth/auth.go +++ b/claat/fetch/drive/auth/auth.go @@ -18,6 +18,7 @@ import ( "fmt" "io/ioutil" "log" + "net" "net/http" "os" "path" @@ -43,7 +44,7 @@ var ( ClientID: googClient, ClientSecret: googSecret, Scopes: []string{scopeDriveReadOnly}, - RedirectURL: "urn:ietf:wg:oauth:2.0:oob", + RedirectURL: "http://localhost:8091", Endpoint: oauth2.Endpoint{ AuthURL: "https://accounts.google.com/o/oauth2/auth", TokenURL: "https://accounts.google.com/o/oauth2/token", @@ -51,6 +52,25 @@ var ( } ) +// The webserver waits for an oauth code in the three-legged auth flow. +func startWebServer() (code string, err error) { + listener, err := net.Listen("tcp", "localhost:8091") + if err != nil { + return "", err + } + codeCh := make(chan string) + + go http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + code := r.FormValue("code") + codeCh <- code // send code to OAuth flow + listener.Close() + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, "Received oauth code\r\nYou can now safely close this browser window.") + })) + code = <- codeCh + return code, nil +} + type authorizationHandler func(conf *oauth2.Config) (*oauth2.Token, error) type internalOptions struct { @@ -214,10 +234,10 @@ func (c *cachedTokenSource) Token() (*oauth2.Token, error) { // authorize performs user authorization flow, asking for permissions grant. func authorize(conf *oauth2.Config) (*oauth2.Token, error) { - aurl := conf.AuthCodeURL("unused", oauth2.AccessTypeOffline) - fmt.Printf("Authorize me at following URL, please:\n\n%s\n\nCode: ", aurl) - var code string - if _, err := fmt.Scan(&code); err != nil { + aURL := conf.AuthCodeURL("unused", oauth2.AccessTypeOffline) + fmt.Printf("Authorize me at following URL, please:\n\n%s\n", aURL) + code, err := startWebServer() + if err != nil { return nil, err } return conf.Exchange(context.Background(), code) diff --git a/claat/fetch/fetch.go b/claat/fetch/fetch.go index 97c536492..66ca877e7 100644 --- a/claat/fetch/fetch.go +++ b/claat/fetch/fetch.go @@ -46,6 +46,9 @@ const ( // driveAPI is a base URL for Drive API driveAPI = "https://www.googleapis.com/drive/v3" + + // Minimum image size in bytes for extension detection. + minImageSize = 11 ) // TODO: create an enum for use with "nometa" for readability's sake @@ -111,6 +114,7 @@ type Fetcher struct { roundTripper http.RoundTripper } +// NewFetcher creates an instance of Fetcher. func NewFetcher(at string, pm map[string]bool, rt http.RoundTripper) (*Fetcher, error) { return &Fetcher{ authHelper: nil, @@ -225,7 +229,7 @@ func (f *Fetcher) SlurpImages(src, dir string, n []nodes.Node, images map[string for _, imageNode := range imageNodes { go func(imageNode *nodes.ImageNode) { url := imageNode.Src - file, err := f.slurpBytes(src, dir, url) + file, err := f.slurpBytes(src, dir, url, imageNode.Bytes) if err == nil { imageNode.Src = filepath.Join(util.ImgDirname, file) } @@ -247,43 +251,52 @@ func (f *Fetcher) SlurpImages(src, dir string, n []nodes.Node, images map[string return nil } -func (f *Fetcher) slurpBytes(codelabSrc, dir, imgURL string) (string, error) { - // images can be local in Markdown cases or remote. +func (f *Fetcher) slurpBytes(codelabSrc, dir, imgURL string, imgBytes []byte) (string, error) { + // images can be data URLs, local in Markdown cases or remote. // Only proceed a simple copy on local reference. var b []byte var ext string - u, err := url.Parse(imgURL) - if err != nil { - return "", err - } + var err error - // If the codelab source is being downloaded from the network, then we should interpret - // the image URL in the same way. - srcUrl, err := url.Parse(codelabSrc) - if err == nil && srcUrl.Host != "" { - u = srcUrl.ResolveReference(u) - } - - if u.Host == "" { - if imgURL, err = restrictPathToParent(imgURL, filepath.Dir(codelabSrc)); err != nil { - return "", err + if len(imgBytes) > 0 { + // Slurp bytes from image URL data. + b = imgBytes + if ext, err = imgExtFromBytes(b); err != nil { + return "", fmt.Errorf("Error reading image type: %v", err) } - b, err = ioutil.ReadFile(imgURL) - ext = filepath.Ext(imgURL) } else { - b, err = f.slurpRemoteBytes(u.String(), 5) - if string(b[6:10]) == "JFIF" { - ext = ".jpeg" - } else if string(b[0:3]) == "GIF" { - ext = ".gif" + // Slurp bytes from local or remote URL. + u, err := url.Parse(imgURL) + if err != nil { + return "", err + } + + // If the codelab source is being downloaded from the network, then we should interpret + // the image URL in the same way. + srcURL, err := url.Parse(codelabSrc) + if err == nil && srcURL.Host != "" { + u = srcURL.ResolveReference(u) + } + + if u.Host == "" { + if imgURL, err = restrictPathToParent(imgURL, filepath.Dir(codelabSrc)); err != nil { + return "", err + } + if b, err = ioutil.ReadFile(imgURL); err != nil { + return "", err + } + ext = filepath.Ext(imgURL) } else { - ext = ".png" + if b, err = f.slurpRemoteBytes(u.String(), 5); err != nil { + return "", fmt.Errorf("Error downloading image at %s: %v", u.String(), err) + } + if ext, err = imgExtFromBytes(b); err != nil { + return "", fmt.Errorf("Error reading image type at %s: %v", u.String(), err) + } } } - if err != nil { - return "", err - } + // Generate image file from slurped bytes. crc := crc64.Checksum(b, f.crcTable) file := fmt.Sprintf("%x%s", crc, ext) dst := filepath.Join(dir, file) @@ -479,7 +492,10 @@ func gdocID(url string) string { } func gdocExportURL(id string) string { - return fmt.Sprintf("%s/files/%s/export?mimeType=text/html", driveAPI, id) + q := url.Values{ + "mimeType": {"text/html"}, + } + return fmt.Sprintf("%s/files/%s/export?%s", driveAPI, id, q.Encode()) } // restrictPathToParent will ensure that assetPath is in parent. @@ -509,3 +525,17 @@ func isStdout(filename string) bool { func codelabDir(base string, m *types.Meta) string { return filepath.Join(base, m.ID) } + +func imgExtFromBytes(b []byte) (string, error) { + if len(b) < minImageSize { + return "", fmt.Errorf("error parsing image - response \"%s\" is too small (< %d bytes)", b, minImageSize) + } + ext := ".png" + switch { + case string(b[6:10]) == "JFIF": + ext = ".jpeg" + case string(b[0:3]) == "GIF": + ext = ".gif" + } + return ext, nil +} diff --git a/claat/fetch/fetch_test.go b/claat/fetch/fetch_test.go index dfb5d3326..0f010ceec 100644 --- a/claat/fetch/fetch_test.go +++ b/claat/fetch/fetch_test.go @@ -104,6 +104,34 @@ func TestFuzzRestrictPathToParent(t *testing.T) { } } +func TestImgExtFromBytes(t *testing.T) { + tests := []struct { + bytes []byte + + wantExt string + wantErr bool + }{ + {[]byte("012345JFIF0"), ".jpeg", false}, + {[]byte("GIF34567890"), ".gif", false}, + {[]byte("SOMETHINGELSE"), ".png", false}, + {[]byte("GIF345JFIF0"), ".jpeg", false}, + {[]byte("toosmall"), "", true}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("bytes: %s", tc.bytes), func(t *testing.T) { + ext, err := imgExtFromBytes(tc.bytes) + + if err != nil != tc.wantErr { + t.Errorf("imgExtFromBytes() error = %v, wantErr %v", err, tc.wantErr) + return + } + if ext != tc.wantExt { + t.Errorf("imgExtFromBytes() return: got %s, wanted %s", ext, tc.wantExt) + } + }) + } +} + // safeAbs compute Abs of p and fail the test if not valid. // Empty string return empty path. func safeAbs(t *testing.T, p string) string { diff --git a/claat/go.mod b/claat/go.mod index 74e2f02bf..6950a1472 100644 --- a/claat/go.mod +++ b/claat/go.mod @@ -4,6 +4,7 @@ go 1.16 require ( github.com/google/go-cmp v0.5.6 + github.com/stoewer/go-strcase v1.2.0 // indirect github.com/x1ddos/csslex v0.0.0-20160125172232-7894d8ab8bfe github.com/yuin/goldmark v1.3.7 golang.org/x/net v0.0.0-20210525063256-abc453219eb5 diff --git a/claat/go.sum b/claat/go.sum index 799f289a2..210c6f58d 100644 --- a/claat/go.sum +++ b/claat/go.sum @@ -107,8 +107,11 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stoewer/go-strcase v1.2.0 h1:Z2iHWqGXH00XYgqDmNgQbIBxf3wrNq0F3feEy0ainaU= +github.com/stoewer/go-strcase v1.2.0/go.mod h1:IBiWB2sKIp3wVVQ3Y035++gc+knqhUQag1KpM8ahLw8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/x1ddos/csslex v0.0.0-20160125172232-7894d8ab8bfe h1:SX7lFdwn40ahL78CxofAh548P+dcWjdRNpirU7+sKiE= github.com/x1ddos/csslex v0.0.0-20160125172232-7894d8ab8bfe/go.mod h1:SwmD4V+Y0RjNqvt8hW2FpZNkQnoFVNtBF9qEnevUueU= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/claat/nodes/iframe.go b/claat/nodes/iframe.go index 664cb7b30..05fc593f0 100644 --- a/claat/nodes/iframe.go +++ b/claat/nodes/iframe.go @@ -6,12 +6,16 @@ var IframeAllowlist = []string{ "carto.com", "codepen.io", "dartlang.org", + "dartpad.dev", + "demo.arcade.software", "github.com", "glitch.com", "google.com", "google.dev", "observablehq.com", "repl.it", + "stackblitz.com", + "vimeo.com", "web.dev", } diff --git a/claat/nodes/image.go b/claat/nodes/image.go index b0fd5e7dd..7c966c42d 100644 --- a/claat/nodes/image.go +++ b/claat/nodes/image.go @@ -7,6 +7,7 @@ type NewImageNodeOptions struct { Width float32 Alt string Title string + Bytes []byte } // NewImageNode creates a new ImageNode with the given options. @@ -18,6 +19,7 @@ func NewImageNode(opts NewImageNodeOptions) *ImageNode { Width: opts.Width, Alt: opts.Alt, Title: opts.Title, + Bytes: opts.Bytes, } } @@ -28,11 +30,12 @@ type ImageNode struct { Width float32 Alt string Title string + Bytes []byte } // Empty returns true if its Src is zero, excluding space runes. func (in *ImageNode) Empty() bool { - return strings.TrimSpace(in.Src) == "" + return strings.TrimSpace(in.Src) == "" && len(in.Bytes) == 0 } // ImageNodes extracts everything except NodeImage nodes, recursively. diff --git a/claat/nodes/image_test.go b/claat/nodes/image_test.go index 503827f74..c1665b140 100644 --- a/claat/nodes/image_test.go +++ b/claat/nodes/image_test.go @@ -1,11 +1,14 @@ package nodes import ( + "encoding/base64" "testing" "github.com/google/go-cmp/cmp" ) +var testBytes, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + func TestNewImageNode(t *testing.T) { tests := []struct { name string @@ -19,7 +22,7 @@ func TestNewImageNode(t *testing.T) { }, }, { - name: "NonEmpty", + name: "StandardURL", inOpts: NewImageNodeOptions{ Src: "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png", Width: 1.0, @@ -34,6 +37,22 @@ func TestNewImageNode(t *testing.T) { Alt: "bar", }, }, + { + name: "DataURL", + inOpts: NewImageNodeOptions{ + Width: 1.0, + Title: "foo", + Alt: "bar", + Bytes: testBytes, + }, + out: &ImageNode{ + node: node{typ: NodeImage}, + Width: 1.0, + Title: "foo", + Alt: "bar", + Bytes: testBytes, + }, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { diff --git a/claat/parser/gdoc/parse.go b/claat/parser/gdoc/parse.go index 14c8b5d2a..53843a5b7 100644 --- a/claat/parser/gdoc/parse.go +++ b/claat/parser/gdoc/parse.go @@ -16,6 +16,7 @@ package gdoc import ( "bytes" + "encoding/base64" "fmt" "io" "net/url" @@ -32,6 +33,7 @@ import ( "github.com/googlecodelabs/tools/claat/parser" "github.com/googlecodelabs/tools/claat/types" "github.com/googlecodelabs/tools/claat/util" + "github.com/stoewer/go-strcase" ) func init() { @@ -390,7 +392,7 @@ func metaTable(ds *docState) { continue } s := stringifyNode(tr.FirstChild.NextSibling, true, false) - fieldName := strings.ToLower(stringifyNode(tr.FirstChild, true, false)) + fieldName := strcase.SnakeCase(stringifyNode(tr.FirstChild, true, false)) switch fieldName { case "id", "url": ds.clab.ID = s @@ -407,9 +409,9 @@ func metaTable(ds *docState) { v := util.NormalizedSplit(s) sv := types.LegacyStatus(v) ds.clab.Status = &sv - case "feedback", "feedback link": + case "feedback", "feedback_link": ds.clab.Feedback = s - case "analytics", "analytics account", "google analytics": + case "analytics", "analytics_account", "google_analytics": ds.clab.GA = s default: // If not explicitly parsed, it might be a pass_metadata value. @@ -685,7 +687,7 @@ func image(ds *docState) nodes.Node { // For iframe, make sure URL ends in allowlisted domain. ok := false for _, domain := range nodes.IframeAllowlist { - if strings.HasSuffix(u.Hostname(), domain) { + if u.Hostname() == domain { ok = true break } @@ -696,12 +698,32 @@ func image(ds *docState) nodes.Node { errorAlt = "The domain of the requested iframe (" + u.Hostname() + ") has not been whitelisted." fmt.Fprint(os.Stderr, errorAlt+"\n") } + + var imageBytes []byte + var imageSrc string s := nodeAttr(ds.cur, "src") if s == "" { return nil + } else if strings.HasPrefix(s, "data:") { + _, data, ok := strings.Cut(s, ",") + if !ok { + fmt.Fprint(os.Stderr, "Failed to decode data URL: "+s+" \n") + return nil + } + b, err := base64.StdEncoding.DecodeString(data) + if err != nil { + fmt.Fprint(os.Stderr, "Failed to decode data URL: "+s+"\n"+err.Error()+"\n") + return nil + } + imageSrc = "" + imageBytes = b + } else { + imageSrc = s + imageBytes = []byte{} } n := nodes.NewImageNode(nodes.NewImageNodeOptions{ - Src: s, + Src: imageSrc, + Bytes: imageBytes, Width: styleFloatValue(ds.cur, "width"), }) n.MutateBlock(findBlockParent(ds.cur)) diff --git a/claat/parser/gdoc/parse_test.go b/claat/parser/gdoc/parse_test.go index dc0ab7083..ec5b37619 100644 --- a/claat/parser/gdoc/parse_test.go +++ b/claat/parser/gdoc/parse_test.go @@ -16,6 +16,7 @@ package gdoc import ( "bytes" + "encoding/base64" "io" "reflect" "strings" @@ -214,7 +215,7 @@ func TestMetaTablePassMetadata(t *testing.T) {
[[import shared]]
+ + +icon.
@@ -405,6 +409,33 @@ func TestParseDoc(t *testing.T) { para.MutateBlock(true) content.Append(para) + bytes, _ := base64.StdEncoding.DecodeString("/9j/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAAA//EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AN//Z") + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Bytes: bytes, + Alt: "JPEG", + }) + para = nodes.NewListNode(img) + para.MutateBlock(true) + content.Append(para) + + bytes, _ = base64.StdEncoding.DecodeString("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Bytes: bytes, + Alt: "GIF", + }) + para = nodes.NewListNode(img) + para.MutateBlock(true) + content.Append(para) + + bytes, _ = base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVQYV2NgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=") + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ + Bytes: bytes, + Alt: "PNG", + }) + para = nodes.NewListNode(img) + para.MutateBlock(true) + content.Append(para) + img = nodes.NewImageNode(nodes.NewImageNodeOptions{ Src: "https://host/small.png", Width: 25.5, diff --git a/claat/parser/md/parse.go b/claat/parser/md/parse.go index 84a5adc3b..efba695b0 100644 --- a/claat/parser/md/parse.go +++ b/claat/parser/md/parse.go @@ -32,6 +32,7 @@ import ( "golang.org/x/net/html" "golang.org/x/net/html/atom" + "github.com/stoewer/go-strcase" "github.com/googlecodelabs/tools/claat/nodes" "github.com/googlecodelabs/tools/claat/parser" @@ -44,17 +45,18 @@ import ( // Metadata constants for the YAML header const ( - MetaAuthors = "authors" - MetaSummary = "summary" - MetaID = "id" - MetaCategories = "categories" - MetaEnvironments = "environments" - MetaStatus = "status" - MetaFeedbackLink = "feedback link" - MetaAnalyticsAccount = "analytics account" - MetaTags = "tags" - MetaSource = "source" - MetaDuration = "duration" + MetaAuthors = "authors" + MetaSummary = "summary" + MetaID = "id" + MetaCategories = "categories" + MetaEnvironments = "environments" + MetaStatus = "status" + MetaFeedbackLink = "feedback_link" + MetaAnalyticsAccount = "analytics_account" + MetaAnalyticsGa4Account = "analytics_ga4_account" + MetaTags = "tags" + MetaSource = "source" + MetaDuration = "duration" ) const ( @@ -181,7 +183,7 @@ type docState struct { survey int // last used survey ID step *types.Step // current codelab step lastNode nodes.Node // last appended node - env []string // current enviornment + env []string // current environment cur *html.Node // current HTML node stack []*stackItem // cur and flags stack } @@ -411,7 +413,7 @@ func parseMetadata(ds *docState, opts parser.Options) error { // and assigns the values to any keys that match a codelab metadata field as defined by the meta* constants. func addMetadataToCodelab(m map[string]string, c *types.Codelab, opts parser.Options) error { for k, v := range m { - switch k { + switch strcase.SnakeCase(k) { case MetaAuthors: // Directly assign the summary to the codelab field. c.Authors = v @@ -438,6 +440,9 @@ func addMetadataToCodelab(m map[string]string, c *types.Codelab, opts parser.Opt case MetaAnalyticsAccount: // Directly assign the GA id to the codelab field. c.GA = v + case MetaAnalyticsGa4Account: + // Directly assign the GA id to the codelab field. + c.GA4 = v case MetaTags: // Standardize the tags and append to the codelab field. c.Tags = append(c.Tags, util.NormalizedSplit(v)...) @@ -775,7 +780,7 @@ func image(ds *docState) nodes.Node { // For iframe, make sure URL ends in allowlisted domain. ok := false for _, domain := range nodes.IframeAllowlist { - if strings.HasSuffix(u.Hostname(), domain) { + if u.Hostname() == domain { ok = true break } diff --git a/claat/parser/md/parse_test.go b/claat/parser/md/parse_test.go index 0399ccb67..1b39bb415 100644 --- a/claat/parser/md/parse_test.go +++ b/claat/parser/md/parse_test.go @@ -171,6 +171,7 @@ func TestParseMetadata(t *testing.T) { Tags: []string{"kiosk", "web"}, Feedback: "https://www.google.com", GA: "12345", + GA4: "54321", Extra: map[string]string{}, } @@ -180,8 +181,9 @@ authors: john smith summary: abcdefghij categories: not, really environments: kiosk, web -analytics account: 12345 -feedback link: https://www.google.com +analytics_account: 12345 +analytics_ga4_account: 54321 +feedback_link: https://www.google.com --- ` @@ -204,8 +206,9 @@ func TestParseMetadataPassMetadata(t *testing.T) { Tags: []string{"kiosk", "web"}, Feedback: "https://www.google.com", GA: "12345", + GA4: "54321", Extra: map[string]string{ - "extrafieldtwo": "bbbbb", + "extra_field_two": "bbbbb", }, } @@ -215,10 +218,11 @@ authors: john smith summary: abcdefghij categories: not, really environments: kiosk, web -analytics account: 12345 -feedback link: https://www.google.com -extrafieldone: aaaaa -extrafieldtwo: bbbbb +analytics_account: 12345 +analytics_ga4_account: 54321 +feedback_link: https://www.google.com +extra_field_one: aaaaa +extra_field_two: bbbbb --- ` @@ -226,7 +230,7 @@ extrafieldtwo: bbbbb opts := *parser.NewOptions() opts.PassMetadata = map[string]bool{ - "extrafieldtwo": true, + "extra_field_two": true, } c := mustParseCodelab(content, opts) @@ -277,8 +281,9 @@ authors: john smith summary: abcdefghij categories: not, really environments: kiosk, web -analytics account: 12345 -feedback link: https://www.google.com +analytics_account: 12345 +analytics_ga4_account: 54321 +feedback_link: https://www.google.com extrafieldone: aaaaa extrafieldtwo: bbbbb diff --git a/claat/render/template.go b/claat/render/template.go index ad73cbc78..5a50c9607 100644 --- a/claat/render/template.go +++ b/claat/render/template.go @@ -35,14 +35,15 @@ import ( // Context is a template context during execution. type Context struct { - Env string - Prefix string - GlobalGA string - Format string - Meta *types.Meta - Steps []*types.Step - Updated string - Extra map[string]string // Extra variables passed from the command line. + Env string + Prefix string + GlobalGA string + GlobalGA4 string + Format string + Meta *types.Meta + Steps []*types.Step + Updated string + Extra map[string]string // Extra variables passed from the command line. } // Execute renders a template of the fmt format into w. @@ -104,6 +105,7 @@ var funcMap = map[string]interface{}{ res += kvLine(mdParse.MetaTags, strings.Join(meta.Tags, ",")) res += kvLine(mdParse.MetaFeedbackLink, meta.Feedback) res += kvLine(mdParse.MetaAnalyticsAccount, meta.GA) + res += kvLine(mdParse.MetaAnalyticsGa4Account, meta.GA4) res += kvLine(mdParse.MetaSource, meta.Source) res += kvLine(mdParse.MetaDuration, strconv.Itoa(meta.Duration)) diff --git a/claat/render/template.html b/claat/render/template.html index 8ef0f442b..335454583 100644 --- a/claat/render/template.html +++ b/claat/render/template.html @@ -23,7 +23,7 @@