getgrav / grav-plugin-langswitcher

Grav LangSwitcher Plugin
https://getgrav.org
MIT License
28 stars 26 forks source link

Plugin ignores the configured slug(s) of the page(s) when changing the language #39

Closed petira closed 2 years ago

petira commented 6 years ago

The primary folder structure is e.g. [/en]/animal/cat, where pages may or may not have a slug set because it would be the same as the directory name in English.

The second language is e.g. Czech, where the content of the URL [/(cs)]/zvire/kocka, defined via slug, should be displayed.

Now we are in the English version. The issue is that the link for Czech version does not include [/(cs)]/zvire/kocka, but [/(cs)]/animal/cat. While the content appears correct (from default.cs.md) but on the wrong link. Consequently, the link for Czech is already correct.

petira commented 6 years ago

Subsequently, I found the solution listed in README.md, part Redirecting after switching language. Still, I would prefer that link is primarily directed to localized URL.

petira commented 6 years ago

Related issue: #34

petira commented 6 years ago

Other related issues: #1, #2, #11, #16, (#30)

Would you be able to find a way to directly use the URL generated from slug without route redirecting?

fabbow commented 6 years ago

Hi! I tried digging into this but have not found a close to decent way to solving this. Apparently the decision to not support this is architectural and final, but it's a huge dealbreaker unfortunately (http://www.thesempost.com/google-dont-link-hreflang-redirecting-urls/).

A very hacky approach I'm thinking of would be to resolve the redirect of the raw route via PHP and smash the destination into a custom sitemap generated by a cronjob...

guategeek commented 6 years ago

This is really unfortunate, if anyone can figure this out that would be great.

fabbow commented 6 years ago

I tried bringing this up in the main Grav repo, since the issue is related to Gravs Core, but it was shot down because "that's how Grav works" unfortunately... Gotta have a look at Statamic now

juliendude commented 6 years ago

I faced the same issue. But I got an answer that fixed my issue. It was a setting in the system.yaml here is the link to the thread: https://discourse.getgrav.org/t/strange-behaviour-of-language-switcher-not-picking-up-language-specific-slug/7254/3

I hope it helps.

fabbow commented 6 years ago

Hi there, thanks for replying! For language switching that does the trick, if you want to output correct hreflang Tags for Google, it has to be the final URL without a redirect in between though.

coolemur commented 6 years ago

Hi, guys,

Setting

pages:
  redirect_default_route: true

Is not an option in many cases, because redirect is a bad practice.

Why can't Grav load only slugs of all pages (not entire translated pages) if it has performance issues?

So we could have something like getSlugByRoute ?

hh-ReBOOM commented 5 years ago

Hi guys,

this thread is already a few days ago but since I encountered the same problem (want to have language specific slugged URLs without redirect) I poked around a little and found a reasonably useful solution. At least for me ;)

I encountered the problem during setting up a multi-language helpdesk site with Grav and using the language-selector by clemdesign which is based on the langswitcher plugin of Grav.

Since Grav does not provide language specific (slugged) URLs for other than the active language I simply cache the slugged URLs per language using the Grav cache. Then in Twig templates I use my own Twig filter (or function, as you like) to retrieve the correct language specific slugged URL for the language selector which then works without redirect.

Therefore I needed to write a plugin which does 2 things:

  1. It caches the language specific slugged URLs (and refreshes cache if content has changed)
  2. It reads a language specific slugged URL from the cache based on the raw route (which is the same in all languages).

Two issues still remain: a) I ignore possible different language specific extensions for the same raw route (would be a very rare case having different extensions for the same raw route in different languages) b) Cached slugged URLs for a language are only available (and valid!) if at least one page in that language has been delivered to the user since last change/update. But precaching (look for 'precache' plugin) may help. I don't need that since my pages are rather static and, additionally, my solution returns the raw route if no cached slugged route can be found (although using the raw route as fallback leads of course to redirect again. But that's ok).

[EDIT] Since I was asked: No you don't need to precache/request EVERY single page of every language. It is sufficient to precache/request A SINGLE page of every language to have the cache filled. Since Grav build the whole page tree for the active language once a page is requested my plugin can grab ALL slugged URLs for that language. So if you have 5 languages it is sufficient to request e.g. the home page of each language to have ALL slugged URLs of your site cached. [/EDIT]

Once you created the plugin you can do something like that to have the language specific slugged URL in your Twig template: {% set lang_url = languageRoute(language, page.rawRoute) ~ page.urlExtension %} where language may be 'en' or 'de' or some other valid language item you configured in your multilanguage setup. page.rawRoute can be any raw route you would like to get the language specific slugged URL for.

The principle is quite simple for anyone who has ever written a Grav plugin. For the rest this helds true, too. First you should install 'devtools' and use it to create your plugin stub (see https://github.com/getgrav/grav-plugin-devtools). Create a 'empty' plugin by typing bin/plugin devtools new-plugin and follow the instructions. Name it as you like. You have to replace my 'AdwaryLanguageRoutesPlugin' in the class statement to your class name as defined in your plugin stub.

Here's the code of my plugin:

namespace Grav\Plugin;

use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;

/**
 * Class AdwaryLanguageRoutesPlugin
 * @package Grav\Plugin
 */
class AdwaryLanguageRoutesPlugin extends Plugin
{
    /**
     * @return array
     *
     * The getSubscribedEvents() gives the core a list of events
     *     that the plugin wants to listen to. The key of each
     *     array section is the event that the plugin listens to
     *     and the value (in the form of an array) contains the
     *     callable (or function) as well as the priority. The
     *     higher the number the higher the priority.
     */
    public static function getSubscribedEvents()
    {
        return [
            'onPluginsInitialized' => ['onPluginsInitialized', 0]
        ];
    }

    /**
     * Initialize the plugin
     */
    public function onPluginsInitialized()
    {
        // Don't proceed if we are in the admin plugin
        if ($this->isAdmin()) {
            return;
        }

        // Enable the main event we are interested in
        $this->enable([
            'onPagesInitialized' => ['onPagesInitialized', 0], // for caching slugged language URLs
            'onTwigInitialized' => ['onTwigInitialized', 0], // for retrieving slugged language URLs
        ]);
    }

    public function onTwigInitialized(Event $e) {
        // add filter and function for 'languageRoute' to be used in Twig templates
        $this->grav['twig']->twig()->addFunction(new \Twig_SimpleFunction('languageRoute', [$this, 'twig_languageRoute']));
        $this->grav['twig']->twig()->addFilter(new \Twig_SimpleFilter('languageRoute', [$this, 'twig_languageRoute']));
    }

    /**
     * If $language is omitted the active language is used.
     * If $rawRoute is omitted the raw route of the current page is used.
     *
     * @param null $language
     * @param null $rawRoute
     *
     * @return string
     */
    public function twig_languageRoute($language = null, $rawRoute = null) {
        // if $rawRoute is omitted use raw route of current page
        $rawRoute = $rawRoute == null ? $this->grav['page']->rawRoute() : $rawRoute;
        // if $language is omitted or not supported use active language of current page
        $language = ($language == null || !$this->grav['language']->validate($language)) ? $this->grav['language']->getActive() : $language;
        return $this->getLanguageRoute($language, $rawRoute);
    }

    /**
     * Check and maybe build cache for slugged URLs for the active language
     *
     * @param Event $e
     */
    public function onPagesInitialized(Event $e)
    {
        // Check if cache is valid
        if (!$this->checkCacheSanity()) {
            // cached URLs are out of date, purge cache and recreate it
            $this->sanitizeCache();
        }
    }

    /**
     * Checks if the cache is valid. If any page has changed the cache will be invalid and needs to be recreated
     *
     * @return bool
     */
    private function checkCacheSanity() : bool {
        $grav = $this->grav;
        // get current language
        $language = $grav['language']->getActive();
        $pages = $grav['pages'];
        // The pages cache id is unique and will change if content has changed
        // The pages cache id is used to determine if the url cache is valid or not (id is same or different)
        $cache_id = $pages->getPagesCacheId();
        $cache = $grav['cache'];
        // Try to get cached urls
        $cached_urls = $cache->fetch('language_routes_' . $language);
        if ($cached_urls) {
            // There are cached urls
            // Get cached cache id for the content to compare with current cache id
            $cached_language_cache_id = $cache->fetch('language_routes_cache_id_' . $language);
            if ($cached_language_cache_id && $cached_language_cache_id == $cache_id) {
                // everything is fine, cached URLs are up to date
                return true;
            } else {
                // different id means content has changed. URLs may be out of date.
                return false;
            }
        } else {
            // Nothing cached. No URLs to retrieve
            return false;
        }
    }

    /**
     * Recreates the language specific URL cache based on the current language
     */
    private function sanitizeCache() {
        $grav = $this->grav;
        // get current language
        $language = $grav['language']->getActive();
        $pages = $grav['pages'];
        // The pages cache id is unique and will change if content has changed
        // The pages cache id is used to determine if the url cache is valid or not (id is same or different)
        $cache_id = $pages->getPagesCacheId();
        $cache = $grav['cache'];
        // get list of all available pages in the current language based on their raw routes, mapped to their slugged routes
        $language_mapping = $pages->getList(null, 0, true, true, true);
        $cache->save('language_routes_cache_id_' . $language, $cache_id);
        $cache->save('language_routes_' . $language, $language_mapping);
    }

    /**
     * Gets a language specific route to a page based on the raw route of the page. The language specific route may contain language specific slugs.
     *
     * @param $language
     * @param $rawRoute
     *
     * @return string
     */
    private function getLanguageRoute($language, $rawRoute) {
        $grav = $this->grav;
        // Hack: Home route '/' needs to be converted to "raw" home route for this to work
        if ($rawRoute == '/') {
            $rawRoute .= $grav['pages']->getHomeRoute();
        }
        $cache = $grav['cache'];
        // Fetch all language specific routes from cache
        $routes = $cache->fetch('language_routes_' . $language);
        if ($routes) {
            // There are language specific routes cached. Try to get the one for the requested raw route
            try {
                $result = $routes[$rawRoute];
            } catch (\Exception $e) {
                $result = $rawRoute;
            }
        } else {
            $result = $rawRoute;
        }
        return $grav['language']->getLanguageURLPrefix($language) . $result;
    }
}

And I needed to modify the Twig template of language-selector plugin a little bit (It is an excerpt from the template to show where to modify, it's not the whole template):

[...]
{% for language in language_selector.languages %}

    {% set show_language = true %}
    {% if language == language_selector.current %}
        {% set lang_url = page.url %}
    {% else %}
        {% set lang_url = languageRoute(language, language_selector.page_route) ~ page.urlExtension %}
        {% set untranslated_pages_behavior = grav.config.plugins.language_selector.untranslated_pages_behavior %}
        {% if untranslated_pages_behavior != 'none' %}
[...]

(The lines after {% else %} is where the mod happened)

And for the href template of language-selector plugin it looks like this (the whole template now):

{% set langobj = grav['language'] %}
{% for key in language_selector.languages %}
    {% set lang_url = languageRoute(key, language_selector.page_route) ~ page.urlExtension ?: '/' %}
    <link rel="alternate" hreflang="{{ key }}" href="{{ lang_url ~ uri.params }}" />
{% endfor %}

I don't know the langswitcher templates but it should be as easy as that to modify them the same way.

Happy slugging!

hh-ReBOOM commented 5 years ago

I improved my caching a little bit (rebuild cache only if slugs have changed instead of rebuilding it if any content changed) and did some renaming of variables. It concerns only checkCacheSanity() as well as sanitizeCache() so to be up to date you just need to copy them.

    /**
     * Checks if the cache is valid. If any page has changed the cache may be invalid and needs to be recreated.
     * There is a two-step cache dirty indicator to improve performance.
     * First step: If the pages cache id has changed the cache MAY be dirty, so check deeper.
     * Second (deeper) step: If the routes cache id has changed (based on the slugged paths) the cache IS really dirty.
     *
     * @return bool
     */
    private function checkCacheSanity() : bool {
        $grav = $this->grav;
        // get current language
        $language = $grav['language']->getActive();
        $pages = $grav['pages'];
        // The pages cache id is unique and will change if content has changed
        // The pages cache id is used to determine if the url cache is valid or not (id is same or different)
        $currentPagesCacheId = $pages->getPagesCacheId();
        $cache = $grav['cache'];
        // Get cached cache id for the content to compare with current cache id
        $cachedPagesCacheId = $cache->fetch('language_routes_pages_cache_id_' . $language);
        if ($cachedPagesCacheId && $cachedPagesCacheId == $currentPagesCacheId) {
            // everything is fine, cached URLs are up to date
            return true;
        } else {
            // different id means content has changed. URLs may be out of date.
            // perform a deeper check on the routes cache id
            $rawToSlugMapping = $pages->getList(null, 0, true, true, true);
            $slugsCacheId = hash('md5', serialize($rawToSlugMapping));
            $cachedSlugsCacheId = $cache->fetch('language_routes_slugs_cache_id_' . $language);
            if (!$cachedSlugsCacheId || $cachedSlugsCacheId != $slugsCacheId) {
                // the cache IS really dirty and needs to be updated
                return false;
            } else {
                // The slugs are still intact but may be content has changed which does not affect the slugs.
                // Therefore the cache is not dirty but the new different pages cache id needs to be cached.
                $cache->save('language_routes_pages_cache_id_' . $language, $currentPagesCacheId);
                return true;
            }
        }
    }

    /**
     * Recreates the language specific URL cache based on the current language
     */
    private function sanitizeCache() {
        $grav = $this->grav;
        // get current language
        $language = $grav['language']->getActive();
        $pages = $grav['pages'];
        // The pages cache id is unique and will change if content has changed
        // The pages cache id is used to determine if the url cache is valid or not (id is same or different)
        $pagesCacheId = $pages->getPagesCacheId();
        $cache = $grav['cache'];
        // get list of all available pages in the current language based on their raw routes, mapped to their slugged routes
        $rawToSlugMapping = $pages->getList(null, 0, true, true, true);
        $slugsCacheId = hash('md5', serialize($rawToSlugMapping));
        $cache->save('language_routes_slugs_cache_id_' . $language, $slugsCacheId);
        $cache->save('language_routes_pages_cache_id_' . $language, $pagesCacheId);
        $cache->save('language_routes_' . $language, $rawToSlugMapping);
    }
hh-ReBOOM commented 5 years ago

I'm back again. I improved my caching again to update the slugs if a page was edited using Grav's built in editor (which I don't use very often since my editor is way better). After saving a page the cache will be updated for that language so that one drawback of my solution (cache being invalid after altering a page's slug) becomes less important.

To clarify: My solution is based on the language-selector plugin by clemdesign which is based on the Grav langswitcher plugin. So my modifications of the Twig templates of the language-selector plugin can be applied easily to these of the langswitcher or any other solution that tries to catch the URLs in the background. In fact my caching of slugged URLs has nothing to do with language switching on the site but provides any need for slugged URLs with, yeah with slugged URLs.

What did I improve this time?

First the cache now updates after saving a page in Grav's editor. Doing this has the advantage that at this moment the language of the page altered is known and therefore Grav knows all slugs of all pages in that language.

Second I switched to a dedicated cache for the slugged URLs. Why? Well, in admin mode two things happen:

  1. The cache is initialized but inactive (though no reading or writing will be performed)
  2. In admin mode the cache's key is different from the one in 'user' mode.

After realising these two facts it becomes clear that a seperate cache is necessary. Fortunately Grav's cache is easy to handle so I just need to define a different key for my own cache and enable it in admin mode. Et voilà caching works fine.

Third I did some refactoring.

In order to keep it organized, I added the complete code of my plugin again and added the full version of my modified (a very tiny modification) language-selector plugin Twig templates.

<?php
namespace Grav\Plugin;

use Grav\Common\Cache;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;

/**
 * Class AdwaryLanguageRoutesPlugin
 * @package Grav\Plugin
 */
class AdwaryLanguageRoutesPlugin extends Plugin
{
    // Needed to hold the previous cache state
    private $cacheStatus = false;
    // Needed to hold the previous cache key
    private $cacheKey = '';

    /**
     * @return array
     *
     * The getSubscribedEvents() gives the core a list of events
     *     that the plugin wants to listen to. The key of each
     *     array section is the event that the plugin listens to
     *     and the value (in the form of an array) contains the
     *     callable (or function) as well as the priority. The
     *     higher the number the higher the priority.
     */
    public static function getSubscribedEvents()
    {
        return [
            'onPluginsInitialized' => ['onPluginsInitialized', 0],
        ];
    }

    /**
     * Initialize the plugin
     */
    public function onPluginsInitialized()
    {
        // We may register listeners for onObjectSave events but
        // the object isn't saved yet since the event occurs before
        // saving the page.
        // Therefore the page's slug isn't reflected in the pages
        // tree as the event is fired although it is reflected in
        // the object (page) to be saved. But to update the cache
        // with the new slug during onObjectSave event makes a lot
        // of code necessary.
        //
        // It is easier to listen to the onPagesInitialized event
        // to handle the cache update. Since then all of the
        // existing code could be used. And it might perform
        // better.

        // The onPagesInitialized event is fired 4 times on
        // saving a page (-> uh, really 4 times? Why? It seems
        // 2 times is necessary but 4?).
        // The first time the pages tree reflects the state
        // before altering the page since the page is not saved
        // yet.
        // The next 3 times (after the page has been saved
        // actually) the pages tree does contain the
        // modifications.
        if ($this->isAdmin()) {
            $this->enable([
                'onPagesInitialized' => ['onPagesInitialized', 0], // for caching slugged language URLs
            ]);
        } else {
            $this->enable([
                'onPagesInitialized' => ['onPagesInitialized', 0], // for caching slugged language URLs
                'onTwigInitialized' => ['onTwigInitialized', 0], // for retrieving slugged language URLs
            ]);
        }
    }

    public function onTwigInitialized(Event $e) {
        // add filter and function for 'languageRoute' to be used in Twig templates
        $this->grav['twig']->twig()->addFunction(new \Twig_SimpleFunction('languageRoute', [$this, 'twig_languageRoute']));
        $this->grav['twig']->twig()->addFilter(new \Twig_SimpleFilter('languageRoute', [$this, 'twig_languageRoute']));
    }

    /**
     * Returns the language specific slugged route based on the
     * raw route of that page and the language given.
     * The route includes the language prefix. For the default
     * language this depends on the site's configuration but
     * this is taken into account automatically.
     *
     * Note: You have to handle file extensions by yourself
     * if necessary (I don't ever need them).
     *
     * If $language is omitted the active language is used.
     * If $rawRoute is omitted the raw route of the current page is used.
     *
     * @param null $language
     * @param null $rawRoute
     *
     * @return string
     */
    public function twig_languageRoute($language = null, $rawRoute = null) {
        // if $rawRoute is omitted use raw route of current page
        $rawRoute = $rawRoute == null ? $this->grav['page']->rawRoute() : $rawRoute;
        // if $language is omitted or not supported use active language of current page
        $language = ($language == null || !$this->grav['language']->validate($language)) ? $this->grav['language']->getActive() : $language;
        return $this->getLanguageRoute($language, $rawRoute);
    }

    /**
     * Check and maybe build or rebuild cache for slugged URLs
     * for the active language
     *
     * @param Event $e
     */
    public function onPagesInitialized(Event $e)
    {
        // Check if cache is valid. Update it if necessary
        $this->checkAndUpdateSlugCache();
    }

    /**
     * Checks if the cache for the slugged routes is valid. If any
     * page has changed the cache may be invalid and needs to be
     * recreated.
     *
     * There is a two-step cache dirty indicator to improve
     * performance.
     *
     * First step: If the pages cache id has changed the cache MAY
     * be dirty, so check deeper.
     *
     * Second (deeper) step: If the routes cache id has changed
     * (based on the slugged paths) the cache IS really dirty.
     * In this case recreate the cached slugged routes.
     *
     */
    private function checkAndUpdateSlugCache() {
        $grav = $this->grav;
        // get current language
        $language = $grav['language']->getActive();
        $pages = $grav['pages'];
        // The pages cache id is unique and will change if
        // content or anything else has changed.
        // The pages cache id is used to determine if the url
        // cache is valid or not (id is same or different)
        $currentPagesCacheId = $pages->getPagesCacheId();
        $cache = $grav['cache'];
        $this->setCacheProperties($cache);
        // Get cached pages cache id for the content to compare
        // with current pages cache id
        $cachedPagesCacheId = $cache->fetch('language_routes_pages_cache_id_' . $language);
        if ($cachedPagesCacheId && $cachedPagesCacheId == $currentPagesCacheId) {
            // everything is fine, cached slugged URLs are up
            // to date. Nothing to do.
        } else {
            // different pages cache id means content has changed.
            // URLs may be out of date.
            // perform a deeper check on the slugs cache id
            $rawToSlugMapping = $pages->getList(null, 0, true, true, true);
            $slugsCacheId = hash('md5', serialize($rawToSlugMapping));
            $cachedSlugsCacheId = $cache->fetch('language_routes_slugs_cache_id_' . $language);
            if (!$cachedSlugsCacheId || $cachedSlugsCacheId != $slugsCacheId) {
                // the cache IS really dirty and needs to be updated
                $cache->save('language_routes_slugs_cache_id_' . $language, $slugsCacheId);
                $cache->save('language_routes_pages_cache_id_' . $language, $currentPagesCacheId);
                $cache->save('language_routes_' . $language, $rawToSlugMapping);
            } else {
                // The slugs are still intact but may be content
                // has changed which does not affect the slugs.
                // Therefore the cache is not dirty but the new
                // different pages cache id needs to be cached.
                $cache->save('language_routes_pages_cache_id_' . $language, $currentPagesCacheId);
            }
        }
        $this->revertCacheProperties($cache);
    }

    /**
     * Gets a language specific slugged route to a page based on
     * the raw route of the page and the language given.
     * The route includes the language prefix. For the default
     * language this depends on the site's configuration but this
     * is taken into account automatically.
     *
     * The language specific route may contain language specific slugs.
     * Note: You have to handle file extensions by yourself if necessary.
     *
     * @param $language
     * @param $rawRoute
     *
     * @return string
     */
    private function getLanguageRoute($language, $rawRoute) {
        $grav = $this->grav;
        // Hack: Home route '/' needs to be converted to "raw"
        // home route for this to work
        if ($rawRoute == '/') {
            $rawRoute .= $grav['pages']->getHomeRoute();
        }
        $cache = $grav['cache'];
        $this->setCacheProperties($cache);
        // Fetch all language specific routes from cache
        $routes = $cache->fetch('language_routes_' . $language);
        if ($routes) {
            // There are language specific routes cached. Try to
            // get the one for the requested raw route
            try {
                $result = $routes[$rawRoute];
            } catch (\Exception $e) {
                $result = $rawRoute;
            }
        } else {
            $result = $rawRoute;
        }
        $this->revertCacheProperties($cache);
        return $grav['language']->getLanguageURLPrefix($language) . $result;
    }

    /**
     * Sets the cache key and enables the cache if disabled (this
     * is the case in admin mode).
     * Setting the cache key is necessary to have the same cache in
     * admin mode as well as on the live site.
     * (In admin mode the regular cache is disabled per default
     * and moreover its key differs from the live site cache key
     * by a trailing '$')
     *
     * @param Cache
     *
     * @return void
     */
    private function setCacheProperties(Cache $cache) {
        $this->cacheStatus = $cache->getEnabled();
        $cache->setEnabled(true);
        $this->cacheKey = $cache->getKey();
        $cache->setKey('g-adwary_slug_cache');
    }

    /**
     * Reverts the changes made by setCacheProperties()
     *
     * @param Cache
     *
     * @return void
     */
    private function revertCacheProperties(Cache $cache) {
        $cache->setKey($this->cacheKey);
        $cache->setEnabled($this->cacheStatus);
    }
}

Twig template partials/language-selector.html.twig:

<div class="d-flex language-selector">
  <button class="btn awlangselector" type="button" data-dropdown="langSelectorList">
    {% if language_display.button == 'default' or language_display.button == 'flag' %}
      <img alt="{{ native_name(language_selector.current)|capitalize }}" src="{{ path_flags ~ language_selector.current }}.png" />
    {% endif %}
    {% if language_display.button == 'default' or language_display.button == 'name' %}
      {{ native_name(language_selector.current)|capitalize }}
    {% endif %}
    <i class="fa fa-caret-down"></i>
  </button>

  <ul class="dropdown-menu" id="langSelectorList">
{% for language in language_selector.languages %}

    {% set show_language = true %}
    {% if language == language_selector.current %}
        {% set lang_url = page.url %}
    {% else %}
        {% set lang_url = sluggedRoute(language, language_selector.page_route) ~ page.urlExtension %}
        {% set untranslated_pages_behavior = grav.config.plugins.language_selector.untranslated_pages_behavior %}
        {% if untranslated_pages_behavior != 'none' %}
            {% set translated_page = language_selector.translated_pages[language] %}
            {% if (not translated_page) or (not translated_page.published) %}
                {% if untranslated_pages_behavior == 'redirect' %}
                    {% set lang_url = base_lang_url ~ '/' %}
                {% elseif untranslated_pages_behavior == 'hide' %}
                    {% set show_language = false %}
                {% endif %}
            {% endif %}
        {% endif %}
    {% endif %}

    {% if show_language %}
        <li>
          <a href="{{ lang_url ~ uri.params }}">
            {% if language_display.select == 'default' or language_display.select == 'flag' %}
            <img alt="{{ native_name(language)|capitalize }}" src="{{ path_flags ~ language }}.png" />
            {% endif %}
            {% if language_display.select == 'default' or language_display.select == 'name' %}
            {{ native_name(language)|capitalize }}
            {% endif %}
            {% if language == language_selector.current %}
              <i class="fal fa-check"></i>
              {% endif %}
          </a>
        </li>
    {% endif %}

{% endfor %}
  </ul>
</div>

Twig template partials/language-selector.hreflang.html.twig:

{% set langobj = grav['language'] %}
{% for key in language_selector.languages %}
    {% set lang_url = sluggedRoute(key, language_selector.page_route) ~ page.urlExtension ?: '/' %}
    <link rel="alternate" hreflang="{{ key }}" href="{{ lang_url ~ uri.params }}" />
{% endfor %}
fabbow commented 5 years ago

Hey there, I have since stopped using Grav but just wanted to hop on and give you kudos for developing this solution, very nicely done!

rhukster commented 5 years ago

This is certainly an interesting approach. I think it might make more sense as an alternative langswitcher plugin as it does certainly add some complexity. Also, we are working on FlexPages with multilang support that we're looking to improve on the current implementation and provide built-in functionality to provide this sort of thing at the core-level.

hh-ReBOOM commented 5 years ago

Would be nice. As long as the improvement is not available, my solution will help. I love Grav since it is simple but yet powerful (and straight forward to enhance by plugins and own functionality) due to its very elaborated core (I love the event system!).

Nonetheless I improved my solution again to get rid of its last drawback - the possibility to have a language and all its slugs not beeing cached until a first page of this language was requested. Now at the first request all slugs of all languages will be cached (yeah, one can switch the language in the background and rebuild the page tree). I will post it the next days.

akindemirci commented 5 years ago

Here's my take on this problem which is much more simpler, yet performance-wise worse, since it parses all other language files on every request. But I prefer simplicity over a negligible drop in performance. Also, it only supports nested routes with one level depth (it can be developed further to support unlimited nesting). I placed this hook in my theme file:

use RocketTheme\Toolbox\File\MarkdownFile;

public static function getSubscribedEvents()
{
    return [
        'onPagesInitialized' => ['onPagesInitialized', 0],
    ];
}

public function onPagesInitialized()
{
    if (!$this->isAdmin()) {
        $page = $this->grav['page'];
        $languages = $this->grav['language']->getLanguages();
        $slugs = $this->getLanguageSlugs($page, $languages);
        $parent = $page->parent();
        if ($parent->header()) {
            $parent_slugs = $this->getLanguageSlugs($parent, $languages);
            foreach ($slugs as $lang => $slug) {
                $slugs[$lang] = $parent_slugs[$lang] . $slug;
            }
        }
        $page->slugs = $slugs;
    }
}

private function getLanguageSlugs($page, $languages)
{
    $active_language = $this->grav['language']->getActive();
    $active_language_file = $page->path() . '/' . $page->name();

    foreach ($languages as $language) {
        if ($language != $active_language) {
            $language_file = str_replace(".$active_language.md", ".$language.md", $active_language_file);
            $file = MarkdownFile::instance($language_file);
            $header = $file->header();
            if (isset($header['slug'])) {
                $slug = '/' . $header['slug'];
            }
            else {
                $slug = '/' . basename($page->rawRoute());
            }
            $slugs[$language] = $slug;
        }
    }

    return $slugs;
}

Then on language switcher template, the slugs can be read from page object's new "slugs" property: {% set lang_url = base_lang_url ~ page.slugs[language] ~ page.urlExtension %}

rhukster commented 2 years ago

Should be fully sorted with next 3.0 release