CGI-Lingua

Appveyor Status CircleCI Coveralls Status CPAN GitHub Workflow Status Perl Version Travis Status Tweet

NAME

CGI::Lingua - Create a multilingual web page

VERSION

Version 0.84

SYNOPSIS

CGI::Lingua is a powerful module for multilingual web applications offering extensive language/country detection strategies.

No longer does your website need to be in English only. CGI::Lingua provides a simple basis to determine which language to display a website. The website tells CGI::Lingua which languages it supports. Based on that list CGI::Lingua tells the application which language the user would like to use.

use CGI::Lingua;
# ...
my $l = CGI::Lingua->new(['en', 'fr', 'en-gb', 'en-us']);
my $language = $l->language();
if ($language eq 'English') {
    print '<P>Hello</P>';
} elsif($language eq 'French') {
    print '<P>Bonjour</P>';
} else {    # $language eq 'Unknown'
    my $rl = $l->requested_language();
    print "<P>Sorry for now this page is not available in $rl.</P>";
}
my $c = $l->country();
if ($c eq 'us') {
  # print contact details in the US
} elsif ($c eq 'ca') {
  # print contact details in Canada
} else {
  # print worldwide contact details
}

# ...

use CHI;
use CGI::Lingua;
# ...
my $cache = CHI->new(driver => 'File', root_dir => '/tmp/cache', namespace => 'CGI::Lingua-countries');
$l = CGI::Lingua->new({ supported => ['en', 'fr'], cache => $cache });

SUBROUTINES/METHODS

new

Creates a CGI::Lingua object.

API SPECIFICATION

Input:
  supported  => ArrayRef[Str] | Str   # required; RFC-1766 language codes
  cache      => Object                # optional; CHI-compatible (get/set)
  config_file => Str                  # optional; YAML/XML/INI config path
  logger     => Object                # optional; must implement warn/info/error
  info       => Object                # optional; CGI::Info-compatible
  data       => Any                   # optional; forwarded to I18N::AcceptLanguage
  dont_use_ip => Bool                 # optional; disable IP-based fallback
  syslog     => Bool | HashRef        # optional; Sys::Syslog integration
  debug      => Bool                  # optional; enable debug logging

Returns: CGI::Lingua blessed hashref, or a clone when called on an object.

EXAMPLE

# Array-ref of supported codes (most common form)
my $l = CGI::Lingua->new({ supported => ['en', 'fr', 'de'] });

# Single scalar code
my $l = CGI::Lingua->new(supported => 'en');

# With cache, logger, and CGI::Info object
use CHI;
my $cache = CHI->new(driver => 'File', root_dir => '/tmp/lingua-cache');
my $l = CGI::Lingua->new({
    supported => ['en', 'fr'],
    cache     => $cache,
    logger    => $my_log_object,
});

# Clone an existing object with different supported list
my $clone = $l->new(supported => ['de']);

MESSAGES

"You must give a list of supported languages"  - no 'supported' key provided
"List of supported languages must be an array ref" - supported is wrong ref type
"Supported languages must be the short code"  - string too short or too long
"Logger must be a blessed object with warn/info/error methods" - bad logger arg

PSEUDOCODE

1. Normalise args via Params::Get and Object::Configure
2. Validate logger (must be blessed with warn/info/error) if provided
3. Validate supported (required, string or arrayref)
4. If cache and REMOTE_ADDR set, attempt to thaw a previously stored state
5. Bless and return fresh object with sentinel flags set to GEO_UNKNOWN

language

Tells the CGI application in what language to display its messages. The language is the natural name e.g. 'English' or 'Japanese'.

Sublanguages are handled sensibly, so that if a client requests U.S. English on a site that only serves British English, language() will return 'English'.

If none of the requested languages is included within the supported lists, language() returns 'Unknown'.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'fr,en;q=0.9';
my $l = CGI::Lingua->new(supported => ['en', 'fr']);
print $l->language();   # "French"

API SPECIFICATION

Input:  none beyond $self
Returns: Str - human-readable language name, or 'Unknown'

preferred_language

Same as language().

name

Synonym for language, for compatibility with Locale::Object::Language.

sublanguage

Tells the CGI what variant to use e.g. 'United Kingdom', or undef if it can't be determined.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->sublanguage();   # "United Kingdom"

API SPECIFICATION

Input:  none beyond $self
Returns: Str | undef

language_code_alpha2

Gives the two-character representation of the supported language, e.g. 'en' when you've asked for en-gb.

If none of the requested languages is included within the supported lists, language_code_alpha2() returns undef.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->language_code_alpha2();   # "en"

API SPECIFICATION

Input:  none beyond $self
Returns: Str (2 chars) | undef

code_alpha2

Synonym for language_code_alpha2, kept for historical reasons.

sublanguage_code_alpha2

Gives the two-character representation of the supported language, e.g. 'gb' when you've asked for en-gb, or undef.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb']);
print $l->sublanguage_code_alpha2();   # "gb"

API SPECIFICATION

Input:  none beyond $self
Returns: Str (2 chars) | undef

requested_language

Gives a human-readable rendition of what language the user asked for whether or not it is supported.

Returns the sublanguage (if appropriate) in parentheses, e.g. "English (United Kingdom)"

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en']);
print $l->requested_language();   # "English (United Kingdom)"

API SPECIFICATION

Input:  none beyond $self
Returns: Str - e.g. "English (United Kingdom)" or "Unknown"

country

Returns the two-character country code of the remote end in lowercase.

If IP::Country, Geo::IPfree or Geo::IP is installed, CGI::Lingua will make use of that, otherwise, it will do a Whois lookup. If you do not have any of those installed I recommend you use the caching capability of CGI::Lingua.

API SPECIFICATION

Input:  none beyond $self
Returns: Str (2 lowercase chars) | undef
  'Unknown' is only returned in the Baidu-EU special case via _handle_eu_country.

EXAMPLE

# With mod_geoip (fastest - no IP lookup at all):
local $ENV{GEOIP_COUNTRY_CODE} = 'DE';
print $l->country();   # "de"

# With REMOTE_ADDR and IP::Country installed:
local $ENV{REMOTE_ADDR} = '8.8.8.8';
print $l->country();   # "us" (depends on geo database)

MESSAGES

"GEOIP_COUNTRY_CODE contains an invalid country code; ignoring"
"HTTP_CF_IPCOUNTRY contains an invalid country code; ignoring"
"X.X.X.X isn't a valid IP address"
"Can't determine country from LAN connection X"
"Can't determine country from loopback connection X"
"cache contains a numeric country: N"
"IP matches to a numeric country"

PSEUDOCODE

1. Return cached _country if set
2. Check GEOIP_COUNTRY_CODE env var (mod_geoip); validate /^[A-Z]{2}$/
3. Check HTTP_CF_IPCOUNTRY (Cloudflare); skip 'XX'; validate /^[A-Z]{2}$/
4. Untaint and validate REMOTE_ADDR; return undef if absent or invalid
5. Skip private and loopback IPs (return undef)
6. Check CHI cache; return cached value if present
7. Try IP::Country::Fast (local DB, fastest)
8. Try Geo::IP (local DB)
9. Try Geo::IPfree (local DB, skip $BROKEN_GEOIPFREE)
10. Try geoplugin.net JSON API (LWP::Simple::WithCache or LWP::Simple)
11. Last resort: Net::Whois::IP then Net::Whois::IANA
12. Sanitise: discard numeric, normalise HK->CN, handle EU special case
13. Store in CHI cache; return result

locale

HTTP doesn't have a way of transmitting a browser's localisation information which would be useful for default currency, date formatting, etc.

This method attempts to detect the information, but it is a best guess and is not 100% reliable. But it's better than nothing ;-)

Returns a Locale::Object::Country object.

EXAMPLE

local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $locale = $l->locale();
if (defined $locale) {
    print $locale->name();          # e.g. "United States"
    print $locale->currency_code(); # e.g. "USD"
}

API SPECIFICATION

Input:  none beyond $self
Returns: Locale::Object::Country | undef

PSEUDOCODE

1. Return cached _locale immediately if already computed
2. Parse HTTP_USER_AGENT parenthetical for xx-YY language tag
3. Try HTTP::BrowserDetect on the full User-Agent string
4. Fall back to country() IP lookup
5. Fall back to GEOIP_COUNTRY_CODE env var (ISO 3166-1 validated)
6. Return undef if all strategies fail

time_zone

Returns the timezone of the web client.

If Geo::IP is installed, CGI::Lingua will make use of that, otherwise it will use ip-api.com

API SPECIFICATION

Input:  none beyond $self
Returns: Str (IANA timezone name) | undef

EXAMPLE

local $ENV{REMOTE_ADDR} = '8.8.8.8';
my $tz = $l->time_zone();
print $tz // 'unknown';   # e.g. "America/New_York"

MESSAGES

"Couldn't determine the timezone"
"LWP::Simple::WithCache and LWP::Simple are both absent; cannot contact ip-api.com"
  Returns undef rather than croaking; install either LWP variant to enable ip-api lookups.

PSEUDOCODE

1. Return cached _timezone immediately if already computed
2. If REMOTE_ADDR is set:
   a. Untaint and validate the IP
   b. Try Geo::IP->time_zone() (local DB)
   c. Try LWP::Simple::WithCache + JSON::Parse against ip-api.com
   d. Fall back to LWP::Simple + JSON::Parse against ip-api.com
   e. Warn and return undef if neither LWP variant is installed
3. If REMOTE_ADDR is absent (local/CLI mode):
   a. Read /etc/timezone if readable
   b. Fall back to DateTime::TimeZone::Local->TimeZone()->name()
4. Warn "Couldn't determine the timezone" and return undef if all fail

is_rtl

Returns true (1) if the negotiated language is written right-to-left, false (0) otherwise. Covers Arabic, Hebrew, Persian, Urdu, Yiddish, Dhivehi, Pashto, Sindhi, Uyghur, and Kurdish.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ar';
my $l = CGI::Lingua->new(supported => ['ar', 'en']);
print $l->is_rtl();   # 1

API SPECIFICATION

Input:  none beyond $self
Returns: 1 | 0

text_direction

Returns 'rtl' or 'ltr' for the negotiated language, suitable for direct use as an HTML dir attribute value.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'he';
my $l = CGI::Lingua->new(supported => ['he', 'en']);
print qq(<html dir="} . $l->text_direction() . qq(">);   # dir="rtl"

API SPECIFICATION

Input:  none beyond $self
Returns: 'rtl' | 'ltr'

plural_category

Returns the CLDR plural category for the integer $n in the negotiated language. The returned string is one of 'zero', 'one', 'two', 'few', 'many', or 'other'.

Rules are embedded for ~70 languages including Arabic (6 forms), Slavic languages (3-4 forms), Celtic languages (up to 6 forms), and Hebrew, Maltese, Romanian, Latvian, Lithuanian, and Slovenian. Languages not in the table fall back to the English rule (n == 1 => 'one', else 'other').

For fractional numbers or full CLDR v42+ accuracy, use Locale::CLDR.

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'ru';
my $l = CGI::Lingua->new(supported => ['ru']);
print $l->plural_category(1);    # "one"
print $l->plural_category(3);    # "few"
print $l->plural_category(11);   # "many"

API SPECIFICATION

Input:  $n - non-negative integer (fractional values are truncated)
Returns: Str - one of zero/one/two/few/many/other

translation_file

Returns the filesystem path to the best matching translation file for the negotiated language in the given directory.

The lookup tries (in order):

Returns undef if no matching file exists.

API SPECIFICATION

Input:
  $dir - Str   path to the directory containing translation files
  $ext - Str   file extension without leading dot (default: 'json')
Returns: Str (absolute or relative path) | undef

EXAMPLE

local $ENV{HTTP_ACCEPT_LANGUAGE} = 'en-gb';
my $l = CGI::Lingua->new(supported => ['en-gb', 'en']);
my $path = $l->translation_file('/var/www/i18n');
# Returns '/var/www/i18n/en-gb.json' if it exists,
# then '/var/www/i18n/en.json', or undef.

# Custom extension:
my $path = $l->translation_file('/var/www/i18n', 'po');

MESSAGES

(none - returns undef silently when no file is found)

LIMITATIONS

AUTHOR

Nigel Horne, <njh at nigelhorne.com>

BUGS

Please report any bugs or feature requests to the author.

If HTTP_ACCEPT_LANGUAGE contains a sub-tag with a 3-digit UN M.49 region code (e.g. es-419 for Latin American Spanish), sublanguage() returns undef because ISO 3166-1 does not define numeric codes.

Please report any bugs or feature requests to bug-cgi-lingua at rt.cpan.org, or through the web interface at http://rt.cpan.org/NoAuth/ReportBug.html?Queue=CGI-Lingua. I will be notified, and then you'll automatically be notified of progress on your bug as I make changes.

Uses I18N::AcceptLanguage to find the highest priority accepted language. This means that if you support languages at a lower priority, it may be missed.

SEE ALSO

SUPPORT

This module is provided as-is without any warranty.

You can find documentation for this module with the perldoc command.

perldoc CGI::Lingua

You can also look for information at:

FORMAL SPECIFICATION

new

new : Class × Params → CGI::Lingua
∀ p : Params • p.supported ≠ ∅ ⟹ result.language ∈ (p.supported ∪ {'Unknown'})

language

language : CGI::Lingua → Str
result ∈ {name(l) | l ∈ supported} ∪ {'Unknown'}

sublanguage

sublanguage : CGI::Lingua -> Str | undef
result = country_name(sublanguage_code_alpha2(self))
         when sublanguage_code_alpha2(self) is defined,
         undef otherwise

language_code_alpha2

language_code_alpha2 : CGI::Lingua -> Str(2) | undef
result = base_code(matched_supported_entry)
         when a supported language was matched, undef otherwise

sublanguage_code_alpha2

sublanguage_code_alpha2 : CGI::Lingua -> Str(2) | undef
result = variety_code(matched_supported_entry) | undef

requested_language

requested_language : CGI::Lingua -> Str
result = name(base) + " (" + name(variety) + ")"
         when variety is known,
       = name(base)   when no variety,
       = 'Unknown'    when no language detected

country

country : CGI::Lingua -> Str(2,lowercase) | undef
-- 'Unknown' returned only in the EU/Baidu special case
result = lc(code) where code satisfies ISO 3166-1 alpha-2
         | undef when IP is private, loopback, or unresolvable

locale

locale : CGI::Lingua -> Locale::Object::Country | undef
-- Best-guess detection; not guaranteed accurate.
result = first defined value from:
    1. UA parenthetical language tag
    2. HTTP::BrowserDetect country
    3. country() IP lookup
    4. GEOIP_COUNTRY_CODE env var

time_zone

time_zone : CGI::Lingua -> Str | undef
result is an IANA timezone name (e.g. 'Europe/London') or undef

is_rtl

is_rtl : CGI::Lingua → Bool
is_rtl(s) ≙ language_code_alpha2(s) ∈ RTL_LANGS

text_direction

text_direction : CGI::Lingua → {'rtl', 'ltr'}
text_direction(s) ≙ is_rtl(s) ? 'rtl' : 'ltr'

plural_category

plural_category : CGI::Lingua x N -> PluralCategory
plural_category(s, n) = PLURAL_RULES[language_code_alpha2(s)](trunc(n))
-- Falls back to English rule (n=1 -> 'one'; else 'other')
-- when language_code_alpha2(s) is undef or not in the rules table.

translation_file

translation_file : CGI::Lingua × Path × Ext → Path | undef
translation_file(s, d, e) ≙
  first p ∈ candidates(s) • ∃ file d/p.e
  where candidates(s) = [lang(s)-sublang(s), lang(s)] \ {undef}

ACKNOWLEDGEMENTS

LICENSE AND COPYRIGHT

Copyright 2010-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms. If you use it, please let me know.