第十六章:範例:產生 HTML

本章的目標是完成一個簡單的 HTML 產生器 —— 這個程式可以自動生成一系列包含超文字連結的網頁。除了介紹特定 Lisp 技術之外,本章還是一個典型的自底向上編程(bottom-up programming)的例子。 我們以一些通用 HTML 實用函數作爲開始,繼而將這些例程看作是一門編程語言,從而更好地編寫這個產生器。

16.1 超文字標記語言 (HTML)

HTML (HyperText Markup Language,超文字標記語言)用於構建網頁,是一種簡單、易學的語言。本節就對這種語言作概括性介紹。

當你使用網頁瀏覽器閱覽網頁時,瀏覽器從遠程服務器獲取 HTML 檔案,並將它們顯示在你的屏幕上。每個 HTML 檔案都包含任意多個標籤(tag),這些標籤相當於發送給瀏覽器的指令。

../_images/Figure-16.11.png

圖 16.1 一個 HTML 檔案

圖 16.1 給出了一個簡單的 HTML 檔案,圖 16.2 展示了這個 HTML 檔案在瀏覽器裡顯示時大概是什麼樣子。

../_images/Figure-16.21.png

圖 16.2 一個網頁

注意在三角符號之間的文字並沒有被顯示出來,這些用三角符號包圍的文字就是標籤。 HTML 的標籤分爲兩種,一種是成雙成對地出現的:

<tag>...</tag>

第一個標籤標誌著某種情景(environment)的開始,而第二個標籤標誌著這種情景的結束。 這種標籤的一個例子是 <h2> :所有被 <h2></h2> 包圍的文字,都會使用比平常字體尺寸稍大的字體來顯示。

另外一些成雙成對出現的標籤包括:創建帶編號列表的 <ol> 標籤(ol 代表 ordered list,有序表),令文字居中的 <center> 標籤,以及創建連結的 <a> 標籤(a 代表 anchor,錨點)。

<a></a> 包圍的文字就是超文字(hypertext)。 在大多數瀏覽器上,超文字都會以一種與衆不同的方式被凸顯出來 —— 它們通常會帶有下劃線 —— 並且點擊這些文字會讓瀏覽器跳轉到另一個頁面。 在標籤 a 之後的部分,指示了連結被點擊時,瀏覽器應該跳轉到的位置。

一個像

<a href="foo.html">

這樣的標籤,就標識了一個指向另一個 HTML 檔案的連結,其中這個 HTML 檔案和當前網頁的檔案夾相同。 當點擊這個連結時,瀏覽器就會獲取並顯示 foo.html 這個檔案。

當然,連結並不一定都要指向相同檔案夾下的 HTML 檔案,實際上,一個連結可以指向互聯網的任何一個檔案。

和成雙成對出現的標籤相反,另一種標籤沒有結束標記。 在圖 16.1 裡有一些這樣的標籤,包括:創建一個新文字行的 <br> 標籤(br 代表 break ,斷行),以及在列表情景中,創建一個新列表項的 <li> 標籤(li 代表 list item ,列表項)。

HTML 還有不少其他的標籤,但是本章要用到的標籤,基本都包含在圖 16.1 裡了。

16.2 HTML 實用函數 (HTML Utilities)

(defmacro as (tag content)
  `(format t "<~(~A~)>~A</~(~A~)>"
           ',tag ,content ',tag))

(defmacro with (tag &rest body)
  `(progn
     (format t "~&<~(~A~)>~%" ',tag)
     ,@body
     (format t "~&</~(~A~)>~%" ',tag)))

(defmacro brs (&optional (n 1))
  (fresh-line)
  (dotimes (i n)
    (princ "<br>"))
  (terpri))

圖 16.3 標籤生成例程

本節會定義一些生成 HTML 的例程。 圖 16.3 包含了三個基本的、生成標籤的例程。 所有例程都將它們的輸出發送到 *standard-output* ;可以通過重新綁定這個變數,將輸出重定向到一個檔案。

aswith 都用於在標籤之間生成表達式。其中 as 接受一個字元串,並將它打印在兩個標籤之間:

> (as center "The Missing Lambda")
<center>The Missing Lambda</center>
NIL

with 則接受一個程式碼主體,並將它放置在兩個標籤之間:

> (with center
    (princ "The Unbalanced Parenthesis"))
<center>
The Unbalanced Parenthesis
</center>
NIL

兩個宏都使用了 ~(...~) 來進行格式化,從而創建包含小寫字母的標籤。 HTML 並不介意標籤是大寫還是小寫,但是在包含許許多多標籤的 HTML 檔案中,小寫字母的標籤可讀性更好一些。

除此之外, as 傾向於將所有輸出都放在同一行,而 with 則將標籤和內容都放在不同的行裡。 (使用 ~& 來進行格式化,以確保輸出從一個新行中開始。) 以上這些工作都只是爲了讓 HTML 更具可讀性,實際上,標籤之外的空白並不影響頁面的顯示方式。

圖 16.3 中的最後一個例程 brs 用於創建多個文字行。 在很多瀏覽器中,這個例程都可以用於控制垂直間距。

(defun html-file (base)
  (format nil "~(~A~).html" base))

(defmacro page (name title &rest body)
  (let ((ti (gensym)))
    `(with-open-file (*standard-output*
                      (html-file ,name)
                      :direction :output
                      :if-exists :supersede)
       (let ((,ti ,title))
         (as title ,ti)
         (with center
           (as h2 (string-upcase ,ti)))
         (brs 3)
         ,@body))))

圖 16.4 HTML 檔案生成例程

圖 16.4 包含用於生成 HTML 檔案的例程。 第一個函數根據給定的符號(symbol)返回一個檔案名。 在一個實際應用中,這個函數可能會返回指向某個特定檔案夾的路徑(path)。 目前來說,這個函數只是簡單地將 .html 後綴追加到給定符號名的後邊。

page 負責生成整個頁面,它的實現和 with-open-file 很相似: body 中的表達式會被求值,求值的結果通過 *standard-output* 所綁定的流,最終被寫入到相應的 HTML 檔案中。

6.7 小節示範了如何臨時性地綁定一個特殊變數。 在 113 頁的例子中,我們在 let 的體內將 *print-base* 綁定爲 16 。 這一次,通過將 *standard-output* 和一個指向 HTML 檔案的流綁定,只要我們在 page 的函數體內呼叫 as 或者 princ ,輸出就會被傳送到 HTML 檔案裡。

page 宏的輸出先在頂部打印 title ,接著打印 body 部分的輸出。

如果我們呼叫

(page 'paren "The Unbalanced Parenthesis"
  (princ "Something in his expression told her..."))

這會產生一個名爲 paren.html 的檔案(檔案名由 html-file 函數生成),檔案中的內容爲:

<title>The Unbalanced Parenthesis</title>
<center>
<h2>THE UNBALANCED PARENTHESIS</h2>
</center>
<br><br><br>
Something in his expression told her...

除了 title 標籤以外,以上輸出的所有 HTML 標簽在前面已經見到過了。 被 <title> 標籤包圍的文字並不顯示在網頁之內,它們會顯示在瀏覽器窗口,用作頁面的標題。

(defmacro with-link (dest &rest body)
  `(progn
     (format t "<a href=\"~A\">" (html-file ,dest))
     ,@body
     (princ "</a>")))

(defun link-item (dest text)
  (princ "<li>")
  (with-link dest
    (princ text)))

(defun button (dest text)
  (princ "[ ")
  (with-link dest
    (princ text))
  (format t " ]~%"))

圖 16.5 生成連結的例程

圖片 16.5 給出了用於生成連結的例程。 with-linkwith 很相似:它根據給定的網址 dest ,創建一個指向 HTML 檔案的連結。 而連結內部的文字,則通過求值 body 參數中的程式碼段得出:

> (with-link 'capture
    (princ "The Captured Variable"))
<a href="capture.html">The Captured Variable</a>
"</a>"

with-link 也被用在 link-item 當中,這個函數接受一個字元串,並創建一個帶連結的列表項:

> (link-item 'bq "Backquote!")
<li><a href="bq.html">Backquote!</a>
"</a>"

最後, button 也使用了 with-link ,從而創建一個被方括號包圍的連結:

> (button 'help "Help")
[ <a href="help.html">Help</a> ]
NIL

16.3 迭代式實用函數 (An Iteration Utility)

在這一節,我們先暫停一下編寫 HTML 產生器的工作,轉到編寫迭代式例程的工作上來。

你可能會問,怎樣才能知道,什麼時候應該編寫主程式,什麼時候又應該編寫子例程?

實際上,這個問題,沒有答案。

通常情況下,你總是先開始寫一個程式,然後發現需要寫一個新的例程,於是你轉而去編寫新例程,完成它,接著再回過頭去編寫原來的程式。 時間關係,要在這裡示範這個開始-完成-又再開始的過程是不太可能的,這裡只展示這個迭代式例程的最終形態,需要注意的是,這個程式的編寫並不如想象中的那麼簡單。 程式通常需要經歷多次重寫,才會變得簡單。

(defun map3 (fn lst)
  (labels ((rec (curr prev next left)
             (funcall fn curr prev next)
             (when left
               (rec (car left)
                    curr
                    (cadr left)
                    (cdr left)))))
    (when lst
      (rec (car lst) nil (cadr lst) (cdr lst)))))

圖 16.6 對樹進行迭代

圖 16.6 裡定義的新例程是 mapc 的一個變種。它接受一個函數和一個列表作爲參數,對於傳入列表中的每個元素,它都會用三個參數來呼叫傳入函數,分別是元素本身,前一個元素,以及後一個元素。(當沒有前一個元素或者後一個元素時,使用 nil 代替。)

> (map3 #'(lambda (&rest args) (princ args))
        '(a b c d))
(A NIL B) (B A C) (C B D) (D C NIL)
NIL

mapc 一樣, map3 總是返回 nil 作爲函數的返回值。需要這類例程的情況非常多。在下一個小節就會看到,這個例程是如何讓每個頁面都實現“前進一頁”和“後退一頁”功能的。

map3 的一個常見功能是,在列表的兩個相鄰元素之間進行某些處理:

> (map3 #'(lambda (c p n)
            (princ c)
            (if n (princ " | ")))
        '(a b c d))
A | B | C | D
NIL

程式設計師經常會遇到上面的這類問題,但只要花些功夫,定義一些例程來處理它們,就能爲後續工作節省不少時間。

16.4 生成頁面 (Generating Pages)

一本書可以有任意數量的大章,每個大章又有任意數量的小節,而每個小節又有任意數量的分節,整本書的結構呈現出一棵樹的形狀。

儘管網頁使用的術語和書本不同,但多個網頁同樣可以被組織成樹狀。

本節要構建的是這樣一個程式,它生成多個網頁,這些網頁帶有以下結構: 第一頁是一個目錄,目錄中的連結指向各個節點(section)頁面。 每個節點包含一些指向(item)的連結。 而一個項就是一個包含純文字的頁面。

除了頁面本身的連結以外,根據頁面在樹狀結構中的位置,每個頁面都會帶有前進、後退和向上的連結。 其中,前進和後退連結用於在同級(sibling)頁面中進行導航。 舉個例子,點擊一個項頁面中的前進連結時,如果這個項的同一個節點下還有下一個項,那麼就跳到這個新項的頁面裡。 另一方面,向上連結將頁面跳轉到樹形結構的上一層 —— 如果當前頁面是項頁面,那麼返回到節點頁面;如果當前頁面是節點頁面,那麼返回到目錄頁面。 最後,還會有索引頁面:這個頁面包含一系列連結,按字母順序排列所有項。

../_images/Figure-16.71.png

圖 16.7 網站的結構

圖 16.7 展示了生成程式創建的頁面所形成的連結結構。

(defparameter *sections* nil)

(defstruct item
  id title text)

(defstruct section
  id title items)

(defmacro defitem (id title text)
  `(setf ,id
         (make-item :id     ',id
                    :title  ,title
                    :text   ,text)))

(defmacro defsection (id title &rest items)
  `(setf ,id
         (make-section :id    ',id
                       :title ,title
                       :items (list ,@items))))

(defun defsite (&rest sections)
  (setf *sections* sections))

圖 16.8 定義一個網站

圖 16.8 包含定義頁面所需的資料結構。程式需要處理兩類物件:項和節點。這兩類物件的結構很相似,不過節點包含的是項的列表,而項包含的是文字塊。

節點和項兩類物件都帶有 id 域。 標識符(id)被用作符號(symbol),並達到以下兩個目的:在 defitemdefsection 的定義中, 標識符會被設置到被創建的項或者節點當中,作爲我們引用它們的一種手段;另一方面,標識符還會作爲相應檔案的前綴名(base name),比如說,如果項的標識符爲 foo ,那麼項就會被寫到 foo.html 檔案當中。

節點和項也同時帶有 title 域。這個域的值應該爲字元串,並且被用作相應頁面的標題。

在節點裡,項的排列順序由傳給 defsection 的參數決定。 與此類似,在目錄裡,節點的排列順序由傳給 defsite 的參數決定。

(defconstant contents "contents")
(defconstant index    "index")

(defun gen-contents (&optional (sections *sections*))
  (page contents contents
    (with ol
      (dolist (s sections)
        (link-item (section-id s) (section-title s))
        (brs 2))
      (link-item index (string-capitalize index)))))

(defun gen-index (&optional (sections *sections*))
  (page index index
    (with ol
      (dolist (i (all-items sections))
        (link-item (item-id i) (item-title i))
        (brs 2)))))

(defun all-items (sections)
  (let ((is nil))
    (dolist (s sections)
      (dolist (i (section-items s))
        (setf is (merge 'list (list i) is #'title<))))
    is))

(defun title< (x y)
  (string-lessp (item-title x) (item-title y)))

圖 16.9 生成索引和目錄

圖 16.9 包含的函數用於生成索引和目錄。 常數 contentsindex 都是字元串,它們分別用作 contents 頁面的標題和 index 頁面的標題;另一方面,如果有其他頁面包含了目錄和索引這兩個頁面,那麼這兩個常數也會作爲這些頁面檔案的前綴名。

函數 gen-contentsgen-index 非常相似。 它們都打開一個 HTML 檔案,生成標題和連結列表。 不同的地方是,索引頁面的項必須是有序的。 有序列表通過 all-items 函數生成,它遍歷各個項並將它加入到保存已知項的列表當中,並使用 title< 函數作爲排序函數。 注意,因爲 title< 函數對大小寫敏感,所以在對比標題前,輸入必須先經過 string-lessp 處理,從而忽略大小寫區別。

實際程式中的對比操作通常更複雜一些。舉個例子,它們需要忽略無意義的句首詞彙,比如 "a""the"

(defun gen-site ()
  (map3 #'gen-section *sections*)
  (gen-contents)
  (gen-index))

(defun gen-section (sect <sect sect>)
  (page (section-id sect) (section-title sect)
    (with ol
      (map3 #'(lambda (item <item item>)
                (link-item (item-id item)
                           (item-title item))
                (brs 2)
                (gen-item sect item <item item>))
            (section-items sect)))
    (brs 3)
    (gen-move-buttons (if <sect (section-id <sect))
                      contents
                      (if sect> (section-id sect>)))))

(defun gen-item (sect item <item item>)
  (page (item-id item) (item-title item)
    (princ (item-text item))
    (brs 3)
    (gen-move-buttons (if <item (item-id <item))
                      (section-id sect)
                      (if item> (item-id item>)))))

(defun gen-move-buttons (back up forward)
  (if back (button back "Back"))
  (if up (button up "Up"))
  (if forward (button forward "Forward")))

圖 16.10 生成網站、節點和項

圖 16.10 包含其餘的程式: gen-site 生成整個頁面集合,並呼叫相應的函數,生成節點和項。

所有頁面的集合包括目錄、索引、各個節點以及各個項的頁面。 目錄和索引的生成由圖 16.9 中的程式完成。 節點和項由分別由生成節點頁面的 gen-section 和生成項頁面的 gen-item 完成。

這兩個函數的開頭和結尾非常相似。 它們都接受一個物件、物件的左兄弟、物件的右兄弟作爲參數;它們都從物件的 title 域中提取標題內容;它們都以呼叫 gen-move-buttons 作爲結束,其中 gen-move-buttons 創建指向左兄弟的後退按鈕、指向右兄弟的前進按鈕和指向雙親(parent)物件的向上按鈕。 它們的不同在於函數體的中間部分: gen-section 創建有序列表,列表中的連結指向節點包含的項,而 gen-item 創建的項則連結到相應的文字頁面。

項所包含的內容完全由用戶決定。 比如說,將 HTML 標籤作爲內容也是完全沒問題的。 項的文字當然也可以由其他程式來生成。

圖 16.11 示範了如何手工地定義一個微型網頁。 在這個例子中,列出的項都是 Fortune 餅乾公司新推出的產品。

(defitem des "Fortune Cookies: Dessert or Fraud?" "...")

(defitem case "The Case for Pessimism" "...")

(defsection position "Position Papers" des case)

(defitem luck "Distribution of Bad Luck" "...")

(defitem haz "Health Hazards of Optimism" "...")

(defsection abstract "Research Abstracts" luck haz)

(defsite position abstract)

圖 16.11 一個微型網站

讨论

comments powered by Disqus