Index: /trunk/doc/2000ft.graffle
===================================================================
--- /trunk/doc/2000ft.graffle	(revision 3)
+++ /trunk/doc/2000ft.graffle	(revision 3)
@@ -0,0 +1,1094 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>ActiveLayerIndex</key>
+	<integer>0</integer>
+	<key>AutoAdjust</key>
+	<false/>
+	<key>CanvasColor</key>
+	<dict>
+		<key>w</key>
+		<string>1</string>
+	</dict>
+	<key>CanvasOrigin</key>
+	<string>{0, 0}</string>
+	<key>CanvasScale</key>
+	<real>1</real>
+	<key>ColumnAlign</key>
+	<integer>1</integer>
+	<key>ColumnSpacing</key>
+	<real>36</real>
+	<key>CreationDate</key>
+	<string>2006-05-08 14:33:49 +0200</string>
+	<key>Creator</key>
+	<string>chris</string>
+	<key>DisplayScale</key>
+	<string>1 cm = 1 cm</string>
+	<key>GraphDocumentVersion</key>
+	<integer>5</integer>
+	<key>GraphicsList</key>
+	<array>
+		<dict>
+			<key>Bounds</key>
+			<string>{{241.464, 258}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>50</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 ...}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Class</key>
+			<string>LineGraphic</string>
+			<key>Head</key>
+			<dict>
+				<key>ID</key>
+				<integer>29</integer>
+			</dict>
+			<key>ID</key>
+			<integer>48</integer>
+			<key>OrthogonalBarAutomatic</key>
+			<true/>
+			<key>OrthogonalBarPosition</key>
+			<real>-1</real>
+			<key>Points</key>
+			<array>
+				<string>{372, 149.5}</string>
+				<string>{317.768, 149.5}</string>
+			</array>
+			<key>Style</key>
+			<dict>
+				<key>stroke</key>
+				<dict>
+					<key>HeadArrow</key>
+					<string>FilledArrow</string>
+					<key>LineType</key>
+					<integer>2</integer>
+					<key>TailArrow</key>
+					<string>0</string>
+				</dict>
+			</dict>
+			<key>Tail</key>
+			<dict>
+				<key>ID</key>
+				<integer>46</integer>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{387.348, 138}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>47</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 Template}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{372, 119}, {125.232, 61}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Color</key>
+				<dict>
+					<key>b</key>
+					<string>1</string>
+					<key>g</key>
+					<string>1</string>
+					<key>r</key>
+					<string>1</string>
+				</dict>
+				<key>Font</key>
+				<string>Helvetica-Bold</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>46</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>fill</key>
+				<dict>
+					<key>Color</key>
+					<dict>
+						<key>b</key>
+						<string>0.835052</string>
+						<key>g</key>
+						<string>0.669573</string>
+						<key>r</key>
+						<string>0.544872</string>
+					</dict>
+				</dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+				<key>stroke</key>
+				<dict>
+					<key>CornerRadius</key>
+					<real>5</real>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica-BoldOblique;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\i\b\fs24 \cf1 templating}</string>
+				<key>VerticalPad</key>
+				<integer>2</integer>
+			</dict>
+			<key>TextPlacement</key>
+			<integer>0</integer>
+		</dict>
+		<dict>
+			<key>Class</key>
+			<string>LineGraphic</string>
+			<key>Head</key>
+			<dict>
+				<key>ID</key>
+				<integer>29</integer>
+			</dict>
+			<key>ID</key>
+			<integer>45</integer>
+			<key>OrthogonalBarAutomatic</key>
+			<true/>
+			<key>OrthogonalBarPosition</key>
+			<real>-1</real>
+			<key>Points</key>
+			<array>
+				<string>{168, 149.5}</string>
+				<string>{222.232, 149.5}</string>
+			</array>
+			<key>Style</key>
+			<dict>
+				<key>stroke</key>
+				<dict>
+					<key>HeadArrow</key>
+					<string>FilledArrow</string>
+					<key>LineType</key>
+					<integer>2</integer>
+					<key>TailArrow</key>
+					<string>0</string>
+				</dict>
+			</dict>
+			<key>Tail</key>
+			<dict>
+				<key>ID</key>
+				<integer>44</integer>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{58.1159, 138}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>43</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 Fragment}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Class</key>
+			<string>LineGraphic</string>
+			<key>Head</key>
+			<dict>
+				<key>ID</key>
+				<integer>23</integer>
+			</dict>
+			<key>ID</key>
+			<integer>41</integer>
+			<key>OrthogonalBarAutomatic</key>
+			<true/>
+			<key>OrthogonalBarPosition</key>
+			<real>-1</real>
+			<key>Points</key>
+			<array>
+				<string>{270, 299}</string>
+				<string>{270, 328}</string>
+			</array>
+			<key>Style</key>
+			<dict>
+				<key>stroke</key>
+				<dict>
+					<key>HeadArrow</key>
+					<string>FilledArrow</string>
+					<key>LineType</key>
+					<integer>2</integer>
+					<key>TailArrow</key>
+					<string>0</string>
+				</dict>
+			</dict>
+			<key>Tail</key>
+			<dict>
+				<key>ID</key>
+				<integer>34</integer>
+			</dict>
+		</dict>
+		<dict>
+			<key>Class</key>
+			<string>LineGraphic</string>
+			<key>Head</key>
+			<dict>
+				<key>ID</key>
+				<integer>29</integer>
+			</dict>
+			<key>ID</key>
+			<integer>40</integer>
+			<key>OrthogonalBarAutomatic</key>
+			<true/>
+			<key>OrthogonalBarPosition</key>
+			<real>-1</real>
+			<key>Points</key>
+			<array>
+				<string>{270, 83}</string>
+				<string>{270, 133.5}</string>
+			</array>
+			<key>Style</key>
+			<dict>
+				<key>stroke</key>
+				<dict>
+					<key>HeadArrow</key>
+					<string>FilledArrow</string>
+					<key>LineType</key>
+					<integer>2</integer>
+					<key>TailArrow</key>
+					<string>0</string>
+				</dict>
+			</dict>
+			<key>Tail</key>
+			<dict>
+				<key>ID</key>
+				<integer>26</integer>
+			</dict>
+		</dict>
+		<dict>
+			<key>Class</key>
+			<string>LineGraphic</string>
+			<key>Head</key>
+			<dict>
+				<key>ID</key>
+				<integer>34</integer>
+			</dict>
+			<key>ID</key>
+			<integer>39</integer>
+			<key>OrthogonalBarAutomatic</key>
+			<true/>
+			<key>OrthogonalBarPosition</key>
+			<real>-1</real>
+			<key>Points</key>
+			<array>
+				<string>{270, 165.5}</string>
+				<string>{270, 210}</string>
+			</array>
+			<key>Style</key>
+			<dict>
+				<key>stroke</key>
+				<dict>
+					<key>HeadArrow</key>
+					<string>FilledArrow</string>
+					<key>LineType</key>
+					<integer>2</integer>
+					<key>TailArrow</key>
+					<string>0</string>
+				</dict>
+			</dict>
+			<key>Tail</key>
+			<dict>
+				<key>ID</key>
+				<integer>29</integer>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{241.464, 220}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>37</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 Whitespace Filter}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{126, 220}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>36</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 XInclude Filter}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{126, 258}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>35</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 HTML Sanitizer}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{116, 210}, {308, 89}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Color</key>
+				<dict>
+					<key>b</key>
+					<string>1</string>
+					<key>g</key>
+					<string>1</string>
+					<key>r</key>
+					<string>1</string>
+				</dict>
+				<key>Font</key>
+				<string>Helvetica-Bold</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>34</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>fill</key>
+				<dict>
+					<key>Color</key>
+					<dict>
+						<key>b</key>
+						<string>0.835052</string>
+						<key>g</key>
+						<string>0.669573</string>
+						<key>r</key>
+						<string>0.544872</string>
+					</dict>
+				</dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+				<key>stroke</key>
+				<dict>
+					<key>CornerRadius</key>
+					<real>5</real>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Align</key>
+				<integer>2</integer>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica-BoldOblique;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qr\pardirnatural
+
+\f0\i\b\fs24 \cf1 filtering}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{222.732, 134}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>11</real>
+			</dict>
+			<key>ID</key>
+			<integer>29</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 Stream}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{241.464, 42}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>28</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 XML Parser}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{126, 42}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>27</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 HTML Parser}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{116, 32}, {308, 51}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Color</key>
+				<dict>
+					<key>b</key>
+					<string>1</string>
+					<key>g</key>
+					<string>1</string>
+					<key>r</key>
+					<string>1</string>
+				</dict>
+				<key>Font</key>
+				<string>Helvetica-Bold</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>26</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>fill</key>
+				<dict>
+					<key>Color</key>
+					<dict>
+						<key>b</key>
+						<string>0.835052</string>
+						<key>g</key>
+						<string>0.669573</string>
+						<key>r</key>
+						<string>0.544872</string>
+					</dict>
+				</dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+				<key>stroke</key>
+				<dict>
+					<key>CornerRadius</key>
+					<real>5</real>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Align</key>
+				<integer>2</integer>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica-BoldOblique;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qr\pardirnatural
+
+\f0\i\b\fs24 \cf1 parsing}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{241.464, 338}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>25</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 XML Serializer}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{126, 338}, {94.5364, 31}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Font</key>
+				<string>Helvetica</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>24</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\fs22 \cf0 HTML Serializer}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{116, 328}, {308, 51}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Color</key>
+				<dict>
+					<key>b</key>
+					<string>1</string>
+					<key>g</key>
+					<string>1</string>
+					<key>r</key>
+					<string>1</string>
+				</dict>
+				<key>Font</key>
+				<string>Helvetica-Bold</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>23</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>fill</key>
+				<dict>
+					<key>Color</key>
+					<dict>
+						<key>b</key>
+						<string>0.835052</string>
+						<key>g</key>
+						<string>0.669573</string>
+						<key>r</key>
+						<string>0.544872</string>
+					</dict>
+				</dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+				<key>stroke</key>
+				<dict>
+					<key>CornerRadius</key>
+					<real>5</real>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Align</key>
+				<integer>2</integer>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica-BoldOblique;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qr\pardirnatural
+
+\f0\i\b\fs24 \cf1 serialization}</string>
+			</dict>
+		</dict>
+		<dict>
+			<key>Bounds</key>
+			<string>{{42.7682, 119}, {125.232, 61}}</string>
+			<key>Class</key>
+			<string>ShapedGraphic</string>
+			<key>FontInfo</key>
+			<dict>
+				<key>Color</key>
+				<dict>
+					<key>b</key>
+					<string>1</string>
+					<key>g</key>
+					<string>1</string>
+					<key>r</key>
+					<string>1</string>
+				</dict>
+				<key>Font</key>
+				<string>Helvetica-Bold</string>
+				<key>Size</key>
+				<real>12</real>
+			</dict>
+			<key>ID</key>
+			<integer>44</integer>
+			<key>Shape</key>
+			<string>Rectangle</string>
+			<key>Style</key>
+			<dict>
+				<key>fill</key>
+				<dict>
+					<key>Color</key>
+					<dict>
+						<key>b</key>
+						<string>0.835052</string>
+						<key>g</key>
+						<string>0.669573</string>
+						<key>r</key>
+						<string>0.544872</string>
+					</dict>
+				</dict>
+				<key>shadow</key>
+				<dict>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+				<key>stroke</key>
+				<dict>
+					<key>CornerRadius</key>
+					<real>5</real>
+					<key>Draws</key>
+					<string>NO</string>
+				</dict>
+			</dict>
+			<key>Text</key>
+			<dict>
+				<key>Text</key>
+				<string>{\rtf1\mac\ansicpg10000\cocoartf824\cocoasubrtf330
+{\fonttbl\f0\fswiss\fcharset77 Helvetica-BoldOblique;}
+{\colortbl;\red255\green255\blue255;}
+\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\qc\pardirnatural
+
+\f0\i\b\fs24 \cf1 building}</string>
+				<key>VerticalPad</key>
+				<integer>2</integer>
+			</dict>
+			<key>TextPlacement</key>
+			<integer>0</integer>
+		</dict>
+	</array>
+	<key>GridInfo</key>
+	<dict/>
+	<key>GuidesLocked</key>
+	<string>NO</string>
+	<key>GuidesVisible</key>
+	<string>YES</string>
+	<key>HPages</key>
+	<integer>1</integer>
+	<key>ImageCounter</key>
+	<integer>1</integer>
+	<key>IsPalette</key>
+	<string>NO</string>
+	<key>KeepToScale</key>
+	<false/>
+	<key>Layers</key>
+	<array>
+		<dict>
+			<key>Lock</key>
+			<string>NO</string>
+			<key>Name</key>
+			<string>Ebene 1</string>
+			<key>Print</key>
+			<string>YES</string>
+			<key>View</key>
+			<string>YES</string>
+		</dict>
+	</array>
+	<key>LayoutInfo</key>
+	<dict>
+		<key>LayoutTarget</key>
+		<integer>3</integer>
+	</dict>
+	<key>LinksVisible</key>
+	<string>NO</string>
+	<key>MagnetsVisible</key>
+	<string>NO</string>
+	<key>MasterSheets</key>
+	<array>
+		<dict>
+			<key>ActiveLayerIndex</key>
+			<integer>0</integer>
+			<key>AutoAdjust</key>
+			<false/>
+			<key>CanvasColor</key>
+			<dict>
+				<key>w</key>
+				<string>1</string>
+			</dict>
+			<key>CanvasOrigin</key>
+			<string>{0, 0}</string>
+			<key>CanvasScale</key>
+			<real>1</real>
+			<key>ColumnAlign</key>
+			<integer>1</integer>
+			<key>ColumnSpacing</key>
+			<real>36</real>
+			<key>DisplayScale</key>
+			<string>1 cm = 1 cm</string>
+			<key>GraphicsList</key>
+			<array/>
+			<key>GridInfo</key>
+			<dict/>
+			<key>HPages</key>
+			<integer>1</integer>
+			<key>IsPalette</key>
+			<string>NO</string>
+			<key>KeepToScale</key>
+			<false/>
+			<key>Layers</key>
+			<array>
+				<dict>
+					<key>Lock</key>
+					<string>NO</string>
+					<key>Name</key>
+					<string>Ebene 1</string>
+					<key>Print</key>
+					<string>YES</string>
+					<key>View</key>
+					<string>YES</string>
+				</dict>
+			</array>
+			<key>LayoutInfo</key>
+			<dict>
+				<key>LayoutTarget</key>
+				<integer>3</integer>
+			</dict>
+			<key>Orientation</key>
+			<integer>2</integer>
+			<key>RowAlign</key>
+			<integer>1</integer>
+			<key>RowSpacing</key>
+			<real>36</real>
+			<key>SheetTitle</key>
+			<string>Master 1</string>
+			<key>UniqueID</key>
+			<integer>1</integer>
+			<key>VPages</key>
+			<integer>1</integer>
+		</dict>
+	</array>
+	<key>ModificationDate</key>
+	<string>2006-05-09 01:09:46 +0200</string>
+	<key>Modifier</key>
+	<string>chris</string>
+	<key>NotesVisible</key>
+	<string>NO</string>
+	<key>Orientation</key>
+	<integer>2</integer>
+	<key>OriginVisible</key>
+	<string>NO</string>
+	<key>PageBreaks</key>
+	<string>YES</string>
+	<key>PrintInfo</key>
+	<dict>
+		<key>NSPaperSize</key>
+		<array>
+			<string>size</string>
+			<string>{595.276, 841.89}</string>
+		</array>
+	</dict>
+	<key>ReadOnly</key>
+	<string>NO</string>
+	<key>RowAlign</key>
+	<integer>1</integer>
+	<key>RowSpacing</key>
+	<real>36</real>
+	<key>SheetTitle</key>
+	<string>Arbeitsfläche 1</string>
+	<key>SmartAlignmentGuidesActive</key>
+	<string>YES</string>
+	<key>SmartDistanceGuidesActive</key>
+	<string>NO</string>
+	<key>UniqueID</key>
+	<integer>1</integer>
+	<key>UseEntirePage</key>
+	<false/>
+	<key>VPages</key>
+	<integer>1</integer>
+	<key>WindowInfo</key>
+	<dict>
+		<key>CurrentSheet</key>
+		<string>0</string>
+		<key>DrawerOpen</key>
+		<false/>
+		<key>DrawerTab</key>
+		<string>Outline</string>
+		<key>DrawerWidth</key>
+		<real>209</real>
+		<key>FitInWindow</key>
+		<false/>
+		<key>Frame</key>
+		<string>{{28, 214}, {757, 512}}</string>
+		<key>ShowRuler</key>
+		<false/>
+		<key>ShowStatusBar</key>
+		<true/>
+		<key>VisibleRegion</key>
+		<string>{{-109, 0}, {742, 422}}</string>
+		<key>Zoom</key>
+		<string>1</string>
+	</dict>
+</dict>
+</plist>
Index: /trunk/markup/__init__.py
===================================================================
--- /trunk/markup/__init__.py	(revision 3)
+++ /trunk/markup/__init__.py	(revision 3)
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+"""This package provides various means for generating and processing web markup
+(XML or HTML).
+
+The design is centered around the concept of streams of markup events (similar
+in concept to SAX parsing events) which can be processed in a uniform manner
+independently of where or how they are produced.
+
+
+Generating content
+------------------
+
+Literal XML and HTML text can be used to easily produce markup streams
+via helper functions in the `markup.input` module:
+
+>>> from markup.input import XML
+>>> doc = XML('<html lang="en"><head><title>My document</title></head></html>')
+
+This results in a `Stream` object that can be used in a number of way.
+
+>>> doc.render(method='html', encoding='utf-8')
+'<html lang="en"><head><title>My document</title></head></html>'
+
+>>> from markup.input import HTML
+>>> doc = HTML('<HTML lang=en><HEAD><TITLE>My document</HTML>')
+>>> doc.render(method='html', encoding='utf-8')
+'<html lang="en"><head><title>My document</title></head></html>'
+
+>>> title = doc.select('head/title')
+>>> title.render(method='html', encoding='utf-8')
+'<title>My document</title>'
+
+
+Markup streams can also be generated programmatically using the
+`markup.builder` module:
+
+>>> from markup.builder import tag
+>>> doc = tag.DOC(tag.TITLE('My document'), lang='en')
+>>> doc.generate().render(method='html')
+'<doc lang="en"><title>My document</title></doc>'
+
+"""
+
+from markup.core import *
+from markup.input import XML, HTML
Index: /trunk/markup/builder.py
===================================================================
--- /trunk/markup/builder.py	(revision 3)
+++ /trunk/markup/builder.py	(revision 3)
@@ -0,0 +1,178 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+from markup.core import Attributes, QName, Stream
+
+__all__ = ['Fragment', 'Element', 'tag']
+
+
+class Fragment(object):
+    __slots__ = ['children']
+
+    def __init__(self):
+        self.children = []
+
+    def append(self, node):
+        """Append an element or string as child node."""
+        if isinstance(node, (Element, basestring, int, float, long)):
+            # For objects of a known/primitive type, we avoid the check for
+            # whether it is iterable for better performance
+            self.children.append(node)
+        elif isinstance(node, Fragment):
+            self.children += node.children
+        elif node is not None:
+            try:
+                children = iter(node)
+            except TypeError:
+                self.children.append(node)
+            else:
+                for child in node:
+                    self.append(children)
+
+    def __add__(self, other):
+        return Fragment()(self, other)
+
+    def __call__(self, *args):
+        for arg in args:
+            self.append(arg)
+        return self
+
+    def generate(self):
+        """Generator that yield tags and text nodes as strings."""
+        def _generate():
+            for child in self.children:
+                if isinstance(child, Fragment):
+                    for event in child.generate():
+                        yield event
+                else:
+                    yield Stream.TEXT, child, (-1, -1)
+        return Stream(_generate())
+
+    def __iter__(self):
+        return iter(self.generate())
+
+    def __str__(self):
+        return str(self.generate())
+
+    def __unicode__(self):
+        return unicode(self.generate())
+
+
+class Element(Fragment):
+    """Simple XML output generator based on the builder pattern.
+
+    Construct XML elements by passing the tag name to the constructor:
+
+    >>> print Element('strong')
+    <strong/>
+
+    Attributes can be specified using keyword arguments. The values of the
+    arguments will be converted to strings and any special XML characters
+    escaped:
+
+    >>> print Element('textarea', rows=10, cols=60)
+    <textarea rows="10" cols="60"/>
+    >>> print Element('span', title='1 < 2')
+    <span title="1 &lt; 2"/>
+    >>> print Element('span', title='"baz"')
+    <span title="&#34;baz&#34;"/>
+
+    The " character is escaped using a numerical entity.
+    The order in which attributes are rendered is undefined.
+
+    If an attribute value evaluates to `None`, that attribute is not included
+    in the output:
+
+    >>> print Element('a', name=None)
+    <a/>
+
+    Attribute names that conflict with Python keywords can be specified by
+    appending an underscore:
+
+    >>> print Element('div', class_='warning')
+    <div class="warning"/>
+
+    Nested elements can be added to an element using item access notation.
+    The call notation can also be used for this and for adding attributes
+    using keyword arguments, as one would do in the constructor.
+
+    >>> print Element('ul')(Element('li'), Element('li'))
+    <ul><li/><li/></ul>
+    >>> print Element('a')('Label')
+    <a>Label</a>
+    >>> print Element('a')('Label', href="target")
+    <a href="target">Label</a>
+
+    Text nodes can be nested in an element by adding strings instead of
+    elements. Any special characters in the strings are escaped automatically:
+
+    >>> print Element('em')('Hello world')
+    <em>Hello world</em>
+    >>> print Element('em')(42)
+    <em>42</em>
+    >>> print Element('em')('1 < 2')
+    <em>1 &lt; 2</em>
+
+    This technique also allows mixed content:
+
+    >>> print Element('p')('Hello ', Element('b')('world'))
+    <p>Hello <b>world</b></p>
+
+    Elements can also be combined with other elements or strings using the
+    addition operator, which results in a `Fragment` object that contains the
+    operands:
+    
+    >>> print Element('br') + 'some text' + Element('br')
+    <br/>some text<br/>
+    
+    Elements with a namespace can be generated using the `Namespace` and/or
+    `QName` classes:
+    
+    >>> from markup.core import Namespace
+    >>> xhtml = Namespace('http://www.w3.org/1999/xhtml')
+    >>> print Element(xhtml.html, lang='en')
+    <html lang="en" xmlns="http://www.w3.org/1999/xhtml"/>
+    """
+    __slots__ = ['tag', 'attrib']
+
+    def __init__(self, tag_, **attrib):
+        Fragment.__init__(self)
+        self.tag = QName(tag_)
+        self.attrib = Attributes()
+        self(**attrib)
+
+    def __call__(self, *args, **kwargs):
+        for attr, value in kwargs.items():
+            if value is None:
+                continue
+            attr = attr.rstrip('_').replace('_', '-')
+            self.attrib.set(attr, value)
+        return Fragment.__call__(self, *args)
+
+    def generate(self):
+        """Generator that yield tags and text nodes as strings."""
+        def _generate():
+            yield Stream.START, (self.tag, self.attrib), (-1, -1)
+            for kind, data, pos in Fragment.generate(self):
+                yield kind, data, pos
+            yield Stream.END, self.tag, (-1, -1)
+        return Stream(_generate())
+
+
+class ElementFactory(object):
+
+    def __getattribute__(self, name):
+        return Element(name.lower())
+
+
+tag = ElementFactory()
Index: /trunk/markup/core.py
===================================================================
--- /trunk/markup/core.py	(revision 3)
+++ /trunk/markup/core.py	(revision 3)
@@ -0,0 +1,309 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+"""Core classes for markup processing."""
+
+import htmlentitydefs
+import re
+from StringIO import StringIO
+
+__all__ = ['Stream', 'Markup', 'escape', 'unescape', 'Namespace', 'QName']
+
+
+class StreamEventKind(object):
+    """A kind of event on an XML stream."""
+
+    __slots__ = ['name']
+
+    def __init__(self, name):
+        self.name = name
+
+    def __repr__(self):
+        return self.name
+
+
+class Stream(object):
+    """Represents a stream of markup events.
+    
+    This class is basically an iterator over the events.
+    
+    Also provided are ways to serialize the stream to text. The `serialize()`
+    method will return an iterator over generated strings, while `render()`
+    returns the complete generated text at once. Both accept various parameters
+    that impact the way the stream is serialized.
+    
+    Stream events are tuples of the form:
+
+      (kind, data, position)
+
+    where `kind` is the event kind (such as `START`, `END`, `TEXT`, etc), `data`
+    depends on the kind of event, and `position` is a `(line, offset)` tuple
+    that contains the location of the original element or text in the input.
+    """
+    __slots__ = ['events']
+
+    START = StreamEventKind('start') # a start tag
+    END = StreamEventKind('end') # an end tag
+    TEXT = StreamEventKind('text') # literal text
+    EXPR = StreamEventKind('expr') # an expression
+    SUB = StreamEventKind('sub') # a "subprogram"
+    PROLOG = StreamEventKind('prolog') # XML prolog
+    DOCTYPE = StreamEventKind('doctype') # doctype declaration
+    START_NS = StreamEventKind('start-ns') # start namespace mapping
+    END_NS = StreamEventKind('end-ns') # end namespace mapping
+    PI = StreamEventKind('pi') # processing instruction
+    COMMENT = StreamEventKind('comment') # comment
+
+    def __init__(self, events):
+        """Initialize the stream with a sequence of markup events.
+        
+        @oaram events: a sequence or iterable providing the events
+        """
+        self.events = events
+
+    def __iter__(self):
+        return iter(self.events)
+
+    def render(self, method='xml', encoding='utf-8', **kwargs):
+        """Return a string representation of the stream.
+        
+        @param method: determines how the stream is serialized; can be either
+                       'xml' or 'html', or a custom `Serializer` subclass
+        @param encoding: how the output string should be encoded; if set to
+                         `None`, this method returns a `unicode` object
+
+        Any additional keyword arguments are passed to the serializer, and thus
+        depend on the `method` parameter value.
+        """
+        retval = u''.join(self.serialize(method=method, **kwargs))
+        if encoding is not None:
+            return retval.encode('utf-8')
+        return retval
+
+    def select(self, path):
+        """Return a new stream that contains the events matching the given
+        XPath expression.
+        
+        @param path: a string containing the XPath expression
+        """
+        from markup.path import Path
+        path = Path(path)
+        return path.select(self)
+
+    def serialize(self, method='xml', **kwargs):
+        """Generate strings corresponding to a specific serialization of the
+        stream.
+        
+        Unlike the `render()` method, this method is a generator this returns
+        the serialized output incrementally, as opposed to returning a single
+        string.
+        
+        @param method: determines how the stream is serialized; can be either
+                       'xml' or 'html', or a custom `Serializer` subclass
+        """
+        from markup import output
+        cls = method
+        if isinstance(method, basestring):
+            cls = {'xml': output.XMLSerializer,
+                   'html': output.HTMLSerializer}[method]
+        else:
+            assert issubclass(cls, serializers.Serializer)
+        serializer = cls(**kwargs)
+        return serializer.serialize(self)
+
+    def __str__(self):
+        return self.render()
+
+    def __unicode__(self):
+        return self.render(encoding=None)
+
+
+class Attributes(list):
+
+    def __init__(self, attrib=None):
+        list.__init__(self, map(lambda (k, v): (QName(k), v), attrib or []))
+
+    def __contains__(self, name):
+        return name in [attr for attr, value in self]
+
+    def get(self, name, default=None):
+        for attr, value in self:
+            if attr == name:
+                return value
+        return default
+
+    def set(self, name, value):
+        for idx, (attr, _) in enumerate(self):
+            if attr == name:
+                self[idx] = (attr, value)
+                break
+        else:
+            self.append((QName(name), value))
+
+
+class Markup(unicode):
+    """Marks a string as being safe for inclusion in HTML/XML output without
+    needing to be escaped.
+    """
+    def __new__(self, text='', *args):
+        if args:
+            text %= tuple([escape(arg) for arg in args])
+        return unicode.__new__(self, text)
+
+    def __add__(self, other):
+        return Markup(unicode(self) + Markup.escape(other))
+
+    def __mod__(self, args):
+        if not isinstance(args, (list, tuple)):
+            args = [args]
+        return Markup(unicode.__mod__(self,
+                                      tuple([escape(arg) for arg in args])))
+
+    def __mul__(self, num):
+        return Markup(unicode(self) * num)
+
+    def join(self, seq):
+        return Markup(unicode(self).join([Markup.escape(item) for item in seq]))
+
+    def stripentities(self, keepxmlentities=False):
+        """Return a copy of the text with any character or numeric entities
+        replaced by the equivalent UTF-8 characters.
+        
+        If the `keepxmlentities` parameter is provided and evaluates to `True`,
+        the core XML entities (&amp;, &apos;, &gt;, &lt; and &quot;).
+        """
+        def _replace_entity(match):
+            if match.group(1): # numeric entity
+                ref = match.group(1)
+                if ref.startswith('x'):
+                    ref = int(ref[1:], 16)
+                else:
+                    ref = int(ref, 10)
+                return unichr(ref)
+            else: # character entity
+                ref = match.group(2)
+                if keepxmlentities and ref in ('amp', 'apos', 'gt', 'lt', 'quot'):
+                    return '&%s;' % ref
+                try:
+                    codepoint = htmlentitydefs.name2codepoint[ref]
+                    return unichr(codepoint)
+                except KeyError:
+                    if keepxmlentities:
+                        return '&amp;%s;' % ref
+                    else:
+                        return ref
+        return Markup(re.sub(r'&(?:#((?:\d+)|(?:[xX][0-9a-fA-F]+));?|(\w+);)',
+                             _replace_entity, self))
+
+    def striptags(self):
+        """Return a copy of the text with all XML/HTML tags removed."""
+        return Markup(re.sub(r'<[^>]*?>', '', self))
+
+    def escape(cls, text, quotes=True):
+        """Create a Markup instance from a string and escape special characters
+        it may contain (<, >, & and \").
+        
+        If the `quotes` parameter is set to `False`, the \" character is left
+        as is. Escaping quotes is generally only required for strings that are
+        to be used in attribute values.
+        """
+        if isinstance(text, cls):
+            return text
+        text = unicode(text)
+        if not text:
+            return cls()
+        text = text.replace('&', '&amp;') \
+                   .replace('<', '&lt;') \
+                   .replace('>', '&gt;')
+        if quotes:
+            text = text.replace('"', '&#34;')
+        return cls(text)
+    escape = classmethod(escape)
+
+    def unescape(self):
+        """Reverse-escapes &, <, > and \" and returns a `unicode` object."""
+        if not self:
+            return ''
+        return unicode(self).replace('&#34;', '"') \
+                            .replace('&gt;', '>') \
+                            .replace('&lt;', '<') \
+                            .replace('&amp;', '&')
+
+    def plaintext(self, keeplinebreaks=True):
+        """Returns the text as a `unicode`with all entities and tags removed."""
+        text = unicode(self.striptags().stripentities())
+        if not keeplinebreaks:
+            text = text.replace('\n', ' ')
+        return text
+
+    def sanitize(self):
+        from markup.filters import HTMLSanitizer
+        from markup.input import HTMLParser
+        sanitize = HTMLSanitizer()
+        text = self.stripentities(keepxmlentities=True)
+        return Stream(sanitize(HTMLParser(StringIO(text)), None))
+
+
+escape = Markup.escape
+
+def unescape(text):
+    """Reverse-escapes &, <, > and \" and returns a `unicode` object."""
+    if not isinstance(text, Markup):
+        return text
+    return text.unescape()
+
+
+class Namespace(object):
+
+    def __init__(self, uri):
+        self.uri = uri
+
+    def __getitem__(self, name):
+        return QName(self.uri + '}' + name)
+
+    __getattr__ = __getitem__
+
+    def __repr__(self):
+        return '<Namespace "%s">' % self.uri
+
+    def __str__(self):
+        return self.uri
+
+    def __unicode__(self):
+        return unicode(self.uri)
+
+
+class QName(unicode):
+    """A qualified element or attribute name.
+    
+    The unicode value of instances of this class contains the qualified name of
+    the element or attribute, in the form `{namespace}localname`. The namespace
+    URI can be obtained through the additional `namespace` attribute, while the
+    local name can be accessed through the `localname` attribute.
+    """
+    __slots__ = ['namespace', 'localname']
+
+    def __new__(cls, qname):
+        if isinstance(qname, QName):
+            return qname
+
+        parts = qname.split('}', 1)
+        if qname.find('}') > 0:
+            self = unicode.__new__(cls, '{' + qname)
+            self.namespace = parts[0]
+            self.localname = parts[1]
+        else:
+            self = unicode.__new__(cls, qname)
+            self.namespace = None
+            self.localname = qname
+        return self
Index: /trunk/markup/eval.py
===================================================================
--- /trunk/markup/eval.py	(revision 3)
+++ /trunk/markup/eval.py	(revision 3)
@@ -0,0 +1,232 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import __builtin__
+import compiler
+import operator
+
+from markup.core import Stream
+
+__all__ = ['Expression']
+
+
+class Expression(object):
+    """Evaluates Python expressions used in templates.
+
+    >>> data = dict(test='Foo', items=[1, 2, 3], dict={'some': 'thing'})
+    >>> Expression('test').evaluate(data)
+    'Foo'
+    >>> Expression('items[0]').evaluate(data)
+    1
+    >>> Expression('items[-1]').evaluate(data)
+    3
+    >>> Expression('dict["some"]').evaluate(data)
+    'thing'
+    
+    Similar to e.g. Javascript, expressions in templates can use the dot
+    notation for attribute access to access items in mappings:
+    
+    >>> Expression('dict.some').evaluate(data)
+    'thing'
+    
+    This also works the other way around: item access can be used to access
+    any object attribute (meaning there's no use for `getattr()` in templates):
+    
+    >>> class MyClass(object):
+    ...     myattr = 'Bar'
+    >>> data = dict(mine=MyClass(), key='myattr')
+    >>> Expression('mine.myattr').evaluate(data)
+    'Bar'
+    >>> Expression('mine["myattr"]').evaluate(data)
+    'Bar'
+    >>> Expression('mine[key]').evaluate(data)
+    'Bar'
+    
+    Most of the standard Python operators are also available to template
+    expressions. Bitwise operators (including inversion and shifting) are not
+    supported.
+    
+    >>> Expression('1 + 1').evaluate(data)
+    2
+    >>> Expression('3 - 1').evaluate(data)
+    2
+    >>> Expression('1 * 2').evaluate(data)
+    2
+    >>> Expression('4 / 2').evaluate(data)
+    2
+    >>> Expression('4 // 3').evaluate(data)
+    1
+    >>> Expression('4 % 3').evaluate(data)
+    1
+    >>> Expression('2 ** 3').evaluate(data)
+    8
+    >>> Expression('not True').evaluate(data)
+    False
+    >>> Expression('True and False').evaluate(data)
+    False
+    >>> Expression('True or False').evaluate(data)
+    True
+    >>> Expression('1 == 3').evaluate(data)
+    False
+    >>> Expression('1 != 3 == 3').evaluate(data)
+    True
+    
+    Built-in functions such as `len()` are also available in template
+    expressions:
+    
+    >>> data = dict(items=[1, 2, 3])
+    >>> Expression('len(items)').evaluate(data)
+    3
+    """
+    __slots__ = ['source', 'ast']
+    __visitors = {}
+
+    def __init__(self, source):
+        self.source = source
+        self.ast = None
+
+    def evaluate(self, data, default=None):
+        if not self.ast:
+            self.ast = compiler.parse(self.source, 'eval')
+        retval = self._visit(self.ast.node, data)
+        if retval is not None:
+            return retval
+        return default
+
+    def __repr__(self):
+        return '<Expression "%s">' % self.source
+
+    # AST traversal
+
+    def _visit(self, node, data):
+        v = self.__visitors.get(node.__class__)
+        if not v:
+            v = getattr(self, '_visit_%s' % node.__class__.__name__.lower())
+            self.__visitors[node.__class__] = v
+        return v(node, data)
+
+    def _visit_expression(self, node, data):
+        for child in node.getChildNodes():
+            return self._visit(child, data)
+
+    # Functions & Accessors
+
+    def _visit_callfunc(self, node, data):
+        func = self._visit(node.node, data)
+        if func is None:
+            return None
+        args = [self._visit(arg, data) for arg in node.args
+                if not isinstance(arg, compiler.ast.Keyword)]
+        kwargs = dict([(arg.name, self._visit(arg.expr, data)) for arg
+                       in node.args if isinstance(arg, compiler.ast.Keyword)])
+        return func(*args, **kwargs)
+
+    def _visit_getattr(self, node, data):
+        obj = self._visit(node.expr, data)
+        try:
+            return getattr(obj, node.attrname)
+        except AttributeError, e:
+            try:
+                return obj[node.attrname]
+            except (KeyError, TypeError):
+                return None
+
+    def _visit_slice(self, node, data):
+        obj = self._visit(node.expr, data)
+        lower = node.lower and self._visit(node.lower, data) or None
+        upper = node.upper and self._visit(node.upper, data) or None
+        return obj[lower:upper]
+
+    def _visit_subscript(self, node, data):
+        obj = self._visit(node.expr, data)
+        subs = map(lambda sub: self._visit(sub, data), node.subs)
+        if len(subs) == 1:
+            subs = subs[0]
+        try:
+            return obj[subs]
+        except (KeyError, IndexError, TypeError):
+            try:
+                return getattr(obj, subs)
+            except (AttributeError, TypeError):
+                return None
+
+    # Operators
+
+    def _visit_and(self, node, data):
+        return reduce(operator.and_, [self._visit(n, data) for n in node.nodes])
+
+    def _visit_or(self, node, data):
+        return reduce(operator.or_, [self._visit(n, data) for n in node.nodes])
+
+    _OP_MAP = {'==': operator.eq, '!=': operator.ne,
+               '<':  operator.lt, '<=': operator.le,
+               '>':  operator.gt, '>=': operator.ge,
+               'in': operator.contains}
+    def _visit_compare(self, node, data):
+        result = self._visit(node.expr, data)
+        ops = node.ops[:]
+        ops.reverse()
+        for op, rval in ops:
+            result = self._OP_MAP[op](result, self._visit(rval, data))
+        return result
+
+    def _visit_add(self, node, data):
+        return self._visit(node.left, data) + self._visit(node.right, data)
+
+    def _visit_div(self, node, data):
+        return self._visit(node.left, data) / self._visit(node.right, data)
+
+    def _visit_floordiv(self, node, data):
+        return self._visit(node.left, data) // self._visit(node.right, data)
+
+    def _visit_mod(self, node, data):
+        return self._visit(node.left, data) % self._visit(node.right, data)
+
+    def _visit_mul(self, node, data):
+        return self._visit(node.left, data) * self._visit(node.right, data)
+
+    def _visit_power(self, node, data):
+        return self._visit(node.left, data) ** self._visit(node.right, data)
+
+    def _visit_sub(self, node, data):
+        return self._visit(node.left, data) - self._visit(node.right, data)
+
+    def _visit_not(self, node, data):
+        return not self._visit(node.expr, data)
+
+    def _visit_unaryadd(self, node, data):
+        return +self._visit(node.expr, data)
+
+    def _visit_unarysub(self, node, data):
+        return -self._visit(node.expr, data)
+
+    # Identifiers & Literals
+
+    def _visit_name(self, node, data):
+        val = data.get(node.name)
+        if val is None:
+            val = getattr(__builtin__, node.name, None)
+        return val
+
+    def _visit_const(self, node, data):
+        return node.value
+
+    def _visit_dict(self, node, data):
+        return dict([(self._visit(k, data), self._visit(v, data))
+                     for k, v in node.items])
+
+    def _visit_tuple(self, node, data):
+        return tuple([self._visit(n, data) for n in node.nodes])
+
+    def _visit_list(self, node, data):
+        return [self._visit(n, data) for n in node.nodes]
Index: /trunk/markup/filters.py
===================================================================
--- /trunk/markup/filters.py	(revision 3)
+++ /trunk/markup/filters.py	(revision 3)
@@ -0,0 +1,319 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+"""Implementation of a number of stream filters."""
+
+try:
+    frozenset
+except NameError:
+    from sets import ImmutableSet as frozenset
+import re
+
+from markup.core import Attributes, Markup, Stream
+from markup.path import Path
+
+__all__ = ['EvalFilter', 'IncludeFilter', 'MatchFilter', 'WhitespaceFilter',
+           'HTMLSanitizer']
+
+
+class EvalFilter(object):
+    """Responsible for evaluating expressions in a template."""
+
+    def __call__(self, stream, ctxt=None):
+        for kind, data, pos in stream:
+
+            if kind is Stream.START:
+                # Attributes may still contain expressions in start tags at
+                # this point, so do some evaluation
+                tag, attrib = data
+                new_attrib = []
+                for name, substream in attrib:
+                    if isinstance(substream, basestring):
+                        value = substream
+                    else:
+                        values = []
+                        for subkind, subdata, subpos in substream:
+                            if subkind is Stream.EXPR:
+                                values.append(subdata.evaluate(ctxt))
+                            else:
+                                values.append(subdata)
+                        value = filter(lambda x: x is not None, values)
+                        if not value:
+                            continue
+                    new_attrib.append((name, ''.join(value)))
+                yield kind, (tag, Attributes(new_attrib)), pos
+
+            elif kind is Stream.EXPR:
+                result = data.evaluate(ctxt)
+                if result is None:
+                    continue
+
+                # First check for a string, otherwise the iterable
+                # test below succeeds, and the string will be
+                # chopped up into characters
+                if isinstance(result, basestring):
+                    yield Stream.TEXT, result, pos
+                else:
+                    # Test if the expression evaluated to an
+                    # iterable, in which case we yield the
+                    # individual items
+                    try:
+                        yield Stream.SUB, ([], iter(result)), pos
+                    except TypeError:
+                        # Neither a string nor an iterable, so just
+                        # pass it through
+                        yield Stream.TEXT, unicode(result), pos
+
+            else:
+                yield kind, data, pos
+
+
+class IncludeFilter(object):
+    """Template filter providing (very) basic XInclude support
+    (see http://www.w3.org/TR/xinclude/) in templates.
+    """
+
+    _NAMESPACE = 'http://www.w3.org/2001/XInclude'
+
+    def __init__(self, loader):
+        """Initialize the filter.
+        
+        @param loader: the `TemplateLoader` to use for resolving references to
+            external template files
+        """
+        self.loader = loader
+
+    def __call__(self, stream, ctxt=None):
+        """Filter the stream, processing any XInclude directives it may
+        contain.
+        
+        @param ctxt: the template context
+        @param stream: the markup event stream to filter
+        """
+        from markup.template import TemplateError, TemplateNotFound
+
+        in_fallback = False
+        include_href, fallback_stream = None, None
+        indent = 0
+
+        for kind, data, pos in stream:
+
+            if kind is Stream.START and data[0].namespace == self._NAMESPACE \
+                    and not in_fallback:
+                tag, attrib = data
+                if tag.localname == 'include':
+                    include_href = attrib.get('href')
+                    indent = pos[1]
+                elif tag.localname == 'fallback':
+                    in_fallback = True
+                    fallback_stream = []
+
+            elif kind is Stream.END and data.namespace == self._NAMESPACE:
+                if data.localname == 'include':
+                    try:
+                        if not include_href:
+                            raise TemplateError('Include misses required '
+                                                'attribute "href"')
+                        template = self.loader.load(include_href)
+                        for ikind, idata, ipos in template.generate(ctxt):
+                            # Fixup indentation of included markup
+                            if ikind is Stream.TEXT:
+                                idata = idata.replace('\n', '\n' + ' ' * indent)
+                            yield ikind, idata, ipos
+
+                        # If the included template defines any filters added at
+                        # runtime (such as py:match templates), those need to be
+                        # applied to the including template, too.
+                        for filter_ in template.filters:
+                            stream = filter_(stream, ctxt)
+
+                    except TemplateNotFound:
+                        if fallback_stream is None:
+                            raise
+                        for event in fallback_stream:
+                            yield event
+
+                    include_href = None
+                    fallback_stream = None
+                    indent = 0
+                    break
+                elif data.localname == 'fallback':
+                    in_fallback = False
+
+            elif in_fallback:
+                fallback_stream.append((kind, data, pos))
+
+            elif kind is Stream.START_NS and data[1] == self._NAMESPACE:
+                continue
+
+            else:
+                yield kind, data, pos
+        else:
+            # The loop exited normally, so there shouldn't be further events to
+            # process
+            return
+
+        for event in self(stream, ctxt):
+            yield event
+
+
+class MatchFilter(object):
+    """A filter that delegates to a given handler function when the input stream
+    matches some path expression.
+    """
+
+    def __init__(self, path, handler):
+        self.path = Path(path)
+        self.handler = handler
+
+    def __call__(self, stream, ctxt=None):
+        test = self.path.test()
+        for kind, data, pos in stream:
+            result = test(kind, data, pos)
+            if result is True:
+                content = [(kind, data, pos)]
+                depth = 1
+                while depth > 0:
+                    ev = stream.next()
+                    if ev[0] is Stream.START:
+                        depth += 1
+                    elif ev[0] is Stream.END:
+                        depth -= 1
+                    content.append(ev)
+                    test(*ev)
+
+                yield (Stream.SUB,
+                       ([lambda stream, ctxt: self.handler(content, ctxt)], []),
+                       pos)
+            else:
+                yield kind, data, pos
+
+
+class WhitespaceFilter(object):
+    """A filter that removes extraneous white space from the stream.
+
+    Todo:
+     * Support for xml:space
+    """
+
+    _TRAILING_SPACE = re.compile('[ \t]+(?=\n)')
+    _LINE_COLLAPSE = re.compile('\n{2,}')
+
+    def __call__(self, stream, ctxt=None):
+        textbuf = []
+        prev_kind = None
+        for kind, data, pos in stream:
+            if kind is Stream.TEXT:
+                textbuf.append(data)
+            elif prev_kind is Stream.TEXT:
+                text = ''.join(textbuf)
+                text = self._TRAILING_SPACE.sub('', text)
+                text = self._LINE_COLLAPSE.sub('\n', text)
+                yield Stream.TEXT, text, pos
+                del textbuf[:]
+            prev_kind = kind
+            if kind is not Stream.TEXT:
+                yield kind, data, pos
+
+        if textbuf:
+            text = self._LINE_COLLAPSE.sub('\n', ''.join(textbuf))
+            yield Stream.TEXT, text, pos
+
+
+class HTMLSanitizer(object):
+    """A filter that removes potentially dangerous HTML tags and attributes
+    from the stream.
+    """
+
+    _SAFE_TAGS = frozenset(['a', 'abbr', 'acronym', 'address', 'area', 'b',
+        'big', 'blockquote', 'br', 'button', 'caption', 'center', 'cite',
+        'code', 'col', 'colgroup', 'dd', 'del', 'dfn', 'dir', 'div', 'dl', 'dt',
+        'em', 'fieldset', 'font', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+        'hr', 'i', 'img', 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'map',
+        'menu', 'ol', 'optgroup', 'option', 'p', 'pre', 'q', 's', 'samp',
+        'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'table',
+        'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'tr', 'tt', 'u',
+        'ul', 'var'])
+
+    _SAFE_ATTRS = frozenset(['abbr', 'accept', 'accept-charset', 'accesskey',
+        'action', 'align', 'alt', 'axis', 'border', 'cellpadding',
+        'cellspacing', 'char', 'charoff', 'charset', 'checked', 'cite', 'class',
+        'clear', 'cols', 'colspan', 'color', 'compact', 'coords', 'datetime',
+        'dir', 'disabled', 'enctype', 'for', 'frame', 'headers', 'height',
+        'href', 'hreflang', 'hspace', 'id', 'ismap', 'label', 'lang',
+        'longdesc', 'maxlength', 'media', 'method', 'multiple', 'name',
+        'nohref', 'noshade', 'nowrap', 'prompt', 'readonly', 'rel', 'rev',
+        'rows', 'rowspan', 'rules', 'scope', 'selected', 'shape', 'size',
+        'span', 'src', 'start', 'style', 'summary', 'tabindex', 'target',
+        'title', 'type', 'usemap', 'valign', 'value', 'vspace', 'width'])
+    _URI_ATTRS = frozenset(['action', 'background', 'dynsrc', 'href', 'lowsrc',
+        'src'])
+    _SAFE_SCHEMES = frozenset(['file', 'ftp', 'http', 'https', 'mailto', None])
+
+    def __call__(self, stream, ctxt=None):
+        waiting_for = None
+
+        for kind, data, pos in stream:
+            if kind is Stream.START:
+                if waiting_for:
+                    continue
+                tag, attrib = data
+                if tag not in self._SAFE_TAGS:
+                    waiting_for = tag
+                    continue
+
+                new_attrib = []
+                for attr, value in attrib:
+                    if attr not in self._SAFE_ATTRS:
+                        continue
+                    elif attr in self._URI_ATTRS:
+                        # Don't allow URI schemes such as "javascript:"
+                        if self._get_scheme(value) not in self._SAFE_SCHEMES:
+                            continue
+                    elif attr == 'style':
+                        # Remove dangerous CSS declarations from inline styles
+                        decls = []
+                        for decl in filter(None, value.split(';')):
+                            is_evil = False
+                            if 'expression' in decl:
+                                is_evil = True
+                            for m in re.finditer(r'url\s*\(([^)]+)', decl):
+                                if self._get_scheme(m.group(1)) not in self._SAFE_SCHEMES:
+                                    is_evil = True
+                                    break
+                            if not is_evil:
+                                decls.append(decl.strip())
+                        if not decls:
+                            continue
+                        value = '; '.join(decls)
+                    new_attrib.append((attr, value))
+
+                yield kind, (tag, new_attrib), pos
+
+            elif kind is Stream.END:
+                tag = data
+                if waiting_for:
+                    if waiting_for == tag:
+                        waiting_for = None
+                else:
+                    yield kind, data, pos
+
+            else:
+                if not waiting_for:
+                    yield kind, data, pos
+
+    def _get_scheme(self, text):
+        if ':' not in text:
+            return None
+        chars = [char for char in text.split(':', 1)[0] if char.isalnum()]
+        return ''.join(chars).lower()
Index: /trunk/markup/input.py
===================================================================
--- /trunk/markup/input.py	(revision 3)
+++ /trunk/markup/input.py	(revision 3)
@@ -0,0 +1,202 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+from xml.parsers import expat
+try:
+    frozenset
+except NameError:
+    from sets import ImmutableSet as frozenset
+import HTMLParser as html
+import htmlentitydefs
+import re
+from StringIO import StringIO
+
+from markup.core import Attributes, Markup, QName, Stream
+
+
+class XMLParser(object):
+    """Generator-based XML parser based on roughly equivalent code in
+    Kid/ElementTree."""
+
+    def __init__(self, source):
+        self.source = source
+
+        # Setup the Expat parser
+        parser = expat.ParserCreate('utf-8', '}')
+        parser.buffer_text = True
+        parser.returns_unicode = True
+        parser.StartElementHandler = self._handle_start
+        parser.EndElementHandler = self._handle_end
+        parser.CharacterDataHandler = self._handle_data
+        parser.XmlDeclHandler = self._handle_prolog
+        parser.StartDoctypeDeclHandler = self._handle_doctype
+        parser.StartNamespaceDeclHandler = self._handle_start_ns
+        parser.EndNamespaceDeclHandler = self._handle_end_ns
+        parser.ProcessingInstructionHandler = self._handle_pi
+        parser.CommentHandler = self._handle_comment
+        parser.DefaultHandler = self._handle_other
+
+        # Location reporting is only support in Python >= 2.4
+        if not hasattr(parser, 'CurrentLineNumber'):
+            self.getpos = self._getpos_unknown
+
+        self.expat = parser
+        self.queue = []
+
+    def __iter__(self):
+        bufsize = 4 * 1024 # 4K
+        done = False
+        while True:
+            while not done and len(self.queue) == 0:
+                data = self.source.read(bufsize)
+                if data == '': # end of data
+                    if hasattr(self, 'expat'):
+                        self.expat.Parse('', True)
+                        del self.expat # get rid of circular references
+                    done = True
+                else:
+                    self.expat.Parse(data, False)
+            for event in self.queue:
+                yield event
+            self.queue = []
+            if done:
+                break
+
+    def _getpos_unknown(self):
+        return (-1, -1)
+
+    def getpos(self):
+        return self.expat.CurrentLineNumber, self.expat.CurrentColumnNumber
+
+    def _handle_start(self, tag, attrib):
+        self.queue.append((Stream.START, (QName(tag), Attributes(attrib.items())),
+                           self.getpos()))
+
+    def _handle_end(self, tag):
+        self.queue.append((Stream.END, QName(tag), self.getpos()))
+
+    def _handle_data(self, text):
+        self.queue.append((Stream.TEXT, text, self.getpos()))
+
+    def _handle_prolog(self, version, encoding, standalone):
+        self.queue.append((Stream.PROLOG, (version, encoding, standalone),
+                           self.getpos()))
+
+    def _handle_doctype(self, name, sysid, pubid, has_internal_subset):
+        self.queue.append((Stream.DOCTYPE, (name, pubid, sysid), self.getpos()))
+
+    def _handle_start_ns(self, prefix, uri):
+        self.queue.append((Stream.START_NS, (prefix or '', uri), self.getpos()))
+
+    def _handle_end_ns(self, prefix):
+        self.queue.append((Stream.END_NS, prefix or '', self.getpos()))
+
+    def _handle_pi(self, target, data):
+        self.queue.append((Stream.PI, (target, data), self.getpos()))
+
+    def _handle_comment(self, text):
+        self.queue.append((Stream.COMMENT, text, self.getpos()))
+
+    def _handle_other(self, text):
+        if text.startswith('&'):
+            # deal with undefined entities
+            try:
+                text = unichr(htmlentitydefs.name2codepoint[text[1:-1]])
+                self.queue.append((Stream.TEXT, text, self.getpos()))
+            except KeyError:
+                lineno, offset = self.getpos()
+                raise expat.error("undefined entity %s: line %d, column %d" %
+                                  (text, lineno, offset))
+
+
+def XML(text):
+    return Stream(list(XMLParser(StringIO(text))))
+
+
+class HTMLParser(html.HTMLParser):
+    """Parser for HTML input based on the Python `HTMLParser` module.
+    
+    This class provides the same interface for generating stream events as
+    `XMLParser`, and attempts to automatically balance tags.
+    """
+
+    _EMPTY_ELEMS = frozenset(['area', 'base', 'basefont', 'br', 'col', 'frame',
+                              'hr', 'img', 'input', 'isindex', 'link', 'meta',
+                              'param'])
+
+    def __init__(self, source):
+        html.HTMLParser.__init__(self)
+        self.source = source
+        self.queue = []
+        self._open_tags = []
+
+    def __iter__(self):
+        bufsize = 4 * 1024 # 4K
+        done = False
+        while True:
+            while not done and len(self.queue) == 0:
+                data = self.source.read(bufsize)
+                if data == '': # end of data
+                    self.close()
+                    done = True
+                else:
+                    self.feed(data)
+            for kind, data, pos in self.queue:
+                yield kind, data, pos
+            self.queue = []
+            if done:
+                open_tags = self._open_tags
+                open_tags.reverse()
+                for tag in open_tags:
+                    yield Stream.END, QName(tag), pos
+                break
+
+    def handle_starttag(self, tag, attrib):
+        pos = self.getpos()
+        self.queue.append((Stream.START, (QName(tag), Attributes(attrib)), pos))
+        if tag in self._EMPTY_ELEMS:
+            self.queue.append((Stream.END, QName(tag), pos))
+        else:
+            self._open_tags.append(tag)
+
+    def handle_endtag(self, tag):
+        if tag not in self._EMPTY_ELEMS:
+            pos = self.getpos()
+            while self._open_tags:
+                open_tag = self._open_tags.pop()
+                if open_tag.lower() == tag.lower():
+                    break
+                self.queue.append((Stream.END, QName(open_tag), pos))
+            self.queue.append((Stream.END, QName(tag), pos))
+
+    def handle_data(self, text):
+        self.queue.append((Stream.TEXT, text, self.getpos()))
+
+    def handle_charref(self, name):
+        self.queue.append((Stream.TEXT, Markup('&#%s;' % name), self.getpos()))
+
+    def handle_entityref(self, name):
+        self.queue.append((Stream.TEXT, Markup('&%s;' % name), self.getpos()))
+
+    def handle_pi(self, data):
+        target, data = data.split(maxsplit=1)
+        data = data.rstrip('?')
+        self.queue.append((Stream.PI, (target.strip(), data.strip()),
+                           self.getpos()))
+
+    def handle_comment(self, text):
+        self.queue.append((Stream.COMMENT, text, self.getpos()))
+
+
+def HTML(text):
+    return Stream(list(HTMLParser(StringIO(text))))
Index: /trunk/markup/output.py
===================================================================
--- /trunk/markup/output.py	(revision 3)
+++ /trunk/markup/output.py	(revision 3)
@@ -0,0 +1,199 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+"""This module provides different kinds of serialization methods for XML event
+streams.
+"""
+
+try:
+    frozenset
+except NameError:
+    from sets import ImmutableSet as frozenset
+
+from markup.core import Markup, QName, Stream
+from markup.filters import WhitespaceFilter
+
+__all__ = ['Serializer', 'XMLSerializer', 'HTMLSerializer']
+
+
+class Serializer(object):
+    """Base class for serializers."""
+
+    def serialize(self, stream):
+        raise NotImplementedError
+
+
+class XMLSerializer(Serializer):
+    """Produces XML text from an event stream.
+    
+    >>> from markup.builder import tag
+    >>> elem = tag.DIV(tag.A(href='foo'), tag.BR, tag.HR(noshade=True))
+    >>> print ''.join(XMLSerializer().serialize(elem.generate()))
+    <div><a href="foo"/><br/><hr noshade="True"/></div>
+    """
+
+    def serialize(self, stream):
+        ns_attrib = []
+        ns_mapping = {}
+
+        stream = PushbackIterator(stream)
+        for kind, data, pos in stream:
+
+            if kind is Stream.DOCTYPE:
+                # FIXME: what if there's no system or public ID in the input?
+                yield Markup('<!DOCTYPE %s "%s" "%s">\n' % data)
+
+            elif kind is Stream.START_NS:
+                prefix, uri = data
+                if uri not in ns_mapping:
+                    ns_mapping[uri] = prefix
+                    if not prefix:
+                        ns_attrib.append((QName('xmlns'), uri))
+                    else:
+                        ns_attrib.append((QName('xmlns:%s' % prefix), uri))
+
+            elif kind is Stream.START:
+                tag, attrib = data
+
+                tagname = tag.localname
+                if tag.namespace:
+                    try:
+                        prefix = ns_mapping[tag.namespace]
+                        if prefix:
+                            tagname = prefix + ':' + tag.localname
+                    except KeyError:
+                        ns_attrib.append((QName('xmlns'), tag.namespace))
+                buf = ['<', tagname]
+
+                if ns_attrib:
+                    attrib.extend(ns_attrib)
+                    ns_attrib = []
+                for attr, value in attrib:
+                    attrname = attr.localname
+                    if attr.namespace:
+                        try:
+                            prefix = ns_mapping[attr.namespace]
+                        except KeyError:
+                            # FIXME: synthesize a prefix for the attribute?
+                            prefix = ''
+                        if prefix:
+                            attrname = prefix + ':' + attrname
+                    buf.append(' %s="%s"' % (attrname, Markup.escape(value)))
+
+                kind, data, pos = stream.next()
+                if kind is Stream.END:
+                    buf.append('/>')
+                else:
+                    buf.append('>')
+                    stream.pushback((kind, data, pos))
+
+                yield Markup(''.join(buf))
+
+            elif kind is Stream.END:
+                tag = data
+                tagname = tag.localname
+                if tag.namespace:
+                    prefix = ns_mapping[tag.namespace]
+                    if prefix:
+                        tagname = prefix + ':' + tag.localname
+                yield Markup('</%s>' % tagname)
+
+            elif kind is Stream.TEXT:
+                yield Markup.escape(data, quotes=False)
+
+
+class HTMLSerializer(Serializer):
+    """Produces HTML text from an event stream.
+    
+    >>> from markup.builder import tag
+    >>> elem = tag.DIV(tag.A(href='foo'), tag.BR, tag.HR(noshade=True))
+    >>> print ''.join(HTMLSerializer().serialize(elem.generate()))
+    <div><a href="foo"></a><br><hr noshade></div>
+    """
+
+    NAMESPACE = 'http://www.w3.org/1999/xhtml'
+
+    _EMPTY_ELEMS = frozenset(['area', 'base', 'basefont', 'br', 'col', 'frame',
+                              'hr', 'img', 'input', 'isindex', 'link', 'meta',
+                              'param'])
+    _BOOLEAN_ATTRS = frozenset(['selected', 'checked', 'compact', 'declare',
+                                'defer', 'disabled', 'ismap', 'multiple',
+                                'nohref', 'noresize', 'noshade', 'nowrap'])
+
+    def serialize(self, stream):
+        ns_mapping = {}
+
+        stream = PushbackIterator(stream)
+        for kind, data, pos in stream:
+
+            if kind is Stream.DOCTYPE:
+                yield Markup('<!DOCTYPE %s "%s" "%s">\n' % data)
+
+            elif kind is Stream.START_NS:
+                prefix, uri = data
+                if uri not in ns_mapping:
+                    ns_mapping[uri] = prefix
+
+            elif kind is Stream.START:
+                tag, attrib = data
+                if tag.namespace and tag.namespace != self.NAMESPACE:
+                    continue # not in the HTML namespace, so don't emit
+                buf = ['<', tag.localname]
+                for attr, value in attrib:
+                    if attr.namespace and attr.namespace != self.NAMESPACE:
+                        continue # not in the HTML namespace, so don't emit
+                    if attr.localname in self._BOOLEAN_ATTRS:
+                        if value:
+                            buf.append(' %s' % attr.localname)
+                    else:
+                        buf.append(' %s="%s"' % (attr.localname,
+                                                 Markup.escape(value)))
+
+                if tag.localname in self._EMPTY_ELEMS:
+                    kind, data, pos = stream.next()
+                    if kind is not Stream.END:
+                        stream.pushback((kind, data, pos))
+
+                yield Markup(''.join(buf + ['>']))
+
+            elif kind is Stream.END:
+                tag = data
+                if tag.namespace and tag.namespace != self.NAMESPACE:
+                    continue # not in the HTML namespace, so don't emit
+                yield Markup('</%s>' % tag.localname)
+
+            elif kind is Stream.TEXT:
+                yield Markup.escape(data, quotes=False)
+
+
+class PushbackIterator(object):
+    """A simple wrapper for iterators that allows pushing items back on the
+    queue via the `pushback()` method.
+    
+    That can effectively be used to peek at the next item."""
+    __slots__ = ['iterable', 'buf']
+
+    def __init__(self, iterable):
+        self.iterable = iter(iterable)
+        self.buf = []
+
+    def __iter__(self):
+        return self
+
+    def next(self):
+        if self.buf:
+            return self.buf.pop(0)
+        return self.iterable.next()
+
+    def pushback(self, item):
+        self.buf.append(item)
Index: /trunk/markup/path.py
===================================================================
--- /trunk/markup/path.py	(revision 3)
+++ /trunk/markup/path.py	(revision 3)
@@ -0,0 +1,308 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+"""Basic support for evaluating XPath expressions against streams."""
+
+import re
+
+from markup.core import QName, Stream
+
+__all__ = ['Path']
+
+_QUOTES = (("'", "'"), ('"', '"'))
+
+class Path(object):
+    """Basic XPath support on markup event streams.
+    
+    >>> from markup.input import XML
+    
+    Selecting specific tags:
+    
+    >>> Path('root').select(XML('<root/>')).render()
+    '<root/>'
+    >>> Path('//root').select(XML('<root/>')).render()
+    '<root/>'
+    
+    Using wildcards for tag names:
+    
+    >>> Path('*').select(XML('<root/>')).render()
+    '<root/>'
+    >>> Path('//*').select(XML('<root/>')).render()
+    '<root/>'
+    
+    Selecting attribute values:
+    
+    >>> Path('@foo').select(XML('<root/>')).render()
+    ''
+    >>> Path('@foo').select(XML('<root foo="bar"/>')).render()
+    'bar'
+    
+    Selecting descendants:
+    
+    >>> Path("root/*").select(XML('<root><foo/><bar/></root>')).render()
+    '<foo/><bar/>'
+    >>> Path("root/bar").select(XML('<root><foo/><bar/></root>')).render()
+    '<bar/>'
+    >>> Path("root/baz").select(XML('<root><foo/><bar/></root>')).render()
+    ''
+    >>> Path("root/foo/*").select(XML('<root><foo><bar/></foo></root>')).render()
+    '<bar/>'
+    
+    Selecting text nodes:
+    >>> Path("item/text()").select(XML('<root><item>Foo</item></root>')).render()
+    'Foo'
+    >>> Path("item/text()").select(XML('<root><item>Foo</item><item>Bar</item></root>')).render()
+    'FooBar'
+    
+    Skipping ancestors:
+    
+    >>> Path("foo/bar").select(XML('<root><foo><bar/></foo></root>')).render()
+    '<bar/>'
+    >>> Path("foo/*").select(XML('<root><foo><bar/></foo></root>')).render()
+    '<bar/>'
+    >>> Path("root/bar").select(XML('<root><foo><bar/></foo></root>')).render()
+    ''
+    >>> Path("root/bar").select(XML('<root><foo><bar id="1"/></foo><bar id="2"/></root>')).render()
+    '<bar id="2"/>'
+    >>> Path("root/*/bar").select(XML('<root><foo><bar/></foo></root>')).render()
+    '<bar/>'
+    >>> Path("root//bar").select(XML('<root><foo><bar id="1"/></foo><bar id="2"/></root>')).render()
+    '<bar id="1"/><bar id="2"/>'
+    >>> Path("root//bar").select(XML('<root><foo><bar id="1"/></foo><bar id="2"/></root>')).render()
+    '<bar id="1"/><bar id="2"/>'
+    
+    Using simple attribute predicates:
+    >>> Path("root/item[@important]").select(XML('<root><item/><item important="very"/></root>')).render()
+    '<item important="very"/>'
+    >>> Path('root/item[@important="very"]').select(XML('<root><item/><item important="very"/></root>')).render()
+    '<item important="very"/>'
+    >>> Path("root/item[@important='very']").select(XML('<root><item/><item important="notso"/></root>')).render()
+    ''
+    >>> Path("root/item[@important!='very']").select(
+    ...     XML('<root><item/><item important="notso"/></root>')).render()
+    '<item/><item important="notso"/>'
+    """
+
+    _TOKEN_RE = re.compile('(::|\.\.|\(\)|[/.:\[\]\(\)@=!])|'
+                           '([^/:\[\]\(\)@=!\s]+)|'
+                           '\s+')
+
+    def __init__(self, text):
+        self.source = text
+
+        steps = []
+        cur_op = ''
+        cur_tag = ''
+        in_predicate = False
+        for op, tag in self._TOKEN_RE.findall(text):
+            if op:
+                if op == '[':
+                    in_predicate = True
+                elif op == ']':
+                    in_predicate = False
+                elif op.startswith('('):
+                    if cur_tag == 'text':
+                        steps[-1] = (False, self.fn_text(), [])
+                    else:
+                        raise NotImplementedError('XPath function "%s" not '
+                                                  'supported' % cur_tag)
+                else:
+                    cur_op += op
+                cur_tag = ''
+            else:
+                closure = cur_op in ('', '//')
+                if cur_op == '@':
+                    if tag == '*':
+                        node_test = self.any_attribute()
+                    else:
+                        node_test = self.attribute_by_name(tag)
+                else:
+                    if tag == '*':
+                        node_test = self.any_element()
+                    elif in_predicate:
+                        if len(tag) > 1 and (tag[0], tag[-1]) in _QUOTES:
+                            node_test = self.literal_string(tag[1:-1])
+                        if cur_op == '=':
+                            node_test = self.op_eq(steps[-1][2][-1], node_test)
+                            steps[-1][2].pop()
+                        elif cur_op == '!=':
+                            node_test = self.op_neq(steps[-1][2][-1], node_test)
+                            steps[-1][2].pop()
+                    else:
+                        node_test = self.element_by_name(tag)
+                if in_predicate:
+                    steps[-1][2].append(node_test)
+                else:
+                    steps.append([closure, node_test, []])
+                cur_op = ''
+                cur_tag = tag
+        self.steps = steps
+
+    def __repr__(self):
+        return '<%s "%s">' % (self.__class__.__name__, self.source)
+
+    def select(self, stream):
+        stream = iter(stream)
+        def _generate(tests):
+            test = self.test()
+            for kind, data, pos in stream:
+                result = test(kind, data, pos)
+                if result is True:
+                    yield kind, data, pos
+                    depth = 1
+                    while depth > 0:
+                        ev = stream.next()
+                        if ev[0] is Stream.START:
+                            depth += 1
+                        elif ev[0] is Stream.END:
+                            depth -= 1
+                        yield ev
+                        test(*ev)
+                elif result:
+                    yield result
+        return Stream(_generate(self.steps))
+
+    def test(self):
+        stack = [0] # stack of cursors into the location path
+
+        def _test(kind, data, pos):
+            #print '\nTracker %r test [%s] %r' % (self, kind, data)
+
+            if not stack:
+                return False
+
+            if kind is Stream.END:
+                stack.pop()
+                return None
+
+            if kind is Stream.START:
+                stack.append(stack[-1])
+
+            matched = False
+            closure, node_test, predicates = self.steps[stack[-1]]
+
+            #print '  Testing against %r' % node_test
+            matched = node_test(kind, data, pos)
+            if matched and predicates:
+                for predicate in predicates:
+                    if not predicate(kind, data, pos):
+                        matched = None
+                        break
+
+            if matched:
+                if stack[-1] == len(self.steps) - 1:
+                    #print '  Last step %r... returned %r' % (node_test, matched)
+                    return matched
+
+                #print '  Matched intermediate step %r... proceed to next step %r' % (node_test, self.steps[stack[-1] + 1])
+                stack[-1] += 1
+
+            elif kind is Stream.START and not closure:
+                # FIXME: If this step is not a closure, it cannot be matched
+                #        until the current element is closed... so we need to
+                #        move the cursor back to the last closure and retest
+                #        that against the current element
+                closures = [step for step in self.steps[:stack[-1]] if step[0]]
+                closures.reverse()
+                for closure, node_test, predicates in closures:
+                    stack[-1] -= 1
+                    if closure:
+                        matched = node_test(kind, data, pos)
+                        if matched:
+                            stack[-1] += 1
+                        break
+
+            return None
+
+        return _test
+
+    class any_element(object):
+        def __call__(self, kind, data, pos):
+            if kind is Stream.START:
+                return True
+            return None
+        def __repr__(self):
+            return '<%s>' % self.__class__.__name__
+
+    class element_by_name(object):
+        def __init__(self, name):
+            self.name = QName(name)
+        def __call__(self, kind, data, pos):
+            if kind is Stream.START:
+                return data[0].localname == self.name
+            return None
+        def __repr__(self):
+            return '<%s "%s">' % (self.__class__.__name__, self.name)
+
+    class any_attribute(object):
+        def __call__(self, kind, data, pos):
+            if kind is Stream.START:
+                text = ''.join([val for name, val in data[1]])
+                if text:
+                    return Stream.TEXT, text, pos
+                return None
+            return None
+        def __repr__(self):
+            return '<%s>' % (self.__class__.__name__)
+
+    class attribute_by_name(object):
+        def __init__(self, name):
+            self.name = QName(name)
+        def __call__(self, kind, data, pos):
+            if kind is Stream.START:
+                if self.name in data[1]:
+                    return Stream.TEXT, data[1].get(self.name), pos
+                return None
+            return None
+        def __repr__(self):
+            return '<%s "%s">' % (self.__class__.__name__, self.name)
+
+    class fn_text(object):
+        def __call__(self, kind, data, pos):
+            if kind is Stream.TEXT:
+                return kind, data, pos
+            return None
+        def __repr__(self):
+            return '<%s>' % (self.__class__.__name__)
+
+    class literal_string(object):
+        def __init__(self, value):
+            self.value = value
+        def __call__(self, kind, data, pos):
+            return Stream.TEXT, self.value, (-1, -1)
+        def __repr__(self):
+            return '<%s>' % (self.__class__.__name__)
+
+    class op_eq(object):
+        def __init__(self, lval, rval):
+            self.lval = lval
+            self.rval = rval
+        def __call__(self, kind, data, pos):
+            lval = self.lval(kind, data, pos)
+            rval = self.rval(kind, data, pos)
+            return (lval and lval[1]) == (rval and rval[1])
+        def __repr__(self):
+            return '<%s %r = %r>' % (self.__class__.__name__, self.lval,
+                                     self.rval)
+
+    class op_neq(object):
+        def __init__(self, lval, rval):
+            self.lval = lval
+            self.rval = rval
+        def __call__(self, kind, data, pos):
+            lval = self.lval(kind, data, pos)
+            rval = self.rval(kind, data, pos)
+            return (lval and lval[1]) != (rval and rval[1])
+        def __repr__(self):
+            return '<%s %r != %r>' % (self.__class__.__name__, self.lval,
+                                      self.rval)
Index: /trunk/markup/template.py
===================================================================
--- /trunk/markup/template.py	(revision 3)
+++ /trunk/markup/template.py	(revision 3)
@@ -0,0 +1,780 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+"""Template engine that is compatible with Kid (http://kid.lesscode.org) to a
+certain extent.
+
+Differences include:
+ * No generation of Python code for a template; the template is "interpreted"
+ * No support for <?python ?> processing instructions
+ * Expressions are evaluated in a more flexible manner, meaning you can use e.g.
+   attribute access notation to access items in a dictionary, etc
+ * Use of XInclude and match templates instead of Kid's py:extends/py:layout
+   directives
+ * Real (thread-safe) search path support
+ * No dependency on ElementTree (due to the lack of pos info)
+ * The original pos of parse events is kept throughout the processing
+   pipeline, so that errors can be tracked back to a specific line/column in
+   the template file
+ * py:match directives use (basic) XPath expressions to match against input
+   nodes, making match templates more powerful while keeping the syntax simple
+
+Todo items:
+ * XPath support needs a real implementation
+ * Improved error reporting
+ * Support for using directives as elements and not just as attributes, reducing
+   the need for wrapper elements with py:strip=""
+ * Support for py:choose/py:when/py:otherwise (similar to XSLT)
+ * Support for list comprehensions and generator expressions in expressions
+
+Random thoughts:
+ * Is there any need to support py:extends and/or py:layout?
+ * Could we generate byte code from expressions?
+"""
+
+import compiler
+from itertools import chain
+import os
+import re
+from StringIO import StringIO
+
+from markup.core import Attributes, Stream
+from markup.eval import Expression
+from markup.filters import EvalFilter, IncludeFilter, MatchFilter, \
+                           WhitespaceFilter
+from markup.input import HTML, XMLParser, XML
+
+__all__ = ['Context', 'BadDirectiveError', 'TemplateError',
+           'TemplateSyntaxError', 'TemplateNotFound', 'Template',
+           'TemplateLoader']
+
+
+class TemplateError(Exception):
+    """Base exception class for errors related to template processing."""
+
+
+class TemplateSyntaxError(TemplateError):
+    """Exception raised when an expression in a template causes a Python syntax
+    error."""
+
+    def __init__(self, message, filename='<string>', lineno=-1, offset=-1):
+        if isinstance(message, SyntaxError) and message.lineno is not None:
+            message = str(message).replace(' (line %d)' % message.lineno, '')
+        TemplateError.__init__(self, message)
+        self.filename = filename
+        self.lineno = lineno
+        self.offset = offset
+
+
+class BadDirectiveError(TemplateSyntaxError):
+    """Exception raised when an unknown directive is encountered when parsing
+    a template.
+    
+    An unknown directive is any attribute using the namespace for directives,
+    with a local name that doesn't match any registered directive.
+    """
+
+    def __init__(self, name, filename='<string>', lineno=-1):
+        TemplateSyntaxError.__init__(self, 'Bad directive "%s"' % name.localname,
+                                     filename, lineno)
+
+
+class TemplateNotFound(TemplateError):
+    """Exception raised when a specific template file could not be found."""
+
+    def __init__(self, name, search_path):
+        TemplateError.__init__(self, 'Template "%s" not found' % name)
+        self.search_path = search_path
+
+
+class Context(object):
+    """A container for template input data.
+    
+    A context provides a stack of scopes. Template directives such as loops can
+    push a new scope on the stack with data that should only be available
+    inside the loop. When the loop terminates, that scope can get popped off
+    the stack again.
+    
+    >>> ctxt = Context(one='foo', other=1)
+    >>> ctxt.get('one')
+    'foo'
+    >>> ctxt.get('other')
+    1
+    >>> ctxt.push(one='frost')
+    >>> ctxt.get('one')
+    'frost'
+    >>> ctxt.get('other')
+    1
+    >>> ctxt.pop()
+    >>> ctxt.get('one')
+    'foo'
+    """
+
+    def __init__(self, **data):
+        self.stack = [data]
+
+    def __getitem__(self, key):
+        """Get a variable's value, starting at the current context and going
+        upward.
+        """
+        return self.get(key)
+
+    def __repr__(self):
+        return repr(self.stack)
+
+    def __setitem__(self, key, value):
+        """Set a variable in the current context."""
+        self.stack[0][key] = value
+
+    def get(self, key):
+        for frame in self.stack:
+            if key in frame:
+                return frame[key]
+
+    def push(self, **data):
+        self.stack.insert(0, data)
+
+    def pop(self):
+        assert self.stack, 'Pop from empty context stack'
+        self.stack.pop(0)
+
+
+class Directive(object):
+    """Abstract base class for template directives.
+    
+    A directive is basically a callable that takes two parameters: `ctxt` is
+    the template data context, and `stream` is an iterable over the events that
+    the directive applies to.
+    
+    Directives can be "anonymous" or "registered". Registered directives can be
+    applied by the template author using an XML attribute with the
+    corresponding name in the template. Such directives should be subclasses of
+    this base class that can  be instantiated with two parameters: `template`
+    is the `Template` instance, and `value` is the value of the directive
+    attribute.
+    
+    Anonymous directives are simply functions conforming to the protocol
+    described above, and can only be applied programmatically (for example by
+    template filters).
+    """
+    __slots__ = ['expr']
+
+    def __init__(self, template, value, pos):
+        self.expr = value and Expression(value) or None
+
+    def __call__(self, stream, ctxt):
+        raise NotImplementedError
+
+    def __repr__(self):
+        expr = ''
+        if self.expr is not None:
+            expr = ' "%s"' % self.expr.source
+        return '<%s%s>' % (self.__class__.__name__, expr)
+
+
+class AttrsDirective(Directive):
+    """Implementation of the `py:attrs` template directive.
+    
+    The value of the `py:attrs` attribute should be a dictionary. The keys and
+    values of that dictionary will be added as attributes to the element:
+    
+    >>> ctxt = Context(foo={'class': 'collapse'})
+    >>> tmpl = Template('''<ul xmlns:py="http://purl.org/kid/ns#">
+    ...   <li py:attrs="foo">Bar</li>
+    ... </ul>''')
+    >>> print tmpl.generate(ctxt)
+    <ul>
+      <li class="collapse">Bar</li>
+    </ul>
+    
+    If the value evaluates to `None` (or any other non-truth value), no
+    attributes are added:
+    
+    >>> ctxt = Context(foo=None)
+    >>> print tmpl.generate(ctxt)
+    <ul>
+      <li>Bar</li>
+    </ul>
+    """
+    def __call__(self, stream, ctxt):
+        kind, (tag, attrib), pos  = stream.next()
+        attrs = self.expr.evaluate(ctxt)
+        if attrs:
+            attrib = attrib[:]
+            for name, value in attrs.items():
+                if value is not None:
+                    value = unicode(value).strip()
+                    attrib.append((name, value))
+        yield kind, (tag, Attributes(attrib)), pos
+        for event in stream:
+            yield event
+
+
+class ContentDirective(Directive):
+    """Implementation of the `py:content` template directive.
+    
+    This directive replaces the content of the element with the result of
+    evaluating the value of the `py:content` attribute:
+    
+    >>> ctxt = Context(bar='Bye')
+    >>> tmpl = Template('''<ul xmlns:py="http://purl.org/kid/ns#">
+    ...   <li py:content="bar">Hello</li>
+    ... </ul>''')
+    >>> print tmpl.generate(ctxt)
+    <ul>
+      <li>Bye</li>
+    </ul>
+    """
+    def __call__(self, stream, ctxt):
+        kind, data, pos = stream.next()
+        if kind is Stream.START:
+            yield kind, data, pos # emit start tag
+        yield Stream.EXPR, self.expr, pos
+        previous = None
+        try:
+            while True:
+                previous = stream.next()
+        except StopIteration:
+            if previous is not None:
+                yield previous
+
+
+class DefDirective(Directive):
+    """Implementation of the `py:def` template directive.
+    
+    This directive can be used to create "Named Template Functions", which
+    are template snippets that are not actually output during normal
+    processing, but rather can be expanded from expressions in other places
+    in the template.
+    
+    A named template function can be used just like a normal Python function
+    from template expressions:
+    
+    >>> ctxt = Context(bar='Bye')
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <p py:def="echo(greeting, name='world')" class="message">
+    ...     ${greeting}, ${name}!
+    ...   </p>
+    ...   ${echo('hi', name='you')}
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <p class="message">
+        hi, you!
+      </p>
+    </div>
+    
+    >>> ctxt = Context(bar='Bye')
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <p py:def="echo(greeting, name='world')" class="message">
+    ...     ${greeting}, ${name}!
+    ...   </p>
+    ...   <div py:replace="echo('hello')"></div>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <p class="message">
+        hello, world!
+      </p>
+    </div>
+    """
+    __slots__ = ['name', 'args', 'defaults', 'stream']
+
+    def __init__(self, template, args, pos):
+        Directive.__init__(self, template, None, pos)
+        ast = compiler.parse(args, 'eval').node
+        self.args = []
+        self.defaults = {}
+        if isinstance(ast, compiler.ast.CallFunc):
+            self.name = ast.node.name
+            for arg in ast.args:
+                if isinstance(arg, compiler.ast.Keyword):
+                    self.args.append(arg.name)
+                    self.defaults[arg.name] = arg.expr.value
+                else:
+                    self.args.append(arg.name)
+        else:
+            self.name = ast.name
+        self.stream = []
+
+    def __call__(self, stream, ctxt):
+        self.stream = list(stream)
+        ctxt[self.name] = lambda *args, **kwargs: self._exec(ctxt, *args,
+                                                             **kwargs)
+        return []
+
+    def _exec(self, ctxt, *args, **kwargs):
+        scope = {}
+        args = list(args) # make mutable
+        for name in self.args:
+            if args:
+                scope[name] = args.pop(0)
+            else:
+                scope[name] = kwargs.pop(name, self.defaults.get(name))
+        ctxt.push(**scope)
+        for event in self.stream:
+            yield event
+        ctxt.pop()
+
+
+class ForDirective(Directive):
+    """Implementation of the `py:for` template directive.
+    
+    >>> ctxt = Context(items=[1, 2, 3])
+    >>> tmpl = Template('''<ul xmlns:py="http://purl.org/kid/ns#">
+    ...   <li py:for="item in items">${item}</li>
+    ... </ul>''')
+    >>> print tmpl.generate(ctxt)
+    <ul>
+      <li>1</li><li>2</li><li>3</li>
+    </ul>
+    """
+    __slots__ = ['targets']
+
+    def __init__(self, template, value, pos):
+        targets, expr_source = value.split(' in ', 1)
+        self.targets = [str(name.strip()) for name in targets.split(',')]
+        Directive.__init__(self, template, expr_source, pos)
+
+    def __call__(self, stream, ctxt):
+        iterable = self.expr.evaluate(ctxt, [])
+        if iterable is not None:
+            stream = list(stream)
+            for item in iter(iterable):
+                if len(self.targets) == 1:
+                    item = [item]
+                scope = {}
+                for idx, name in enumerate(self.targets):
+                    scope[name] = item[idx]
+                ctxt.push(**scope)
+                for event in stream:
+                    yield event
+                ctxt.pop()
+
+    def __repr__(self):
+        return '<%s "%s in %s">' % (self.__class__.__name__,
+                                    ', '.join(self.targets), self.expr.source)
+
+
+class IfDirective(Directive):
+    """Implementation of the `py:if` template directive.
+    
+    >>> ctxt = Context(foo=True, bar='Hello')
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <b py:if="foo">${bar}</b>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <b>Hello</b>
+    </div>
+    """
+    def __call__(self, stream, ctxt):
+        if self.expr.evaluate(ctxt):
+            return stream
+        return []
+
+
+class MatchDirective(Directive):
+    """Implementation of the `py:match` template directive.
+    
+    >>> ctxt = Context()
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <span py:match="div/greeting">
+    ...     Hello ${select('@name')}
+    ...   </span>
+    ...   <greeting name="Dude" />
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <span>
+        Hello Dude
+      </span>
+    </div>
+    """
+    __slots__ = ['path', 'stream']
+
+    def __init__(self, template, value, pos):
+        Directive.__init__(self, template, None, pos)
+        template.filters.append(MatchFilter(value, self._handle_match))
+        self.path = value
+        self.stream = []
+
+    def __call__(self, stream, ctxt):
+        self.stream = list(stream)
+        return []
+
+    def __repr__(self):
+        return '<%s "%s">' % (self.__class__.__name__, self.path)
+
+    def _handle_match(self, orig_stream, ctxt):
+        ctxt.push(select=lambda path: Stream(orig_stream).select(path))
+        for event in self.stream:
+            yield event
+        ctxt.pop()
+
+
+class ReplaceDirective(Directive):
+    """Implementation of the `py:replace` template directive.
+    
+    >>> ctxt = Context(bar='Bye')
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <span py:replace="bar">Hello</span>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      Bye
+    </div>
+    
+    This directive is equivalent to `py:content` combined with `py:strip`,
+    providing a less verbose way to achieve the same effect:
+    
+    >>> ctxt = Context(bar='Bye')
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <span py:content="bar" py:strip="">Hello</span>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      Bye
+    </div>
+    """
+    def __call__(self, stream, ctxt):
+        kind, data, pos = stream.next()
+        yield Stream.EXPR, self.expr, pos
+
+
+class StripDirective(Directive):
+    """Implementation of the `py:strip` template directive.
+    
+    When the value of the `py:strip` attribute evaluates to `True`, the element
+    is stripped from the output
+    
+    >>> ctxt = Context()
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <div py:strip="True"><b>foo</b></div>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <b>foo</b>
+    </div>
+    
+    On the other hand, when the attribute evaluates to `False`, the element is
+    not stripped:
+    
+    >>> ctxt = Context()
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <div py:strip="False"><b>foo</b></div>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <div><b>foo</b></div>
+    </div>
+    
+    Leaving the attribute value empty is equivalent to a truth value:
+    
+    >>> ctxt = Context()
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <div py:strip=""><b>foo</b></div>
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+      <b>foo</b>
+    </div>
+    
+    This directive is particulary interesting for named template functions or
+    match templates that do not generate a top-level element:
+    
+    >>> ctxt = Context()
+    >>> tmpl = Template('''<div xmlns:py="http://purl.org/kid/ns#">
+    ...   <div py:def="echo(what)" py:strip="">
+    ...     <b>${what}</b>
+    ...   </div>
+    ...   ${echo('foo')}
+    ... </div>''')
+    >>> print tmpl.generate(ctxt)
+    <div>
+        <b>foo</b>
+    </div>
+    """
+    def __call__(self, stream, ctxt):
+        if self.expr:
+            strip = self.expr.evaluate(ctxt)
+        else:
+            strip = True
+        if strip:
+            stream.next() # skip start tag
+            # can ignore StopIteration since it will just break from this
+            # generator
+            previous = stream.next()
+            for event in stream:
+                yield previous
+                previous = event
+        else:
+            for event in stream:
+                yield event
+
+
+class Template(object):
+    """Can parse a template and transform it into the corresponding output
+    based on context data.
+    """
+    NAMESPACE = 'http://purl.org/kid/ns#'
+
+    directives = [('def', DefDirective),
+                  ('match', MatchDirective),
+                  ('for', ForDirective),
+                  ('if', IfDirective),
+                  ('replace', ReplaceDirective),
+                  ('content', ContentDirective),
+                  ('attrs', AttrsDirective),
+                  ('strip', StripDirective)]
+    _dir_by_name = dict(directives)
+    _dir_order = [directive[1] for directive in directives]
+
+    def __init__(self, source, filename=None):
+        """Initialize a template from either a string or a file-like object."""
+        if isinstance(source, basestring):
+            self.source = StringIO(source)
+        else:
+            self.source = source
+        self.filename = filename or '<string>'
+
+        self.pre_filters = [EvalFilter()]
+        self.filters = []
+        self.post_filters = [WhitespaceFilter()]
+        self.parse()
+
+    def __repr__(self):
+        return '<%s "%s">' % (self.__class__.__name__,
+                              os.path.basename(self.filename))
+
+    def parse(self):
+        """Parse the template.
+        
+        The parsing stage parses the XML template and constructs a list of
+        directives that will be executed in the render stage. The input is
+        split up into literal output (markup that does not depend on the
+        context data) and actual directives (commands or variable
+        substitution).
+        """
+        stream = [] # list of events of the "compiled" template
+        dirmap = {} # temporary mapping of directives to elements
+        ns_prefix = {}
+        depth = 0
+
+        for kind, data, pos in XMLParser(self.source):
+
+            if kind is Stream.START_NS:
+                # Strip out the namespace declaration for template directives
+                prefix, uri = data
+                if uri == self.NAMESPACE:
+                    ns_prefix[prefix] = uri
+                else:
+                    stream.append((kind, data, pos))
+
+            elif kind is Stream.END_NS:
+                if data in ns_prefix:
+                    del ns_prefix[data]
+                else:
+                    stream.append((kind, data, pos))
+
+            elif kind is Stream.START:
+                # Record any directive attributes in start tags
+                tag, attrib = data
+                directives = []
+                new_attrib = []
+                for name, value in attrib:
+                    if name.namespace == self.NAMESPACE:
+                        cls = self._dir_by_name.get(name.localname)
+                        if cls is None:
+                            raise BadDirectiveError(name, self.filename, pos[0])
+                        else:
+                            directives.append(cls(self, value, pos))
+                    else:
+                        value = list(self._interpolate(value, *pos))
+                        new_attrib.append((name, value))
+                if directives:
+                    directives.sort(lambda a, b: cmp(self._dir_order.index(a.__class__),
+                                                     self._dir_order.index(b.__class__)))
+                    dirmap[(depth, tag)] = (directives, len(stream))
+
+                stream.append((kind, (tag, Attributes(new_attrib)), pos))
+                depth += 1
+
+            elif kind is Stream.END:
+                depth -= 1
+                stream.append((kind, data, pos))
+
+                # If there have have directive attributes with the corresponding
+                # start tag, move the events inbetween into a "subprogram"
+                if (depth, data) in dirmap:
+                    directives, start_offset = dirmap.pop((depth, data))
+                    substream = stream[start_offset:]
+                    stream[start_offset:] = [(Stream.SUB,
+                                              (directives, substream), pos)]
+
+            elif kind is Stream.TEXT:
+                for kind, data, pos in self._interpolate(data, *pos):
+                    stream.append((kind, data, pos))
+
+            else:
+                stream.append((kind, data, pos))
+
+        self.stream = stream
+
+    def generate(self, ctxt):
+        """Transform the template based on the given context data."""
+
+        def _transform(stream):
+            # Apply pre and runtime filters
+            for filter_ in chain(self.pre_filters, self.filters):
+                stream = filter_(iter(stream), ctxt)
+
+            try:
+                for kind, data, pos in stream:
+
+                    if kind is Stream.SUB:
+                        # This event is a list of directives and a list of
+                        # nested events to which those directives should be
+                        # applied
+                        directives, substream = data
+                        directives.reverse()
+                        for directive in directives:
+                            substream = directive(iter(substream), ctxt)
+                        for event in _transform(iter(substream)):
+                            yield event
+
+                    else:
+                        yield kind, data, pos
+            except SyntaxError, err:
+                raise TemplateSyntaxError(err, self.filename, pos[0],
+                                          pos[1] + (err.offset or 0))
+
+        stream = _transform(self.stream)
+
+        # Apply post-filters
+        for filter_ in self.post_filters:
+            stream = filter_(iter(stream), ctxt)
+
+        return Stream(stream)
+
+    _FULL_EXPR_RE = re.compile(r'(?<!\$)\$\{(.+?)\}')
+    _SHORT_EXPR_RE = re.compile(r'(?<!\$)\$([a-zA-Z][a-zA-Z0-9_\.]*)')
+
+    def _interpolate(cls, text, lineno=-1, offset=-1):
+        """Parse the given string and extract expressions.
+        
+        This method returns a list containing both literal text and `Expression`
+        objects.
+
+        @param text: the text to parse
+        @param lineno: the line number at which the text was found (optional)
+        @param offset: the column number at which the text starts in the source
+            (optional)
+        """
+        patterns = [cls._FULL_EXPR_RE, cls._SHORT_EXPR_RE]
+        def _interpolate(text):
+            for idx, group in enumerate(patterns.pop(0).split(text)):
+                if idx % 2:
+                    yield Stream.EXPR, Expression(group), (lineno, offset)
+                elif group:
+                    if patterns:
+                        for result in _interpolate(group):
+                            yield result
+                    else:
+                        yield Stream.TEXT, group.replace('$$', '$'), \
+                              (lineno, offset)
+        return _interpolate(text)
+    _interpolate = classmethod(_interpolate)
+
+
+class TemplateLoader(object):
+    """Responsible for loading templates from files on the specified search
+    path.
+    
+    >>> import tempfile
+    >>> fd, path = tempfile.mkstemp(suffix='.html', prefix='template')
+    >>> os.write(fd, '<p>$var</p>')
+    11
+    >>> os.close(fd)
+    
+    The template loader accepts a list of directory paths that are then used
+    when searching for template files, in the given order:
+    
+    >>> loader = TemplateLoader([os.path.dirname(path)])
+    
+    The `load()` method first checks the template cache whether the requested
+    template has already been loaded. If not, it attempts to locate the
+    template file, and returns the corresponding `Template` object:
+    
+    >>> template = loader.load(os.path.basename(path))
+    >>> isinstance(template, Template)
+    True
+    
+    Template instances are cached: requesting a template with the same name
+    results in the same instance being returned:
+    
+    >>> loader.load(os.path.basename(path)) is template
+    True
+    """
+    def __init__(self, search_path=None, auto_reload=False):
+        """Create the template laoder.
+        
+        @param search_path: a list of absolute path names that should be
+            searched for template files
+        @param auto_reload: whether to check the last modification time of
+            template files, and reload them if they have changed
+        """
+        self.search_path = search_path
+        if self.search_path is None:
+            self.search_path = []
+        self.auto_reload = auto_reload
+        self._cache = {}
+        self._mtime = {}
+
+    def load(self, filename):
+        """Load the template with the given name.
+        
+        This method searches the search path trying to locate a template
+        matching the given name. If no such template is found, a
+        `TemplateNotFound` exception is raised. Otherwise, a `Template` object
+        representing the requested template is returned.
+        
+        Template searches are cached to avoid having to parse the same template
+        file more than once. Thus, subsequent calls of this method with the
+        same template file name will return the same `Template` object.
+        
+        @param filename: the relative path of the template file to load
+        """
+        filename = os.path.normpath(filename)
+        try:
+            tmpl = self._cache[filename]
+            if not self.auto_reload or \
+                    os.path.getmtime(tmpl.filename) == self._mtime[filename]:
+                return tmpl
+        except KeyError:
+            pass
+        for dirname in self.search_path:
+            filepath = os.path.join(dirname, filename)
+            try:
+                fileobj = file(filepath, 'rt')
+                try:
+                    tmpl = Template(fileobj, filename=filepath)
+                    tmpl.pre_filters.append(IncludeFilter(self))
+                finally:
+                    fileobj.close()
+                self._cache[filename] = tmpl
+                self._mtime[filename] = os.path.getmtime(filepath)
+                return tmpl
+            except IOError:
+                continue
+        raise TemplateNotFound(filename, self.search_path)
Index: /trunk/markup/tests/__init__.py
===================================================================
--- /trunk/markup/tests/__init__.py	(revision 3)
+++ /trunk/markup/tests/__init__.py	(revision 3)
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+import unittest
+
+def suite():
+    import markup
+    from markup.tests import builder, core, eval, input, output, path, template
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(markup))
+    suite.addTest(builder.suite())
+    suite.addTest(core.suite())
+    suite.addTest(eval.suite())
+    suite.addTest(input.suite())
+    suite.addTest(output.suite())
+    suite.addTest(path.suite())
+    suite.addTest(template.suite())
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/builder.py
===================================================================
--- /trunk/markup/tests/builder.py	(revision 3)
+++ /trunk/markup/tests/builder.py	(revision 3)
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+from HTMLParser import HTMLParseError
+import unittest
+
+from markup.builder import Element, tag
+from markup.core import Stream
+
+
+class ElementFactoryTestCase(unittest.TestCase):
+
+    def test_link(self):
+        link = tag.A(href='#', title='Foo', accesskey=None)('Bar')
+        bits = iter(link.generate())
+        self.assertEqual((Stream.START, ('a', [('href', "#"), ('title', "Foo")]),
+                          (-1, -1)), bits.next())
+        self.assertEqual((Stream.TEXT, u'Bar', (-1, -1)), bits.next())
+        self.assertEqual((Stream.END, 'a', (-1, -1)), bits.next())
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(Element.__module__))
+    suite.addTest(unittest.makeSuite(ElementFactoryTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/core.py
===================================================================
--- /trunk/markup/tests/core.py	(revision 3)
+++ /trunk/markup/tests/core.py	(revision 3)
@@ -0,0 +1,189 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+from HTMLParser import HTMLParseError
+import unittest
+
+from markup.core import Markup, escape, unescape
+
+
+class MarkupTestCase(unittest.TestCase):
+
+    def test_escape(self):
+        markup = escape('<b>"&"</b>')
+        assert isinstance(markup, Markup)
+        self.assertEquals('&lt;b&gt;&#34;&amp;&#34;&lt;/b&gt;', markup)
+
+    def test_escape_noquotes(self):
+        markup = escape('<b>"&"</b>', quotes=False)
+        assert isinstance(markup, Markup)
+        self.assertEquals('&lt;b&gt;"&amp;"&lt;/b&gt;', markup)
+
+    def test_unescape_markup(self):
+        string = '<b>"&"</b>'
+        markup = Markup.escape(string)
+        assert isinstance(markup, Markup)
+        self.assertEquals(string, unescape(markup))
+
+    def test_add_str(self):
+        markup = Markup('<b>foo</b>') + '<br/>'
+        assert isinstance(markup, Markup)
+        self.assertEquals('<b>foo</b>&lt;br/&gt;', markup)
+
+    def test_add_markup(self):
+        markup = Markup('<b>foo</b>') + Markup('<br/>')
+        assert isinstance(markup, Markup)
+        self.assertEquals('<b>foo</b><br/>', markup)
+
+    def test_add_reverse(self):
+        markup = 'foo' + Markup('<b>bar</b>')
+        assert isinstance(markup, unicode)
+        self.assertEquals('foo<b>bar</b>', markup)
+
+    def test_mod(self):
+        markup = Markup('<b>%s</b>') % '&'
+        assert isinstance(markup, Markup)
+        self.assertEquals('<b>&amp;</b>', markup)
+
+    def test_mod_multi(self):
+        markup = Markup('<b>%s</b> %s') % ('&', 'boo')
+        assert isinstance(markup, Markup)
+        self.assertEquals('<b>&amp;</b> boo', markup)
+
+    def test_mul(self):
+        markup = Markup('<b>foo</b>') * 2
+        assert isinstance(markup, Markup)
+        self.assertEquals('<b>foo</b><b>foo</b>', markup)
+
+    def test_join(self):
+        markup = Markup('<br />').join(['foo', '<bar />', Markup('<baz />')])
+        assert isinstance(markup, Markup)
+        self.assertEquals('foo<br />&lt;bar /&gt;<br /><baz />', markup)
+
+    def test_stripentities_all(self):
+        markup = Markup('&amp; &#106;').stripentities()
+        assert isinstance(markup, Markup)
+        self.assertEquals('& j', markup)
+
+    def test_stripentities_keepxml(self):
+        markup = Markup('<a href="#">fo<br />o</a>').striptags()
+        assert isinstance(markup, Markup)
+        self.assertEquals('foo', markup)
+
+    def test_striptags_empty(self):
+        markup = Markup('<br />').striptags()
+        assert isinstance(markup, Markup)
+        self.assertEquals('', markup)
+
+    def test_striptags_mid(self):
+        markup = Markup('<a href="#">fo<br />o</a>').striptags()
+        assert isinstance(markup, Markup)
+        self.assertEquals('foo', markup)
+
+    def test_sanitize_unchanged(self):
+        markup = Markup('<a href="#">fo<br />o</a>')
+        self.assertEquals('<a href="#">fo<br/>o</a>', str(markup.sanitize()))
+
+    def test_sanitize_escape_text(self):
+        markup = Markup('<a href="#">fo&amp;</a>')
+        self.assertEquals('<a href="#">fo&amp;</a>', str(markup.sanitize()))
+        markup = Markup('<a href="#">&lt;foo&gt;</a>')
+        self.assertEquals('<a href="#">&lt;foo&gt;</a>', str(markup.sanitize()))
+
+    def test_sanitize_entityref_text(self):
+        markup = Markup('<a href="#">fo&ouml;</a>')
+        self.assertEquals(u'<a href="#">foö</a>', unicode(markup.sanitize()))
+
+    def test_sanitize_escape_attr(self):
+        markup = Markup('<div title="&lt;foo&gt;"></div>')
+        self.assertEquals('<div title="&lt;foo&gt;"/>', str(markup.sanitize()))
+
+    def test_sanitize_close_empty_tag(self):
+        markup = Markup('<a href="#">fo<br>o</a>')
+        self.assertEquals('<a href="#">fo<br/>o</a>', str(markup.sanitize()))
+
+    def test_sanitize_invalid_entity(self):
+        markup = Markup('&junk;')
+        self.assertEquals('&amp;junk;', str(markup.sanitize()))
+
+    def test_sanitize_remove_script_elem(self):
+        markup = Markup('<script>alert("Foo")</script>')
+        self.assertEquals('', str(markup.sanitize()))
+        markup = Markup('<SCRIPT SRC="http://example.com/"></SCRIPT>')
+        self.assertEquals('', str(markup.sanitize()))
+        markup = Markup('<SCR\0IPT>alert("foo")</SCR\0IPT>')
+        self.assertRaises(HTMLParseError, markup.sanitize().render)
+        markup = Markup('<SCRIPT&XYZ SRC="http://example.com/"></SCRIPT>')
+        self.assertRaises(HTMLParseError, markup.sanitize().render)
+
+    def test_sanitize_remove_onclick_attr(self):
+        markup = Markup('<div onclick=\'alert("foo")\' />')
+        self.assertEquals('<div/>', str(markup.sanitize()))
+
+    def test_sanitize_remove_style_scripts(self):
+        # Inline style with url() using javascript: scheme
+        markup = Markup('<DIV STYLE=\'background: url(javascript:alert("foo"))\'>')
+        self.assertEquals('<div/>', str(markup.sanitize()))
+        # Inline style with url() using javascript: scheme, using control char
+        markup = Markup('<DIV STYLE=\'background: url(&#1;javascript:alert("foo"))\'>')
+        self.assertEquals('<div/>', str(markup.sanitize()))
+        # Inline style with url() using javascript: scheme, in quotes
+        markup = Markup('<DIV STYLE=\'background: url("javascript:alert(foo)")\'>')
+        self.assertEquals('<div/>', str(markup.sanitize()))
+        # IE expressions in CSS not allowed
+        markup = Markup('<DIV STYLE=\'width: expression(alert("foo"));\'>')
+        self.assertEquals('<div/>', str(markup.sanitize()))
+        markup = Markup('<DIV STYLE=\'background: url(javascript:alert("foo"));'
+                                     'color: #fff\'>')
+        self.assertEquals('<div style="color: #fff"/>', str(markup.sanitize()))
+
+    def test_sanitize_remove_src_javascript(self):
+        markup = Markup('<img src=\'javascript:alert("foo")\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+        # Case-insensitive protocol matching
+        markup = Markup('<IMG SRC=\'JaVaScRiPt:alert("foo")\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+        # Grave accents (not parsed)
+        markup = Markup('<IMG SRC=`javascript:alert("RSnake says, \'foo\'")`>')
+        self.assertRaises(HTMLParseError, markup.sanitize().render)
+        # Protocol encoded using UTF-8 numeric entities
+        markup = Markup('<IMG SRC=\'&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;'
+                        '&#112;&#116;&#58;alert("foo")\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+        # Protocol encoded using UTF-8 numeric entities without a semicolon
+        # (which is allowed because the max number of digits is used)
+        markup = Markup('<IMG SRC=\'&#0000106&#0000097&#0000118&#0000097'
+                        '&#0000115&#0000099&#0000114&#0000105&#0000112&#0000116'
+                        '&#0000058alert("foo")\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+        # Protocol encoded using UTF-8 numeric hex entities without a semicolon
+        # (which is allowed because the max number of digits is used)
+        markup = Markup('<IMG SRC=\'&#x6A&#x61&#x76&#x61&#x73&#x63&#x72&#x69'
+                        '&#x70&#x74&#x3A;alert("foo")\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+        # Embedded tab character in protocol
+        markup = Markup('<IMG SRC=\'jav\tascript:alert("foo");\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+        # Embedded tab character in protocol, but encoded this time
+        markup = Markup('<IMG SRC=\'jav&#x09;ascript:alert("foo");\'>')
+        self.assertEquals('<img/>', str(markup.sanitize()))
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(MarkupTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/eval.py
===================================================================
--- /trunk/markup/tests/eval.py	(revision 3)
+++ /trunk/markup/tests/eval.py	(revision 3)
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+import unittest
+
+from markup import eval
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(eval))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/input.py
===================================================================
--- /trunk/markup/tests/input.py	(revision 3)
+++ /trunk/markup/tests/input.py	(revision 3)
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import unittest
+
+from markup.core import Stream
+from markup.input import XMLParser
+
+
+class XMLParserTestCase(unittest.TestCase):
+    pass
+
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(XMLParserTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/output.py
===================================================================
--- /trunk/markup/tests/output.py	(revision 3)
+++ /trunk/markup/tests/output.py	(revision 3)
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+import unittest
+import sys
+
+from markup import output
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(output))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/path.py
===================================================================
--- /trunk/markup/tests/path.py	(revision 3)
+++ /trunk/markup/tests/path.py	(revision 3)
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+import unittest
+
+from markup import path
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(path))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/markup/tests/template.py
===================================================================
--- /trunk/markup/tests/template.py	(revision 3)
+++ /trunk/markup/tests/template.py	(revision 3)
@@ -0,0 +1,111 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+import doctest
+import unittest
+import sys
+
+from markup.core import Stream
+from markup.template import BadDirectiveError, Context, Template, \
+                            TemplateSyntaxError
+
+
+class TemplateTestCase(unittest.TestCase):
+
+    def test_interpolate_string(self):
+        parts = list(Template._interpolate('bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(Stream.TEXT, parts[0][0])
+        self.assertEqual('bla', parts[0][1])
+
+    def test_interpolate_simple(self):
+        parts = list(Template._interpolate('${bla}'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(Stream.EXPR, parts[0][0])
+        self.assertEqual('bla', parts[0][1].source)
+
+    def test_interpolate_escaped(self):
+        parts = list(Template._interpolate('$${bla}'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(Stream.TEXT, parts[0][0])
+        self.assertEqual('${bla}', parts[0][1])
+
+    def test_interpolate_short(self):
+        parts = list(Template._interpolate('$bla'))
+        self.assertEqual(1, len(parts))
+        self.assertEqual(Stream.EXPR, parts[0][0])
+        self.assertEqual('bla', parts[0][1].source)
+
+    def test_interpolate_mixed1(self):
+        parts = list(Template._interpolate('$foo bar $baz'))
+        self.assertEqual(3, len(parts))
+        self.assertEqual(Stream.EXPR, parts[0][0])
+        self.assertEqual('foo', parts[0][1].source)
+        self.assertEqual(Stream.TEXT, parts[1][0])
+        self.assertEqual(' bar ', parts[1][1])
+        self.assertEqual(Stream.EXPR, parts[2][0])
+        self.assertEqual('baz', parts[2][1].source)
+
+    def test_interpolate_mixed2(self):
+        parts = list(Template._interpolate('foo $bar baz'))
+        self.assertEqual(3, len(parts))
+        self.assertEqual(Stream.TEXT, parts[0][0])
+        self.assertEqual('foo ', parts[0][1])
+        self.assertEqual(Stream.EXPR, parts[1][0])
+        self.assertEqual('bar', parts[1][1].source)
+        self.assertEqual(Stream.TEXT, parts[2][0])
+        self.assertEqual(' baz', parts[2][1])
+
+    def test_bad_directive_error(self):
+        xml = '<p xmlns:py="http://purl.org/kid/ns#" py:do="nothing" />'
+        try:
+            tmpl = Template(xml, 'test.html')
+        except BadDirectiveError, e:
+            self.assertEqual('test.html', e.filename)
+            if sys.version_info[:2] >= (2, 4):
+                self.assertEqual(1, e.lineno)
+
+    def test_directive_value_syntax_error(self):
+        xml = '<p xmlns:py="http://purl.org/kid/ns#" py:if="bar\'" />'
+        tmpl = Template(xml, 'test.html')
+        try:
+            list(tmpl.generate(Context()))
+            self.fail('Expected SyntaxError')
+        except TemplateSyntaxError, e:
+            self.assertEqual('test.html', e.filename)
+            if sys.version_info[:2] >= (2, 4):
+                self.assertEqual(1, e.lineno)
+                # We don't really care about the offset here, do we?
+
+    def test_expression_syntax_error(self):
+        xml = '<p>\n  Foo <em>${bar"}</em>\n</p>'
+        tmpl = Template(xml, filename='test.html')
+        ctxt = Context(bar='baz')
+        try:
+            list(tmpl.generate(ctxt))
+            self.fail('Expected SyntaxError')
+        except TemplateSyntaxError, e:
+            self.assertEqual('test.html', e.filename)
+            if sys.version_info[:2] >= (2, 4):
+                self.assertEqual(2, e.lineno)
+                self.assertEqual(10, e.offset)
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(doctest.DocTestSuite(Template.__module__))
+    suite.addTest(unittest.makeSuite(TemplateTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
Index: /trunk/setup.cfg
===================================================================
--- /trunk/setup.cfg	(revision 3)
+++ /trunk/setup.cfg	(revision 3)
@@ -0,0 +1,3 @@
+[egg_info]
+tag_build = dev
+tag_svn_revision = true
Index: /trunk/setup.py
===================================================================
--- /trunk/setup.py	(revision 3)
+++ /trunk/setup.py	(revision 3)
@@ -0,0 +1,25 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2006 Christopher Lenz
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.com/license.html.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://projects.edgewall.com/trac/.
+
+from setuptools import setup, find_packages
+
+setup(
+    name='Markup', version='0.1',
+    description='Toolkit for stream-based generation of markup for the web',
+    author='Christopher Lenz', author_email='cmlenz@gmx.net',
+    license='BSD', url='http://markup.cmlenz.net/',
+    packages=find_packages(exclude=['*.tests*']),
+    test_suite = 'markup.tests.suite',
+    zip_safe = True
+)
