Применение временных деревьев № 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