Skip to content

Instantly share code, notes, and snippets.

@karlwilcox
Created May 10, 2015 21:49
Show Gist options
  • Save karlwilcox/f9582c5afd6b22b0d515 to your computer and use it in GitHub Desktop.
Save karlwilcox/f9582c5afd6b22b0d515 to your computer and use it in GitHub Desktop.
An object which builds HTML code for subsequent use in a UIWebView. The HTML code is always valid as elements are created and closed automatically as required.
//
// HTMLBuilder.swift
//
// Created by Karl Wilcox on 16/03/2015.
// Copyright (c) 2015 Karl Wilcox. All rights reserved.
//
import Foundation
/// Builds and returns valid HTML for use in UIWebView
/* Example Usage:
* let page = HTMLBuilder()
* page.stylesheet = "stylesheet.css"
* page.startDiv("content")
* page.AddHeading("A Heading")
* page.addPlainText("The content is always valid HTML")
* page.addListItem("item 1")
* page.addListItem("item 2")
* let stringForUIWebView = page.HTMLasString
*/
class HTMLBuilder {
/* public type for specifying the list */
enum listType { case none, bullet, numbered }
/* private vars to track what state the HTML is in */
private var content:String = ""
private var inParagraph = false
private var inList = listType.none
private var divStack:[String] = []
private var newLine = ""
/// prettyPrint boolean, use newlines
var prettyPrint:Bool = false {
didSet {
if prettyPrint == true {
newLine = "\n"
} else {
newLine = ""
}
}
}
/// stylesheetName the basename (without .css) of the required stylesheet
var stylesheetName:String = "" {
didSet {
if stylesheetName != "" {
stylesheetName = "<link type=\"text/css\" rel=\"stylesheet\" href=\"\(stylesheetName).css\"/>\(newLine)"
}
}
}
/// paragraphClassAttribute sets class attribute of subsequently added paragraphs
var paragraphClassAttribute:String = "" {
didSet {
if paragraphClassAttribute != "" {
paragraphClassAttribute = " class=\"\(paragraphClassAttribute)\" "
}
}
}
/// listClassAttribute sets class attribute of subsequently added list (<ol> or <ul>)
var listClassAttribute:String = "" {
didSet {
if listClassAttribute != "" {
listClassAttribute = " class=\"\(listClassAttribute)\" "
}
}
}
/// rawHTML returns the actual code, and then deletes contents, allowing re-use with new content
var HTMLasString:String {
addMatchingCloseIfNeeded()
while divStack.count > 0 {
endDiv()
}
let valueToReturn = "<head>\(newLine)\(stylesheetName)</head>\(newLine)<body>\(newLine)\(content)</body>\(newLine)"
content = "" // makes it a reusable object
return valueToReturn
}
private func addMatchingCloseIfNeeded() {
if inList == .bullet {
content += "</ul>\(newLine)"
inList = .none
} else if inList == .numbered {
content += "</ol>\(newLine)"
inList = .none
} else if inParagraph {
content += "</p>\(newLine)"
inParagraph = false
}
}
private func addStartParaIfNeeded() {
if !inParagraph {
content += "<p\(paragraphClassAttribute)>"
inParagraph = true
}
}
private func escapeHTMLCharacters(var text:String) ->String {
let replacements = ["&", "&amp;", // This MUST be first!
"\"", "&quot;",
"'", "&#39;",
"<", "&lt;",
">", "&gt;"
]
for i in stride(from: 0, to: replacements.count, by: 2) {
text = text.stringByReplacingOccurrencesOfString(replacements[i], withString: replacements[i+1], options: nil, range: nil)
}
return text
}
/// Adds a heading to the HTML, of any level, with an optional class
///
/// :param: title The text content of the heading
/// :param: headingClass? value of class attribute to, or nil for no attribute at all
/// :param: level heading level (digit only, 1 to 5 allowed)
func addHeading(title:String, headingClass:String? = nil, var level:Int = 1) {
var classAttribute = ""
level = min(5,max(1,level))
addMatchingCloseIfNeeded()
if headingClass != nil && headingClass! != "" {
classAttribute = " class=\"\(headingClass!)\" "
}
content += "<h\(level)\(classAttribute)>\(escapeHTMLCharacters(title))</h\(level)>\(newLine)"
}
/// Starts a new paragraph. Only needed if already in a paragraph
func newParagraph() {
addMatchingCloseIfNeeded()
content += "<p\(paragraphClassAttribute)>"
}
/// Adds just plain text to the current paragraph, escaping HTML characters as required
/// The current class attribute will be added, if set
///
/// :param: text the text to be added, any embedded HTML will be escaped!
func addPlainText(text:String) {
addStartParaIfNeeded()
content += escapeHTMLCharacters(text) + " "
}
/// inserts raw HTML - use with care!!!
func addInnerHTML(text:String) {
content += text
}
/// Adds bold text to the current paragraph, escaping HTML characters as required
///
/// :param: text the text to be added, any embedded HTML will be escaped!
func addBoldText(text:String) {
addStartParaIfNeeded()
content += "<strong>\(escapeHTMLCharacters(text))</strong> "
}
/// Adds italic text to the current paragraph, escaping HTML characters as required
///
/// :param: text the text to be added, any embedded HTML will be escaped!
func addItalicText(text:String) {
addStartParaIfNeeded()
content += "<em>\(escapeHTMLCharacters(text))</em> "
}
/// Adds a link to the current paragraph, with optional link text
///
/// :param: url a URL string, which will be properly encoded so may contain spaces etc.
/// :param: text optional string, if present will be used as the link text instead of the URL
func addLink(url:String, text:String? = nil) {
addStartParaIfNeeded()
content += "<a href=\"\(url.stringByAddingPercentEscapesUsingEncoding(NSUTF8StringEncoding)!)\"> "
if text != nil && text! != "" {
content += escapeHTMLCharacters(text!)
} else {
content += escapeHTMLCharacters(url)
}
content += "</a>"
}
/// Adds a list item of a given type, ending paragraph and starting list if necessary
///
/// :param: text the contain of the list item (sorry, plain text only at the moment)
/// :param: type optional, set to .bullet (the default) or .numbered, if .none or missing, continue same
func addListItem(text:String, type:listType = .none) {
if inParagraph {
content += "</p>\(newLine)"
inParagraph = false
}
switch inList {
case .none: // not in any list, so start a new one
if type == .numbered {
content += "<ol\(listClassAttribute)>\(newLine)"
inList = .numbered
} else { // wether it is .none or .bullet, go with bulleted
content += "<ul\(listClassAttribute)>\(newLine)"
inList = .bullet
}
case .bullet: // already in a bullet, change if different type
if type == .numbered {
content += "</ul><ol\(listClassAttribute)>\(newLine)"
inList = .numbered
}
case .numbered: // already in a numbered list, change if different type
if type == .bullet {
content += "</ol><ul\(listClassAttribute)>\(newLine)"
inList = .bullet
}
}
// Now add the list item
content += "<li>\(escapeHTMLCharacters(text))</li>\(newLine)"
}
/// Adds an image, ending paragraph or list as necessary (images are on their own)
///
/// :param: url location of the image, will be properly encoded
/// :param: imageClass optional string will be set as the value of the class attribute
/// :param: width optional string, used as value of width attribute
/// :param: height optional string used as value of height attribute
func addImage(url: String, imageClass:String? = nil, width:String? = nil, height:String? = nil) {
var widthAttribute = ""
var heightAttribute = ""
var classAttribute = ""
addMatchingCloseIfNeeded()
if imageClass != nil && imageClass! != "" {
classAttribute = " class=\"\(imageClass!)\" "
}
if width != nil && width! != "" {
widthAttribute = " width=\"\(width!)\" "
}
if height != nil && height! != "" {
heightAttribute = " height=\"\(height!)\" "
}
content += "<img\(classAttribute) src=\"\(url.stringByAddingPercentEscapesUsingEncoding(NSUTF8StringEncoding)!)\"\(widthAttribute)\(heightAttribute)/>\(newLine)"
}
private var dummyID = 1
/// Starts a div, with an optional class and enforced ID, closing any paras or lists first
///
/// :param: divID optional ID, if nil a dummy unique value will be used
/// :param: classAttribute optional string used as value of class attribute
func startDiv(divID:String?, divClass:String? = nil) {
var idValue = ""
var classAttribute = ""
addMatchingCloseIfNeeded() // can't start divs in paragraphs or lists
if divID == nil {
idValue = "div\(dummyID)"
dummyID += 1
} else {
idValue = divID!
}
divStack.append(idValue)
if divClass != nil && divClass != "" {
classAttribute = " class=\"\(divClass!)\" "
}
content += "<div id=\"\(idValue)\"\(classAttribute)>\(newLine)"
}
/// ends the most recent div, harmless if no existing div
func endDiv() {
if divStack.count > 0 {
addMatchingCloseIfNeeded() // must end paragraph or list before ending div
if prettyPrint {
content += "<!-- \(divStack.last) -->"
}
content += "</div>\(newLine)"
divStack.removeLast()
}
}
func printRaw() {
println(content)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment