11ty / eleventy

A simpler site generator. Transforms a directory of templates (of varying types) into HTML.
https://www.11ty.dev/
MIT License
17.13k stars 495 forks source link

i18n: Can't get localized URL for current language using `locale_url` #2791

Open danburzo opened 1 year ago

danburzo commented 1 year ago

Operating system

macOS Ventura 13.2

Eleventy

2.0.0-beta.3

Describe the bug

With a basic i18n setup and the default permalink schema, an error is thrown when attempting to use locale_url on an URL that contains the default language code in it.

File structure:

src/
  en/
    my-article.md
  ro/
    my-article.md
  it/
    my-article.md

Template file:

<h1>{{ title }}</h1>
{{ content | safe }}
<a href="{{ '/en/my-article/' | locale_url }}">Permalink</a>

Config file:

const { EleventyI18nPlugin } = require('@11ty/eleventy');

module.exports = config => {

    config.addPlugin(EleventyI18nPlugin, {
        defaultLanguage: 'en'
    });

    return {
        dir: {
            input: 'src',
            output: 'dist'
        },
        markdownTemplateEngine: 'njk',
        dataTemplateEngine: 'njk',
        htmlTemplateEngine: 'njk'
    };
};

Reproduction steps

  1. Clone the reproduction repro, navigate to basic-langcode and npm install
  2. Run npm run build

The following error is displayed:

[11ty] 1. Having trouble writing to "dist/en/my-article/index.html" from "./src/en/my-article.md" (via EleventyTemplateError)
[11ty] 2. (./src/_includes/base.njk)
[11ty]   Error: Localized file for URL /en/en/my-article/ was not found in your project. A non-localized version does exist—are you sure you meant to use the `locale_url` filter for this? You can bypass this error using the `errorMode` option in the I18N plugin (current value: "strict"). (via Template render error)

Reproduction URL

https://github.com/danburzo/eleventy-i18n-repro/tree/master/basic-langcode

danburzo commented 1 year ago

One possible fix for it would be to check if the page already has the correct language code in the locale_url filter:

// Already has the correct language code
if (Comparator.urlHasLangCode(url, langCode)) {
  return url;
}
danburzo commented 1 year ago

I've added a second test case, default-langcode-omitted, which coincidentally also gets fixed with the change above. This case tests whether we can omit the language prefix for the default language, with this permalink setup for the en/ folder:

/* en/en.11tydata.js */
module.exports = {
    /*
        With English being the default language,
        remove the `/en/` prefix from all permalinks.
    */
    permalink: data => {
        const stem = data.page.filePathStem.replace(/^\/en\//, '');
        const ext = data.page.outputFileExtension;
        if (stem === 'index' || stem.match(/\/index$/)) {
            return `${stem}.${ext}`;
        }
        return `${stem}/index.${ext}`;
    }
};

In the docs, this arrangement is handled with server-side redirects, but there's nothing much in the way of doing it in Eleventy directly.

Before the fix, the template:

<a href="{{ '/my-article/' | locale_url }}">Permalink</a>

Here's the before-fix / after-fix for dist/my-article/index.html, for the English locale:

<!-- before fix, `de` locale is picked up for English -->
<a href="/de/my-article/">Permalink</a>

<!-- after fix, correct URL -->
<a href="/my-article/">Permalink</a>

I say coincidentally fixed because I noticed some weird things, such as:

Comparator.isLangCode('my-article'); // => true
danburzo commented 1 year ago

For now I've settled on a pared-down implementation for the i18n plugin that:

const path = require('path');

module.exports = function EleventyPlugin(config, opts = {}) {
    let options = {
        defaultLocale: 'en',
        locales: ['en'],
        ...opts
    };

    let byCanonicalPath = {};
    let byUrl = {};
    config.on('eleventy.contentMap', function (map) {
        Object.entries(map.urlToInputPath)
            .map(function (entry) {
                let locale;
                return {
                    url: entry[0],
                    canonicalPath: entry[1]
                        .split(path.sep)
                        .map(function (seg) {
                            if (options.locales.includes(seg)) {
                                if (!locale) locale = seg;
                                return ':locale:';
                            }
                            return seg;
                        })
                        .join(path.sep),
                    lang: locale || options.defaultLocale,
                    label: `TODO[${locale}]`
                };
            })
            .forEach(function (entry) {
                if (!byCanonicalPath[entry.canonicalPath]) {
                    byCanonicalPath[entry.canonicalPath] = {};
                }
                byCanonicalPath[entry.canonicalPath][entry.lang] = entry;
                byUrl[entry.url] = entry;
            });
    });

    config.addFilter('locale_url', function (url, overrideLang) {
        const lang = overrideLang || this.page?.lang || options.defaultLocale;
        const canonicalPath = byUrl[url].canonicalPath;
        return byCanonicalPath[canonicalPath][lang]?.url || url;
    });

    config.addFilter('locale_links', function (url) {
        const canonicalPath = byUrl[url].canonicalPath;
        return Object.values(byCanonicalPath[canonicalPath]);
    });
};