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
8 changes: 5 additions & 3 deletions src/koubou/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def resolve_output_size(size: Union[str, Tuple[int, int]]) -> Tuple[int, int]:

# Hex color validation pattern: #RGB, #RRGGBB, or #RRGGBBAA
HEX_COLOR_PATTERN = re.compile(r"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$")
LANGUAGE_CODE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$")


def validate_hex_color(color: str, field_name: str = "Color") -> None:
Expand Down Expand Up @@ -651,12 +652,13 @@ def validate_asset_format(
if not v:
raise ValueError("Asset dict cannot be empty")

# Validate keys are valid language codes (2-3 letters or 'default')
# Validate keys are valid language codes or 'default'
for key in v.keys():
if key != "default" and not (2 <= len(key) <= 3 and key.isalpha()):
if key != "default" and not LANGUAGE_CODE_PATTERN.match(key):
raise ValueError(
f"Invalid language code '{key}'. "
"Use 2-3 letter codes (e.g., 'en', 'es', 'pt') or 'default'"
"Use language codes (e.g., 'en', 'es', 'en-US') "
"or 'default'"
)

# Validate all values are non-empty strings
Expand Down
44 changes: 31 additions & 13 deletions src/koubou/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,24 @@ def resolve_localized_asset(
if not asset:
return ""

def language_candidates(*codes: str) -> List[str]:
candidates: List[str] = []
for code in codes:
if not code:
continue
normalized = code.strip()
short = normalized.split("-", 1)[0].split("_", 1)[0]
for candidate in (normalized, short):
if candidate and candidate not in candidates:
candidates.append(candidate)
return candidates

# Case 1: Dict format - Explicit per-language mapping
if isinstance(asset, dict):
# Try exact language match
if language in asset:
return asset[language]
# Try exact language match, then language-only fallbacks like en-US -> en
for candidate in language_candidates(language):
if candidate in asset:
return asset[candidate]
# Try default fallback
if "default" in asset:
return asset["default"]
Expand All @@ -113,16 +126,21 @@ def path_exists(p: Path) -> bool:
return (config_dir / p).exists()
return p.exists()

# Try {lang}/ convention
lang_path = asset_path.parent / language / asset_path.name
if path_exists(lang_path):
return str(lang_path)

# Try {base_lang}/ convention (if different from current lang)
if base_language != language:
base_lang_path = asset_path.parent / base_language / asset_path.name
if path_exists(base_lang_path):
return str(base_lang_path)
# Try {lang}/ convention, including language-only fallback en-US -> en
for candidate in language_candidates(language, base_language):
localized_path = asset_path.parent / candidate / asset_path.name
if path_exists(localized_path):
return str(localized_path)

# Also support a language directory directly under the asset root:
# screenshots/iphone/01.png -> screenshots/{lang}/iphone/01.png
if not asset_path.is_absolute() and len(asset_path.parts) > 2:
asset_root = Path(asset_path.parts[0])
asset_rest = Path(*asset_path.parts[1:])
for candidate in language_candidates(language, base_language):
localized_path = asset_root / candidate / asset_rest
if path_exists(localized_path):
return str(localized_path)

# Fallback to direct path
return asset
Expand Down
27 changes: 27 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,33 @@ def test_alignment_rejects_invalid_value(self):
)


class TestContentItemLocalizedAssets:
"""Tests for ContentItem localized asset validation."""

def test_asset_mapping_accepts_region_language_codes(self):
item = ContentItem(
type="image",
asset={
"en-US": "screenshots/en/iphone/01.png",
"es-ES": "screenshots/es/iphone/01.png",
"fr": "screenshots/fr/iphone/01.png",
"default": "screenshots/iphone/01.png",
},
position=("50%", "50%"),
)

assert isinstance(item.asset, dict)
assert item.asset["en-US"] == "screenshots/en/iphone/01.png"

def test_asset_mapping_rejects_invalid_language_code(self):
with pytest.raises(ValidationError, match="Invalid language code"):
ContentItem(
type="image",
asset={"english": "screenshots/en/iphone/01.png"},
position=("50%", "50%"),
)


class TestContentItemHighlight:
"""Tests for ContentItem with type='highlight'."""

Expand Down
51 changes: 51 additions & 0 deletions tests/test_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,35 @@ def test_dict_format_exact_match(self):
result = resolve_localized_asset(asset, "es", "en")
assert result == "path/to/es.png"

def test_dict_format_region_language_fallback(self):
"""Test dict format falls back from region code to language code."""
from koubou.generator import resolve_localized_asset

asset = {
"en": "path/to/en.png",
"es": "path/to/es.png",
"default": "path/to/default.png",
}

result = resolve_localized_asset(asset, "es-ES", "en-US")
assert result == "path/to/es.png"

result = resolve_localized_asset(asset, "fr-FR", "en-US")
assert result == "path/to/default.png"

def test_dict_format_region_exact_match_wins(self):
"""Test dict format prefers exact region match before language fallback."""
from koubou.generator import resolve_localized_asset

asset = {
"en": "path/to/en.png",
"en-US": "path/to/en-us.png",
"es": "path/to/es.png",
}

result = resolve_localized_asset(asset, "en-US", "en-US")
assert result == "path/to/en-us.png"

def test_dict_format_default_fallback(self):
"""Test dict format falls back to default when language not found."""
from koubou.generator import resolve_localized_asset
Expand Down Expand Up @@ -980,6 +1009,28 @@ def test_string_format_lang_convention(self):
result = resolve_localized_asset(asset, "es", "en", self.temp_dir)
assert result == "screenshots/es/hero.png"

def test_string_format_region_language_fallback(self):
"""Test string format falls back from region code to language directory."""
from koubou.generator import resolve_localized_asset

asset = "screenshots/hero.png"

result = resolve_localized_asset(asset, "es-ES", "en-US", self.temp_dir)
assert result == "screenshots/es/hero.png"

def test_string_format_root_language_subdirectory_fallback(self):
"""Test string format supports screenshots/{lang}/iphone/file.png."""
from koubou.generator import resolve_localized_asset

iphone_dir = self.screenshots_dir / "es" / "iphone"
iphone_dir.mkdir(parents=True)
Image.new("RGB", (100, 100), (255, 255, 0)).save(iphone_dir / "hero.png")

asset = "screenshots/iphone/hero.png"

result = resolve_localized_asset(asset, "es-ES", "en-US", self.temp_dir)
assert result == "screenshots/es/iphone/hero.png"

def test_string_format_base_lang_fallback(self):
"""Test string format falls back to base_language when lang not found."""
from koubou.generator import resolve_localized_asset
Expand Down
Loading