Xpoint
   [напомнить пароль]

Меню для сайта средствами XML/XSL

Оглавление

Основой для данной статьи послужил вот этот туториал, который первоначально размещался здесь. Однако, разврернувшаяся затем дискуссия вскрыла множество связанных с этим вопросом проблем, что побудило меня полностью переработать этот материал.

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


Постановка задачи.

Общее описание проблемы.

На практике часто встречается необходимость создания для сайта иерархических меню. При этом XML — наиболее очевидная и простая форма для хранения этого меню. Соответственно, задача состоит в том, чтобы перевести его в HTML, с соблюдением следующих очевидных условий:

  • текущий раздел должен быть выделен, все остальные — представлены как ссылки на соответствующие страницы
  • текущее поддерево должно быть развернуто, все остальные, по умолчанию — свернуты.

Иными словами — пользователь должен видеть примерно то, к чему он привык при работе с операционной системой.

winmenu

А наша задача — разработать связку XML/XSL, выдающую на выходе список, который с помощью CSS можно было бы привести к подобному виду.

Необходимые замечания.

Сама по себе задача относительно проста. Единственная нетривиальное место — как правильно развернуть всех предков.

Однако, поставлена она, вообще говоря, не совсем корректно. Существует очень много разных движков, в той или иной степени использующих XSL. И в зависимости от подходов, которые используются в том или ином движке оптимальное решение может отличаться.

В принципе, это может показаться странным — XSLT позицируется, как кроссплатформенное средство, позволяющее раз и навсегда забыть о зависимости от того или иного языка. И это, в основном, верно (хотя и тут есть свои сложности).

Но дело тут не в этом, а в разнице подходов к организации работы самой связки XML/XSL. У Дмитрия Котерова[досье] есть замечательная статья, в которой рассматриваются разные способы разделения кода, шаблона и содержимого страниц. И хотя XSL там не рассматривается, очень многое из того, что там говорится, применимо и в этом случае.

Что у нас ведёт в этой паре — XML или XSL? Используется ли единообразный XML-формат для всего сайта, или там ворох разнородных форматов, которые нужно преобразовать к единообразному HTML, "обернутому" в дизайн? У нас для разных разделов используются разные преобразования, которые наследуются от общего корня, или существует единый для всего сайта набор преобразований, а разница между разделами отображена в XML?

Для разных подходов решения будут различны... И, строго говоря, прежде, чем решать эту конкретную задачу, нам следовало бы рассмотреть более общий вопрос: достоинства и недостатки тех или иных способов построения движка на XSLT. Возможно, этому стоило бы посвятить отдельную статью.

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

Конкретизация задачи.

Чтобы не ходить далеко, в качестве примера, который мы будем рассматривать, возьмем сильно урезанный список форумов xpoint.ru:

Для определенности предположим, что мы сейчас находимся в разделе XML. Тогда список должен выглядеть примерно так:

sitemenu

Иными словами, для данного конкретного случая на выходе мы должны получить примерно такой HTML:

<ul class="menu">
  <li class="off"><a href="/programming/">Программирование</a><ul>
    <li><a href="/programming/perl/">Perl</a></li>
    <li><a href="/programming/javascript/">JavaScript</a></li>
    <li><a href="/programming/PHP/">PHP</a></li>
    <li><a href="/programming/java/">Java</a></li>
  </ul></li>
  <li class="on"><a href="/internet/">Интернет</a><ul>
    <li><a href="/internet/html_css/">HTML</a></li>
    <li>XML</li>
    <li><a href="/internet/theory/">Теория</a></li>
  </ul></li>
  <li><a href="/misc/">Прочее</a></li>
</ul>

Задачу написания CSS, который превратил его бы его в то, что показано на рисунке, а также JS, который позволил бы открывать и закрывать разделы, я оставляю читателям в качестве самостоятельного упражнения.

В то время как в исходном XML может содержатся, например, такой текст:

<menu>
  <group><head href="/programming/">Программирование</head>
    <item href="/programming/perl/">Perl</item>
    <item href="/programming/javascript/">JavaScript</item>
    <item href="/programming/PHP/">PHP</item>
    <item href="/programming/java/">Java</item>
  </group>
  <group><head href="/internet/">Интернет</head>
    <item href="/internet/html_css/">HTML</item>
    <item href="/internet/XML/">XML</item>
    <item href="/internet/theory/">Теория</item>
  </group>
  <item href="/misc/">Прочее</item>
</menu>

Иными словами — требуется правильно расставить классы и ссылки, информация о которых в исходном XML отсутствует.

У некоторых начинающих программистов возникает соблазн внедрить недостающую информацию непосредственно в само XML-описание меню, выделев текущий пункт и всех его предков прямо в нем. В самых тяжелых случаях для каждой страницы вообще пишется свой вариант меню (думаю, не нужно объяснять, к чему это ведет). Те, кто немного поумнее, пытаются сгенерить нужный XML динамически, что тоже совершенно не оправдано.
Подобные «решения» мы даже не будем рассматривать...

Единственным источником информации о текущей странице будем считать ее URI. Для определенности, предположим, что он передается в XSL через параметр $request-uri.

Строго говоря, в некоторых случаях мы не сможем его передать. Или — будем вынуждены это делать не через xsl:param, a каким-либо другим способом. Но об этом — несколько позже.

Решение в общем виде.

Итак, нам надо определить, соответствует ли текущей странице некоторый элемент меню, либо какой-либо из его потомков.

Проверка текущего элемента.

Первая задача решается элементарно. Если для общего случая head|item мы имеем

<xsl:template match="*" mode='menu-check'> 
   ...
</xsl:template>

то текущей странице будет соответствовать шаблон

<xsl:template match="*[@href = $request-uri]" mode='menu-check'> 
   ...
</xsl:template>

У вышеприведенного кода одна проблема — он невалиден. И, хотя значительная часть процессоров его понимает, рекомендовать его к применению в таком виде было бы безответсвенно. Это, конечно, не повод отказыватся от подобных конструкций в пользу, скажем, xsl:choose. Это повод подумать, чем можно заменить переменную в этом контексте.

Но об этом речь пойдет в следующем разделе. Пока будем считать, что наш процессор такие конструкции — а они нам еще пригодятся — понимает нормально.

Проверка потомков

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

Однако, можно сделать все гораздо проще. Определим ключ, ссылающийся на предков нужного элемента:

<xsl:key name='menu-curr' match="*[@href = $request-uri]" use='ancestor::group / head / @href'/>

Теперь задача стала совсем простой — достаточно проверить, ссылается ли ключ на нужную группу:

<xsl:template match='group' mode='menu'>
  <xsl:variable name='class'>
    <xsl:choose>
      <xsl:when test="key('menu-curr', head / @href)">on</xsl:when>
      <xsl:otherwise>off</xsl:otherwise>
    </xsl:choose>
  </xsl:variable>
  
  <li class='{$class}'>
    ...

Результат.

С учетом всего вышесказанного, мы могли бы построить следующее преобразование:

<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet version="1.0"
  exclude-result-prefixes="xsl"
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 >

<xsl:key name='menu-curr' match="*[@href = $request-uri]" use='ancestor::group / head / @href'/>

<xsl:template match='menu' mode='menu'>
  <ul class='menu'>
    <xsl:apply-templates mode='menu'/>
  </ul>
</xsl:template>

<xsl:template match='group' mode='menu'>
  <xsl:variable name='class'>
    <xsl:choose>
      <xsl:when test="key('menu-curr', head / @href)">on</xsl:when>
      <xsl:otherwise>off</xsl:otherwise>
    </xsl:choose>
  </xsl:variable>
  
  <li class='{$class}'>
    <xsl:apply-templates select='head' mode='menu-check'/>
    <ul>
      <xsl:apply-templates mode='menu'/>
    </ul>
  </li>
</xsl:template>

<xsl:template match="head" mode="menu"/>

<xsl:template match="item" mode="menu">
  <li>
    <xsl:apply-templates select="." mode="menu-check"/>
  </li>
</xsl:template>


<xsl:template match="*" mode='menu-check'>
  <a href="{@href}">
    <xsl:value-of select="."/>
  </a>
</xsl:template>

<xsl:template match="*[@href = $request-uri]" mode='menu-check'>
  <xsl:value-of select="."/>
</xsl:template>

</xsl:stylesheet>

И подключать его примерно так:

<xsl:stylesheet version="1.0"
  exclude-result-prefixes="xsl"
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 >
<xsl:import href='menu.xsl'/>

<xsl:param name='request-uri' select="'/'"/>
<xsl:variable name='menu' select="document('menu.xml')"/>

...

<xsl:template match='/*'>
  .....
  <xsl:apply-templates select='$menu' mode='menu'/>

И теперь нас ждут сюрпризы, потому что работать это будет далеко не везде.

Частные вопросы.

Передача URI и переменные в паттернах

Итак, наш код имеет две проблемы:

Во-первых, мы предположили, что всегда можем передать URI через xsl:param. Однако в некоторых случаях — например, если XSL задается через директиву <?xml-stylesheet ...?> — это становится проблематичным.

Во-вторых — даже если мы спокойно можем передать параметры нашему шаблону, исползовать их в правилах шаблона и ключах мы не имеем права. И тот факт, что Xalan и Transformiix понимают подобные выражения правильно, а Saxon вообще понимает XSLT 2.0, где это разрешено вполне оффициально, нас не спасет, если мы работаем в msxml или libxslt.

В последних версиях libxslt поддержка переменных в паттернах появилась, но по умолчанию она отключена.

Есть разные способы обхода этой проблемы. Мы могли бы, например, использовать отличные от URI уникальные идентификаторы сопоставленные с каждым XML файлом. Этот подход имеет свои плюсы и минусы, и может использоваться не только для преодоления данной проблемы, так что поговорим о нем позже.
Строго говоря здесь он, как раз, подходит лишь частично — используя этот метод для устранения данной проблемы мы будем вынуждены отказаться от использования document() (см. ниже).

Динамические DTD

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

Очевидно, что в процессе преобразования может понадобится не только URI. Есть переменные окружения, данные форм, наконец, есть такие вещи, как, скажем, сегодняшнее число. В ряде случаев к этим величины было бы полезно не только иметь доступ из XSL — неплохо было бы вставить их непосредственно в исходный XML. Однако делать XML динамическим чтобы вставить одно такое значение в большой массив статичного текста — совершенно неоправданно.

Выход прост — использовать для таких значений именованные сущности XML (entities), которые будут определены в динамически генерируемом DTD. Затраты на его создание будут относительно малы, а значения, в нем определенные, могут легко использоваться как внутри исходного XML, так и в процессе XSL-преобразования.

С использованием этого механизма решение задачи элементарно:

<!DOCTYPE xsl:stylesheet SYSTEM "myuri://autodtd.php"> 
<!-- DTD генерится динамически -->

<xsl:stylesheet version="1.0"
  exclude-result-prefixes="xsl"
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 >

<xsl:key name='menu-curr' match="*[@href = '&uri;']" use='ancestor::group / head / @href'/>
<!-- в &uri; — ссылка на текущую страницу в том же формате, как в menu.xml -->

....

В этом примере предполагается, что для динамической генерации DTD на PHP используется stream, с его возможностью callback-вызовов из libxml.

Использование идентификаторов

Как уже отмечалось выше, можно подойти к проблеме более радикально и вообще отказаться от использования URI в качестве идентификаторов. Вместо этого уникальный идентификатор присваивается корневому элементу XML, над которым осуществляется преобразование. Тогда, пока мы не переключили контекст при помощи document(), он будет нам доспупен как /*/@id (* — потому что мы, в общем случае, не знаем, какой у этого XML корневой элемент). В общем случае целесообразно определить в головном XSL переменную $id:

<xsl:variable name='id' select='/* / @id'/>

И в меню, наряду с адресами страниц, используются эти id:

<item href="/internet/XML/" idref="xml">XML</item>

XSL же будет отличаться от приведенного выше лишь тем, что вместо сравнения @href = $request-uri используется @idref = $id, и, соответственно, @idref используется при генерации ключей.

Теперь, полагаю, понятно, почему это не самый лучший выход для преодоления бага libxslt: если мы пользуемся document(), мы все равно вынуждены использовать переменные. Можно, конечно, включить меню в основной XML (и явно использовать @idref = /*/@id) — но тогда могут возникнуть другие проблемы... Впрочем, об этом — позже.

Меню, устойчивое к изменению адресов страниц

Вообще, сама по себе идея ввести над URI дополнительный слой абстракции довольно интересна. Но тут появляется проблема синхронизации между этими двумя слоями. Действительно, изменив идентификатор или URI, мы должны внести исправления везде, где у нас используются предположения об их связи. В том числе — и в наше меню.
Строго говоря, проблема возникала и раньше — при изменении адреса страницы мы были вынуждены вручную менять ссылку в menu.xml.

Однако, если мы автоматизируем этот процесс, мы можем избавиться от необходимости следить за сменой адресов раз и навсегда. Действительно, каждая страница сайта имеет адрес. И каждая страница сайта имеет id. Значит мы можем создать таблицу соответствия одного другому. И можем написать робота, который будет периодически запускаться (например по cron) и обновлять эту таблицу.

Дальнейшее элементарно. Пусть таблица имеет примерно такой вид:

<cite>
  <page ref='prog'>/programming/</page>
  <page ref='perl'>/programming/perl/</page>
  <page ref='js'>/programming/javascript/</page>
  ... и т.д.

Тогда нам достаточно всего лишь изменить наш XSL следующим образом:

<xsl:template match="*" mode='menu-check'>
  <a href="{document('citepages.xml') / cite / page[@ref = current() / @idref]}">
    <xsl:value-of select="."/>
  </a>
</xsl:template>

чтобы навсегда забыть о необходимости следить за адресами страниц. Атрибут @href в menu.xml больше не нужен, и от него следует избавиться, так как он теперь только сбивает с толку.

XInclude vs. document

Однако, как уже отмечалось выше, из-за запрета использования переменных в паттернах и ключах, мы не имеем права использовать идентификаторы в нашем решении до тех пор, пока меню подключается при помощи ф-ции document. Посмотрим, какие тут есть альтернативы.

Самое очевидное решение для этого случая — использование XInclude, поскольку libxml это позволяет. В простейшем случае мы просто пишем где-то в основном XML

<xi:include href="menu.xml" xmlns:xi="http://www.w3.org/2001/XInclude"/>

обеспечивая тем самым добавление содержимого menu.xml в основное дерево. Но тут имеются ряд подводных камней, на которые при таком подходе очень легко напороться.

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

<xsl:stylesheet version="1.0"
  exclude-result-prefixes="xsl m"
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:m="urn:menu"
  xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 >

<xsl:key name='menu-curr' match="m:*[@ref = /* / @id]" use='ancestor::m:group / m:head / @ref'/>
...

Во-вторых, что, если для некоторого раздела мы захотим сделать отдельное меню? В случае с document решение элементарно — мы определяем для него отдельный XSL, определяем в нем переменную $menu с нужным значением, а затем ипортируем в него основной XSL, из которого происходит вызов шаблонов для меню. Согласно правилам импорта, наша переменная заменяет собой переменную $menu из импортированного шаблона, а значит все нужные преобразования выполнятся для того XML, который нужен нам.

А что мы будем делать, если меню подключено через xi:include? Правильно, нам придется заменить его у всех страниц раздела.

Конечно, можно это обойти. Например, создать файл с метаданными раздела, все такого рода вещи подключать уже из него, а к конкретным страницам подключать только его. Но и здесь трудно избежать дублирования, потому что метаданные разных разделов будут перекрываться, а метаданные дочерних разделов зачастую — повторять родительские. Значит, имеет смысл подумать о наследовании в стиле .htaccess. Удастся ли здесь обойтись XInclude, или придется генерить метаданные динамически? Для разных проектов ответ может отличаться... А в XSL это наследование реализовалось бы просто и естественно — через xsl:import.

Как видим, от создания меню мы ушли уже очень далеко. Тема, которая здесь затронута, гораздо более общая — разные способы организации самого XML/XSL движка, их достоинства и недостатки.
 
Но об этом — как-нибудь в другой раз.

Powered by POEM™ Engine Copyright © 2002-2005