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) { Final - Feedback + Feedback Link https://example.com/issues @@ -237,7 +238,7 @@ func TestMetaTablePassMetadata(t *testing.T) { p := &Parser{} opts := *parser.NewOptions() opts.PassMetadata = map[string]bool{ - "extrafieldone": true, + "extra_field_one": true, } clab, err := p.Parse(markupReader(markup), opts) @@ -256,7 +257,7 @@ func TestMetaTablePassMetadata(t *testing.T) { // TODO: move sorting to Parse of the parser package Tags: []string{"kiosk", "web"}, Extra: map[string]string{ - "extrafieldone": "11111", + "extra_field_one": "11111", }, } if !reflect.DeepEqual(clab.Meta, meta) { @@ -295,6 +296,9 @@ func TestParseDoc(t *testing.T) {

[[import shared]]

alt text +

JPEG

+

GIF

+

PNG

icon.

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

@@ -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 @@ {{.Meta.Title}} - + - + - - - - + + + + diff --git a/claat/types/codelab.go b/claat/types/codelab.go index cfe7a85b3..fdb0ddd82 100644 --- a/claat/types/codelab.go +++ b/claat/types/codelab.go @@ -35,6 +35,7 @@ type Meta struct { 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 + GA4 string `json:"ga4,omitempty"` // Codelab-specific GA4 tracking ID Extra map[string]string `json:"extra,omitempty"` // Extra metadata specified in pass_metadata URL string `json:"url"` // Legacy ID; TODO: remove diff --git a/codelab-elements/google-codelab-about/google_codelab_about.js b/codelab-elements/google-codelab-about/google_codelab_about.js index 0b89a0903..40416ef65 100644 --- a/codelab-elements/google-codelab-about/google_codelab_about.js +++ b/codelab-elements/google-codelab-about/google_codelab_about.js @@ -64,6 +64,12 @@ class CodelabAbout extends HTMLElement { } } + /** + * @export + * @override + */ + disconnectedCallback() {} + /** * @return {!Array} * @export diff --git a/codelab-elements/google-codelab-analytics/google_codelab_analytics.js b/codelab-elements/google-codelab-analytics/google_codelab_analytics.js index 3c1df8970..6a5e32365 100644 --- a/codelab-elements/google-codelab-analytics/google_codelab_analytics.js +++ b/codelab-elements/google-codelab-analytics/google_codelab_analytics.js @@ -17,7 +17,10 @@ goog.module('googlecodelabs.CodelabAnalytics'); +const Const = goog.require('goog.string.Const'); const EventHandler = goog.require('goog.events.EventHandler'); +const TrustedResourceUrl = goog.require('goog.html.TrustedResourceUrl'); +const {safeScriptEl} = goog.require('safevalues.dom'); /** * The general codelab action event fired for trackable interactions. @@ -38,6 +41,24 @@ const PAGEVIEW_EVENT = 'google-codelab-pageview'; */ const GAID_ATTR = 'gaid'; +/** + * The Google Analytics GA4 ID. + * @const {string} + */ +const GA4ID_ATTR = 'ga4id'; + +/** @const {string} */ +const GTAG = 'gtag'; + +/** + * Namespaced data layer for use with GA4 properties. Allows for independent + * data layers so that other data layers, like that for GTM, don't receive data + * they don't need. + * + * @const {string} + */ +const CODELAB_DATA_LAYER = 'codelabDataLayer'; + /** @const {string} */ const CODELAB_ID_ATTR = 'codelab-id'; @@ -47,6 +68,12 @@ const CODELAB_ID_ATTR = 'codelab-id'; */ const CODELAB_GAID_ATTR = 'codelab-gaid'; +/** + * The GA4ID defined by the current codelab. + * @const {string} + */ +const CODELAB_GA4ID_ATTR = 'codelab-ga4id'; + /** @const {string} */ const CODELAB_ENV_ATTR = 'environment'; @@ -103,6 +130,9 @@ class CodelabAnalytics extends HTMLElement { /** @private {?string} */ this.gaid_; + /** @private {?string} */ + this.ga4Id_; + /** @private {?string} */ this.codelabId_; @@ -125,8 +155,9 @@ class CodelabAnalytics extends HTMLElement { */ connectedCallback() { this.gaid_ = this.getAttribute(GAID_ATTR) || ''; + this.ga4Id_ = this.getAttribute(GA4ID_ATTR) || ''; - if (this.hasSetup_ || !this.gaid_) { + if (this.hasSetup_ || (!this.gaid_ && !this.ga4Id_)) { return; } @@ -139,6 +170,14 @@ class CodelabAnalytics extends HTMLElement { } else { this.init_(); } + + if (this.ga4Id_) { + this.initializeGa4_(); + } + + if (this.ga4Id_ && !this.gaid_) { + this.addEventListeners_(); + } } /** @private */ @@ -153,7 +192,7 @@ class CodelabAnalytics extends HTMLElement { addEventListeners_() { this.eventHandler_.listen(document.body, ACTION_EVENT, (e) => { - const detail = /** @type {AnalyticsTrackingEvent} */ ( + const detail = /** @type {!AnalyticsTrackingEvent} */ ( e.getBrowserEvent().detail); // Add tracking... this.trackEvent_( @@ -162,7 +201,7 @@ class CodelabAnalytics extends HTMLElement { this.eventHandler_.listen(document.body, PAGEVIEW_EVENT, (e) => { - const detail = /** @type {AnalyticsPageview} */ ( + const detail = /** @type {!AnalyticsPageview} */ ( e.getBrowserEvent().detail); this.trackPageview_(detail['page'], detail['title']); }); @@ -216,6 +255,7 @@ class CodelabAnalytics extends HTMLElement { * @private */ trackEvent_(category, opt_action, opt_label) { + // UA related section. const params = { // Always event for trackEvent_ method 'hitType': 'event', @@ -227,6 +267,30 @@ class CodelabAnalytics extends HTMLElement { 'eventLabel': opt_label || '', }; this.gaSend_(params); + + // GA4 related section. + if (!this.getGa4Ids_().length) { + return; + } + + window[CODELAB_DATA_LAYER] = window[CODELAB_DATA_LAYER] || []; + window[GTAG] = window[GTAG] || function() { + window[CODELAB_DATA_LAYER].push(arguments); + }; + + for (const ga4Id of this.getGa4Ids_()) { + window[GTAG]('event', category, { + // Snakecase naming convention is followed for all built-in GA4 event + // properties. + 'send_to': ga4Id, + // Camelcase naming convention is followed for all custom dimensions + // constructed in the custom element. + 'eventAction': opt_action || '', + 'eventLabel': opt_label || '', + 'codelabEnv': this.codelabEnv_ || '', + 'codelabId': this.codelabId_ || '', + }); + } } /** @@ -235,6 +299,7 @@ class CodelabAnalytics extends HTMLElement { * @private */ trackPageview_(opt_page, opt_title) { + // UA related section. const params = { 'hitType': 'pageview', 'dimension1': this.codelabEnv_, @@ -244,6 +309,33 @@ class CodelabAnalytics extends HTMLElement { 'title': opt_title || '' }; this.gaSend_(params); + + // GA4 related section. + if (!this.getGa4Ids_().length) { + return; + } + + window[CODELAB_DATA_LAYER] = window[CODELAB_DATA_LAYER] || []; + window[GTAG] = window[GTAG] || function() { + window[CODELAB_DATA_LAYER].push(arguments); + }; + + for (const ga4Id of this.getGa4Ids_()) { + window[GTAG]('event', 'page_view', { + // Snakecase naming convention is followed for all built-in GA4 event + // properties. + 'send_to': ga4Id, + 'page_location': + `${document.location.origin}${document.location.pathname}`, + 'page_path': opt_page || '', + 'page_title': opt_title || '', + // Camelcase naming convention is followed for all custom dimensions + // constructed in the custom element. + 'codelabCategory': this.codelabCategory_ || '', + 'codelabEnv': this.codelabEnv_ || '', + 'codelabId': this.codelabId_ || '', + }); + } } /** @@ -385,6 +477,68 @@ class CodelabAnalytics extends HTMLElement { } return isCreated; } + + /** + * Gets all GA4 IDs for the current page. + * @return {!Array} + * @private + */ + getGa4Ids_() { + if (!this.ga4Id_) { + return []; + } + const ga4Ids = []; + ga4Ids.push(this.ga4Id_); + const codelabGa4Id = this.getAttribute(CODELAB_GA4ID_ATTR); + if (codelabGa4Id) { + ga4Ids.push(codelabGa4Id); + } + if (ga4Ids.length) { + return ga4Ids; + } + return []; + } + + /** + * Initialize the gtag script element and namespaced data layer based on the + * codelabs primary GA4 ID. + * @private + */ + initializeGa4_() { + if (!this.ga4Id_) { + return; + } + + // First, set the GTAG data layer before pushing anything to it. + window[CODELAB_DATA_LAYER] = window[CODELAB_DATA_LAYER] || []; + + const firstScriptElement = document.querySelector('script'); + const gtagScriptElement = /** @type {!HTMLScriptElement} */ ( + document.createElement('script')); + gtagScriptElement.async = true; + // Key for the formatted params below: + // 'id': the stream id for the GA4 analytics property. The gtag script + // element must only be created once, and only the ID of the primary + // stream is appended when creating the src for that element. + // Additional streams are initialized via the function call + // `window[GTAG]('config', ga4Id...` + // 'l': the namespaced dataLayer used to separate codelabs related GA4 + // data from other data layers that may exist on a site or page. + safeScriptEl.setSrc( + gtagScriptElement, TrustedResourceUrl.formatWithParams( + Const.from('//www.googletagmanager.com/gtag/js'), + {}, {'id': this.ga4Id_, 'l': CODELAB_DATA_LAYER})); + firstScriptElement.parentNode.insertBefore( + gtagScriptElement, firstScriptElement); + + window[GTAG] = function() { + window[CODELAB_DATA_LAYER].push(arguments); + }; + window[GTAG]('js', new Date(Date.now())); + + // Set send_page_view to false. We send pageviews manually. + window[GTAG]('config', this.ga4Id_, {send_page_view: false}); + } } exports = CodelabAnalytics; diff --git a/codelab-elements/google-codelab-step/google_codelab_step.js b/codelab-elements/google-codelab-step/google_codelab_step.js index a4d4d02bf..a569d3a22 100644 --- a/codelab-elements/google-codelab-step/google_codelab_step.js +++ b/codelab-elements/google-codelab-step/google_codelab_step.js @@ -96,6 +96,12 @@ class CodelabStep extends HTMLElement { this.setupDom_(); } + /** + * @export + * @override + */ + disconnectedCallback() {} + /** * @return {!Array} * @export diff --git a/codelab-elements/google-codelab/google_codelab.js b/codelab-elements/google-codelab/google_codelab.js index 2627e46ab..132ef48ae 100644 --- a/codelab-elements/google-codelab/google_codelab.js +++ b/codelab-elements/google-codelab/google_codelab.js @@ -46,6 +46,9 @@ const CATEGORY_ATTR = 'category'; /** @const {string} */ const GAID_ATTR = 'codelab-gaid'; +/** @const {string} */ +const GA4ID_ATTR = 'codelab-ga4id'; + /** @const {string} */ const CODELAB_ID_ATTR = 'codelab-id'; @@ -271,8 +274,8 @@ class Codelab extends HTMLElement { if (this.ready_) { this.firePageLoadEvents_(); } else { - this.addEventListener( - CODELAB_READY_EVENT, () => this.firePageLoadEvents_()); + this.eventHandler_.listen( + this, CODELAB_READY_EVENT, () => this.firePageLoadEvents_()); } } break; @@ -297,6 +300,10 @@ class Codelab extends HTMLElement { if (gaid) { analytics.setAttribute(GAID_ATTR, gaid); } + const ga4id = this.getAttribute(GA4ID_ATTR); + if (ga4id) { + analytics.setAttribute(GA4ID_ATTR, ga4id); + } if (this.id_) { analytics.setAttribute(CODELAB_ID_ATTR, this.id_); } @@ -329,6 +336,24 @@ class Codelab extends HTMLElement { this.setAttribute(SELECTED_ATTR, index); } + /** + * @export + * @return{string} + */ + get hash() { + return window.location.hash; + } + + /** + * @export + * @param {string} newHash + */ + set hash(newHash) { + if (newHash !== '' && window.location.hash !== newHash) { + window.history.replaceState({newHash}, document.title, newHash); + } + } + /** * @protected */ @@ -484,32 +509,6 @@ class Codelab extends HTMLElement { } } - /** - * History popState callback - * @param {!Event} e - * @private - */ - handlePopStateChanged_(e) { - const step = this.getStepFromHash_(document.location.hash); - this.setAttribute(DONT_SET_HISTORY_ATTR, ''); - this.setAttribute(SELECTED_ATTR, `${step}`); - this.removeAttribute(DONT_SET_HISTORY_ATTR); - } - - /** - * Updates the browser history state - * @param {string} path The new browser state - * @param {boolean=} replaceState optionally replace state instead of pushing - * @export - */ - updateHistoryState(path, replaceState = false) { - if (replaceState) { - window.history.replaceState({path}, document.title, path); - } else { - window.history.pushState({path}, document.title, path); - } - } - /** * @param {!Event} e * @private @@ -694,7 +693,7 @@ class Codelab extends HTMLElement { this.currentSelectedStep = selected; this.firePageViewEvent(); - // Set the focus on the new step after the animation is finished becasue it + // Set the focus on the new step after the animation is finished because it // messes up the animation. clearTimeout(this.setFocusTimeoutId_); this.setFocusTimeoutId_ = setTimeout(() => { @@ -864,22 +863,15 @@ class Codelab extends HTMLElement { */ init_() { this.id_ = this.getAttribute(ID_ATTR); - let step = this.getStepFromHash_(document.location.hash) || - this.getStepFromStorage_(); + let step = this.getStepFromHash_(this.hash) || this.getStepFromStorage_(); this.setAttribute(SELECTED_ATTR, `${step}`); - this.eventHandler_.listen( - dom.getWindow(), events.EventType.POPSTATE, (e) => { - this.handlePopStateChanged_(e); - }); } /** * @protected */ saveStep() { - if (!this.hasAttribute(DONT_SET_HISTORY_ATTR)) { - this.updateHistoryState(`#${this.currentSelectedStep}`, true); - } + this.hash = `#${this.currentSelectedStep}`; if (this.id_) { this.storage_.set( `progress_${this.id_}`, String(this.currentSelectedStep)); diff --git a/codelab-elements/google-codelab/google_codelab.scss b/codelab-elements/google-codelab/google_codelab.scss index 4d2c0cc73..41a94ba43 100644 --- a/codelab-elements/google-codelab/google_codelab.scss +++ b/codelab-elements/google-codelab/google_codelab.scss @@ -49,6 +49,7 @@ google-codelab #codelab-title { padding: 0 36px 0 16px; -webkit-font-smoothing: antialiased; z-index: 1000; + max-width: 100vw; } google-codelab #codelab-title h1 { @@ -120,6 +121,7 @@ google-codelab #controls { google-codelab #fabs { display: flex; justify-content: space-between; + margin: 0 auto; max-width: 1025px; } @@ -197,6 +199,10 @@ google-codelab .metadata a:focus { padding-top: 0; } + google-codelab #codelab-title { + padding: 0 16px; + } + google-codelab #codelab-title .codelab-time-container { display: none; }