diff --git a/internal/graph2md/graph2md.go b/internal/graph2md/graph2md.go index bd09cbe..303900a 100644 --- a/internal/graph2md/graph2md.go +++ b/internal/graph2md/graph2md.go @@ -1464,6 +1464,10 @@ type graphNode struct { Label string `json:"label"` Type string `json:"type"` Slug string `json:"slug"` + LC int `json:"lc,omitempty"` // line count + Lang string `json:"lang,omitempty"` // language + CC int `json:"cc,omitempty"` // call count (calls out) + CBC int `json:"cbc,omitempty"` // called by count } type graphEdge struct { @@ -1499,11 +1503,26 @@ func (c *renderContext) writeGraphData(sb *strings.Builder) { if len(n.Labels) > 0 { nodeType = n.Labels[0] } + // Enrichment data + lineCount := 0 + startLine := getNum(n.Properties, "startLine") + endLine := getNum(n.Properties, "endLine") + if startLine > 0 && endLine > 0 { + lineCount = endLine - startLine + 1 + } + lang := getStr(n.Properties, "language") + callCount := len(c.calls[nodeID]) + calledByCount := len(c.calledBy[nodeID]) + nodes = append(nodes, graphNode{ ID: nodeID, Label: label, Type: nodeType, Slug: c.slugLookup[nodeID], + LC: lineCount, + Lang: lang, + CC: callCount, + CBC: calledByCount, }) } diff --git a/internal/pssg/build/build.go b/internal/pssg/build/build.go index 5b619c9..0c5bc6b 100644 --- a/internal/pssg/build/build.go +++ b/internal/pssg/build/build.go @@ -394,6 +394,102 @@ func (b *Builder) renderEntityPage( title := e.GetString("title") description := e.GetString("description") + // Entity profile chart data (compact format for JS) + profileData := map[string]interface{}{} + if lc := e.GetInt("line_count"); lc > 0 { + profileData["lc"] = lc + } + if co := e.GetInt("call_count"); co > 0 { + profileData["co"] = co + } + if cb := e.GetInt("called_by_count"); cb > 0 { + profileData["cb"] = cb + } + if ic := e.GetInt("import_count"); ic > 0 { + profileData["ic"] = ic + } + if ib := e.GetInt("imported_by_count"); ib > 0 { + profileData["ib"] = ib + } + if fn := e.GetInt("function_count"); fn > 0 { + profileData["fn"] = fn + } + if cl := e.GetInt("class_count"); cl > 0 { + profileData["cl"] = cl + } + if tc := e.GetInt("type_count"); tc > 0 { + profileData["tc"] = tc + } + if fc := e.GetInt("file_count"); fc > 0 { + profileData["fc"] = fc + } + if sl := e.GetInt("start_line"); sl > 0 { + profileData["sl"] = sl + } + if el := e.GetInt("end_line"); el > 0 { + profileData["el"] = el + } + // Edge type breakdown + edgeTypes := map[string]int{} + if v := e.GetInt("import_count"); v > 0 { + edgeTypes["imports"] = v + } + if v := e.GetInt("imported_by_count"); v > 0 { + edgeTypes["imports"] += v + } + if v := e.GetInt("call_count"); v > 0 { + edgeTypes["calls"] = v + } + if v := e.GetInt("called_by_count"); v > 0 { + edgeTypes["calls"] += v + } + if v := e.GetInt("function_count"); v > 0 { + edgeTypes["defines"] += v + } + if v := e.GetInt("class_count"); v > 0 { + edgeTypes["defines"] += v + } + if v := e.GetInt("type_count"); v > 0 { + edgeTypes["defines"] += v + } + if len(edgeTypes) > 0 { + profileData["et"] = edgeTypes + } + var entityChartJSON []byte + if len(profileData) > 0 { + entityChartJSON, _ = json.Marshal(profileData) + } + + // Source code (read from workspace if available) + var sourceCode, sourceLang string + if filePath := e.GetString("file_path"); filePath != "" { + if sl := e.GetInt("start_line"); sl > 0 { + if el := e.GetInt("end_line"); el > 0 { + sourceDir := b.cfg.Paths.SourceDir + if sourceDir != "" { + fullPath := filepath.Join(sourceDir, filePath) + if data, err := os.ReadFile(fullPath); err == nil { + lines := strings.Split(string(data), "\n") + if sl <= len(lines) && el <= len(lines) { + sourceCode = strings.Join(lines[sl-1:el], "\n") + } + } + } + } + } + sourceLang = e.GetString("language") + if sourceLang == "" { + ext := filepath.Ext(filePath) + langMap := map[string]string{ + ".js": "javascript", ".ts": "typescript", ".tsx": "typescript", + ".py": "python", ".go": "go", ".rs": "rust", ".java": "java", + ".rb": "ruby", ".php": "php", ".c": "c", ".cpp": "cpp", + ".cs": "csharp", ".swift": "swift", ".kt": "kotlin", + } + sourceLang = langMap[ext] + } + } + ctx := render.EntityPageContext{ Site: b.cfg.Site, Entity: e, @@ -410,6 +506,9 @@ func (b *Builder) renderEntityPage( AllTaxonomies: taxonomies, ValidSlugs: validSlugs, Contributors: contributors, + ChartData: template.JS(entityChartJSON), + SourceCode: sourceCode, + SourceLang: sourceLang, CTA: b.cfg.Extra.CTA, OG: render.OGMeta{ Title: title + " \u2014 " + b.cfg.Site.Name, @@ -551,7 +650,7 @@ func (b *Builder) renderTaxonomyPages( Type: "article", SiteName: b.cfg.Site.Name, }, - ChartData: template.HTML(hubChartJSON), + ChartData: template.JS(hubChartJSON), CTA: b.cfg.Extra.CTA, } @@ -650,7 +749,7 @@ func (b *Builder) renderTaxonomyPages( Type: "article", SiteName: b.cfg.Site.Name, }, - ChartData: template.HTML(taxChartJSON), + ChartData: template.JS(taxChartJSON), CTA: b.cfg.Extra.CTA, } @@ -724,7 +823,7 @@ func (b *Builder) renderTaxonomyPages( Type: "article", SiteName: b.cfg.Site.Name, }, - ChartData: template.HTML(letterChartJSON), + ChartData: template.JS(letterChartJSON), CTA: b.cfg.Extra.CTA, } @@ -845,9 +944,9 @@ func (b *Builder) renderAllEntitiesPages( jsonLD := schema.MarshalSchemas(collectionSchema, breadcrumbSchema) // Only include chart data on page 1 - var pageChartData template.HTML + var pageChartData template.JS if page == 1 { - pageChartData = template.HTML(chartJSON) + pageChartData = template.JS(chartJSON) } ctx := render.AllEntitiesPageContext{ @@ -911,14 +1010,11 @@ func (b *Builder) renderHomepage( } imageURL := shareImageURL(b.cfg.Site.BaseURL, "homepage.svg") - // Chart data: treemap of taxonomies -> entries - type chartEntry struct { + // Chart data: treemap of taxonomies + type chartTax struct { Name string `json:"name"` Count int `json:"count"` - } - type chartTax struct { - Label string `json:"label"` - TopEntries []chartEntry `json:"topEntries"` + Slug string `json:"slug"` } type homepageChart struct { Taxonomies []chartTax `json:"taxonomies"` @@ -926,15 +1022,100 @@ func (b *Builder) renderHomepage( } var chartTaxonomies []chartTax for _, tax := range taxonomies { - top := taxonomy.TopEntries(tax.Entries, 10) - var entries []chartEntry - for _, e := range top { - entries = append(entries, chartEntry{Name: e.Name, Count: len(e.Entities)}) + totalCount := 0 + for _, entry := range tax.Entries { + totalCount += len(entry.Entities) } - chartTaxonomies = append(chartTaxonomies, chartTax{Label: tax.Label, TopEntries: entries}) + chartTaxonomies = append(chartTaxonomies, chartTax{ + Name: tax.Label, + Count: totalCount, + Slug: tax.Name, + }) } chartJSON, _ := json.Marshal(homepageChart{Taxonomies: chartTaxonomies, TotalEntities: len(entities)}) + // Architecture overview: domain/subdomain force graph + type archNode struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Count int `json:"count"` + Slug string `json:"slug,omitempty"` + } + type archLink struct { + Source string `json:"source"` + Target string `json:"target"` + } + type archOverview struct { + Nodes []archNode `json:"nodes"` + Links []archLink `json:"links"` + } + + var archNodes []archNode + var archLinks []archLink + + // Root node is the repo/site + rootID := "__root__" + archNodes = append(archNodes, archNode{ID: rootID, Name: b.cfg.Site.Name, Type: "root", Count: len(entities)}) + + // Find subdomain -> domain parent relationships + subdomainParent := make(map[string]string) // subdomain name -> domain name + for _, tax := range taxonomies { + if tax.Name == "subdomain" { + for _, entry := range tax.Entries { + parentDomain := "" + if len(entry.Entities) > 0 { + parentDomain = entry.Entities[0].GetString("domain") + } + subdomainParent[entry.Name] = parentDomain + } + } + } + + // Add domain nodes + for _, tax := range taxonomies { + if tax.Name == "domain" { + for _, entry := range tax.Entries { + nodeID := "domain:" + entry.Slug + archNodes = append(archNodes, archNode{ + ID: nodeID, + Name: entry.Name, + Type: "domain", + Count: len(entry.Entities), + Slug: "domain/" + entry.Slug, + }) + archLinks = append(archLinks, archLink{Source: rootID, Target: nodeID}) + } + } + } + // Add subdomain nodes + for _, tax := range taxonomies { + if tax.Name == "subdomain" { + for _, entry := range tax.Entries { + nodeID := "subdomain:" + entry.Slug + archNodes = append(archNodes, archNode{ + ID: nodeID, + Name: entry.Name, + Type: "subdomain", + Count: len(entry.Entities), + Slug: "subdomain/" + entry.Slug, + }) + parentDomain := subdomainParent[entry.Name] + if parentDomain != "" { + parentSlug := entity.ToSlug(parentDomain) + archLinks = append(archLinks, archLink{Source: "domain:" + parentSlug, Target: nodeID}) + } else { + archLinks = append(archLinks, archLink{Source: rootID, Target: nodeID}) + } + } + } + } + + var archJSON []byte + if len(archNodes) > 1 { + archJSON, _ = json.Marshal(archOverview{Nodes: archNodes, Links: archLinks}) + } + // JSON-LD websiteSchema := schemaGen.GenerateWebSiteSchema(imageURL) @@ -970,7 +1151,8 @@ func (b *Builder) renderHomepage( Type: "website", SiteName: b.cfg.Site.Name, }, - ChartData: template.HTML(chartJSON), + ChartData: template.JS(chartJSON), + ArchData: template.JS(archJSON), CTA: b.cfg.Extra.CTA, } diff --git a/internal/pssg/config/types.go b/internal/pssg/config/types.go index 23fb420..47b3767 100644 --- a/internal/pssg/config/types.go +++ b/internal/pssg/config/types.go @@ -42,6 +42,7 @@ type PathsConfig struct { Output string `yaml:"output"` Cache string `yaml:"cache"` Static string `yaml:"static"` + SourceDir string `yaml:"source_dir"` } type DataConfig struct { diff --git a/internal/pssg/render/funcs.go b/internal/pssg/render/funcs.go index d29e514..954ead7 100644 --- a/internal/pssg/render/funcs.go +++ b/internal/pssg/render/funcs.go @@ -6,6 +6,7 @@ import ( "html/template" "math" "net/url" + "reflect" "regexp" "strconv" "strings" @@ -257,19 +258,13 @@ func sliceHelper(items interface{}, start, end int) interface{} { } func length(v interface{}) int { - switch val := v.(type) { - case []string: - return len(val) - case []*entity.Entity: - return len(val) - case []interface{}: - return len(val) - case string: - return len(val) - case map[string]interface{}: - return len(val) - case []map[string]interface{}: - return len(val) + if v == nil { + return 0 + } + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Slice, reflect.Map, reflect.Array, reflect.String: + return rv.Len() } return 0 } diff --git a/internal/pssg/render/render.go b/internal/pssg/render/render.go index 45de4b4..912d2db 100644 --- a/internal/pssg/render/render.go +++ b/internal/pssg/render/render.go @@ -38,7 +38,7 @@ type EntityPageContext struct { ValidSlugs map[string]map[string]bool Contributors map[string]interface{} OG OGMeta - ChartData template.HTML + ChartData template.JS CTA config.CTAConfig SourceCode string SourceLang string @@ -54,9 +54,9 @@ type HomepageContext struct { EntityCount int Contributors map[string]interface{} OG OGMeta - ChartData template.HTML + ChartData template.JS CTA config.CTAConfig - ArchData template.HTML + ArchData template.JS } // HubPageContext is the template context for taxonomy hub (category) pages. @@ -72,7 +72,7 @@ type HubPageContext struct { Contributors map[string]interface{} ContributorProfile map[string]interface{} OG OGMeta - ChartData template.HTML + ChartData template.JS CTA config.CTAConfig } @@ -89,7 +89,7 @@ type TaxonomyIndexContext struct { Breadcrumbs []Breadcrumb AllTaxonomies []taxonomy.Taxonomy OG OGMeta - ChartData template.HTML + ChartData template.JS CTA config.CTAConfig } @@ -104,7 +104,7 @@ type LetterPageContext struct { Breadcrumbs []Breadcrumb AllTaxonomies []taxonomy.Taxonomy OG OGMeta - ChartData template.HTML + ChartData template.JS CTA config.CTAConfig } @@ -119,7 +119,7 @@ type AllEntitiesPageContext struct { EntityCount int TotalEntities int OG OGMeta - ChartData template.HTML + ChartData template.JS CTA config.CTAConfig } diff --git a/internal/pssg/render/render_test.go b/internal/pssg/render/render_test.go new file mode 100644 index 0000000..287f305 --- /dev/null +++ b/internal/pssg/render/render_test.go @@ -0,0 +1,192 @@ +package render + +import ( + "bytes" + "encoding/json" + "html/template" + "regexp" + "testing" +) + +// TestScriptTagJSONNotDoubleEncoded verifies that template.JS fields +// inside `, + data: struct { + Data template.JS + }{ + Data: template.JS(`{"nodes":[{"id":"root","name":"Root"}],"links":[]}`), + }, + scriptID: "test-data", + }, + { + name: "template.JS with nested JSON", + tmplText: ``, + data: struct { + ChartData template.JS + }{ + ChartData: template.JS(`{"taxonomies":[{"label":"Category","topEntries":[{"name":"Web","count":5}]}],"totalEntities":42}`), + }, + scriptID: "chart-data", + }, + { + name: "empty template.JS produces empty output", + tmplText: ``, + data: struct { + ChartData template.JS + }{ + ChartData: template.JS(""), + }, + scriptID: "empty-data", + }, + } + + scriptContentRe := regexp.MustCompile(`]*id="([^"]+)"[^>]*>(.*?)`) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpl, err := template.New("test").Parse(tt.tmplText) + if err != nil { + t.Fatalf("failed to parse template: %v", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, tt.data); err != nil { + t.Fatalf("failed to execute template: %v", err) + } + + rendered := buf.String() + + matches := scriptContentRe.FindStringSubmatch(rendered) + if matches == nil { + t.Fatalf("no ` + tmpl, err := template.New("test").Parse(tmplText) + if err != nil { + t.Fatalf("failed to parse template: %v", err) + } + + data := struct { + Data template.HTML + }{ + Data: template.HTML(`{"nodes":[{"id":"root"}]}`), + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + t.Fatalf("failed to execute template: %v", err) + } + + rendered := buf.String() + + // Extract content between script tags + re := regexp.MustCompile(`]*>(.*?)`) + matches := re.FindStringSubmatch(rendered) + if matches == nil { + t.Fatal("no script tag found") + } + content := matches[1] + + // With template.HTML in a script context, html/template double-encodes it. + // The content should NOT parse as a valid JSON object directly. + var result interface{} + if err := json.Unmarshal([]byte(content), &result); err != nil { + // If it doesn't parse at all, that's also a form of brokenness + t.Logf("template.HTML in script tag produced unparseable content (expected): %s", content) + return + } + + // If it parses as a string, it's the double-encoding we saw + if s, isString := result.(string); isString { + maxLen := len(s) + if maxLen > 60 { + maxLen = 60 + } + t.Logf("Confirmed: template.HTML causes double-encoding in script tags. JSON.parse returns string: %s", s[:maxLen]) + return + } + + // If it somehow parses as an object, that's unexpected for template.HTML in script context + t.Log("Warning: template.HTML in script tag parsed as object — behavior may have changed in this Go version") +} + +// TestLengthWithVariousTypes verifies the reflect-based length function +// works with all slice types including taxonomy.Entry slices. +func TestLengthWithVariousTypes(t *testing.T) { + type CustomStruct struct { + Name string + } + + tests := []struct { + name string + input interface{} + expected int + }{ + {"nil", nil, 0}, + {"empty string", "", 0}, + {"string", "hello", 5}, + {"string slice", []string{"a", "b", "c"}, 3}, + {"empty slice", []string{}, 0}, + {"interface slice", []interface{}{1, "two", 3.0}, 3}, + {"map", map[string]interface{}{"a": 1, "b": 2}, 2}, + {"custom struct slice", []CustomStruct{{Name: "a"}, {Name: "b"}}, 2}, + {"int slice", []int{1, 2, 3, 4, 5}, 5}, + {"map string string", map[string]string{"a": "1"}, 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := length(tt.input) + if got != tt.expected { + t.Errorf("length(%v) = %d, want %d", tt.input, got, tt.expected) + } + }) + } +}