Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions internal/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,28 +227,39 @@ func referenceChanges(project scanner.Project, item scanner.AssetItem, targetPat
File: ref.File,
Line: ref.Line,
OldSpecifier: ref.Specifier,
NewSpecifier: rewriteSpecifier(ref.Specifier, ref.File, targetPath),
NewSpecifier: rewriteSpecifier(ref.Specifier, ref.File, targetPath, project.ImportAliases),
})
}
return changes, blockers
}

func rewriteSpecifier(oldSpec, importerRepoPath, targetRepoPath string) string {
func rewriteSpecifier(oldSpec, importerRepoPath, targetRepoPath string, aliases map[string]string) string {
spec := strings.Split(oldSpec, "?")[0]
query := ""
if i := strings.IndexByte(oldSpec, '?'); i >= 0 {
query = oldSpec[i:]
}
for _, prefix := range []string{"@/", "~/"} {
if strings.HasPrefix(spec, prefix) {
srcBase := findSrcBase(importerRepoPath)
if strings.HasPrefix(targetRepoPath, srcBase+"/") {
newSpec := prefix + strings.TrimPrefix(targetRepoPath, srcBase+"/")
return newSpec + query
for aliasKey, aliasPath := range aliases {
cleanKey := strings.Trim(aliasKey, "/")
cleanPath := strings.Trim(aliasPath, "/")
if spec == cleanKey || strings.HasPrefix(spec, cleanKey+"/") {
if strings.HasPrefix(targetRepoPath, cleanPath+"/") || targetRepoPath == cleanPath {
suffix := strings.TrimPrefix(targetRepoPath, cleanPath)
if suffix == "" {
return cleanKey + query
}
return cleanKey + "/" + strings.TrimPrefix(suffix, "/") + query
}
return relativeSpecifier(importerRepoPath, targetRepoPath) + query
}
}
Comment thread
hiro05097952 marked this conversation as resolved.
if strings.HasPrefix(spec, "@/") || strings.HasPrefix(spec, "~/") {
prefix := spec[:2]
srcBase := findSrcBase(importerRepoPath)
if strings.HasPrefix(targetRepoPath, srcBase+"/") {
return prefix + strings.TrimPrefix(targetRepoPath, srcBase+"/") + query
}
return relativeSpecifier(importerRepoPath, targetRepoPath) + query
}
if strings.HasPrefix(spec, "/") {
return "/" + targetRepoPath + query
}
Expand Down
19 changes: 14 additions & 5 deletions internal/actions/actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,25 +276,34 @@ func TestPathAndSpecifierHelpers(t *testing.T) {
t.Fatalf("relativeSpecifier nested = %q", got)
}
// rewriteSpecifier preserves @/ alias
if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/src/assets/logo.avif"); got != "@/assets/logo.avif" {
if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/src/assets/logo.avif", nil); got != "@/assets/logo.avif" {
t.Fatalf("rewriteSpecifier @/ = %q", got)
}
// rewriteSpecifier preserves ~/ alias
if got := rewriteSpecifier("~/assets/icon.svg", "packages/ui/src/components/Card.tsx", "packages/ui/src/assets/icon.avif"); got != "~/assets/icon.avif" {
if got := rewriteSpecifier("~/assets/icon.svg", "packages/ui/src/components/Card.tsx", "packages/ui/src/assets/icon.avif", nil); got != "~/assets/icon.avif" {
t.Fatalf("rewriteSpecifier ~/ = %q", got)
}
// rewriteSpecifier preserves query string
if got := rewriteSpecifier("@/assets/bg.png?raw", "src/App.tsx", "src/assets/bg.avif"); got != "@/assets/bg.avif?raw" {
if got := rewriteSpecifier("@/assets/bg.png?raw", "src/App.tsx", "src/assets/bg.avif", nil); got != "@/assets/bg.avif?raw" {
t.Fatalf("rewriteSpecifier query = %q", got)
}
// rewriteSpecifier falls back when the target leaves the alias base
if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/public/logo.avif"); got != "../../public/logo.avif" {
if got := rewriteSpecifier("@/assets/logo.png", "apps/web/src/views/Home.vue", "apps/web/public/logo.avif", nil); got != "../../public/logo.avif" {
t.Fatalf("rewriteSpecifier outside alias base = %q", got)
}
// rewriteSpecifier falls back to relative for non-alias paths
if got := rewriteSpecifier("./assets/logo.png", "src/components/App.tsx", "src/assets/logo.avif"); got != "../assets/logo.avif" {
if got := rewriteSpecifier("./assets/logo.png", "src/components/App.tsx", "src/assets/logo.avif", nil); got != "../assets/logo.avif" {
t.Fatalf("rewriteSpecifier relative = %q", got)
}
// rewriteSpecifier with custom import alias
aliases := map[string]string{"@acme/shared-ui": "packages/shared-ui"}
if got := rewriteSpecifier("@acme/shared-ui/images/icon.png", "src/App.tsx", "packages/shared-ui/images/icon.avif", aliases); got != "@acme/shared-ui/images/icon.avif" {
t.Fatalf("rewriteSpecifier alias = %q", got)
}
// rewriteSpecifier with alias + query string
if got := rewriteSpecifier("@acme/shared-ui/images/icon.svg?component", "src/App.tsx", "packages/shared-ui/images/icon.avif", aliases); got != "@acme/shared-ui/images/icon.avif?component" {
t.Fatalf("rewriteSpecifier alias+query = %q", got)
}
for _, invalid := range []string{"", "../escape.png", "/absolute.png", "."} {
if got := cleanRepoPath(invalid); got != "" {
t.Fatalf("cleanRepoPath(%q) = %q", invalid, got)
Expand Down
3 changes: 3 additions & 0 deletions internal/config/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,9 @@ func (s *Store) UpdateSettings(update SettingsUpdate) (AppSettings, error) {
if update.ExcludePatternsByIntent != nil {
settings.ExcludePatternsByIntent = normalizeExcludePatternsByIntent(update.ExcludePatternsByIntent)
}
if update.ImportAliases != nil {
settings.ImportAliases = update.ImportAliases
}
if update.OptimizationDefaultQuality != nil {
settings.OptimizationDefaultQuality = *update.OptimizationDefaultQuality
}
Expand Down
2 changes: 2 additions & 0 deletions internal/config/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type AppSettings struct {
OCRFuzzySearch bool `json:"ocrFuzzySearch"`
ExcludePatterns []string `json:"excludePatterns"`
ExcludePatternsByIntent scanner.ExcludePatternsByIntent `json:"excludePatternsByIntent"`
ImportAliases map[string]string `json:"importAliases"`
OptimizationDefaultQuality int `json:"optimizationDefaultQuality"`
OptimizationWorkers int `json:"optimizationWorkers"`
OptimizationAvifSpeed int `json:"optimizationAvifSpeed"`
Expand Down Expand Up @@ -101,6 +102,7 @@ type SettingsUpdate struct {
OCRFuzzySearch *bool `json:"ocrFuzzySearch"`
ExcludePatterns []string `json:"excludePatterns"`
ExcludePatternsByIntent scanner.ExcludePatternsByIntent `json:"excludePatternsByIntent"`
ImportAliases map[string]string `json:"importAliases"`
OptimizationDefaultQuality *int `json:"optimizationDefaultQuality"`
OptimizationWorkers *int `json:"optimizationWorkers"`
OptimizationAvifSpeed *int `json:"optimizationAvifSpeed"`
Expand Down
83 changes: 80 additions & 3 deletions internal/references/references.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type Project struct {
ID string
Path string
ExcludePatterns []string
ImportAliases map[string]string
}

type Asset struct {
Expand Down Expand Up @@ -85,10 +86,18 @@ func BuildMapWithProgress(ctx context.Context, projects []Project, assets []Asse
}
for _, ref := range Extract(string(bytes)) {
ref.File = file.repo
resolved := Resolve(file.project.Path, file.repo, ref.Specifier)
resolved := ResolveWithAliases(file.project.Path, file.repo, ref.Specifier, file.project.ImportAliases)
if ref.Kind == "pattern" {
globPattern := resolvePattern(file.repo, ref.Specifier, file.project.ImportAliases)
for candidate := range assetSets[file.project.ID] {
if referenceMayPointTo(file.project.Path, candidate, file.repo, ref.Specifier) {
matched := false
if globPattern != "" {
matched = globMatchRepoPath(globPattern, candidate)
}
if !matched {
matched = referenceMayPointToWithAliases(file.project.Path, candidate, file.repo, ref.Specifier, file.project.ImportAliases)
}
if matched {
ref.ProjectID = file.project.ID
ref.AssetPath = candidate
out[key(file.project.ID, candidate)] = append(out[key(file.project.ID, candidate)], ref)
Expand All @@ -103,7 +112,7 @@ func BuildMapWithProgress(ctx context.Context, projects []Project, assets []Asse
continue
}
for candidate := range assetSets[file.project.ID] {
if referenceMayPointTo(file.project.Path, candidate, file.repo, ref.Specifier) {
if referenceMayPointToWithAliases(file.project.Path, candidate, file.repo, ref.Specifier, file.project.ImportAliases) {
ref.ProjectID = file.project.ID
ref.AssetPath = candidate
out[key(file.project.ID, candidate)] = append(out[key(file.project.ID, candidate)], ref)
Expand Down Expand Up @@ -302,10 +311,17 @@ func spanCovered(start, end int, spans [][2]int) bool {
}

func Resolve(projectRoot, importerRepoPath, specifier string) string {
return ResolveWithAliases(projectRoot, importerRepoPath, specifier, nil)
}

func ResolveWithAliases(projectRoot, importerRepoPath, specifier string, aliases map[string]string) string {
spec := stripQuery(filepath.ToSlash(strings.TrimSpace(specifier)))
if spec == "" || strings.Contains(spec, "${") || strings.ContainsAny(spec, "*{}") {
return ""
}
if resolved := resolveAlias(spec, aliases); resolved != "" {
return cleanRepoPath(resolved)
}
if strings.HasPrefix(spec, "@/") || strings.HasPrefix(spec, "~/") {
srcBase := findSrcAncestor(importerRepoPath)
return cleanRepoPath(filepath.ToSlash(filepath.Join(srcBase, spec[2:])))
Expand All @@ -327,6 +343,53 @@ func Resolve(projectRoot, importerRepoPath, specifier string) string {
return cleanRepoPath(spec)
}

func resolvePattern(importerRepoPath, specifier string, aliases map[string]string) string {
spec := stripQuery(filepath.ToSlash(strings.TrimSpace(specifier)))
if spec == "" {
return ""
}
if resolved := resolveAlias(spec, aliases); resolved != "" {
return resolved
}
if strings.HasPrefix(spec, "@/") || strings.HasPrefix(spec, "~/") {
srcBase := findSrcAncestor(importerRepoPath)
return filepath.ToSlash(filepath.Join(srcBase, spec[2:]))
}
if strings.HasPrefix(spec, "/") {
return strings.TrimPrefix(spec, "/")
}
if strings.HasPrefix(spec, "./") || strings.HasPrefix(spec, "../") {
base := filepath.Dir(filepath.FromSlash(importerRepoPath))
return filepath.ToSlash(filepath.Join(base, filepath.FromSlash(spec)))
}
return spec
}

func globMatchRepoPath(pattern, repoPath string) bool {
return matchPathParts(splitPathPattern(pattern), splitPathPattern(repoPath))
}

func resolveAlias(spec string, aliases map[string]string) string {
if len(aliases) == 0 {
return ""
}
bestKey := ""
bestClean := ""
for key := range aliases {
clean := strings.Trim(key, "/")
if (spec == clean || strings.HasPrefix(spec, clean+"/")) && len(clean) > len(bestClean) {
bestKey = key
bestClean = clean
}
}
if bestKey == "" {
return ""
}
aliasPath := strings.Trim(aliases[bestKey], "/")
suffix := strings.TrimPrefix(spec, bestClean)
return filepath.ToSlash(filepath.Clean(aliasPath + "/" + suffix))
}
Comment thread
hiro05097952 marked this conversation as resolved.

func findSrcAncestor(importerRepoPath string) string {
dir := pathpkg.Dir(importerRepoPath)
for dir != "." && dir != "/" {
Expand Down Expand Up @@ -418,6 +481,20 @@ func referenceMayPointTo(projectRoot, repoPath, importerRepoPath, specifier stri
return false
}

func referenceMayPointToWithAliases(projectRoot, repoPath, importerRepoPath, specifier string, aliases map[string]string) bool {
if referenceMayPointTo(projectRoot, repoPath, importerRepoPath, specifier) {
return true
}
resolved := resolveAlias(stripQuery(filepath.ToSlash(specifier)), aliases)
if resolved != "" {
resolved = cleanRepoPath(resolved)
if resolved == repoPath || strings.HasSuffix(resolved, "/"+repoPath) || strings.HasSuffix(repoPath, "/"+resolved) {
return true
}
}
return false
}

func isPattern(spec string) bool {
return strings.Contains(spec, "${") || strings.ContainsAny(spec, "*{}")
}
Expand Down
76 changes: 76 additions & 0 deletions internal/references/references_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,55 @@ func TestResolveReferenceKinds(t *testing.T) {
}
}

func TestResolveWithImportAliases(t *testing.T) {
root := t.TempDir()
aliases := map[string]string{
"@acme/shared-ui": "packages/shared-ui",
"@acme/design-tokens": "packages/design-tokens",
}
tests := []struct {
importer string
spec string
want string
}{
{"src/App.tsx", "@acme/shared-ui/images/icon.svg", "packages/shared-ui/images/icon.svg"},
{"src/App.tsx", "@acme/shared-ui/images/icon.svg?component", "packages/shared-ui/images/icon.svg"},
{"src/App.tsx", "@acme/design-tokens/images/logo.png", "packages/design-tokens/images/logo.png"},
// No alias match falls through to existing behavior
{"src/App.tsx", "@/assets/logo.png", "src/assets/logo.png"},
{"src/App.tsx", "./assets/logo.png", "src/assets/logo.png"},
}
for _, tt := range tests {
if got := ResolveWithAliases(root, tt.importer, tt.spec, aliases); got != tt.want {
t.Fatalf("ResolveWithAliases(%q, %q) = %q, want %q", tt.importer, tt.spec, got, tt.want)
}
}
// Nil aliases = same as Resolve
if got := ResolveWithAliases(root, "src/App.tsx", "@/assets/logo.png", nil); got != "src/assets/logo.png" {
t.Fatalf("ResolveWithAliases nil aliases = %q", got)
}
}

func TestBuildMapResolvesImportAliases(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "packages", "shared-assets", "images", "icon.svg"), "image")
mustWrite(t, filepath.Join(root, "apps", "web", "src", "views", "Home.vue"),
`import Icon from '@acme/shared-ui/images/icon.svg'`)

aliases := map[string]string{"@acme/shared-ui": "packages/shared-ui"}
refs, err := BuildMap(context.Background(),
[]Project{{ID: "p", Path: root, ImportAliases: aliases}},
[]Asset{{ProjectID: "p", RepoPath: "packages/shared-ui/images/icon.svg"}},
)
if err != nil {
t.Fatal(err)
}
got := refs["p\x00packages/shared-ui/images/icon.svg"]
if len(got) != 1 || got[0].File != "apps/web/src/views/Home.vue" {
t.Fatalf("alias refs = %#v, want 1 ref from Home.vue", got)
}
}

func TestExtractCSSStringAndPatternReferences(t *testing.T) {
content := `
const a = "./assets/a.png"
Expand Down Expand Up @@ -110,6 +159,33 @@ func TestBuildMapResolvesAbsolutePublicReferences(t *testing.T) {
}
}

func TestBuildMapResolvesGlobPattern(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "apps", "web", "src", "components", "Card", "images", "icons", "star.avif"), "image")
mustWrite(t, filepath.Join(root, "apps", "web", "src", "components", "Card", "images", "icons", "heart.avif"), "image")
mustWrite(t, filepath.Join(root, "apps", "web", "src", "components", "Card", "index.vue"),
"const imgs = import.meta.glob('./images/icons/**/*.avif', { eager: true })")

refs, err := BuildMap(context.Background(),
[]Project{{ID: "p", Path: root}},
[]Asset{
{ProjectID: "p", RepoPath: "apps/web/src/components/Card/images/icons/star.avif"},
{ProjectID: "p", RepoPath: "apps/web/src/components/Card/images/icons/heart.avif"},
},
)
if err != nil {
t.Fatal(err)
}
star := refs["p\x00apps/web/src/components/Card/images/icons/star.avif"]
if len(star) != 1 || star[0].File != "apps/web/src/components/Card/index.vue" {
t.Fatalf("glob star refs = %#v", star)
}
heart := refs["p\x00apps/web/src/components/Card/images/icons/heart.avif"]
if len(heart) != 1 || heart[0].File != "apps/web/src/components/Card/index.vue" {
t.Fatalf("glob heart refs = %#v", heart)
}
}

func TestBuildMapResolvesAbsolutePathInMonorepo(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "apps", "dashboard", "src", "assets", "hero.webp"), "image")
Expand Down
1 change: 1 addition & 0 deletions internal/scanner/references.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func buildReferenceMap(ctx context.Context, projects []Project, items []AssetIte
ID: project.ID,
Path: project.Path,
ExcludePatterns: EffectiveExcludePatterns(project, options),
ImportAliases: options.ImportAliases,
})
}
assets := make([]references.Asset, 0, len(items))
Expand Down
14 changes: 8 additions & 6 deletions internal/scanner/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ type ScanOptions struct {
Profile ScanProfile `json:"profile"`
ExcludePatterns []string `json:"excludePatterns,omitempty"`
ExcludePatternsByIntent ExcludePatternsByIntent `json:"excludePatternsByIntent,omitempty"`
ImportAliases map[string]string `json:"importAliases,omitempty"`
Analyses AnalysisOptions `json:"analyses"`
OptimizationThresholds imageproc.OptimizationThresholds `json:"optimizationThresholds,omitempty"`
LintSettings lint.Settings `json:"lintSettings,omitempty"`
Expand All @@ -120,12 +121,13 @@ type ScanProgress struct {
type ProgressFunc func(ScanProgress)

type Project struct {
ID string `json:"id"`
WorkspaceID string `json:"workspaceId,omitempty"`
Name string `json:"name"`
Path string `json:"path"`
ScanIntent ProjectScanIntent `json:"scanIntent"`
CreatedAt string `json:"createdAt,omitempty"`
ID string `json:"id"`
WorkspaceID string `json:"workspaceId,omitempty"`
Name string `json:"name"`
Path string `json:"path"`
ScanIntent ProjectScanIntent `json:"scanIntent"`
CreatedAt string `json:"createdAt,omitempty"`
ImportAliases map[string]string `json:"-"`
}

type Catalog struct {
Expand Down
Loading
Loading