From cc7785d4e6be335284143ca1f0ba92bc53d1725b Mon Sep 17 00:00:00 2001 From: hiro05097952 Date: Tue, 9 Jun 2026 11:36:43 +0800 Subject: [PATCH 1/3] feat: add import aliases setting for monorepo workspace package resolution Add configurable importAliases (map[string]string) to settings so that workspace package imports like @scope/shared-ui/images/icon.svg resolve to their actual repo paths (packages/shared-ui/images/icon.svg). Backend: resolveAlias with longest-prefix matching in Resolve and fuzzy match, rewriteSpecifier preserves alias format during optimization apply, aliases plumbed through ScanOptions and scanner.Project. Frontend: key-value editor in ScanningSection, alias changes trigger rescan via settingsCatalogInputsChanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/actions/actions.go | 23 +++--- internal/actions/actions_test.go | 19 +++-- internal/config/settings.go | 3 + internal/config/types.go | 2 + internal/references/references.go | 44 ++++++++++- internal/references/references_test.go | 49 ++++++++++++ internal/scanner/references.go | 1 + internal/scanner/types.go | 14 ++-- internal/server/actions_handlers.go | 10 +++ internal/server/catalog.go | 3 + internal/server/settings.go | 1 + ui/src/features/settings/ScanningSection.tsx | 81 ++++++++++++++++++++ ui/src/features/settings/helpers.ts | 9 +++ ui/src/features/settings/types.ts | 1 + ui/src/i18n/locales/en.json | 4 + ui/src/i18n/locales/zh-TW.json | 4 + ui/src/types/settings.ts | 1 + 17 files changed, 246 insertions(+), 23 deletions(-) diff --git a/internal/actions/actions.go b/internal/actions/actions.go index d110ba3d..4cc101e0 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -227,28 +227,33 @@ 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 { + if spec == aliasKey || strings.HasPrefix(spec, aliasKey+"/") { + if strings.HasPrefix(targetRepoPath, aliasPath+"/") || targetRepoPath == aliasPath { + return aliasKey + strings.TrimPrefix(targetRepoPath, aliasPath) + query } - return relativeSpecifier(importerRepoPath, targetRepoPath) + query } } + 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 } diff --git a/internal/actions/actions_test.go b/internal/actions/actions_test.go index 834d859a..3fbf1cc9 100644 --- a/internal/actions/actions_test.go +++ b/internal/actions/actions_test.go @@ -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) diff --git a/internal/config/settings.go b/internal/config/settings.go index e030b584..c4130c71 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -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 } diff --git a/internal/config/types.go b/internal/config/types.go index 101237a1..85ed378d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -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"` @@ -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"` diff --git a/internal/references/references.go b/internal/references/references.go index 6e9b049c..670ad10a 100644 --- a/internal/references/references.go +++ b/internal/references/references.go @@ -14,6 +14,7 @@ type Project struct { ID string Path string ExcludePatterns []string + ImportAliases map[string]string } type Asset struct { @@ -85,10 +86,10 @@ 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" { 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) @@ -103,7 +104,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) @@ -302,10 +303,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:]))) @@ -327,6 +335,22 @@ func Resolve(projectRoot, importerRepoPath, specifier string) string { return cleanRepoPath(spec) } +func resolveAlias(spec string, aliases map[string]string) string { + if len(aliases) == 0 { + return "" + } + bestKey := "" + for key := range aliases { + if (spec == key || strings.HasPrefix(spec, key+"/")) && len(key) > len(bestKey) { + bestKey = key + } + } + if bestKey == "" { + return "" + } + return aliases[bestKey] + strings.TrimPrefix(spec, bestKey) +} + func findSrcAncestor(importerRepoPath string) string { dir := pathpkg.Dir(importerRepoPath) for dir != "." && dir != "/" { @@ -418,6 +442,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, "*{}") } diff --git a/internal/references/references_test.go b/internal/references/references_test.go index 5580858a..6610e23a 100644 --- a/internal/references/references_test.go +++ b/internal/references/references_test.go @@ -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" diff --git a/internal/scanner/references.go b/internal/scanner/references.go index 7d17b21e..f761356d 100644 --- a/internal/scanner/references.go +++ b/internal/scanner/references.go @@ -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)) diff --git a/internal/scanner/types.go b/internal/scanner/types.go index 157f9cee..11417117 100644 --- a/internal/scanner/types.go +++ b/internal/scanner/types.go @@ -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"` @@ -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 { diff --git a/internal/server/actions_handlers.go b/internal/server/actions_handlers.go index fe6120b3..a0181642 100644 --- a/internal/server/actions_handlers.go +++ b/internal/server/actions_handlers.go @@ -49,6 +49,9 @@ func (s *Server) handleOptimizationPreview(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, err) return } + if settings, err := s.store.Settings(); err == nil { + project.ImportAliases = settings.ImportAliases + } for _, item := range items { if item.ProjectID != project.ID { writeError(w, http.StatusBadRequest, apierr.New("optimization_project_mixed", "optimization preview can only apply one project at a time")) @@ -456,6 +459,9 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err) return } + if settings, err := s.store.Settings(); err == nil { + project.ImportAliases = settings.ImportAliases + } var result actions.ApplyResult if preview.Type == "optimization" { result, err = optimize.Apply(project, preview) @@ -507,6 +513,10 @@ func (s *Server) projectAndItem(ctx context.Context, assetID string) (scanner.Pr if err != nil { return scanner.Project{}, scanner.AssetItem{}, err } + settings, err := s.store.Settings() + if err == nil { + project.ImportAliases = settings.ImportAliases + } return project, detail.Item, nil } diff --git a/internal/server/catalog.go b/internal/server/catalog.go index 5f3e8d87..d20cbd80 100644 --- a/internal/server/catalog.go +++ b/internal/server/catalog.go @@ -610,6 +610,7 @@ func (s *Server) scanWithProgress(ctx context.Context, override scanner.ScanOpti Analyses: settings.ScanAnalyses, ExcludePatterns: settings.ExcludePatterns, ExcludePatternsByIntent: settings.ExcludePatternsByIntent, + ImportAliases: settings.ImportAliases, OptimizationThresholds: settings.OptimizationThresholds, LintSettings: settings.LintRules, }) @@ -619,6 +620,7 @@ func (s *Server) scanWithProgress(ctx context.Context, override scanner.ScanOpti options = scanner.NormalizeScanOptions(options) options.ExcludePatterns = settings.ExcludePatterns options.ExcludePatternsByIntent = settings.ExcludePatternsByIntent + options.ImportAliases = settings.ImportAliases options.LintSettings = settings.LintRules } catalog, err := s.scanner.ScanWithOptions(ctx, projects, options, progress) @@ -695,6 +697,7 @@ func (s *Server) analysisIncomplete(summary config.CatalogSummary) bool { Analyses: settings.ScanAnalyses, ExcludePatterns: settings.ExcludePatterns, ExcludePatternsByIntent: settings.ExcludePatternsByIntent, + ImportAliases: settings.ImportAliases, }) want := options.Analyses if want.References && a.References != scanner.AnalysisComputed { diff --git a/internal/server/settings.go b/internal/server/settings.go index 6c0d8402..9bc6f024 100644 --- a/internal/server/settings.go +++ b/internal/server/settings.go @@ -110,6 +110,7 @@ func settingsCatalogInputsChanged(update config.SettingsUpdate, previous, update (update.OptimizationStrategies != nil && !reflect.DeepEqual(previous.OptimizationStrategies, updated.OptimizationStrategies)) || (update.ExcludePatterns != nil && !reflect.DeepEqual(previous.ExcludePatterns, updated.ExcludePatterns)) || (update.ExcludePatternsByIntent != nil && !reflect.DeepEqual(previous.ExcludePatternsByIntent, updated.ExcludePatternsByIntent)) || + (update.ImportAliases != nil && !reflect.DeepEqual(previous.ImportAliases, updated.ImportAliases)) || (update.LintRules != nil && !reflect.DeepEqual(previous.LintRules, updated.LintRules)) } diff --git a/ui/src/features/settings/ScanningSection.tsx b/ui/src/features/settings/ScanningSection.tsx index 3dc376f0..a8000554 100644 --- a/ui/src/features/settings/ScanningSection.tsx +++ b/ui/src/features/settings/ScanningSection.tsx @@ -1,11 +1,14 @@ import { Download, Globe2, + Link, LoaderCircle, + Plus, ScanText, Sliders, Square, Trash2, + X, } from "lucide-react"; import type { ReactNode } from "react"; import { useState } from "react"; @@ -265,6 +268,84 @@ export function ScanningSection({

+ } + align="start" + > +
+ {draft.importAliases.map((alias, index) => ( +
+ { + const value = event.target.value; + onUpdateDraft((prev) => ({ + ...prev, + importAliases: prev.importAliases.map((a, i) => + i === index ? { ...a, key: value } : a, + ), + })); + }} + placeholder="@scope/package" + className="flex-1" + inputClassName="font-g-mono text-g-ui tracking-g-mono" + /> + + { + const value = event.target.value; + onUpdateDraft((prev) => ({ + ...prev, + importAliases: prev.importAliases.map((a, i) => + i === index ? { ...a, value } : a, + ), + })); + }} + placeholder="packages/package" + className="flex-1" + inputClassName="font-g-mono text-g-ui tracking-g-mono" + /> + +
+ ))} + +
+
{updateError && ( {errorMessage(updateError)} )} diff --git a/ui/src/features/settings/helpers.ts b/ui/src/features/settings/helpers.ts index e49f3229..66f02c69 100644 --- a/ui/src/features/settings/helpers.ts +++ b/ui/src/features/settings/helpers.ts @@ -397,6 +397,9 @@ export function draftFromSettings(settings?: SettingsInfo): SettingsDraft { vlmBackendTranslate: settings?.vlmBackendTranslate ?? "", vlmBackendCanvas: settings?.vlmBackendCanvas ?? "", aiNickname: settings?.aiNickname ?? "", + importAliases: Object.entries(settings?.importAliases ?? {}).map( + ([key, value]) => ({ key, value }), + ), excludePatternsText: (settings?.excludePatterns ?? []).join("\n"), excludePatternsByIntentText: Object.fromEntries( projectScanIntentValues.map((intent) => [ @@ -475,6 +478,11 @@ export function updateFromDraft(draft: SettingsDraft): SettingsUpdate { vlmBackendTranslate: draft.vlmBackendTranslate, vlmBackendCanvas: draft.vlmBackendCanvas, aiNickname: draft.aiNickname, + importAliases: Object.fromEntries( + draft.importAliases + .filter((a) => a.key.trim() && a.value.trim()) + .map((a) => [a.key.trim(), a.value.trim()]), + ), excludePatterns: splitPatterns(draft.excludePatternsText), excludePatternsByIntent: Object.fromEntries( projectScanIntentValues.map((intent) => [ @@ -544,6 +552,7 @@ export function resetSectionDraft( scanAnalyses: defaults.scanAnalyses, excludePatternsText: defaults.excludePatternsText, excludePatternsByIntentText: defaults.excludePatternsByIntentText, + importAliases: defaults.importAliases, }; case "ocr": return { diff --git a/ui/src/features/settings/types.ts b/ui/src/features/settings/types.ts index ec60c618..2203b12e 100644 --- a/ui/src/features/settings/types.ts +++ b/ui/src/features/settings/types.ts @@ -116,6 +116,7 @@ export type SettingsDraft = { aiNickname: string; excludePatternsText: string; excludePatternsByIntentText: Record; + importAliases: Array<{ key: string; value: string }>; optimizationDefaultQuality: number; optimizationWorkers: number; optimizationAvifSpeed: number; diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index a5412c95..7f8b170f 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -852,6 +852,10 @@ }, "excludePatterns": "Exclude patterns", "excludePatternsHint": "Patterns are project-relative. Choose Global rules or rules appended for one project type.", + "importAliases": "Import aliases", + "importAliasesHint": "Map package names to repo paths so references like @scope/pkg/image.png resolve correctly.", + "importAliasAdd": "Add alias", + "importAliasRemove": "Remove alias", "excludeScopeLabel": "Exclude pattern scope", "excludeScope": { "global": "Global", diff --git a/ui/src/i18n/locales/zh-TW.json b/ui/src/i18n/locales/zh-TW.json index e1320564..9dfcea6f 100644 --- a/ui/src/i18n/locales/zh-TW.json +++ b/ui/src/i18n/locales/zh-TW.json @@ -1773,6 +1773,10 @@ }, "excludePatterns": "排除規則", "excludePatternsHint": "規則以專案根目錄為基準;選擇 Global,或只追加到特定專案類型", + "importAliases": "匯入別名", + "importAliasesHint": "將套件名稱對應到 repo 內的實際路徑,讓 @scope/pkg/image.png 形式的引用能正確解析", + "importAliasAdd": "新增別名", + "importAliasRemove": "移除別名", "excludeScopeLabel": "排除規則範圍", "excludeScope": { "global": "Global", diff --git a/ui/src/types/settings.ts b/ui/src/types/settings.ts index e3c82a00..8c1df7db 100644 --- a/ui/src/types/settings.ts +++ b/ui/src/types/settings.ts @@ -18,6 +18,7 @@ export type AppSettings = { ocrFuzzySearch: boolean; excludePatterns: string[]; excludePatternsByIntent: ExcludePatternsByIntent; + importAliases: Record | null; optimizationDefaultQuality: number; optimizationWorkers: number; optimizationAvifSpeed: number; From 747dc02977227ddd020494252d0c796834f5f431 Mon Sep 17 00:00:00 2001 From: hiro05097952 Date: Tue, 9 Jun 2026 11:42:11 +0800 Subject: [PATCH 2/3] feat: resolve glob patterns in import.meta.glob references Pattern references like ./images/**/*.avif were matched by literal suffix comparison which always failed for ** and * wildcards. Add resolvePattern to expand the relative/alias base path while preserving glob characters, then match candidates with the existing matchPathParts glob engine. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/references/references.go | 36 +++++++++++++++++++++++++- internal/references/references_test.go | 27 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/internal/references/references.go b/internal/references/references.go index 670ad10a..13e10bfe 100644 --- a/internal/references/references.go +++ b/internal/references/references.go @@ -88,8 +88,16 @@ func BuildMapWithProgress(ctx context.Context, projects []Project, assets []Asse ref.File = file.repo 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 referenceMayPointToWithAliases(file.project.Path, candidate, file.repo, ref.Specifier, file.project.ImportAliases) { + 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) @@ -335,6 +343,32 @@ func ResolveWithAliases(projectRoot, importerRepoPath, specifier string, aliases 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 "" diff --git a/internal/references/references_test.go b/internal/references/references_test.go index 6610e23a..21063925 100644 --- a/internal/references/references_test.go +++ b/internal/references/references_test.go @@ -159,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") From 4a5f0e7c4062ef26b2759761537e0514e75b8876 Mon Sep 17 00:00:00 2001 From: hiro05097952 Date: Tue, 9 Jun 2026 11:53:40 +0800 Subject: [PATCH 3/3] fix: sanitize alias keys/values and use stable React keys Trim leading/trailing slashes from alias keys and values in resolveAlias and rewriteSpecifier to prevent double-slash or mismatched paths from user input. Use filepath.Clean for robust path normalization. Replace array index with unique id for import alias React list keys to avoid DOM reuse glitches on add/delete. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/actions/actions.go | 12 +++++++++--- internal/references/references.go | 9 +++++++-- ui/src/features/settings/ScanningSection.tsx | 16 ++++++++-------- ui/src/features/settings/helpers.ts | 2 +- ui/src/features/settings/types.ts | 2 +- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/internal/actions/actions.go b/internal/actions/actions.go index 4cc101e0..fa255aee 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -240,9 +240,15 @@ func rewriteSpecifier(oldSpec, importerRepoPath, targetRepoPath string, aliases query = oldSpec[i:] } for aliasKey, aliasPath := range aliases { - if spec == aliasKey || strings.HasPrefix(spec, aliasKey+"/") { - if strings.HasPrefix(targetRepoPath, aliasPath+"/") || targetRepoPath == aliasPath { - return aliasKey + strings.TrimPrefix(targetRepoPath, aliasPath) + query + 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 } } } diff --git a/internal/references/references.go b/internal/references/references.go index 13e10bfe..509e918c 100644 --- a/internal/references/references.go +++ b/internal/references/references.go @@ -374,15 +374,20 @@ func resolveAlias(spec string, aliases map[string]string) string { return "" } bestKey := "" + bestClean := "" for key := range aliases { - if (spec == key || strings.HasPrefix(spec, key+"/")) && len(key) > len(bestKey) { + clean := strings.Trim(key, "/") + if (spec == clean || strings.HasPrefix(spec, clean+"/")) && len(clean) > len(bestClean) { bestKey = key + bestClean = clean } } if bestKey == "" { return "" } - return aliases[bestKey] + strings.TrimPrefix(spec, bestKey) + aliasPath := strings.Trim(aliases[bestKey], "/") + suffix := strings.TrimPrefix(spec, bestClean) + return filepath.ToSlash(filepath.Clean(aliasPath + "/" + suffix)) } func findSrcAncestor(importerRepoPath string) string { diff --git a/ui/src/features/settings/ScanningSection.tsx b/ui/src/features/settings/ScanningSection.tsx index a8000554..5d6b8029 100644 --- a/ui/src/features/settings/ScanningSection.tsx +++ b/ui/src/features/settings/ScanningSection.tsx @@ -275,8 +275,8 @@ export function ScanningSection({ align="start" >
- {draft.importAliases.map((alias, index) => ( -
+ {draft.importAliases.map((alias) => ( +
({ ...prev, - importAliases: prev.importAliases.map((a, i) => - i === index ? { ...a, key: value } : a, + importAliases: prev.importAliases.map((a) => + a.id === alias.id ? { ...a, key: value } : a, ), })); }} @@ -301,8 +301,8 @@ export function ScanningSection({ const value = event.target.value; onUpdateDraft((prev) => ({ ...prev, - importAliases: prev.importAliases.map((a, i) => - i === index ? { ...a, value } : a, + importAliases: prev.importAliases.map((a) => + a.id === alias.id ? { ...a, value } : a, ), })); }} @@ -317,7 +317,7 @@ export function ScanningSection({ onUpdateDraft((prev) => ({ ...prev, importAliases: prev.importAliases.filter( - (_, i) => i !== index, + (a) => a.id !== alias.id, ), })) } @@ -336,7 +336,7 @@ export function ScanningSection({ ...prev, importAliases: [ ...prev.importAliases, - { key: "", value: "" }, + { id: `alias-${Date.now()}`, key: "", value: "" }, ], })) } diff --git a/ui/src/features/settings/helpers.ts b/ui/src/features/settings/helpers.ts index 66f02c69..dd4b77c0 100644 --- a/ui/src/features/settings/helpers.ts +++ b/ui/src/features/settings/helpers.ts @@ -398,7 +398,7 @@ export function draftFromSettings(settings?: SettingsInfo): SettingsDraft { vlmBackendCanvas: settings?.vlmBackendCanvas ?? "", aiNickname: settings?.aiNickname ?? "", importAliases: Object.entries(settings?.importAliases ?? {}).map( - ([key, value]) => ({ key, value }), + ([key, value], index) => ({ id: `alias-${index}`, key, value }), ), excludePatternsText: (settings?.excludePatterns ?? []).join("\n"), excludePatternsByIntentText: Object.fromEntries( diff --git a/ui/src/features/settings/types.ts b/ui/src/features/settings/types.ts index 2203b12e..14bd4448 100644 --- a/ui/src/features/settings/types.ts +++ b/ui/src/features/settings/types.ts @@ -116,7 +116,7 @@ export type SettingsDraft = { aiNickname: string; excludePatternsText: string; excludePatternsByIntentText: Record; - importAliases: Array<{ key: string; value: string }>; + importAliases: Array<{ id: string; key: string; value: string }>; optimizationDefaultQuality: number; optimizationWorkers: number; optimizationAvifSpeed: number;