• Техногрет
  • Применение временных деревьев № 3 — шаблонизация однотипных строк

    HTML и CSSXSLTJavaScriptИзображенияСофтEtc
    Александр Самиляк

    30 июня 2011


    Задача.

    Разобрать по косточкам теорию и практику использования временных деревьев в XSLT.

    Представим, что нам надо сделать вот такую галерею:

    Обращаем внимание на ссылки вида «еще x фотографий и y видео» и думаем, как бы нам их собрать. Будем считать, что данные о количестве фотографий и видео у нас есть в XML:

     <galleries>
      <gallery>
        <name>Интерьеры</name>
        <preview>...</preview>
      </gallery>
      <gallery>
        <name>Экстерьеры</name>
        <preview>...</preview>
        <more photos="4" videos="2" />
      </gallery>
      <gallery>
        <name>Окрестности</name>
        <preview>...</preview>
        <more photos="34" videos="8" />
      </gallery>
    </galleries>
    

    Способ в лоб — делаем шаблон с двумя параметрами photos, videos и старательно выводим в нем каждое слово, одно за другим. Не забываем о том, что сайт может быть многоязычным, поэтому данные, зависящие от языка, лучше положить во входящий XML (пусть это будут элементы <label>) через админку. Не будем волноваться и решим, что в собираемой нами строке всегда будут и фото, и видео. Вот что должно получиться:

     <xsl:variable name="labels" select="//label" />
      
    <xsl:template name="make_more_text">
      <xsl:param name="photos" />
      <xsl:param name="videos" />
      
      <!-- еще -->
      <xsl:value-of select="$labels[@for = 'more']" /><xsl:text> </xsl:text>
      
      <!-- 34 -->
      <xsl:value-of select="$photos" /><xsl:text> </xsl:text>
      
      <!-- фотографии -->
      <xsl:value-of select="$labels[@for = 'photos']" /><xsl:text> </xsl:text>
      
      <!-- и -->
      <xsl:value-of select="$labels[@for = 'and']" /><xsl:text> </xsl:text>
      
      <!-- 8 -->
      <xsl:value-of select="$videos" /><xsl:text> </xsl:text>
      
      <!-- видео -->
      <xsl:value-of select="$labels[@for = 'video']" />
    </xsl:template>
      
      
    <xsl:template match="gallery">
      <h2>
        <xsl:value-of select="name" />
      </h2>
      ...
      <span class="pseudo more">
        <xsl:call-template name="make_more_text">
          <xsl:with-param name="photos" select="more/@photos" />
          <xsl:with-param name="videos" select="more/@videos" />
        </xsl:call-template>
      </span>
    </xsl:template>
    

    Написав это, понимаем, что оно никуда не годится. Во-первых, мы жестко захардкодили порядок слов, что плохо, ведь в другом языке порядок может быть другим, да и количество слов может быть не таким. Во-вторых, каждое слово нашей заветной фразы нужно хранить отдельным лейблом, а грязь разводить не хочется.

    Поэтому, отправив написанное в топку, чешем репу дальше. Чешем и вспоминаем, что XSL — это же шаблонизатор, черт возьми. В результате приходим к способу второму, не в лоб (который хоть и не очевиден, но известен давно). Вместо десятка отдельных лейблов нам нужно собрать один, пометив в нем тегами те места, в которые нужно вставить числа:

     <label for="more_template">еще <photos /> фотографии и <videos /> видео</label>
    

    Язык наш велик и могуч, поэтому окончания слов после числительных меняются в зависимости от числа, притом черт знает как. Эх, встретить бы того паразита, который это придумал... Короче, дорабатываем наш лейбл, добавляя еще парочку и не забывая про типографику:

     <!-- Этот XML будет меняться в админке для каждого языка на сайте -->
    <label for="more_template">
      еще
      <nobr><photos /> <photo_word /></nobr>
      <nobr>и <videos /> <video_word /></nobr>
    </label>
    <label for="photo_word_forms">
      <form>фотография</form>
      <form>фотографии</form>
      <form>фотографий</form>
    </label>
    <label for="video_word_forms">
      <form>видео</form>
      <form>видео</form>
      <form>видео</form>
    </label>
    

    Вот теперь мы защищены от любых невзгод. При такой схеме мы можем в разных языковых версиях сайта без труда менять порядок слов (скажем, написать 4 další fotky a 2 videa по-чешски) и их окончания. Для использования подходящего окончания нам нужно пропускать число с вариантами слов через отдельный шаблон (у нас он называется word_forms), который в зависимости от числа выбирает слово c нужным окончанием.


    Покончив с данными, перейдем к коду. Нам нужно отправить на рекурсивную обработку содержимое лейбла <label for="more_template">. При этом особые элементы (<photos>, <photo_word>, <videos>, <video_word>) нужно заменить на числа со словами в соответствующих падежах, а остальные элементы (<nobr>) и текстовые узлы — скопировать без изменений.

    Начнем с простого рекурсивного обхода. В первом приближении он выглядит так:

     <!-- Заведем специальный mode="html", который выводит конечную разметку -->
    <xsl:template match="*" mode="html">
      <xsl:element name="{name()}">
        <xsl:copy-of select="@*" />
        <xsl:apply-templates mode="html" />
      </xsl:element>
    </xsl:template>
    

    Осталось сделать сущие пустяки — отправить наш <label for="more_template"> на обработку с mode="html" и перехватить интересующие нас элементы, чтобы заменить их реальными данными. Попробуем:

     <xsl:template match="gallery">
      <h2>
        <xsl:value-of select="name" />
      </h2>
      ...
      <span class="pseudo more">
        <xsl:apply-templates select="$labels[@for = 'more_template']/node()" mode="html" />
      </span>
    </xsl:template>
      
    <xsl:template match="photos[ancestor::label[@for = 'more_template']]" mode="html">
      <!-- WTF? И откуда нам тут взять число фотографий? -->
    </xsl:template>
    

    Черт, очередная засада. Мы уперлись в то, что в теле последнего шаблона mode="html" мы не знаем, о какой галерее выводим сейчас информацию. То есть мы просто заматчили элемент <photos>, находящийся в лейбле, но в контексте какой галереи мы находимся, неизвестно. Как бы нам сообщить в этот шаблон, какую галерею мы сейчас обрабатываем? Первая мысль — через параметр:

     <xsl:template match="gallery">
      ...
      <xsl:apply-templates select="$labels[@for = 'more_template']/node()" mode="html">
        <xsl:with-param name="gallery" select="." />
      </xsl:apply-templates>
    </xsl:template>
      
    <xsl:template match="photos[ancestor::label[@for = 'more_template']]" mode="html">
      <xsl:param name="gallery" />
      <xsl:value-of select="$gallery/more/@photos" />
    </xsl:template>
    

    Но тут тоже есть подводный камень. Шаблон mode="html" рекурсивно обходит отправленный ему узел, поэтому наш параметр дойдет до адресата (match="photos") только в том случае, если элемент <photos> находится на первом уровне нашего лейбла и не обернут в другие элементы. А если обернут, как в нашем случае в <nobr>, то сначала сработает шаблон:

     <!-- Здесь под звездочкой в match="*" подразумеваем nobr -->
    <xsl:template match="*" mode="html">
      <xsl:element name="{name()}">
        <xsl:copy-of select="@*" />
        <xsl:apply-templates mode="html" />
      </xsl:element>
    </xsl:template>
    

    который про параметр gallery ничего не знает, поэтому детей элемента <nobr> (в том числе и наш элемент <photos>) он отправит на обработку без этого параметра.

    Но мы ведь можем усовершенствовать наш шаблон mode="html", чтобы он умел принимать какой-то абстрактный параметр и передавать его глубже:

     <xsl:template match="*" mode="html">
      <xsl:param name="params" />
      
      <xsl:element name="{name()}">
        <xsl:copy-of select="@*" />
        <xsl:apply-templates mode="html">
          <xsl:with-param name="params" select="$params" />
        </xsl:apply-templates>
      </xsl:element>
    </xsl:template>
    

    Теперь воспользуемся этим параметром params. Надо только подумать, чтó в этот params отдать, потому что вывести нам надо две вещи (количество фотографий и количество видео), а параметр только один (мы же сделали шаблон общего значения, который не может думать о том, сколько понадобится параметров в каждом частном случае — всегда найдется ситуация, при которой объявленного количества параметров будет мало).

    Пожалуй, отдадим-ка мы туда весь элемент <more> (см. исходный XML), в атрибутах которого хранятся сразу оба числа (если бы нужные нам данные были сильнее размазаны по <gallery>, мы отдали бы в параметре всю <gallery>):

     <xsl:template match="gallery">
      ...
      <xsl:apply-templates select="$labels[@for = 'more_template']/node()" mode="html">
        <xsl:with-param name="params" select="more" />
      </xsl:apply-templates>
    </xsl:template>
      
    <xsl:template match="photos[ancestor::label[@for = 'more_template']]" mode="html">
      <xsl:param name="params" />
      <xsl:value-of select="$params/@photos" />
    </xsl:template>
      
    <xsl:template match="photo_word[ancestor::label[@for = 'more_template']]" mode="html">
      <xsl:param name="params" />
      
      <xsl:call-template name="word_forms">
        <xsl:with-param name="number" select="$params/@photos" />
        <xsl:with-param name="forms" select="$labels[@for = 'photo_word_forms']/form" />
      </xsl:call-template>
    </xsl:template>
      
    <!-- И то же самое для видео -->
    ...
    

    Таким образом, этот params передается каждому узлу, обрабатываемому с mode="html" и доходит до наших элементов-адресатов, как бы глубоко они ни лежали. Доставка «ФедЭкс» просто.


    А при чем тут временные деревья? А при том, что у описанного метода есть маленький недостаток — необходимость принимать параметр params и передавать его глубже. Дело в следующем. Сейчас у нас только один шаблон mode="html", который матчит любые элементы (match="*") и просто отправляет их на вывод без изменений. Но завтра мы можем захотеть определенные HTML-элементы обрабатывать особым образом. Скажем, выводить ссылку <a>, только если она не ведет на текущую страницу (сама на себя), в противном случае показывать лишь ее текст без ссылки. Во всех таких случаях мы должны помнить, что надо принять и передать глубже параметр params:

     <xsl:template match="a" mode="html">
      <xsl:param name="params" />
      
      <xsl:choose>
        <xsl:when test="@href = $current_page_href">
          <xsl:apply-templates mode="html">
            <xsl:with-param name="params" select="$params" />
          </xsl:apply-templates>
        </xsl:when>
        <xsl:otherwise>
          <a>
            <xsl:copy-of select="@*" />
            <xsl:apply-templates mode="html">
              <xsl:with-param name="params" select="$params" />
            </xsl:apply-templates>
          </a>
        </xsl:otherwise>
      </xsl:choose>
    </xsl:template>
    

    В реальном проекте таких шаблонов обычно разводится пруд пруди. Тут надо поставить особый класс у абзаца <p>, идущего перед списком <ul> (потому что IE6, глупец, не понимает CSS-селектор p+ul). Там дизайнер выдумал ставить красивые скобки вокруг блоков-цитат, поэтому нужно заматчить все <blockquote> и снабдить дополнительной разметкой. И во всем этом пруду нужно не забывать про наш несчастный параметр params. Ведь если его не передать хотя бы в одном подобном шаблоне mode="html", то вся цепочка может прерваться и параметр не дойдет до своего получателя. Лично мне это напоминает процедурное программирование, когда все передается через параметры и не дай бог что-нибудь забыть.

    Итак, если мы передали параметр params, но он не пришел в конечный шаблон и мы не можем найти, в каком из тридцати шаблонов mode="html" прервалась цепочка, есть обходной путь. Мы можем собрать временное дерево, содержащее шаблонизируемую строку (<label for="more_template">) и данные (<more photos="…" videos="…">), которые необходимы для расчета чисел, вставляемых в шаблон:

     <xsl:template match="gallery">
      ...
      <xsl:variable name="template_with_data">
        <xsl:copy-of select="$labels[@for = 'more_template']" />
        <xsl:copy-of select="more" />
        <!-- Здесь можно не просто скопировать фрагмент входящего XML,
             а провести любой расчет -->
      </xsl:variable>
      
      <xsl:apply-templates
        select="exsl:node-set($template_with_data)/label/node()"
        mode="html"
      />
    </xsl:template>
      
    <xsl:template match="photos[ancestor::label[@for = 'more_template']]" mode="html">
      <xsl:value-of select="/more/@photos" />
    </xsl:template>
    

    Мы собрали вместе лейбл с шаблонной строкой и данные, которые мы вставляем в эту шаблонную строку. Теперь они находятся в одном временном дереве, и кроме них в этом дереве больше ничего нет. Выглядит оно примерно так:

    Дальше — самое интересное. Когда мы матчим элемент <photos> в глубине этого временного дерева (обведен красным), мы с помощью XPath "/more/@photos" поднимаемся вверх до корня дерева, заходим в элемент <more> первого уровня и забираем из него нужные нам данные.

    Таким образом, мы передали контекст исполнения в шаблон <xsl:template match="photos[…]" mode="html"> не параметром params, а тем деревом, в котором сейчас находится элемент <photos>. Для каждой галереи это дерево будет своим.

    При таком подходе params мы не используем, и нам не страшно, что он где-то может потеряться.


    В этом примере хочу обратить внимание читателя на важную вещь. Оператор / в начале XPath-а возвращает корень того дерева, в котором находится контекстный узел (в контексте которого был вызван оператор /). Обычно это входящее дерево, но может быть и временное, как в нашем случае внутри шаблона <xsl:template match="photos[…]" mode="html">.

    Если мы, находясь в контексте временного дерева, хотим получить данные из входящего, то нужно за пределами временного дерева (лучше всего где-нибудь на первом уровне XSL-шаблона) предварительно создать переменную, указывающую на корень входящего дерева:

     <xsl:variable name="input_root" select="/" />
    

    Далее из контекста временного дерева обращаться к входящему можно по $input_root.


    И последнее. Если мы отправляем на обработку все временное дерево целиком:

     <xsl:apply-templates select="exsl:node-set($var)" mode="process" />
    

    то матчить надо именно корень:

     <xsl:template match="/" mode="process">
      ...
    </xsl:template>
    

    В очередной части я расскажу про следующее применение временных деревьев — подстройку под готовый шаблон.

    1
    2
    3
    4
    5
    6
    7
    8
    9