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(``)
+
+ 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)
+ }
+ })
+ }
+}