Jakiś czas temu, w trakcie prac nad serwisem webologia.pl postanowiłem przejść z HTML 4.01 Transitional na XHTML 1.0 Strict. Początek pracy był prosty – pozmieniałem literki na małe w nazwach znaczników i atrybutach, pozamykałem znaczniki i przeniosłem elementy prezentacyjne do arkusza CSS. Po wykonaniu tego strona przeszła test poprawności składniowej na walidatorze W3C, ale nadal wymagała jeszcze poprawek ze względu na różnice w interpretacji kodu przez przeglądarki, głównie przez MSIE, który nawet w wersji 7 wciąż nie jest w pełni zgodny ze standardami. Ostatecznie strona jednak zaczęła sensownie wyglądać pod wszystkimi przeglądarkami, więc postanowiłem wykonać kolejny krok, czyli zmienić typ MIME.

Dokumenty XHTML 1.0 powinny być wysyłane z użyciem typu MIME application/xhtml+xml. Jak zwykle jednak przeglądarka Microsoftu sprawiała problemy – zamiast wyświetlić stronę proponowała zapisanie pliku na dysku. Na szczęście dzięki mechanizmowi negocjacji zawartości (ang. Content Negotiation) można to łatwo obejść. W Internecie można znaleźć wiele gotowych skryptów – ja wykorzystałem ten znajdujący się na stronie Content Negotiation – raz a dobrze. Po jego zastosowaniu strona zmieniła jednak swój wygląd – jak się okazało, zniknęły części strony generowane przez skrypty JavaScript. W szczególności zniknęły reklamy Adsense, poznikały też przykłady z Kursu JavaScript. Po krótkim śledztwie znalazłem przyczynę – zgodnie z zaleceniami skrypty miałem umieszczone wewnątrz komentarzy HTML. W przypadku HTML 4.01 działało to poprawnie, w przypadku XHTML 1.0 już nie – przeglądarka traktowała skrypty jako komentarz, i po prostu ignorowała je. Najprostszym rozwiązaniem było umieszczenie całego skryptu w sekcji CDATA. Ponieważ mogło to sprawić problemy przeglądarkom które nie wspierają XHTML, więc w zależności od tego czy strona jest wysyłana jako application/xhtml+xml czy text/html dałem wysyłanie odpowiednich dodatkowych znaczników:

<?php
if ($xhtml)
    print "<script type=\"text/javascript\"><![CDATA[\n";
else
    print "<script type=\"text/javascript\"><!--\n";
?>
// Tutaj jest ciało skryptu
<?php
if ($xhtml)
    print "//--></script>\n";
else
    print "]]></script>\n";
?>

Alternatywnym rozwiązaniem byłoby przeniesienie skryptów do zewnętrznych plików i dołączenie ich do strony, ale uznałem że w moim przypadku albo tego nie mogę zastosować (Adsense), albo nie warto (krótkie skrypty JavaScript umieszczone w Kursie JavaScript.

Po zastosowaniu powyższego fragmentu kodu część skryptów JavaScript zaczęła działać. Niestety nadal nie działały te które korzystały z document.write(), w szczególności skrypty Adsense. Problem okazał się bardziej poważny – ta funkcja po prostu nie jest dostępna w dokumentach XHTML wysyłanych jako application/xhtml+xml. W sumie ma to sens – do modyfikacji struktury dokumentu powinny być używane funkcje operujące na drzewie DOM, gdyż użycie funkcji document.write() może łatwo uczynić dokument niepoprawnym. Na szczęście udało mi się znaleźć rozwiązanie – John Resig umieścił na swoim blogu artykuł zatytułowany XHTML, document.write, and Adsense. W artykule tym opisał że problem ten można rozwiązać korzystając z właściwości innerHTML. Samo rozwiązanie jest trochę bardziej skomplikowane, gdyż w grę wchodzą tutaj różnice w działaniu różnych przeglądarek. Na szczęście na wspomnianej stronie John umieścił także gotowy skrypt JavaScript. Skopiowałem go do siebie i naniosłem poprawki zasugerowane przez inne osoby w komentarzach do tamtego artykułu. Po dołączeniu tego skryptu do strony reklamy Adsense ponownie się pojawiły. Wciąż miałem jednak problem z niektórymi przykładowymi skryptami JavaScript które umieściłem, więc dokonałem dodatkowych modyfikacji tego skryptu tak aby one także zaczęły działać. Gotowy skrypt z wszystkimi modyfikacjami wygląda następująco:

document.write = function(str){
    // convert str to string
    str += "";
    
    var moz = !window.opera && !/Apple/.test(navigator.vendor);
    
    // Watch for writing out closing tags, we just
    // ignore these (as we auto-generate our own)
    if ( str.match(/^<\//) ) return;
 
    // Make sure & are formatted properly, but Opera
    // messes this up and just ignores it
    if ( !window.opera )
        str = str.replace(/&(?![#a-z0-9]+;)/g, "&amp;");
 
    // Watch for when no closing tag is provided
    // (Only does one element, quite weak)
    // Skip already closed elements
    if (!str.match(/^<([a-z0-9]+).*<\/\1>$/))
        str = str.replace(/<([a-z]+)(.*[^\/])>$/, "<$1$2></$1>");
    
    // Mozilla assumes that everything in XHTML innerHTML
    // is actually XHTML - Opera and Safari assume that it's XML
    if ( !moz )
        str = str.replace(/(<[a-z]+)/g, "$1 xmlns='http://www.w3.org/1999/xhtml'");
     
    // The HTML needs to be within a XHTML element
    //var div = document.createElementNS("http://www.w3.org/1999/xhtml","div");
    if(document.createElementNS){
        var div = document.createElementNS("http://www.w3.org/1999/xhtml", "div")
    } else {
        var div = document.createElement("div");
        div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml");
    }    
    div.innerHTML = str;
    
    // Find the last element in the document
    var pos;
    
    // Opera and Safari treat getElementsByTagName("*") accurately
    // always including the last element on the page
    if ( !moz ) {
        pos = document.getElementsByTagName("*");
        pos = pos[pos.length - 1];
        
    // Mozilla does not, we have to traverse manually
    } else {
        pos = document;
        while ( pos.lastChild && pos.lastChild.nodeType == 1 )
            pos = pos.lastChild;
    }
    
    // Add all the nodes in that position
    var nodes = div.childNodes;
    while ( nodes.length )
        pos.parentNode.appendChild( nodes[0] );
};

Gotowy skrypt najlepiej jest umieścić w zewnętrznym pliku i następnie dołączyć do wysyłanego dokumentu jeżeli jest wysyłany jako application/xhtml+xml:

if ($xhtml)
    print "<script type=\"text/javascript\" src=\"doc_write.js\"></script>";

Zaprezentowane powyżej rozwiązanie jest to pewna “proteza” która pozwoli na uruchomienie niektórych skryptów JavaScript korzystających z document.write(). Nie jest to rozwiązanie idealne – fragment kodu który ma być wypisany powinien być poprawnym fragmentem kodu HTML. Dodatkowo powinien on jak najbardziej przypominać XHTML – chodzi głównie o wielkość znaków w nazwach znaczników i atrybutów, zamykanie tych znaczników które trzeba (oraz także można) zamknąć, oraz otaczanie wartości atrybutów cudzysłowami lub apostrofami. Dodatkowo fragment ten powinien dać się łatwo przetworzyć w poprawny fragment drzewa XML, zatem odpada przyrostowe wypisywanie fragmentów dokumentu HTML – tego typu skrypty nie będą działać. Dlatego też najlepszym rozwiązaniem byłoby zmodyfikowanie istniejących skryptów tak aby nie korzystały z document.write().

W przypadku gdy jednak nie ma wyjścia i trzeba użyć skryptu który wypisuje kod HTML “po kawałku”, lub też np. zawiera znaczniki pisane dużymi literami, trzeba zastosować dodatkowe rozwiązanie – na czas działania tamtego skryptu trzeba dostarczyć funkcję document.write() która zapisze wypisywany ciąg do zmiennej, i na koniec ją podmienić z powrotem na oryginalną:

<script type="text/javascript"><![CDATA[
var str = "";
orig_doc_write = document.write;
document.write = function(s) { str += s; }
]]></script>
 
<!-- tutaj jest skrypt sprawiajacy problemy -->
 
<script type="text/javascript"><![CDATA[
document.write = orig_doc_write;
document.write(str);
]]></script>

W powyższym przykładzie wszystkie wywołania document.write() pochodzące z zewnętrznego skryptu spowodują tylko dopisanie wartości przekazanego parametru do zmiennej str. W momencie gdy tamten skrypt zakończy działanie, cały fragment kodu HTML do wypisania jest w tej zmiennej, i można go już wypisać. W tym miejscu można go także poddać dalszej obróbce z wykorzystaniem wyrażeń regularnych, np. zamienić wielkość znaków w nazwach atrybutów na małe.

Oczywiście zaprezentowane tutaj na końcu rozwiązanie powinno być traktowane jako rozwiązanie awaryjne, które można zastosować w sytuacji gdy nie ma już innej możliwości poradzenia sobie z problemem. W każdej innej sytuacji warto natomiast zastosować inne rozwiązania, które są “przyjazne” XHTML.