最佳实践多语言网站

我一直在这个问题上挣扎了好几个月,但我以前从来没有遇到过需要探索所有可能的选择的情况。现在,我觉得是时候去了解各种可能性,并创建我自己的个人偏好,以便在我即将到来的项目中使用。

让我先概述一下我要找的情况。

我即将升级/重新开发一个内容管理系统,我已经使用了很长一段时间了。然而,我觉得多语言是这个系统的一个很大的改进。之前我没有使用任何框架,但我将在即将到来的项目中使用LaraVal4。Laravel似乎是编写PHP代码的最佳选择,Sidenote: Laraval4 should be no factor in your answer。我正在寻找独立于平台/框架的通用翻译方法。

应该翻译什么

由于我正在寻找的系统需要尽可能的用户友好,管理翻译的方法应该在CMS内部。应该不需要启动FTP连接来修改翻译文件或任何HTML/PHP解析的模板。

此外,我正在寻找最简单的方法来翻译多个数据库表,也许不需要制作额外的表。

我自己想出了什么?

因为我一直在寻找,阅读和尝试自己的东西。我有几个选择。但我仍然不觉得我已经达到了我真正追求的最佳实践方法。现在,这是我想出来的,但这种方法也有副作用。

  1. PHP解析模板:模板系统需要PHP解析。通过这种方式,我可以将翻译后的参数插入到HTML中,而不必打开模板并修改它们。除此之外,PHP解析的模板让我能够为完整的网站创建一个模板,而不是为每种语言创建一个子文件夹(我以前就有)。达到此目标的方法可以是Smarty、TemplatePower、Laravel s Blade或任何其他模板解析器。正如我所说,这应该独立于书面解决方案。
  2. 数据库驱动:也许我不需要再提这件事。但解决方案应该是数据库驱动的。CMS的目标是面向对象和MVC,所以我需要为字符串考虑一个逻辑数据结构。由于我的模板结构是:templates/controller/view.PHP,也许这种结构最有意义:Controller.View.parameter。数据库表将具有这些带有value字段的长型字段。在模板中,我们可以使用一些排序方法,比如__abc2,参数包含__abc3。因此结果是Welcome, Joshua。这似乎是一种很好的方法,因为诸如:name之类的参数很容易被编辑器理解。
  3. 低数据库负载:当然,如果这些字符串是在运行中加载的,则上述系统将导致大量的数据库负载。因此,我需要一个缓存系统,一旦在管理环境中编辑/保存语言文件,就可以重新呈现这些文件。由于生成了文件,因此还需要良好的文件系统布局。我想我们可以使用languages/en_EN/Controller/View.php或.INI,选择最适合您的。也许最终.ini的解析速度会更快。该文件应包含format parameter=value;中的数据 .我想这是最好的方法,因为每个呈现的视图都可以包含它自己的语言文件(如果存在的话)。然后,应将语言参数加载到特定视图中,而不是全局范围内,以防止参数相互覆盖。
  4. 数据库表转换:这其实是我最担心的事情。我正在寻找一种方法来创建新闻/网页/等的翻译。越快越好每个模块都有两个表(例如,NewsNews_translations)是一种选择,但要获得一个好的系统,感觉工作量太大。我想到的其中一件事是基于我编写的data versioning系统:有一个名为Translations的数据库表,该表具有languagetablenameprimarykey的唯一组合。例如:en_en/news/1(指ID=1的新闻条目的英文版本)。但这种方法有两个巨大的缺点:首先,由于数据库中有大量数据,这个表往往会变得很长;其次,使用这种设置来搜索表将是一项艰巨的工作。例如,搜索项目的SEO片段将是全文搜索,这是相当愚蠢的。但另一方面:这是一种在每个表中快速创建可翻译内容的快速方法,但我不认为这一优点会超过缺点。
  5. 前端工作:前端也需要一些思考。当然,我们会将可用的语言存储在数据库中,并(取消)激活我们需要的语言。通过这种方式,脚本可以生成一个下拉菜单来选择一种语言,后端可以自动决定使用CMS进行哪些翻译。然后,当获得用于视图的语言文件或获得用于网站上的内容项的正确翻译时,将使用所选择的语言(例如,en_en)。

所以,他们在那里。到目前为止我的想法。它们甚至还不包括日期等的本地化选项,但由于我的服务器支持PHP5.3.2+,因此最好的选择是使用intl扩展,如下所述:http://devzone.zend.com/1500/internationalization-in-php-53/-但这将在以后的任何开发阶段中使用。目前的主要问题是如何有最好的实践翻译的内容在一个网站

除了我在这里解释的一切,我还有一件事还没有决定,这是一个看似简单的问题,但实际上它一直让我头疼:

URL转换?我们到底该不该这么做?以什么方式?

所以..如果我有这个网址:http://www.domain.com/about-us和英语是我的默认语言。当我选择荷兰语作为我的语言时,是否应将此URL翻译为http://www.domain.com/over-ons?或者,我们应该走一条简单的路,简单地更改/about上可见页面的内容。最后一件事似乎不是一个有效的选择,因为这将生成同一URL的多个版本,这种索引内容将以正确的方式失败。

另一种选择是使用http://www.domain.com/nl/about-us。这将为每个内容生成至少一个唯一的URL.此外,这将更容易转到另一种语言,例如http://www.domain.com/en/about-us,并且所提供的URL对于Google和人类访问者来说都更容易理解。使用此选项,我们如何处理默认语言?默认语言是否应删除默认选择的语言?因此将http://www.domain.com/en/about-us重定向到http://www.domain.com/about-us。在我看来,这是最好的解决方案,因为当CMS只为一种语言设置时,不需要在URL中有这种语言标识。

第三个选项是两个选项的组合:使用“ language-identification-less ”-URL(http://www.domain.com/about-us)作为主语言。并为子语言使用带有已翻译SEO片段的URL:http://www.domain.com/nl/over-ons&;http://www.domain.com/de/uber-uns

我希望我的问题能让你们头晕目眩,他们肯定能让我头晕目眩!它确实已经帮助我解决了这里的问题。给了我一个机会来回顾我以前使用过的方法,以及我对即将到来的CMS的想法。

我已经想感谢你花时间阅读这一堆文字!

// Edit #1

我忘了说:__()函数是翻译给定字符串的别名。在这个方法中,显然应该有某种回退方法,当还没有可用的翻译时,就加载默认文本。如果缺少翻译,则应将其插入或重新生成翻译文件。

174057 次浏览

只是一个子答案: 绝对使用前面带有语言标识符的已翻译URL:http://www.domain.com/nl/over-ons
混合动力解决方案往往会变得复杂,所以我会坚持使用它。为什么?因为URL对于SEO是必不可少的。

关于数据库翻译:语言的数量是固定的还是固定的?或者更确切地说是不可预测和动态的?如果它是固定的,我只会添加新的列,否则将使用多个表。

但一般来说,为什么不使用Drupal呢?我知道每个人都想建立自己的CMS,因为它更快,更精简,等等。等等。但这真的是个坏主意!

我建议你不要发明一个轮子,使用gettext和ISO语言abbrevs列表。你见过i18n/l10n是如何在流行的CMS或框架中实现的吗?

使用gettext,您将拥有一个强大的工具,其中许多情况已经实现,如数字的复数形式。在英语中,你只有两种选择:单数和复数。但以俄语为例,有三种形式,不像英语那么简单。

此外,许多翻译人员已经有了使用gettext的经验。

查看CakephpDrupal。都启用了多语言。CakePHP作为接口本地化示例,Drupal作为内容翻译示例。

对于L10N,使用数据库根本不是这样。这将是对查询的吨。标准方法是在早期阶段(或者在第一次调用i10n函数期间,如果您喜欢延迟加载)获取内存中的所有l10n数据。它可以从.Po文件或从数据库一次读取所有数据。然后从数组中读取所请求的字符串。

如果你需要实现在线工具来翻译界面,你可以在数据库中拥有所有数据,但仍然将所有数据保存到文件中以使用它。为了减少内存中的数据量,您可以将所有已翻译的消息/字符串拆分为组,然后在可能的情况下只加载所需的组。

所以你在你的#3中完全正确。但有一个例外:通常它是一个大文件,而不是每个控制器的文件。因为打开一个文件对性能来说是最好的。您可能知道,一些高负载的Web应用程序将所有PHP代码编译在一个文件中,以避免在调用include/require时进行文件操作。

关于URL.谷歌间接建议以使用转换:

为了清楚地表明法语内容: http://example.ca/fr/vélo-de-Montagne.HTML

另外,我认为您需要将用户重定向到默认的语言前缀,例如http://examlpe.com/about-us将重定向到http://examlpe.com/en/about-us. 但是如果你的网站只使用一种语言,那么你根本不需要前缀。

结帐: http://www.audiomicro.com/trailer-hit-impact-psychodrama-sound-effects-836925. http://nl.audiomicro.com/aanhangwagen-hit-effect-psychodrama-geluidseffecten-836925. http://de.audiomicro.com/anhanger-hit-auswirkungen-psychodrama-sound-effekte-836925

翻译内容是一项更困难的任务。我认为不同类型的内容会有一些差异,例如文章、菜单项等。但在#4中,你是在正确的道路上。看看Drupal,有更多的想法。它有足够清晰的数据库模式和足够好的翻译界面。就像你创建文章并为它选择语言一样。然后你可以把它翻译成其他语言。

Drupal translation interface

我认为这不是URL插件的问题。你可以为鼻涕虫创建单独的表,这将是一个正确的决定。此外,使用正确的索引,即使有大量的数据,查询表也不是问题。 它不是全文搜索,而是字符串匹配,如果将使用VARCHAR数据类型作为slug,您也可以在该字段上创建索引。

PS:对不起,我的英语还远远不够完美。

不久前,在开始使用塞姆福尼框架之前,我遇到了同样的问题。

  1. 只需使用函数__(),该函数具有参数PageID(或#2中描述的ObjectID、ObjectTable)、目标语言和回退(默认)语言的可选参数。可以在某个全局配置中设置默认语言,以便以后可以更轻松地更改它。

  2. 为了在数据库中存储内容,我使用了以下结构:(页面ID,语言,内容,变量)。

    • PageID将是您要翻译的页面的FK.如果您有其他对象,如新闻、图库或其他对象,只需将其拆分为2个字段ObjectID和ObjectTable.

    • 语言-显然,它将存储ISO语言字符串EN_EN,LT_LT,EN_US等。

    • 内容-要翻译的文本以及用于变量替换的通配符。示例:您好,%%Name%%先生。您的帐户余额为%%balance%%。

    • 变量-JSON编码的变量。PHP提供了快速解析这些内容的函数。例如“姓名:Laurynas,余额:15.23 ”。

    • 你还提到了鼻涕虫领域。您可以自由地将其添加到此表中,以便快速搜索它。

  3. 必须通过缓存转换将数据库调用减少到最低限度。它必须存储在PHP数组中,因为它是PHP语言中最快的结构。如何进行此缓存取决于您自己。从我的经验来看,您应该为每种支持的语言创建一个文件夹,并为每个页面ID创建一个数组。更新转换后,应重建缓存。只应重新生成更改后的数组。

  4. 我想我在#2中回答了这个问题。

  5. 你的想法完全合乎逻辑。这个很简单,我认为不会给你带来任何问题。

应使用转换表中存储的嵌块来转换URL.

最后的话

研究最佳实践总是好的,但不要重新发明轮子。只需从众所周知的框架中获取并使用组件并使用它们。

看一下Symfony转换组件。它对你来说可能是一个很好的代码库。

我建议你不要真的依赖数据库进行翻译,这可能是一项非常棘手的任务,在数据编码的情况下可能是一个极端的问题。

我以前也遇到过类似的问题,并写了下面的课程来解决我的问题。

对象:locale\locale

<?php


namespace Locale;


class Locale{


// Following array stolen from Zend Framework
public $country_to_locale = array(
'AD' => 'ca_AD',
'AE' => 'ar_AE',
'AF' => 'fa_AF',
'AG' => 'en_AG',
'AI' => 'en_AI',
'AL' => 'sq_AL',
'AM' => 'hy_AM',
'AN' => 'pap_AN',
'AO' => 'pt_AO',
'AQ' => 'und_AQ',
'AR' => 'es_AR',
'AS' => 'sm_AS',
'AT' => 'de_AT',
'AU' => 'en_AU',
'AW' => 'nl_AW',
'AX' => 'sv_AX',
'AZ' => 'az_Latn_AZ',
'BA' => 'bs_BA',
'BB' => 'en_BB',
'BD' => 'bn_BD',
'BE' => 'nl_BE',
'BF' => 'mos_BF',
'BG' => 'bg_BG',
'BH' => 'ar_BH',
'BI' => 'rn_BI',
'BJ' => 'fr_BJ',
'BL' => 'fr_BL',
'BM' => 'en_BM',
'BN' => 'ms_BN',
'BO' => 'es_BO',
'BR' => 'pt_BR',
'BS' => 'en_BS',
'BT' => 'dz_BT',
'BV' => 'und_BV',
'BW' => 'en_BW',
'BY' => 'be_BY',
'BZ' => 'en_BZ',
'CA' => 'en_CA',
'CC' => 'ms_CC',
'CD' => 'sw_CD',
'CF' => 'fr_CF',
'CG' => 'fr_CG',
'CH' => 'de_CH',
'CI' => 'fr_CI',
'CK' => 'en_CK',
'CL' => 'es_CL',
'CM' => 'fr_CM',
'CN' => 'zh_Hans_CN',
'CO' => 'es_CO',
'CR' => 'es_CR',
'CU' => 'es_CU',
'CV' => 'kea_CV',
'CX' => 'en_CX',
'CY' => 'el_CY',
'CZ' => 'cs_CZ',
'DE' => 'de_DE',
'DJ' => 'aa_DJ',
'DK' => 'da_DK',
'DM' => 'en_DM',
'DO' => 'es_DO',
'DZ' => 'ar_DZ',
'EC' => 'es_EC',
'EE' => 'et_EE',
'EG' => 'ar_EG',
'EH' => 'ar_EH',
'ER' => 'ti_ER',
'ES' => 'es_ES',
'ET' => 'en_ET',
'FI' => 'fi_FI',
'FJ' => 'hi_FJ',
'FK' => 'en_FK',
'FM' => 'chk_FM',
'FO' => 'fo_FO',
'FR' => 'fr_FR',
'GA' => 'fr_GA',
'GB' => 'en_GB',
'GD' => 'en_GD',
'GE' => 'ka_GE',
'GF' => 'fr_GF',
'GG' => 'en_GG',
'GH' => 'ak_GH',
'GI' => 'en_GI',
'GL' => 'iu_GL',
'GM' => 'en_GM',
'GN' => 'fr_GN',
'GP' => 'fr_GP',
'GQ' => 'fan_GQ',
'GR' => 'el_GR',
'GS' => 'und_GS',
'GT' => 'es_GT',
'GU' => 'en_GU',
'GW' => 'pt_GW',
'GY' => 'en_GY',
'HK' => 'zh_Hant_HK',
'HM' => 'und_HM',
'HN' => 'es_HN',
'HR' => 'hr_HR',
'HT' => 'ht_HT',
'HU' => 'hu_HU',
'ID' => 'id_ID',
'IE' => 'en_IE',
'IL' => 'he_IL',
'IM' => 'en_IM',
'IN' => 'hi_IN',
'IO' => 'und_IO',
'IQ' => 'ar_IQ',
'IR' => 'fa_IR',
'IS' => 'is_IS',
'IT' => 'it_IT',
'JE' => 'en_JE',
'JM' => 'en_JM',
'JO' => 'ar_JO',
'JP' => 'ja_JP',
'KE' => 'en_KE',
'KG' => 'ky_Cyrl_KG',
'KH' => 'km_KH',
'KI' => 'en_KI',
'KM' => 'ar_KM',
'KN' => 'en_KN',
'KP' => 'ko_KP',
'KR' => 'ko_KR',
'KW' => 'ar_KW',
'KY' => 'en_KY',
'KZ' => 'ru_KZ',
'LA' => 'lo_LA',
'LB' => 'ar_LB',
'LC' => 'en_LC',
'LI' => 'de_LI',
'LK' => 'si_LK',
'LR' => 'en_LR',
'LS' => 'st_LS',
'LT' => 'lt_LT',
'LU' => 'fr_LU',
'LV' => 'lv_LV',
'LY' => 'ar_LY',
'MA' => 'ar_MA',
'MC' => 'fr_MC',
'MD' => 'ro_MD',
'ME' => 'sr_Latn_ME',
'MF' => 'fr_MF',
'MG' => 'mg_MG',
'MH' => 'mh_MH',
'MK' => 'mk_MK',
'ML' => 'bm_ML',
'MM' => 'my_MM',
'MN' => 'mn_Cyrl_MN',
'MO' => 'zh_Hant_MO',
'MP' => 'en_MP',
'MQ' => 'fr_MQ',
'MR' => 'ar_MR',
'MS' => 'en_MS',
'MT' => 'mt_MT',
'MU' => 'mfe_MU',
'MV' => 'dv_MV',
'MW' => 'ny_MW',
'MX' => 'es_MX',
'MY' => 'ms_MY',
'MZ' => 'pt_MZ',
'NA' => 'kj_NA',
'NC' => 'fr_NC',
'NE' => 'ha_Latn_NE',
'NF' => 'en_NF',
'NG' => 'en_NG',
'NI' => 'es_NI',
'NL' => 'nl_NL',
'NO' => 'nb_NO',
'NP' => 'ne_NP',
'NR' => 'en_NR',
'NU' => 'niu_NU',
'NZ' => 'en_NZ',
'OM' => 'ar_OM',
'PA' => 'es_PA',
'PE' => 'es_PE',
'PF' => 'fr_PF',
'PG' => 'tpi_PG',
'PH' => 'fil_PH',
'PK' => 'ur_PK',
'PL' => 'pl_PL',
'PM' => 'fr_PM',
'PN' => 'en_PN',
'PR' => 'es_PR',
'PS' => 'ar_PS',
'PT' => 'pt_PT',
'PW' => 'pau_PW',
'PY' => 'gn_PY',
'QA' => 'ar_QA',
'RE' => 'fr_RE',
'RO' => 'ro_RO',
'RS' => 'sr_Cyrl_RS',
'RU' => 'ru_RU',
'RW' => 'rw_RW',
'SA' => 'ar_SA',
'SB' => 'en_SB',
'SC' => 'crs_SC',
'SD' => 'ar_SD',
'SE' => 'sv_SE',
'SG' => 'en_SG',
'SH' => 'en_SH',
'SI' => 'sl_SI',
'SJ' => 'nb_SJ',
'SK' => 'sk_SK',
'SL' => 'kri_SL',
'SM' => 'it_SM',
'SN' => 'fr_SN',
'SO' => 'sw_SO',
'SR' => 'srn_SR',
'ST' => 'pt_ST',
'SV' => 'es_SV',
'SY' => 'ar_SY',
'SZ' => 'en_SZ',
'TC' => 'en_TC',
'TD' => 'fr_TD',
'TF' => 'und_TF',
'TG' => 'fr_TG',
'TH' => 'th_TH',
'TJ' => 'tg_Cyrl_TJ',
'TK' => 'tkl_TK',
'TL' => 'pt_TL',
'TM' => 'tk_TM',
'TN' => 'ar_TN',
'TO' => 'to_TO',
'TR' => 'tr_TR',
'TT' => 'en_TT',
'TV' => 'tvl_TV',
'TW' => 'zh_Hant_TW',
'TZ' => 'sw_TZ',
'UA' => 'uk_UA',
'UG' => 'sw_UG',
'UM' => 'en_UM',
'US' => 'en_US',
'UY' => 'es_UY',
'UZ' => 'uz_Cyrl_UZ',
'VA' => 'it_VA',
'VC' => 'en_VC',
'VE' => 'es_VE',
'VG' => 'en_VG',
'VI' => 'en_VI',
'VN' => 'vn_VN',
'VU' => 'bi_VU',
'WF' => 'wls_WF',
'WS' => 'sm_WS',
'YE' => 'ar_YE',
'YT' => 'swb_YT',
'ZA' => 'en_ZA',
'ZM' => 'en_ZM',
'ZW' => 'sn_ZW'
);


/**
* Store the transaltion for specific languages
*
* @var array
*/
protected $translation = array();


/**
* Current locale
*
* @var string
*/
protected $locale;


/**
* Default locale
*
* @var string
*/
protected $default_locale;


/**
*
* @var string
*/
protected $locale_dir;


/**
* Construct.
*
*
* @param string $locale_dir
*/
public function __construct($locale_dir)
{
$this->locale_dir = $locale_dir;
}


/**
* Set the user define localte
*
* @param string $locale
*/
public function setLocale($locale = null)
{
$this->locale = $locale;


return $this;
}


/**
* Get the user define locale
*
* @return string
*/
public function getLocale()
{
return $this->locale;
}


/**
* Get the Default locale
*
* @return string
*/
public function getDefaultLocale()
{
return $this->default_locale;
}


/**
* Set the default locale
*
* @param string $locale
*/
public function setDefaultLocale($locale)
{
$this->default_locale = $locale;


return $this;
}


/**
* Determine if transltion exist or translation key exist
*
* @param string $locale
* @param string $key
* @return boolean
*/
public function hasTranslation($locale, $key = null)
{
if (null == $key && isset($this->translation[$locale])) {
return true;
} elseif (isset($this->translation[$locale][$key])) {
return true;
}


return false;
}


/**
* Get the transltion for required locale or transtion for key
*
* @param string $locale
* @param string $key
* @return array
*/
public function getTranslation($locale, $key = null)
{
if (null == $key && $this->hasTranslation($locale)) {
return $this->translation[$locale];
} elseif ($this->hasTranslation($locale, $key)) {
return $this->translation[$locale][$key];
}


return array();
}


/**
* Set the transtion for required locale
*
* @param string $locale
*            Language code
* @param string $trans
*            translations array
*/
public function setTranslation($locale, $trans = array())
{
$this->translation[$locale] = $trans;
}


/**
* Remove transltions for required locale
*
* @param string $locale
*/
public function removeTranslation($locale = null)
{
if (null === $locale) {
unset($this->translation);
} else {
unset($this->translation[$locale]);
}
}


/**
* Initialize locale
*
* @param string $locale
*/
public function init($locale = null, $default_locale = null)
{
// check if previously set locale exist or not
$this->init_locale();
if ($this->locale != null) {
return;
}


if ($locale == null || (! preg_match('#^[a-z]+_[a-zA-Z_]+$#', $locale) && ! preg_match('#^[a-z]+_[a-zA-Z]+_[a-zA-Z_]+$#', $locale))) {
$this->detectLocale();
} else {
$this->locale = $locale;
}


$this->init_locale();
}


/**
* Attempt to autodetect locale
*
* @return void
*/
private function detectLocale()
{
$locale = false;


// GeoIP
if (function_exists('geoip_country_code_by_name') && isset($_SERVER['REMOTE_ADDR'])) {


$country = geoip_country_code_by_name($_SERVER['REMOTE_ADDR']);


if ($country) {


$locale = isset($this->country_to_locale[$country]) ? $this->country_to_locale[$country] : false;
}
}


// Try detecting locale from browser headers
if (! $locale) {


if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {


$languages = explode(',', $_SERVER['HTTP_ACCEPT_LANGUAGE']);


foreach ($languages as $lang) {


$lang = str_replace('-', '_', trim($lang));


if (strpos($lang, '_') === false) {


if (isset($this->country_to_locale[strtoupper($lang)])) {


$locale = $this->country_to_locale[strtoupper($lang)];
}
} else {


$lang = explode('_', $lang);


if (count($lang) == 3) {
// language_Encoding_COUNTRY
$this->locale = strtolower($lang[0]) . ucfirst($lang[1]) . strtoupper($lang[2]);
} else {
// language_COUNTRY
$this->locale = strtolower($lang[0]) . strtoupper($lang[1]);
}


return;
}
}
}
}


// Resort to default locale specified in config file
if (! $locale) {
$this->locale = $this->default_locale;
}
}


/**
* Check if config for selected locale exists
*
* @return void
*/
private function init_locale()
{
if (! file_exists(sprintf('%s/%s.php', $this->locale_dir, $this->locale))) {
$this->locale = $this->default_locale;
}
}


/**
* Load a Transtion into array
*
* @return void
*/
private function loadTranslation($locale = null, $force = false)
{
if ($locale == null)
$locale = $this->locale;


if (! $this->hasTranslation($locale)) {
$this->setTranslation($locale, include (sprintf('%s/%s.php', $this->locale_dir, $locale)));
}
}


/**
* Translate a key
*
* @param
*            string Key to be translated
* @param
*            string optional arguments
* @return string
*/
public function translate($key)
{
$this->init();
$this->loadTranslation($this->locale);


if (! $this->hasTranslation($this->locale, $key)) {


if ($this->locale !== $this->default_locale) {


$this->loadTranslation($this->default_locale);


if ($this->hasTranslation($this->default_locale, $key)) {


$translation = $this->getTranslation($this->default_locale, $key);
} else {
// return key as it is or log error here
return $key;
}
} else {
return $key;
}
} else {
$translation = $this->getTranslation($this->locale, $key);
}
// Replace arguments
if (false !== strpos($translation, '{a:')) {
$replace = array();
$args = func_get_args();
for ($i = 1, $max = count($args); $i < $max; $i ++) {
$replace['{a:' . $i . '}'] = $args[$i];
}
// interpolate replacement values into the messsage then return
return strtr($translation, $replace);
}


return $translation;
}
}

用法

 <?php
## /locale/en.php


return array(
'name' => 'Hello {a:1}'
'name_full' => 'Hello {a:1} {a:2}'
);


$locale = new Locale(__DIR__ . '/locale');
$locale->setLocale('en');// load en.php from locale dir
//want to work with auto detection comment $locale->setLocale('en');


echo $locale->translate('name', 'Foo');
echo $locale->translate('name', 'Foo', 'Bar');

它的工作原理

__abc0由传递给方法__abc1的第一个参数替换 __abc0由传递给方法__abc1

的第二个参数替换

检测的工作原理

  • 默认情况下,如果安装了geoip,则它将通过geoip_country_code_by_name返回国家代码,如果未安装GeoIP,则回退到HTTP_ACCEPT_LANGUAGE报头

按照Thomas Bley的建议,使用预处理器实现I18N而不影响性能

在工作中,我们最近在我们的几个物业上实施了i18n,我们一直在努力解决的一件事是处理动态转换的性能问题,然后我发现了Thomas Bley的这篇精彩博文,它启发了我们使用i18n来处理大流量负载的方式,并将性能问题降至最低。

我们用占位符定义基本文件,然后使用预处理器来缓存这些文件(我们存储文件修改时间,以确保我们始终提供最新的内容),而不是为每个翻译操作调用函数(我们知道这在PHP中是非常昂贵的)。

翻译标签

Thomas使用{tr}{/tr}标签来定义翻译的起点和终点。由于我们使用的是Twig,我们不想使用{来避免混淆,所以我们使用[%tr%][%/tr%]。基本上,这看起来像这样:

`return [%tr%]formatted_value[%/tr%];`

请注意,Thomas建议在文件中使用基本英语。我们没有这样做,因为我们不希望在更改英语中的值时必须修改所有的翻译文件。

INI文件

然后,我们为每种语言创建一个INI文件,格式为placeholder = translated

// lang/fr.ini
formatted_value = number_format($value * Model_Exchange::getEurRate(), 2, ',', ' ') . '€'


// lang/en_gb.ini
formatted_value = '£' . number_format($value * Model_Exchange::getStgRate())


// lang/en_us.ini
formatted_value = '$' . number_format($value)

允许用户在CMS内部修改这些密钥是微不足道的,只需通过\n=上的preg_split获取密钥对,并使CMS能够写入INI文件。

预处理器组件

从本质上讲,Thomas建议使用这样的即时“编译器”(尽管实际上它是一个预处理器)函数来获取翻译文件并在磁盘上创建静态PHP文件。这样,我们实际上缓存了翻译后的文件,而不是为文件中的每个字符串调用翻译函数:

// This function was written by Thomas Bley, not by me
function translate($file) {
$cache_file = 'cache/'.LANG.'_'.basename($file).'_'.filemtime($file).'.php';
// (re)build translation?
if (!file_exists($cache_file)) {
$lang_file = 'lang/'.LANG.'.ini';
$lang_file_php = 'cache/'.LANG.'_'.filemtime($lang_file).'.php';


// convert .ini file into .php file
if (!file_exists($lang_file_php)) {
file_put_contents($lang_file_php, '<?php $strings='.
var_export(parse_ini_file($lang_file), true).';', LOCK_EX);
}
// translate .php into localized .php file
$tr = function($match) use (&$lang_file_php) {
static $strings = null;
if ($strings===null) require($lang_file_php);
return isset($strings[ $match[1] ]) ? $strings[ $match[1] ] : $match[1];
};
// replace all {t}abc{/t} by tr()
file_put_contents($cache_file, preg_replace_callback(
'/\[%tr%\](.*?)\[%\/tr%\]/', $tr, file_get_contents($file)), LOCK_EX);
}
return $cache_file;
}

注意:我没有验证正则表达式是否有效,我没有从我们公司的服务器上复制它,但您可以看到操作是如何工作的。

怎么称呼它?

同样,这个例子来自托马斯·布雷,而不是我:

// instead of
require("core/example.php");
echo (new example())->now();


// we write
define('LANG', 'en_us');
require(translate('core/example.php'));
echo (new example())->now();

我们将语言存储在cookie中(如果无法获得cookie,则存储在会话变量中),然后在每次请求时检索它。您可以将其与可选的$_GET参数结合使用来覆盖语言,但我不建议使用每语言子域或每语言页面,因为这将使您更难看到哪些页面是受欢迎的,并将降低入站链接的价值,因为您将使它们更难以传播。

为什么要用这种方法?

我们喜欢这种预处理方法有三个原因:

  1. 不用为很少更改的内容调用一大堆函数,就能获得巨大的性能提升(使用该系统,10万名法语访问者仍然只能运行一次翻译替换)。
  2. 它不会给我们的数据库增加任何负载,因为它使用简单的平面文件,并且是一个纯PHP解决方案。
  3. 能够在我们的翻译中使用PHP表达式。

获取已翻译的数据库内容

我们只需为数据库中的内容添加一个名为language的列,然后为前面定义的LANG常量使用一个访问器方法,因此我们的SQL调用(遗憾的是,使用ZF1)如下所示:

$query = select()->from($this->_name)
->where('language = ?', User::getLang())
->where('id       = ?', $articleId)
->limit(1);

我们的文章在idlanguage上有一个复合主键,因此文章54可以存在于所有语言中。如果未指定,我们的LANG默认为en_US

URL Slug翻译

我想在这里结合两件事,一个是引导程序中的函数,它接受语言的__abc0参数并覆盖cookie变量,另一个是路由,它接受多个slug.然后,您可以在路由中执行以下操作:

"/wilkommen" => "/welcome/lang/de"
... etc ...

这些可以存储在一个平面文件中,可以很容易地从您的管理面板写入。JSON或XML可以提供良好的结构来支持它们。

关于其他几个选项的说明

基于PHP的即时翻译

我看不出这些比预处理的翻译有任何优势。

基于前端的翻译

我一直觉得这些很有趣,但有几点需要注意。例如,您必须向用户提供您计划翻译的网站上的整个短语列表,如果您隐藏或不允许他们访问网站的某些区域,这可能会出现问题。

您还必须假设您的所有用户都愿意并能够在您的站点上使用JavaScript,但从我的统计数据来看,大约2.5%的用户在运行时没有使用JavaScript(或使用NoScript来阻止我们的站点使用它)。

数据库驱动的翻译

PHP的数据库连接速度没有什么值得大书特书的,这增加了对每个要翻译的短语调用函数的高开销。性能&;这种方法的可伸缩性问题似乎是压倒性的。

话题的前提

多语言网站有三个不同的方面:

  • 接口转换
  • 内容
  • URL路由

虽然它们都以不同的方式相互连接,但从CMS的角度来看,它们使用不同的UI元素进行管理,并以不同的方式进行存储。你似乎对前两项的实施和理解很有信心。问题是关于后一个方面-";URL转换?我们到底该不该这么做?用什么方式?";

URL可以由什么组成?

非常重要的一点是,不要对IDN产生幻想。相反,倾向于音译(还有:转录和罗马化)。乍一看,IDN似乎是国际URL的可行选择,但实际上它并不像宣传的那样有效,原因有两个:

  • 某些浏览器会将非ASCII字符(如'ч''ž')转换为'%D1%87''%C5%BE'
  • 如果用户有自定义主题,主题的字体很可能没有这些字母的符号。

实际上,几年前我在一个基于Yii的项目中尝试了IDN方法(可怕的框架,恕我直言)。我遇到了上述两个问题之前刮该解决方案。此外,我怀疑它可能是一种攻击媒介。

可用选项..在我看来。

基本上你有两个选择,可以抽象为:

  • http://site.tld/[:query]:其中[:query]决定语言和内容选择

  • http://site.tld/[:language]/[:query]:其中URL的[:language]部分定义语言的选择,而[:query]仅用于标识内容

查询是Α和Ω.。

假设您选择http://site.tld/[:query]

在这种情况下,你有一个主要的语言源:[:query]段的内容;以及另外两个来源:

  • 值__该特定浏览器的ABC0
  • HTTP Accept-Language(1),(2)标头中的语言列表

首先,您需要将查询与已定义的路由模式之一进行匹配(如果您选择的是Laravel,则在这里阅读)。模式匹配成功后,您需要找到语言。

你必须检查模式的所有部分。找到所有这些片段的潜在翻译,并确定使用了哪种语言。当(而不是“如果”)出现路由冲突时,将使用两个额外的源(cookie和标头)来解决路由冲突。

例如:http://site.tld/blog/novinka

这是"блог, новинка"的音译,在英语中的意思是大约"blog", "latest"

正如你已经注意到的,在俄语中将被音译为";博客";。这意味着对于[:query]的第一部分,您(在最好的情况中)将以__可能语言的ABC1列表结束。然后你采取下一段-“诺温卡”。可能只有一种语言:['ru']

如果列表中只有一项,则表示您已成功找到该语言。

但是,如果你最终有2个(例如:俄罗斯和乌克兰)或更多的可能性..或0种可能性,视情况而定。您必须使用Cookie和/或标头才能找到正确的选项。

如果其他方法都失败了,你可以选择网站的默认语言。

语言作为参数

另一种方法是使用URL,它可以定义为http://site.tld/[:language]/[:query]。在这种情况下,在翻译查询时,您不需要猜测语言,因为此时您已经知道要使用哪种语言。

还有一个次要的语言来源:cookie值。但在这里,没有必要弄乱Accept-Language标头,因为在“冷启动”的情况下,您不会处理未知数量的可能语言。(当用户首次使用自定义查询打开站点时)。

相反,您有3个简单的优先选项:

  1. 如果设置了[:language]段,则使用它
  2. 如果设置了$_COOKIE['lang'],则使用它
  3. 使用默认语言

当您有语言时,您只需尝试翻译查询,如果翻译失败,则使用“默认值”。对于该特定段(基于路由结果)。

这不是第三种选择吗?

是的,从技术上讲,您可以将两种方法结合使用,但这会使过程变得复杂,并且只适合那些希望将http://site.tld/en/news的URL手动更改为http://site.tld/de/news并希望将新闻页面更改为德语的用户。

但即使是这种情况,也可以使用cookie值(它将包含有关以前选择的语言的信息)来缓解,以实现较少的魔力和希望。

使用哪种方法?

正如您可能已经猜到的那样,我建议将http://site.tld/[:language]/[:query]作为更明智的选择。

此外,在真实的情况下,你会在URL中有第三个主要部分:";标题";。如在线商店中的产品名称或新闻网站中的文章标题。

例如:http://site.tld/en/news/article/121415/EU-as-global-reserve-currency

在这种情况下,'/news/article/121415'将是查询,而'EU-as-global-reserve-currency'是标题。纯粹为了SEO的目的。

可以在Laravel完成吗?

有点,但不是默认的。

我对它不太熟悉,但据我所见,Laravel使用简单的基于模式的路由机制。要实现多语言URL,您可能必须扩展核心类,因为多语言路由需要访问不同形式的存储(数据库、缓存和/或配置文件)。

它被击溃了。现在怎么办?

因此,您最终将获得两条有价值的信息:查询的当前语言和翻译片段。然后,这些值可用于分派到将产生结果的类。

基本上,以下URL:http://site.tld/ru/blog/novinka(或没有'/ru'的版本)将转换为以下内容

$parameters = [
'language' => 'ru',
'classname' => 'blog',
'method' => 'latest',
];

你只是用它来调度:

$instance = new {$parameter['classname']};
$instance->{'get'.$parameters['method']}( $parameters );

..或者它的一些变体,这取决于特定的实现。

我一遍又一遍地问自己相关的问题,然后迷失在正式的语言中……但为了帮助你,我想分享一些发现:

我建议看看先进的CMS.

PHPTypo3(我知道有很多东西,但我认为这是最成熟的)

Python中的Plone

如果你发现2013年的网络应该有所不同,那就从头开始吧。这意味着要组建一支由高技能/经验丰富的人员组成的团队来构建新的CMS. 为了这个目的,你可能想看看聚合物。

如果涉及到编码和多语言网站/本地语言支持,我认为每个程序员都应该对Unicode有所了解。如果你不知道Unicode,你肯定会弄乱你的数据。不要使用成千上万的ISO代码。它们只会为你节省一些记忆。但是您可以使用UTF-8做任何事情,甚至可以存储中文字符。但为此,您需要存储2个或4个字节的字符,这使得它基本上是UTF-16或UTF-32。

如果它是关于URL编码的,同样,您不应该混合编码,并且请注意,至少对于域名,存在由提供应用程序(如浏览器)的不同大厅定义的规则。例如,域可以非常相似,如:

Ankofamerica.com或Bankofamerica.com相同但不同;)

当然,您需要文件系统来处理所有编码。使用UTF-8文件系统的Unicode的另一个优点。

如果是关于翻译,考虑一下文档的结构。例如一本书或一篇文章。您可以使用docbook规范来了解这些结构。但在HTML中,它只是关于内容块。所以你想在这个层面上有一个翻译,也在网页层面或域名层面上。 所以如果一个块不存在,它就不存在,如果一个网页不存在,你就会被重定向到更高的导航级别。如果一个域名在导航结构上应该完全不同,那么……这是一个完全不同的管理结构。 Typo3已经可以做到这一点。

如果它是关于框架的,我所知道的最成熟的框架,来做像MVC(我真的很讨厌这个词)这样的通用东西!就像“性能”一样,如果你想卖东西,用“性能”和“功能丰富”这个词,你就能卖……什么鬼)是Zend。事实证明,将标准引入PHP Chaos编码器是一件好事。但是,除了CMS之外,Typo3还有一个框架。最近它被重新开发,现在被称为Flow3。当然,这些框架涵盖了数据库抽象、模板和缓存概念,但也有各自的优势。

如果是关于缓存..这可能是非常复杂/多层次的。在PHP中,你会想到加速器,操作码,还有HTML,HTTPD,MySQL,XML,CSS,JS..任何类型的缓存。当然,有些部分应该被缓存,而像博客答案这样的动态部分不应该被缓存。有些应该通过AJAX请求生成的URL,JSON,Hashbangs等。

然后,你想在你的网站上有任何小组件,只能由某些用户访问或管理,所以从概念上讲,这起着很大的作用。

你也想做统计学,也许有分布式系统/Facebook的Facebook等。任何软件都要建立在你的顶级CMS之上。所以你需要不同类型的数据库,InMemory、大数据、XML

好了,我想现在够了。如果你没有听说过Typo3/Plone或提到过的框架,那么你有足够的东西可以学习。在这条道路上,你会找到很多你还没有问过的问题的解决方案。

如果你认为,让我们做一个新的CMS,因为现在是2013年,PHP无论如何都要死了,那么欢迎你加入任何其他的开发团队,希望不会迷路。

祝你好运!

顺便说一句。将来人们不会再有任何网站了吗?我们都会在Google+上?我希望开发人员变得更有创造力,做一些有用的事情(不要被Borgle同化)。

//编辑/// 对您现有的应用程序稍加考虑:

如果你有一个PHP MySQL CMS,并且你想嵌入多语言支持。您可以对任何语言使用带有附加列的表,也可以在同一个表中插入带有对象ID和语言ID的翻译,或者为任何语言创建一个相同的表并在其中插入对象,然后创建一个选择联合(如果您希望将它们全部显示)。对于数据库,使用UTF8通用CI,当然在前端/后端使用UTF8文本/编码。 我已经以你已经解释过的方式为URL使用了URL路径段。

domain.org/en/about 您可以将语言ID映射到内容表。无论如何,你需要为你的URL有一个参数映射,所以你想在你的URL中定义一个从路径段映射的参数,例如

domain.org/en/about/employees/it/administrators/

查找配置

页面ID|URL

1|/about/employees/./.

1|/./关于/员工././

将参数映射到URL路径段“ ”

$parameterlist[lang] = array(0=>"nl",1=>"en"); // default nl if 0
$parameterlist[branch] = array(1=>"IT",2=>"DESIGN"); // default nl if 0
$parameterlist[employertype] = array(1=>"admin",1=>"engineer"); //could be a sql result


$websiteconfig[]=$userwhatever;
$websiteconfig[]=$parameterlist;
$someparameterlist[] = array("branch"=>$someid);
$someparameterlist[] = array("employertype"=>$someid);
function getURL($someparameterlist){
// todo foreach someparameter lookup pathsegment
return path;
}

每个人都说,这已经在上面的帖子中被覆盖了。

不要忘记,您需要“重写”生成PHP文件的URL,在大多数情况下是index.PHP

数据库工作:

创建语言表“语言”:

字段:

language_id(primary and auto increamented)


language_name


created_at


created_by


updated_at


updated_by

在数据库“内容”中创建一个表:

字段:

content_id(primary and auto incremented)


main_content


header_content


footer_content


leftsidebar_content


rightsidebar_content


language_id(foreign key: referenced to languages table)


created_at


created_by


updated_at


updated_by

前端工作:

当用户从下拉列表或任何区域中选择任何语言,然后将所选语言ID保存在会话中时,

$_SESSION['language']=1;

现在根据会话中存储的语言ID从数据库表' content '中获取数据。

详情可在此处找到http://skillrow.com/multilingual-website-in-php-2/

这取决于你的网站有多少内容。起初,我像这里的所有其他人一样使用数据库,但编写数据库的所有工作脚本可能很耗时。我不认为这是一个理想的方法,特别是如果你有很多文本,但如果你想在不使用数据库的情况下快速完成,这种方法可以工作,但你不能允许用户输入将用作翻译文件的数据。但如果你自己添加翻译,它就会起作用:

假设您有以下文本:

Welcome!

您可以将其输入到带有翻译的数据库中,但您也可以这样做:

$welcome = array(
"English"=>"Welcome!",
"German"=>"Willkommen!",
"French"=>"Bienvenue!",
"Turkish"=>"Hoşgeldiniz!",
"Russian"=>"Добро пожаловать!",
"Dutch"=>"Welkom!",
"Swedish"=>"Välkommen!",
"Basque"=>"Ongietorri!",
"Spanish"=>"Bienvenito!"
"Welsh"=>"Croeso!");

现在,如果您的网站使用cookie,例如:

$_COOKIE['language'];

为了简单起见,让我们将其转换为易于使用的代码:

$language=$_COOKIE['language'];

如果你的cookie语言是威尔士语,并且你有这段代码:

echo $welcome[$language];

这样做的结果是:

Croeso!

如果您需要为您的网站添加大量翻译,并且数据库消耗太大,那么使用数组可能是一个理想的解决方案。

作为一个生活在魁北克的人,那里几乎所有的网站都是法语和英语。我已经尝试了许多,如果不是最多的多语言插件。与我所有站点一起工作的唯一有用的解决方案是MQTranslate..我与它同生共死!

https://wordpress.org/plugins/mqtranslate/.

我不打算试图完善已经给出的答案。相反,我将告诉您我自己的OOP PHP框架处理翻译的方式。

在内部,我的框架使用en、fr、es、CN等代码。数组包含网站支持的语言:array(' en ',' fr ',' es ',' CN ') 语言代码通过$_get(Lang=fr)传递,如果未传递或无效,则将其设置为数组中的第一种语言。因此,在程序执行过程中的任何时候,从一开始,当前语言就是已知的。

了解典型应用程序中需要翻译的内容类型非常有用:

1)来自类(或过程代码)的错误消息 2)来自类(或过程代码)的非错误消息 3)页面内容(通常存储在数据库中) 4)站点范围的字符串(如网站名称) 5)特定于脚本的字符串

第一种类型简单易懂。基本上,我们讨论的是像“无法连接到数据库..”这样的消息。只有在发生错误时才需要加载这些消息。我的管理器类接收来自其他类的调用,并使用作为参数传递的信息简单地转到相关的类文件夹并检索错误文件。

第二种类型的错误消息更像是表单验证出错时收到的消息。(你不能离开..空白“或”请选择超过5个字符的密码")。在类运行之前,需要加载字符串。

对于实际的页面内容,我对每种语言使用一个表,每个表都以该语言的代码作为前缀。因此,EN_内容是英语语言内容的表格,ES_内容是西班牙语言内容,CN_内容是中国语言内容,FR_内容是法语内容。

第四种字符串与整个网站相关。这是通过名为的配置文件使用该语言的代码加载的,即en_Lang.PHP,es_Lang.PHP等等。在全局语言文件中,您需要加载已翻译的语言,例如英语全局文件中的数组(' English '、' Chinese '、' Spanish '、' French ')和法语文件中的数组(' Anglais ',' Chinois ',' Espagnol ',' Francais ')。因此,当您填充语言选择的下拉列表时,它使用的是正确的语言;)

最后是特定于脚本的字符串。因此,如果您编写一个烹饪应用程序,它可能是“您的烤箱不够热”。

在我的应用程序周期中,首先加载全局语言文件。在那里,您不仅可以找到全局字符串(如“杰克的网站”),还可以找到一些类的设置。基本上任何依赖于语言或文化的东西。其中的一些字符串包括日期掩码(MMDDYYYY或DDMMYYYY)或ISO语言代码。在主语言文件中,我包含了单个类的字符串,因为它们的数量太少了。

从磁盘读取的第二个也是最后一个语言文件是脚本语言文件。Lang_en_home_welcome.PHP是home/welcome脚本的语言文件。脚本由模式(主页)和操作(欢迎)定义。每个脚本都有自己的文件夹,其中包含config和Lang文件。

该脚本从数据库中提取内容,并如上所述命名内容表。

如果出现错误,管理器知道从哪里获取与语言相关的错误文件。只有在发生错误的情况下才加载该文件。

所以结论是显而易见的。在开始开发应用程序或框架之前,请考虑一下翻译问题。您还需要一个包含翻译的开发工作流。使用我的框架,我用英语开发整个网站,然后翻译所有相关文件。

只是关于翻译字符串实现方式的最后一句话。我的框架有一个单独的全局$manager,它运行可用于任何其他服务的服务。例如,表单服务获得HTML服务并使用它来编写HTML.我的系统上的服务之一是翻译服务。$translator->set($service,$code,$string)设置当前语言的字符串。语言文件是此类语句的列表。$translator->get($service,$code)检索翻译字符串。$code可以是数字(如1)或字符串(如“无_连接”)。服务之间不会有冲突,因为每个服务在转换器的数据区中都有自己的名称空间。

我把这篇文章贴在这里,希望它能帮一些人完成重新发明轮子的任务,就像我几年前不得不做的那样。

WordPress+MULTI-LANGUAGE SITE BASIS(插件)

如何? 该网站将有结构:

  • example.com/__abc0/category1/..
  • example.com/__abc0/my-page..
  • example.com/__abc0/category1/..
  • example.com/__abc0/my-page..

该插件提供了翻译所有短语的接口,逻辑简单:

(ENG) my_title - "Hello user"
(SPA) my_title - "Holla usuario"

然后就可以输出了:
echo translate('my_title', LNG); // LNG is auto-detected

P.S.但是,请检查插件是否仍处于活动状态。

www.multilingualizer.com是一个非常简单的选项,它适用于任何可以上传JavaScript的网站。

它允许您将所有语言的所有文本放在一个页面上,然后隐藏用户不需要查看的语言。效果很好。

在制作多语言网站时,真正的挑战是内容。如何存储同一篇文章的不同版本?您使用的是关系型数据库还是非关系型数据库?

使用关系数据库(如MySQL),您可以利用JSON数据类型来存储同一字段的所有不同版本。

当使用非关系数据库时,您可以简单地将不同的版本存储在同一个对象中,该对象可以通过它们的键来识别。

如果您正在使用Laravel,您可能会发现Laravel可译包在处理传统关系数据库时非常有用。

如果您正在托管静态内容,则Google的Firebase托管支持i18n托管规则,该规则可返回国家、语言或语言+国家特定内容,包括index.html404.htmlmanifest.json文件(适用于渐进式Web应用程序)。

他们的主机将根据用户的IP地址选择国家,并根据浏览器的Accept-Language标题选择语言,然后应用优先规则返回每个请求的文件内容。

  1. 语言代码+国家/地区代码(例如,FR_CA/的内容)

  2. 国家/地区代码(例如,来自所有_CA/的内容)

  3. 语言代码(例如,FR/或ES_ALL/中的内容)

  4. ";默认";在“国际化内容”之外的内容目录,比如在公共目录的根目录。

规则1&;3按照请求的Accept-Language报头中每种语言的质量值的顺序应用。

例子

public/
index.html  // Default homepage
manifest.json  // Default manifest.json
404.html  // Default custom 404 page


localized-files/
ALL_ca/
index.html
es_ALL/
index.html
404.html
manifest.json   << Spanish
fr/
index.html
404.html
manifest.json   << French
fr_ca/
index.html
manifest.json
// firebase.json


"hosting": {


"public": "public",


"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],


"i18n": {
"root": "/localized-files"  // <<< "i18n content" folder
}


...
}

全部细节..