package gsp import ( "fmt" "net/url" "os" "path/filepath" "sort" "strings" ) var allowedLinkRoles = map[string]bool{ "reference": true, "source": true, "binding": true, "output": true, "evidence": true, } func (p *Project) Links(id string, depth int) LinkResult { flattened := p.Flatten(id, depth, Filter{}) links := p.resolveLinks(flattened.Units) summary := summarizeLinks(links) return LinkResult{ Entry: id, Depth: depth, Links: links, Summary: summary, Warnings: flattened.Warnings, } } func (p *Project) resolveLinks(units []*Unit) []ResolvedLink { var result []ResolvedLink for _, unit := range units { for _, link := range unit.Links { result = append(result, p.resolveLink(unit, link)) } } sort.Slice(result, func(i, j int) bool { if result[i].Owner == result[j].Owner { return result[i].Path < result[j].Path } return result[i].Owner < result[j].Owner }) return result } func (p *Project) resolveLink(unit *Unit, link Link) ResolvedLink { role := strings.TrimSpace(link.Role) if role == "" { role = "reference" } resolved := ResolvedLink{ Owner: unit.ID, Title: unit.Title, Path: strings.TrimSpace(link.Path), Role: role, Context: strings.TrimSpace(link.Context), File: unit.File, } resolved.Kind, resolved.Status, resolved.Exists = p.classifyLink(resolved.Path) return resolved } func (p *Project) classifyLink(path string) (kind, status string, exists bool) { if path == "" { return "unknown", "invalid", false } if isURL(path) { parsed, err := url.ParseRequestURI(path) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "url", "invalid", false } return "url", "unchecked", false } checkPath := path if !filepath.IsAbs(checkPath) { checkPath = filepath.Join(p.Root, filepath.FromSlash(checkPath)) } info, err := os.Stat(checkPath) if err != nil { if os.IsNotExist(err) { return "missing", "missing", false } return "unknown", "invalid", false } if info.IsDir() { return "folder", "ok", true } return "file", "ok", true } func isURL(value string) bool { lower := strings.ToLower(value) return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") } func summarizeLinks(links []ResolvedLink) LinkSummary { summary := LinkSummary{ Total: len(links), ByKind: map[string]int{}, ByRole: map[string]int{}, ByState: map[string]int{}, } for _, link := range links { summary.ByKind[link.Kind]++ summary.ByRole[link.Role]++ summary.ByState[link.Status]++ } if len(summary.ByKind) == 0 { summary.ByKind = nil } if len(summary.ByRole) == 0 { summary.ByRole = nil } if len(summary.ByState) == 0 { summary.ByState = nil } return summary } func (r LinkResult) Markdown() string { var builder strings.Builder builder.WriteString("# GSP Links\n\n") builder.WriteString(fmt.Sprintf("- Entry: `%s`\n", r.Entry)) builder.WriteString(fmt.Sprintf("- Links: `%d`\n", r.Summary.Total)) if len(r.Links) == 0 { builder.WriteString("\nNo links.\n") return builder.String() } builder.WriteString("\n## Links\n\n") for _, link := range r.Links { builder.WriteString(fmt.Sprintf("- `%s` %s\n", link.Owner, link.Path)) builder.WriteString(fmt.Sprintf(" - role: `%s`\n", link.Role)) builder.WriteString(fmt.Sprintf(" - kind: `%s`\n", link.Kind)) builder.WriteString(fmt.Sprintf(" - status: `%s`\n", link.Status)) if link.Context != "" { builder.WriteString(fmt.Sprintf(" - context: %s\n", link.Context)) } } return builder.String() }