Skip to content
This repository was archived by the owner on Apr 2, 2026. It is now read-only.

Commit 88b261d

Browse files
fix: combine graph visualization fixes — populate ArchData, fix double-encoding, fix len()
Combines changes from both fix branches to fully resolve graph rendering: 1. Populate ArchData (domain/subdomain force graph) in renderHomepage 2. Populate entity ChartData (profile chart) in renderEntityPage 3. Fix homepage chart JSON field names (label/topEntries → name/count/slug) 4. Change template.HTML → template.JS for all JSON in <script> tags to prevent double-encoding by html/template's JS context escaper 5. Fix len() template function to use reflect.ValueOf().Len() so it works with any slice/map/array/string type (fixes #8) 6. Add graph node enrichment fields (lc, lang, cc, cbc) in graph2md 7. Add SourceDir to PathsConfig for source code display 8. Add regression tests for double-encoding and length function Fixes #10 Co-authored-by: Jonathan Popham <jonathanpopham@users.noreply.github.com>
1 parent b9ddc46 commit 88b261d

6 files changed

Lines changed: 426 additions & 37 deletions

File tree

internal/graph2md/graph2md.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1464,6 +1464,10 @@ type graphNode struct {
14641464
Label string `json:"label"`
14651465
Type string `json:"type"`
14661466
Slug string `json:"slug"`
1467+
LC int `json:"lc,omitempty"` // line count
1468+
Lang string `json:"lang,omitempty"` // language
1469+
CC int `json:"cc,omitempty"` // call count (calls out)
1470+
CBC int `json:"cbc,omitempty"` // called by count
14671471
}
14681472

14691473
type graphEdge struct {
@@ -1499,11 +1503,26 @@ func (c *renderContext) writeGraphData(sb *strings.Builder) {
14991503
if len(n.Labels) > 0 {
15001504
nodeType = n.Labels[0]
15011505
}
1506+
// Enrichment data
1507+
lineCount := 0
1508+
startLine := getNum(n.Properties, "startLine")
1509+
endLine := getNum(n.Properties, "endLine")
1510+
if startLine > 0 && endLine > 0 {
1511+
lineCount = endLine - startLine + 1
1512+
}
1513+
lang := getStr(n.Properties, "language")
1514+
callCount := len(c.calls[nodeID])
1515+
calledByCount := len(c.calledBy[nodeID])
1516+
15021517
nodes = append(nodes, graphNode{
15031518
ID: nodeID,
15041519
Label: label,
15051520
Type: nodeType,
15061521
Slug: c.slugLookup[nodeID],
1522+
LC: lineCount,
1523+
Lang: lang,
1524+
CC: callCount,
1525+
CBC: calledByCount,
15071526
})
15081527
}
15091528

internal/pssg/build/build.go

Lines changed: 199 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,102 @@ func (b *Builder) renderEntityPage(
394394
title := e.GetString("title")
395395
description := e.GetString("description")
396396

397+
// Entity profile chart data (compact format for JS)
398+
profileData := map[string]interface{}{}
399+
if lc := e.GetInt("line_count"); lc > 0 {
400+
profileData["lc"] = lc
401+
}
402+
if co := e.GetInt("call_count"); co > 0 {
403+
profileData["co"] = co
404+
}
405+
if cb := e.GetInt("called_by_count"); cb > 0 {
406+
profileData["cb"] = cb
407+
}
408+
if ic := e.GetInt("import_count"); ic > 0 {
409+
profileData["ic"] = ic
410+
}
411+
if ib := e.GetInt("imported_by_count"); ib > 0 {
412+
profileData["ib"] = ib
413+
}
414+
if fn := e.GetInt("function_count"); fn > 0 {
415+
profileData["fn"] = fn
416+
}
417+
if cl := e.GetInt("class_count"); cl > 0 {
418+
profileData["cl"] = cl
419+
}
420+
if tc := e.GetInt("type_count"); tc > 0 {
421+
profileData["tc"] = tc
422+
}
423+
if fc := e.GetInt("file_count"); fc > 0 {
424+
profileData["fc"] = fc
425+
}
426+
if sl := e.GetInt("start_line"); sl > 0 {
427+
profileData["sl"] = sl
428+
}
429+
if el := e.GetInt("end_line"); el > 0 {
430+
profileData["el"] = el
431+
}
432+
// Edge type breakdown
433+
edgeTypes := map[string]int{}
434+
if v := e.GetInt("import_count"); v > 0 {
435+
edgeTypes["imports"] = v
436+
}
437+
if v := e.GetInt("imported_by_count"); v > 0 {
438+
edgeTypes["imports"] += v
439+
}
440+
if v := e.GetInt("call_count"); v > 0 {
441+
edgeTypes["calls"] = v
442+
}
443+
if v := e.GetInt("called_by_count"); v > 0 {
444+
edgeTypes["calls"] += v
445+
}
446+
if v := e.GetInt("function_count"); v > 0 {
447+
edgeTypes["defines"] += v
448+
}
449+
if v := e.GetInt("class_count"); v > 0 {
450+
edgeTypes["defines"] += v
451+
}
452+
if v := e.GetInt("type_count"); v > 0 {
453+
edgeTypes["defines"] += v
454+
}
455+
if len(edgeTypes) > 0 {
456+
profileData["et"] = edgeTypes
457+
}
458+
var entityChartJSON []byte
459+
if len(profileData) > 0 {
460+
entityChartJSON, _ = json.Marshal(profileData)
461+
}
462+
463+
// Source code (read from workspace if available)
464+
var sourceCode, sourceLang string
465+
if filePath := e.GetString("file_path"); filePath != "" {
466+
if sl := e.GetInt("start_line"); sl > 0 {
467+
if el := e.GetInt("end_line"); el > 0 {
468+
sourceDir := b.cfg.Paths.SourceDir
469+
if sourceDir != "" {
470+
fullPath := filepath.Join(sourceDir, filePath)
471+
if data, err := os.ReadFile(fullPath); err == nil {
472+
lines := strings.Split(string(data), "\n")
473+
if sl <= len(lines) && el <= len(lines) {
474+
sourceCode = strings.Join(lines[sl-1:el], "\n")
475+
}
476+
}
477+
}
478+
}
479+
}
480+
sourceLang = e.GetString("language")
481+
if sourceLang == "" {
482+
ext := filepath.Ext(filePath)
483+
langMap := map[string]string{
484+
".js": "javascript", ".ts": "typescript", ".tsx": "typescript",
485+
".py": "python", ".go": "go", ".rs": "rust", ".java": "java",
486+
".rb": "ruby", ".php": "php", ".c": "c", ".cpp": "cpp",
487+
".cs": "csharp", ".swift": "swift", ".kt": "kotlin",
488+
}
489+
sourceLang = langMap[ext]
490+
}
491+
}
492+
397493
ctx := render.EntityPageContext{
398494
Site: b.cfg.Site,
399495
Entity: e,
@@ -410,6 +506,9 @@ func (b *Builder) renderEntityPage(
410506
AllTaxonomies: taxonomies,
411507
ValidSlugs: validSlugs,
412508
Contributors: contributors,
509+
ChartData: template.JS(entityChartJSON),
510+
SourceCode: sourceCode,
511+
SourceLang: sourceLang,
413512
CTA: b.cfg.Extra.CTA,
414513
OG: render.OGMeta{
415514
Title: title + " \u2014 " + b.cfg.Site.Name,
@@ -551,7 +650,7 @@ func (b *Builder) renderTaxonomyPages(
551650
Type: "article",
552651
SiteName: b.cfg.Site.Name,
553652
},
554-
ChartData: template.HTML(hubChartJSON),
653+
ChartData: template.JS(hubChartJSON),
555654
CTA: b.cfg.Extra.CTA,
556655
}
557656

@@ -650,7 +749,7 @@ func (b *Builder) renderTaxonomyPages(
650749
Type: "article",
651750
SiteName: b.cfg.Site.Name,
652751
},
653-
ChartData: template.HTML(taxChartJSON),
752+
ChartData: template.JS(taxChartJSON),
654753
CTA: b.cfg.Extra.CTA,
655754
}
656755

@@ -724,7 +823,7 @@ func (b *Builder) renderTaxonomyPages(
724823
Type: "article",
725824
SiteName: b.cfg.Site.Name,
726825
},
727-
ChartData: template.HTML(letterChartJSON),
826+
ChartData: template.JS(letterChartJSON),
728827
CTA: b.cfg.Extra.CTA,
729828
}
730829

@@ -845,9 +944,9 @@ func (b *Builder) renderAllEntitiesPages(
845944
jsonLD := schema.MarshalSchemas(collectionSchema, breadcrumbSchema)
846945

847946
// Only include chart data on page 1
848-
var pageChartData template.HTML
947+
var pageChartData template.JS
849948
if page == 1 {
850-
pageChartData = template.HTML(chartJSON)
949+
pageChartData = template.JS(chartJSON)
851950
}
852951

853952
ctx := render.AllEntitiesPageContext{
@@ -911,30 +1010,112 @@ func (b *Builder) renderHomepage(
9111010
}
9121011
imageURL := shareImageURL(b.cfg.Site.BaseURL, "homepage.svg")
9131012

914-
// Chart data: treemap of taxonomies -> entries
915-
type chartEntry struct {
1013+
// Chart data: treemap of taxonomies
1014+
type chartTax struct {
9161015
Name string `json:"name"`
9171016
Count int `json:"count"`
918-
}
919-
type chartTax struct {
920-
Label string `json:"label"`
921-
TopEntries []chartEntry `json:"topEntries"`
1017+
Slug string `json:"slug"`
9221018
}
9231019
type homepageChart struct {
9241020
Taxonomies []chartTax `json:"taxonomies"`
9251021
TotalEntities int `json:"totalEntities"`
9261022
}
9271023
var chartTaxonomies []chartTax
9281024
for _, tax := range taxonomies {
929-
top := taxonomy.TopEntries(tax.Entries, 10)
930-
var entries []chartEntry
931-
for _, e := range top {
932-
entries = append(entries, chartEntry{Name: e.Name, Count: len(e.Entities)})
1025+
totalCount := 0
1026+
for _, entry := range tax.Entries {
1027+
totalCount += len(entry.Entities)
9331028
}
934-
chartTaxonomies = append(chartTaxonomies, chartTax{Label: tax.Label, TopEntries: entries})
1029+
chartTaxonomies = append(chartTaxonomies, chartTax{
1030+
Name: tax.Label,
1031+
Count: totalCount,
1032+
Slug: tax.Name,
1033+
})
9351034
}
9361035
chartJSON, _ := json.Marshal(homepageChart{Taxonomies: chartTaxonomies, TotalEntities: len(entities)})
9371036

1037+
// Architecture overview: domain/subdomain force graph
1038+
type archNode struct {
1039+
ID string `json:"id"`
1040+
Name string `json:"name"`
1041+
Type string `json:"type"`
1042+
Count int `json:"count"`
1043+
Slug string `json:"slug,omitempty"`
1044+
}
1045+
type archLink struct {
1046+
Source string `json:"source"`
1047+
Target string `json:"target"`
1048+
}
1049+
type archOverview struct {
1050+
Nodes []archNode `json:"nodes"`
1051+
Links []archLink `json:"links"`
1052+
}
1053+
1054+
var archNodes []archNode
1055+
var archLinks []archLink
1056+
1057+
// Root node is the repo/site
1058+
rootID := "__root__"
1059+
archNodes = append(archNodes, archNode{ID: rootID, Name: b.cfg.Site.Name, Type: "root", Count: len(entities)})
1060+
1061+
// Find subdomain -> domain parent relationships
1062+
subdomainParent := make(map[string]string) // subdomain name -> domain name
1063+
for _, tax := range taxonomies {
1064+
if tax.Name == "subdomain" {
1065+
for _, entry := range tax.Entries {
1066+
parentDomain := ""
1067+
if len(entry.Entities) > 0 {
1068+
parentDomain = entry.Entities[0].GetString("domain")
1069+
}
1070+
subdomainParent[entry.Name] = parentDomain
1071+
}
1072+
}
1073+
}
1074+
1075+
// Add domain nodes
1076+
for _, tax := range taxonomies {
1077+
if tax.Name == "domain" {
1078+
for _, entry := range tax.Entries {
1079+
nodeID := "domain:" + entry.Slug
1080+
archNodes = append(archNodes, archNode{
1081+
ID: nodeID,
1082+
Name: entry.Name,
1083+
Type: "domain",
1084+
Count: len(entry.Entities),
1085+
Slug: "domain/" + entry.Slug,
1086+
})
1087+
archLinks = append(archLinks, archLink{Source: rootID, Target: nodeID})
1088+
}
1089+
}
1090+
}
1091+
// Add subdomain nodes
1092+
for _, tax := range taxonomies {
1093+
if tax.Name == "subdomain" {
1094+
for _, entry := range tax.Entries {
1095+
nodeID := "subdomain:" + entry.Slug
1096+
archNodes = append(archNodes, archNode{
1097+
ID: nodeID,
1098+
Name: entry.Name,
1099+
Type: "subdomain",
1100+
Count: len(entry.Entities),
1101+
Slug: "subdomain/" + entry.Slug,
1102+
})
1103+
parentDomain := subdomainParent[entry.Name]
1104+
if parentDomain != "" {
1105+
parentSlug := entity.ToSlug(parentDomain)
1106+
archLinks = append(archLinks, archLink{Source: "domain:" + parentSlug, Target: nodeID})
1107+
} else {
1108+
archLinks = append(archLinks, archLink{Source: rootID, Target: nodeID})
1109+
}
1110+
}
1111+
}
1112+
}
1113+
1114+
var archJSON []byte
1115+
if len(archNodes) > 1 {
1116+
archJSON, _ = json.Marshal(archOverview{Nodes: archNodes, Links: archLinks})
1117+
}
1118+
9381119
// JSON-LD
9391120
websiteSchema := schemaGen.GenerateWebSiteSchema(imageURL)
9401121

@@ -970,7 +1151,8 @@ func (b *Builder) renderHomepage(
9701151
Type: "website",
9711152
SiteName: b.cfg.Site.Name,
9721153
},
973-
ChartData: template.HTML(chartJSON),
1154+
ChartData: template.JS(chartJSON),
1155+
ArchData: template.JS(archJSON),
9741156
CTA: b.cfg.Extra.CTA,
9751157
}
9761158

internal/pssg/config/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type PathsConfig struct {
4242
Output string `yaml:"output"`
4343
Cache string `yaml:"cache"`
4444
Static string `yaml:"static"`
45+
SourceDir string `yaml:"source_dir"`
4546
}
4647

4748
type DataConfig struct {

internal/pssg/render/funcs.go

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"html/template"
77
"math"
88
"net/url"
9+
"reflect"
910
"regexp"
1011
"strconv"
1112
"strings"
@@ -257,19 +258,13 @@ func sliceHelper(items interface{}, start, end int) interface{} {
257258
}
258259

259260
func length(v interface{}) int {
260-
switch val := v.(type) {
261-
case []string:
262-
return len(val)
263-
case []*entity.Entity:
264-
return len(val)
265-
case []interface{}:
266-
return len(val)
267-
case string:
268-
return len(val)
269-
case map[string]interface{}:
270-
return len(val)
271-
case []map[string]interface{}:
272-
return len(val)
261+
if v == nil {
262+
return 0
263+
}
264+
rv := reflect.ValueOf(v)
265+
switch rv.Kind() {
266+
case reflect.Slice, reflect.Map, reflect.Array, reflect.String:
267+
return rv.Len()
273268
}
274269
return 0
275270
}

0 commit comments

Comments
 (0)