- Notifications
You must be signed in to change notification settings - Fork144
A DSL for writing type-safe HTML, XML and RSS in Swift.
License
JohnSundell/Plot
Folders and files
| Name | Name | Last commit message | Last commit date | |
|---|---|---|---|---|
Repository files navigation
Welcome toPlot, a domain-specific language (DSL) for writing type-safe HTML, XML and RSS in Swift. It can be used to build websites, documents and feeds, as a templating tool, or as a renderer for higher-level components and tools. It’s primary focus is on static site generation and Swift-based web development.
Plot is used to build and render all ofswiftbysundell.com.
Plot enables you to write HTML using native, fully compiled Swift code, by modeling the HTML5 standard’s various elements as Swift APIs. The result is a very lightweight DSL that lets you build complete web pages in a highly expressive way:
lethtml=HTML(.head(.title("My website"),.stylesheet("styles.css")),.body(.div(.h1("My website"),.p("Writing HTML in Swift is pretty great!"))))
Looking at the above, it may at first seem like Plot simply maps each function call directly to an equivalent HTML element — and while that’s the case forsome elements, Plot also inserts many kinds of highly valuable metadata automatically. For example, the above expression will result in this HTML:
<!DOCTYPE html><html><head><title>My website</title><metaname="twitter:title"content="My website"/><metaproperty="og:title"content="My website"/><linkrel="stylesheet"href="styles.css"type="text/css"/></head><body><div><h1>My website</h1><p>Writing HTML in Swift is pretty great!</p></div></body></html>
As you can see above, Plot added both all of the necessary attributes to load the requested CSS stylesheet, along with additional metadata for the page’s title as well — improving page rendering, social media sharing, and search engine optimization.
Plot ships with a very wide coverage of the HTML5 standard, enabling all sorts of elements to be defined using the same lightweight syntax — such as tables, lists, and inline text styling:
lethtml=HTML(.body(.h2("Countries and their capitals"),.table(.tr(.th("Country"),.th("Capital")),.tr(.td("Sweden"),.td("Stockholm")),.tr(.td("Japan"),.td("Tokyo"))),.h2("List of",.strong("programming languages")),.ul(.li("Swift"),.li("Objective-C"),.li("C"))))
Above we’re also using Plot’s powerful composition capabilities, which lets us express all sorts of HTML hierarchies by simply adding new elements as comma-separated values.
Attributes can also be applied the exact same way as child elements are added, by simply adding another entry to an element’s comma-separated list of content. For example, here’s how an anchor element with both a CSS class and a URL can be defined:
lethtml=HTML(.body(.a(.class("link"),.href("https://github.com"),"GitHub")))
The fact that attributes, elements and inline text are all defined the same way both makes Plot’s API easier to learn, and also enables a really fast and fluid typing experience — as you can simply type. within any context to keep defining new attributes and elements.
Plot makes heavy use of Swift’s advanced generics capabilities to not only make itpossible to write HTML and XML using native code, but to also make that process completely type-safe as well.
All of Plot’s elements and attributes are implemented as context-boundnodes, which both enforces valid HTML semantics, and also enables Xcode and other IDEs to provide rich autocomplete suggestions when writing code using Plot’s DSL.
For example, above thehref attribute was added to an<a> element, which is completely valid. However, if we instead attempted to add that same attribute to a<p> element, we’d get a compiler error:
lethtml=HTML(.body( // Compiler error: Referencing static method 'href' on // 'Node' requires that 'HTML.BodyContext' conform to // 'HTMLLinkableContext'..p(.href("https://github.com"))))
Plot also leverages the Swift type system to verify each document’s element structure as well. For example, within HTML lists (such as<ol> and<ul>), it’s only valid to place<li> elements — and if we break that rule, we’ll again get a compiler error:
lethtml=HTML(.body( // Compiler error: Member 'p' in 'Node<HTML.ListContext>' // produces result of type 'Node<Context>', but context // expects 'Node<HTML.ListContext>'..ul(.p("Not allowed"))))
This high degree of type safety both results in a really pleasant development experience, and that the HTML and XML documents created using Plot will have a much higher chance of being semantically correct — especially when compared to writing documents and markup using raw strings.
Plot’sComponent protocol enables you to define and render higher-level components using a very SwiftUI-like API.Node andComponent-based elements can be mixed when creating an HTML document, giving you the flexibility to freely choose which way to implement which part of a website or document.
For example, let’s say that we’re building a news website using Plot, and that we’d like to render news articles in several different places. Here’s how we could define a reusableNewsArticle component that in turn uses a series of built-in HTML components to render its UI:
structNewsArticle:Component{varimagePath:Stringvartitle:Stringvardescription:Stringvarbody:Component{Article{Image(url: imagePath, description:"Header image")H1(title)Span(description).class("description")}.class("news")}}
As the above example shows, modifiers can also be applied to components to set the value of attributes, such asclass orid.
To then integrate the above component into aNode-based hierarchy, we can simply wrap it within aNode using the.component API, like this:
func newsArticlePage(for article:NewsArticle)->HTML{returnHTML(.body(.div(.class("wrapper"),.component(article))))}
You can also directly inlineNode-based elements within a component’sbody, which gives you complete freedom to mix and match between the two APIs:
structBanner:Component{vartitle:StringvarimageURL:URLRepresentablevarbody:Component{Div{Node.h2(.text(title))Image(imageURL)}.class("banner")}}
It’s highly recommended that you use the above component-based approach as much as possible when building websites and documents with Plot — as doing so will let you build up a growing library of reusable components, which will most likely accelerate your overall workflow over time.
However, note that theComponent API can currently only be used to define elements that appear within the<body> of an HTML page. For<head> elements, or non-HTML elements, theNode-based API always has to be used.
Another important note is that, although Plot has been heavily optimized across the board,Component-based elements do require a bit of extra processing compared toNode-based ones — so in situations where maximum performance is required, you might want to stick to theNode-based API.
Just like SwiftUI views, Plot components can pass values downwards through a hierarchy using anenvironment API. Once a value has been entered into the environment using anEnvironmentKey and theenvironmentValue modifier, it can then be retrieved by defining a property marked with the@EnvironmentValue attribute within aComponent implementation.
In the following example, the environment API is used to enable aPage component to assign a givenclass to allActionButton components that appear within its hierarchy:
// We start by defining a custom environment key that can be// used to enter String values into the environment:extensionEnvironmentKeywhere Value==String{staticvaractionButtonClass:Self{Self(defaultValue:"action-button")}}structPage:Component{varbody:Component{Div{InfoView(title:"...", text:"...")} // Here we enter a custom action button class // into the environment, which will apply to // all child components within our above Div:.environmentValue("action-button-large", key:.actionButtonClass)}}// Our info view doesn't have to have any awareness of// our environment value. Plot will automatically pass// it down to the action buttons defined below:structInfoView:Component{vartitle:Stringvartext:Stringvarbody:Component{Div{H2(title)Paragraph(text)ActionButton(title:"OK")ActionButton(title:"Cancel")}.class("info-view")}}structActionButton:Component{vartitle:String // Here we pick up the current environment value for // our custom "actionButtonClass" key, which in this // example will be the value that our "Page" component // entered into the environment:@EnvironmentValue(.actionButtonClass)varclassNamevarbody:Component{Button(title).class(className)}}
Plot also ships with several components that utilize the environment API for customization. For example, you can change the style of allList components within a hierarchy using thelistStyle key/modifier, and thelinkRelationship key/modifier lets you tweak therel attribute of allLink components within a hierarchy.
Since Plot is focused on static site generation, it also ships with several control flow mechanisms that let you inline logic when using either itsNode-based orComponent-based APIs. For example, using the.if() command, you can optionally add a node only when a given condition istrue, and within a component’sbody, you can simply inline a regularif statement to do the same thing:
letrating:Rating=...// When using the Node-based API:lethtml=HTML(.body(.if(rating.hasEnoughVotes,.span("Average score:\(rating.averageScore)"))))// When using the Component API:lethtml=HTML{if rating.hasEnoughVotes{Span("Average score:\(rating.averageScore)")}}
You can also attach anelse clause to the node-based.if() command as well, which will act as a fallback node to be displayed when the command’s condition isfalse. You can also use a standardelse clause when using the component API:
// When using the Node-based API:lethtml=HTML(.body(.if(rating.hasEnoughVotes,.span("Average score:\(rating.averageScore)"), else:.span("Not enough votes yet."))))// When using the Component API:lethtml=HTML{if rating.hasEnoughVotes{Span("Average score:\(rating.averageScore)")}else{Span("Not enough votes yet.")}}
Optional values can also be unwrapped inline using theNode-based.unwrap() command, which takes an optional to unwrap, and a closure used to transform its value into a node. When using theComponent-based API, you can simply use a standardif let expression to do the same thing.
Here’s how those capabilities could be used to conditionally display a part of an HTML page only if a user is logged in.
letuser:User?=loadUser()// When using the Node-based API:lethtml=HTML(.body(.unwrap(user){.p("Hello,\($0.name)")}))// When using the Component-based API:lethtml=HTML{iflet user= user{Paragraph("Hello,\(user.name)")}}
Just like.if(), the.unwrap() command can also be passed anelse clause that will be used if the optional being unwrapped turned out to benil (and the equivalent logic can once again be implemented using a standardelse clause when using theComponent-based API):
letuser:User?=loadUser()// When using the Node-based API:lethtml=HTML(.body(.unwrap(user,{.p("Hello,\($0.name)")}, else:.text("Please log in"))))// When using the Component-based API:lethtml=HTML{iflet user= user{Paragraph("Hello,\(user.name)")}else{Text("Please log in")}}
Finally, the.forEach() command can be used to transform any SwiftSequence into a group of nodes, which is incredibly useful when constructingNode-based lists. When buildingComponent-based lists, you could either directly pass your sequence to the built-inList component, or use afor loop:
letnames:[String]=...// When using the Node-based API:lethtml=HTML(.body(.h2("People"),.ul(.forEach(names){.li(.class("name"),.text($0))})))// When using the Component-based API:lethtml=HTML{H2("People") // Passing our array directly to List:List(names){ nameinListItem(name).class("name")} // Using a manual for loop within a List closure:List{fornamein names{ListItem(name).class("name")}}}
Using the above control flow mechanisms, especially when combined with the approach of defining custom components, lets you build really flexible templates, documents and HTML pages — all in a completely type-safe way.
While Plot aims to cover as much of the standards associated with the document formats that it supports (see“Compatibility with standards” for more info), chances are that you’ll eventually encounter some form of element or attribute that Plot doesn’t yet cover.
Thankfully, Plot also makes it trivial to define custom elements and attributes — which is both useful when building more free-form XML documents, and as an“escape hatch” when Plot does not yet support a given part of a standard:
// When using the Node-based API:lethtml=HTML(.body(.element(named:"custom", text:"Hello..."),.p(.attribute(named:"custom", value:"...world!"))))// When using the Component-based API:lethtml=HTML{Element(name:"custom"){Text("Hello...")}Paragraph().attribute( named:"custom", value:"...world!")}
While the above APIs are great for constructing one-off custom elements, or for temporary working around a limitation in Plot’s built-in functionality, it’s (in most cases) recommended to instead either:
- Add and submit the missing API if it’s for an element or attribute that Plot should ideally cover.
- Define your own type-safe elements and attributes the same way Plot does — by first extending the relevant document format in order to add your own context type, and then extending the
Nodetype with your own DSL APIs:
extensionXML{enumProductContext{}}extensionNodewhere Context==XML.DocumentContext{staticfunc product(_ nodes:Node<XML.ProductContext>...)->Self{.element(named:"product", nodes: nodes)}}extensionNodewhere Context==XML.ProductContext{staticfunc name(_ name:String)->Self{.element(named:"name", text: name)}staticfunc isAvailable(_ bool:Bool)->Self{.attribute(named:"available", value:String(bool))}}
The above may at first seem like unnecessary busywork, but just like Plot itself, it can really improve the stability and predictability of your custom documents going forward.
Once you’ve finished constructing a document using Plot’s DSL, call therender method to render it into aString, which can optionally be indented using either tabs or spaces:
lethtml=HTML(...)letnonIndentedString= html.render()letspacesIndentedString= html.render(indentedBy:.spaces(4))lettabsIndentedString= html.render(indentedBy:.tabs(1))
Individual nodes can also be rendered independently, which makes it possible to use Plot to construct just a single part of a larger document:
letheader=Node.header(.h1("Title"),.span("Description"))letstring= header.render()
Just like nodes, components can also be rendered on their own:
letheader=Header{H1("Title")Span("Description")}letstring= header.render()
Plot was built with performance in mind, so regardless of how you render a document, the goal is for that rendering process to be as fast as possible — with very limited node tree traversal and as little string copying and interpolation as possible.
Besides HTML and free-form XML, Plot also ships with DSL APIs for constructing RSS and podcast feeds, as well as SiteMap XMLs for search engine indexing.
While these APIs are most likely only relevant when building tools and custom generators (the upcoming static site generator Publish includes implementations of all of these formats), they provide the same level of type safety as when building HTML pages using Plot:
letrss=RSS(.item(.guid("https://mysite.com/post",.isPermaLink(true)),.title("My post"),.link("https://mysite.com/post")))letpodcastFeed=PodcastFeed(.title("My podcast"),.owner(.name("John Appleseed"),.email("john.appleseed@url.com")),.item(.title("My first episode"),.audio( url:"https://mycdn.com/episode.mp3", byteSize:79295410, title:"My first episode")))letsiteMap=SiteMap(.url(.loc("https://mysite.com/post"),.lastmod(Date()),.changefreq(.daily),.priority(1)))
For more information about what data is required to build a podcast feed, seeApple’s podcasting guide, and for more information about the SiteMap format, seeits official spec.
To be able to successfully use Plot, make sure that your system has Swift version 5.4 (or later) installed. If you’re using a Mac, also make sure thatxcode-select is pointed at an Xcode installation that includes the required version of Swift, and that you’re running macOS Big Sur (11.0) or later.
Please note that Plotdoes not officially support any form of beta software, including beta versions of Xcode and macOS, or unreleased versions of Swift.
Plot is distributed using theSwift Package Manager. To install it into a project, simply add it as a dependency within yourPackage.swift manifest:
letpackage=Package(... dependencies:[.package(url:"https://github.com/johnsundell/plot.git", from:"0.9.0")],...)
Then import Plot wherever you’d like to use it:
import PlotFor more information on how to use the Swift Package Manager, check outthis article, orits official documentation.
Plot consists of four core parts, that together make up both its DSL and its overall document rendering API:
Nodeis the core building block for all elements and attributes within any Plot document. It can represent elements and attributes, as well as text content and groups of nodes. Each node is bound to aContexttype, which determines which kind of DSL APIs that it gets access to (for exampleHTML.BodyContextfor nodes placed within the<body>of an HTML page).Elementrepresents an element, and can either be opened and closed using two separate tags (like<body></body>) or self-closed (like<img/>). You normally don’t have to interact with this type when using Plot, since you can create instances of it through its DSL.Attributerepresents an attribute attached to an element, such as thehrefof an<a>element, or thesrcof an<img>element. You can either constructAttributevalues through its initializer, or through the DSL, using the.attribute()command.- The
Componentprotocol is used to define components in a very SwiftUI-like way. Every component needs to implement abodyproperty, in which its rendered output can be constructed using either other components, orNode-based elements. DocumentandDocumentFormatrepresent documents of a given format, such asHTML,RSSandPodcastFeed. These are the top level types that you use in order to start a document building session using Plot’s DSL.
Plot makes heavy use of a technique known asPhantom Types, which is when types are used as “markers” for the compiler, to be able to enforce type safety throughgeneric constraints. BothDocumentFormat, and theContext of a node, element or attribute, are used this way — as these types are never instantiated, but rather just there to associate their values with a given context or format.
Plot also uses a verylightweight API design, minimizing external argument labels in favor of reducing the amount of syntax needed to render a document — giving its API a very “DSL-like” design.
TheComponent API uses theResult Builders andProperty Wrappers language features to bring its very SwiftUI-like API to life.
Plot’s ultimate goal to be fully compatible with all standards that back the document formats that it supports. However, being a very young project, it will most likely need the community’s help to move it closer to that goal.
The following standards are intended to be covered by Plot’s DSL:
Note that theComponent API currently only covers a subset of the HTML 5.0 spec, and can currently only be used to define elements within the<body> of an HTML page.
If you discover an element or attribute that’s missing, pleaseadd it and open a Pull Request with that addition.
Plot was originally written byJohn Sundell as part of the Publish suite of static site generation tools, which is used to build and generateSwift by Sundell. That suite also includes the Markdown parserInk, as well asPublish itself.
The idea of using Swift to generate HTML has also been explored by many other people and projects in the community, some of them similar to Plot, some of them completely different. For exampleLeaf byVapor,swift-html byPoint-Free, and theSwift Talk backend byobjc.io. The fact that there’s a lot of simultaneous innovation within this area is a great thing — since all of these tools (including Plot) have made different decisions around their overall API design and scope, which lets each developer pick the tool that best fits their individual taste and needs (or perhaps build yet another one?).
Plot’s main focus is on Swift-based static site generation, and on supporting a wide range of formats used when building websites, including RSS and podcast feeds. It’s also tightly integrated with thePublish static site generator, and was built to enable Publish to be as fast and flexible as possible, without having to take on any third-party dependencies. It was open sourced as a separate project both from an architectural perspective, and to enable other tools to be built on top of it without having to depend on Publish.
Plot is developed completely in the open, and your contributions are more than welcome.
Before you start using Plot in any of your projects, it’s highly recommended that you spend a few minutes familiarizing yourself with its documentation and internal implementation, so that you’ll be ready to tackle any issues or edge cases that you might encounter.
Since this is still a young project, it’s likely to have many limitations and missing features, which is something that can really only be discovered and addressed as more people start using it. While Plot is used in production to build and render all ofSwift by Sundell, it’s recommended that you first try it out for your specific use case, to make sure it supports the features that you need.
This project doesnot come with GitHub Issues-based support, or any other kind of direct support channels, and users are instead encouraged to become active participants in its continued development — by fixing any bugs that they encounter, or by improving the documentation wherever it’s found to be lacking.
If you wish to make a change,open a Pull Request — even if it just contains a draft of the changes you’re planning, or a test that reproduces an issue — and we can discuss it further from there. SeePlot’s contribution guide for more information about how to contribute to this project.
Hope you’ll enjoy using Plot!
About
A DSL for writing type-safe HTML, XML and RSS in Swift.
Topics
Resources
License
Contributing
Uh oh!
There was an error while loading.Please reload this page.
Stars
Watchers
Forks
Packages0
Uh oh!
There was an error while loading.Please reload this page.
