diff options
author | Li Haoyi <haoyi.sg@gmail.com> | 2018-08-12 22:42:47 +0800 |
---|---|---|
committer | Li Haoyi <haoyi.sg@gmail.com> | 2018-08-12 22:55:53 +0800 |
commit | c0f39d743fbdf07544a6f5b6284d7123e5c36296 (patch) | |
tree | 64470286acb80c61e711299eded0b67fd516a8b8 /docs | |
parent | fd9c399db8c1c0d86cc65d5e1c41968b42a813d1 (diff) | |
download | cask-c0f39d743fbdf07544a6f5b6284d7123e5c36296.tar.gz cask-c0f39d743fbdf07544a6f5b6284d7123e5c36296.tar.bz2 cask-c0f39d743fbdf07544a6f5b6284d7123e5c36296.zip |
auto publishing of docs w/ example downloads works
Diffstat (limited to 'docs')
-rw-r--r-- | docs/build.sc | 198 | ||||
-rw-r--r-- | docs/favicon.png | bin | 0 -> 206 bytes | |||
-rw-r--r-- | docs/logo-white.svg | 1 | ||||
-rw-r--r-- | docs/pageStyles.sc | 130 | ||||
-rw-r--r-- | docs/pages.sc | 192 | ||||
-rw-r--r-- | docs/pages/1 - Cask: a Scala HTTP micro-framework .md (renamed from docs/readme.md) | 91 |
6 files changed, 582 insertions, 30 deletions
diff --git a/docs/build.sc b/docs/build.sc new file mode 100644 index 0000000..48fd3e7 --- /dev/null +++ b/docs/build.sc @@ -0,0 +1,198 @@ +// Load dependencies +import $ivy.{`org.pegdown:pegdown:1.6.0`, `com.lihaoyi::scalatags:0.6.5`} +import $file.pageStyles, pageStyles._ +import $file.pages, pages._ +import scalatags.Text.all._ +import $file.^.version +import ammonite.ops._ +import collection.JavaConverters._ +import org.pegdown.{PegDownProcessor, ToHtmlSerializer, LinkRenderer, Extensions} +import org.pegdown.ast.{VerbatimNode, ExpImageNode, HeaderNode, TextNode, SimpleNode, TableNode} + +val (releaseTag, label) = version.publishVersion + +val postsFolder = cwd/'pages + +interp.watch(postsFolder) + +val targetFolder = cwd/'target + + +val (markdownFiles, otherFiles) = ls! postsFolder partition (_.ext == "md") +markdownFiles.foreach(println) +// Walk the posts/ folder and parse out the name, full- and first-paragraph- +// HTML of each post to be used on their respective pages and on the index + +val posts = { + val split = for(path <- markdownFiles) yield { + val Array(number, name) = path.last.split(" - ", 2) + (number, name.stripSuffix(".md"), path) + } + for ((index, name, path) <- split.sortBy(_._1.toInt)) yield { + val processor = new PegDownProcessor( + Extensions.FENCED_CODE_BLOCKS | Extensions.TABLES | Extensions.AUTOLINKS + ) + + val txt = read(path) + .replaceAll( + """\$\$\$([a-zA-Z_0-9]+)""", + s"[example project](https://github.com/lihaoyi/cask/releases/download/$releaseTag/$label.$$1)" + ) + + val ast = processor.parseMarkdown(txt.toArray) + val headers = collection.mutable.Buffer.empty[(String, Int)] + class Serializer extends ToHtmlSerializer(new LinkRenderer){ + override def printImageTag(rendering: LinkRenderer.Rendering) { + printer.print("<div style=\"text-align: center\"><img") + printAttribute("src", rendering.href) + // shouldn't include the alt attribute if its empty + if(!rendering.text.equals("")){ + printAttribute("alt", rendering.text) + } + import collection.JavaConversions._ + for (attr <- rendering.attributes) { + printAttribute(attr.name, attr.value) + } + printer.print(" style=\"max-width: 100%; max-height: 500px\"") + printer.print(" /></div>") + } + override def visit(node: HeaderNode) = { + val tag = "h" + node.getLevel() + + + val id = + node + .getChildren + .asScala + .collect{case t: TextNode => t.getText} + .mkString + + headers.append(id -> node.getLevel()) + + + val setId = s"id=${'"'+sanitize(id)+'"'}" + printer.print(s"""<$tag $setId class="${Styles.hoverBox.name}">""") + visitChildren(node) + printer.print( + a(href := ("#" + sanitize(id)), Styles.hoverLink)( + i(cls := "fa fa-link", aria.hidden := true) + ).render + ) + printer.print(s"</$tag>") + } + + override def visit(node: VerbatimNode) = { + printer.println().print( + """<pre style="background-color: #f8f8f8">""" + + s"""<code style="white-space:pre; background-color: #f8f8f8" class="${node.getType()}">""" + ) + + var text = node.getText() + // print HTML breaks for all initial newlines + while(text.charAt(0) == '\n') { + printer.print("\n") + text = text.substring(1) + } + printer.printEncoded(text) + printer.print("</code></pre>") + } + override def visit(node: TableNode) = { + currentTableNode = node + printer.print("<table class=\"table table-bordered\">") + visitChildren(node) + printer.print("</table>") + currentTableNode = null + } + } + + val postlude = Seq[Frag]( + hr, + + p( + b("About the Author:"), + i( + " Haoyi is a software engineer, an early contributor to ", + a(href:="http://www.scala-js.org/")("Scala.js"), + ", and the author of many open-source Scala tools such as Mill, the ", + a(href:="lihaoyi.com/Ammonite", "Ammonite REPL"), " and ", + a(href:="https://github.com/lihaoyi/fastparse", "FastParse"), ". " + ) + ), + p( + i( + "If you've enjoy using Mill, or enjoyed using Haoyi's other open ", + "source libraries, please chip in (or get your Company to chip in!) via ", + a(href:="https://www.patreon.com/lihaoyi", "Patreon"), " so he can ", "continue his open-source work" + ) + ), + hr + ).render + + val rawHtmlContent = new Serializer().toHtml(ast) + postlude + PostInfo(name, headers, rawHtmlContent) + } +} + +def formatRssDate(date: java.time.LocalDate) = { + date + .atTime(0, 0) + .atZone(java.time.ZoneId.of("UTC")) + .format(java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME) +} + +@main +def main(publish: Boolean = false) = { + + rm! targetFolder + + mkdir! targetFolder/'page + for(otherFile <- otherFiles){ + cp(otherFile, targetFolder/'page/(otherFile relativeTo postsFolder)) + } + + cp(pwd/"favicon.png", targetFolder/"favicon.ico") + cp(pwd/"logo-white.svg", targetFolder/"logo-white.svg") + + for(i <- posts.indices){ + val post = posts(i) + + val adjacentLinks = div(display.flex, flexDirection.row, justifyContent.spaceBetween)( + for((j, isNext) <- Seq(i-1 -> false, i+1 -> true)) + yield posts.lift(j) match{ + case None => div() + case Some(dest) => + renderAdjacentLink( + isNext, + dest.name, + (i == 0, j == 0) match { + case (true, true) => s"index.html" + case (true, false) => s"page/${sanitize(dest.name)}.html" + case (false, true) => s"../index.html" + case (false, false) => s"${sanitize(dest.name)}.html" + } + ) + } + ) + + + write( + if (i == 0) targetFolder / "index.html" + else targetFolder/'page/s"${sanitize(post.name)}.html", + postContent( + i == 0, + post, + adjacentLinks, + posts.map(_.name) + ) + ) + } + + if (publish){ + implicit val wd = cwd/'target + %git 'init + %git('add, "-A", ".") + %git('commit, "-am", "first commit") + %git('remote, 'add, 'origin, "git@github.com:lihaoyi/cask.git") + %git('push, "-uf", 'origin, "master:gh-pages") + } +}
\ No newline at end of file diff --git a/docs/favicon.png b/docs/favicon.png Binary files differnew file mode 100644 index 0000000..82430a7 --- /dev/null +++ b/docs/favicon.png diff --git a/docs/logo-white.svg b/docs/logo-white.svg new file mode 100644 index 0000000..a681aa9 --- /dev/null +++ b/docs/logo-white.svg @@ -0,0 +1 @@ +<!DOCTYPE html><svg xmlns="http://www.w3.org/2000/svg" height="24" width="24"><polyline points="24,-44.0 0,4.0 0,20.0 24,-28.0" fill="#f8f8f8"></polyline><polyline points="24,-20.0 0,28.0 0,44.0 24,-4.0" fill="#f8f8f8"></polyline><polyline points="24,4.0 0,52.0 0,68.0 24,20.0" fill="#f8f8f8"></polyline></svg>
\ No newline at end of file diff --git a/docs/pageStyles.sc b/docs/pageStyles.sc new file mode 100644 index 0000000..06651b1 --- /dev/null +++ b/docs/pageStyles.sc @@ -0,0 +1,130 @@ +import $ivy.`com.lihaoyi::scalatags:0.6.5` + +import scalatags.stylesheet._ +import scalatags.Text.all._ + + +val marginWidth = "25%" +object WideStyles extends StyleSheet{ + initStyleSheet() + override def customSheetName = Some("WideStyles") + def header = cls( + position.fixed, + top := 0, + bottom := 0, + width := marginWidth, + justifyContent.center, + display.flex, + flexDirection.column + ) + def tableOfContentsItem = cls( + // We have to use inline-block and verticalAlign.middle and width: 100% + // here, instead of simply using display.block, because display.block items + // with overflow.hidden seem to misbehave and render badly in different ways + // between firefox (renders correctly), chrome (body of list item is offset + // one row from the bullet) and safari (bullet is entirely missing) + display.`inline-block`, + width := "100%", + verticalAlign.middle, + overflow.hidden, + textOverflow.ellipsis + + ) + def tableOfContents = cls( + display.flex, + flexDirection.column, + flexGrow := 1, + flexShrink := 1, + minHeight := 0, + width := "100%" + + ) + def content = cls( + padding := "2em 3em 0", + padding := 48, + marginLeft := marginWidth, + boxSizing.`border-box` + ) + def footer = cls( + position.fixed, + bottom := 0, + height := 50, + width := marginWidth + ) + def marginLeftZero = cls( + marginLeft := 0 + ) +} +object NarrowStyles extends StyleSheet{ + initStyleSheet() + override def customSheetName = Some("NarrowStyles") + def header = cls( + marginBottom := 10 + ) + def content = cls( + padding := 16 + ) + def headerContent = cls( + flexDirection.row, + width := "100%", + display.flex, + alignItems.center + ) + + def flexFont = cls( + fontSize := "4vw" + ) + def disappear = cls( + display.none + ) + def floatLeft = cls( + float.left, + marginLeft := 30 + ) +} +object Styles extends CascadingStyleSheet{ + initStyleSheet() + override def customSheetName = Some("Styles") + def hoverBox = cls( + display.flex, + flexDirection.row, + alignItems.center, + justifyContent.spaceBetween, + &hover( + hoverLink( + opacity := 0.5 + ) + ) + ) + def hoverLink = cls( + opacity := 0.1, + &hover( + opacity := 1.0 + ) + ) + def headerStyle = cls( + backgroundColor := "rgb(61, 79, 93)", + display.flex, + boxSizing.`border-box` + ) + def headerLinkBox = cls( + flex := 1, + display.flex, + flexDirection.column, + ) + def headerLink = cls( + flex := 1, + display.flex, + justifyContent.center, + alignItems.center, + padding := "10px 10px" + ) + def footerStyle = cls( + display.flex, + justifyContent.center, + color := "rgb(158, 167, 174)" + ) + def subtleLink = cls( + textDecoration.none + ) +}
\ No newline at end of file diff --git a/docs/pages.sc b/docs/pages.sc new file mode 100644 index 0000000..a6c05c0 --- /dev/null +++ b/docs/pages.sc @@ -0,0 +1,192 @@ +import $ivy.`com.lihaoyi::scalatags:0.6.5` +import scalatags.Text.all._, scalatags.Text.tags2 +import java.time.LocalDate +import $file.pageStyles, pageStyles._ + +case class PostInfo(name: String, + headers: Seq[(String, Int)], + rawHtmlContent: String) + + +def sanitize(s: String): String = { + s.split(" |-", -1).map(_.filter(_.isLetterOrDigit)).mkString("-").toLowerCase +} +def pageChrome(titleText: Option[String], + homePage: Boolean, + contents: Frag, + contentHeaders: Seq[(String, Int)], + pageHeaders: Seq[String]): String = { + val pageTitle = titleText.getOrElse("Mill") + val sheets = Seq( + "https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css", + "https://maxcdn.bootstrapcdn.com/font-awesome/4.5.0/css/font-awesome.min.css", + "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.1.0/styles/github-gist.min.css" + ) + + + html( + head( + meta(charset := "utf-8"), + for(sheet <- sheets) + yield link(href := sheet, rel := "stylesheet", `type` := "text/css" ), + tags2.title(pageTitle), + tags2.style(s"@media (min-width: 60em) {${WideStyles.styleSheetText}}"), + tags2.style(s"@media (max-width: 60em) {${NarrowStyles.styleSheetText}}"), + tags2.style(Styles.styleSheetText), + script(src:="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.1.0/highlight.min.js"), + script(src:="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.1.0/languages/scala.min.js"), + script(raw("hljs.initHighlightingOnLoad();")), + // This makes media queries work on iphone (???) + // http://stackoverflow.com/questions/13002731/responsive-design-media-query-not-working-on-iphone + meta(name:="viewport", content:="initial-scale = 1.0,maximum-scale = 1.0") + ), + body(margin := 0, backgroundColor := "#f8f8f8")( + navBar(homePage, contentHeaders, pageHeaders), + div( + WideStyles.content, + NarrowStyles.content, + maxWidth := 900, + titleText.map(h1(_)), + contents + ), + if (contentHeaders.nonEmpty) frag() + else div( + WideStyles.footer, + Styles.footerStyle, + "Last published ", currentTimeText + ) + + ) + ).render +} + +def navBar(homePage: Boolean, contentHeaders: Seq[(String, Int)], pageHeaders: Seq[String]) = { + def navList(navLabel: String, frags: Frag, narrowHide: Boolean) = { + div( + WideStyles.tableOfContents, + if(narrowHide) NarrowStyles.disappear else frag(), + color := "#f8f8f8" + )( + div(paddingLeft := 40, NarrowStyles.disappear)( + b(navLabel) + ), + div(overflowY.auto, flexShrink := 1, minHeight := 0)( + ul( + overflow.hidden, + textAlign.start, + marginTop := 10, + whiteSpace.nowrap, + textOverflow.ellipsis, + marginRight := 10 + )( + frags + ) + ) + ) + } + val pageList = navList( + "Pages", + for((header, i) <- pageHeaders.zipWithIndex) yield li( + WideStyles.marginLeftZero, + NarrowStyles.floatLeft + )( + a( + color := "#f8f8f8", + WideStyles.tableOfContentsItem, + href := { + (homePage, i == 0) match { + case (true, true) => s"index.html" + case (true, false) => s"page/${sanitize(header)}.html" + case (false, true) => s"../index.html" + case (false, false) => s"${sanitize(header)}.html" + } + } + )( + header + ) + ), + narrowHide = false + ) + + val headerBox = div( + NarrowStyles.headerContent, + h1( + textAlign.center, + a( + img( + src := {homePage match{ + case false => s"../logo-white.svg" + case true => "logo-white.svg" + }}, + height := 30, + marginTop := -5 + ), + color := "#f8f8f8", + " Mill", + href := (if (homePage) "" else ".."), + Styles.subtleLink, + NarrowStyles.flexFont, + fontWeight.bold + ), + padding := "30px 30px", + margin := 0 + ), + div( + Styles.headerLinkBox, + pageList + ) + ) + + + val tableOfContents = navList( + "Table of Contents", + for { + (header, indent) <- contentHeaders + offset <- indent match{ + case 2 => Some(0) + case 3 => Some(20) + case _ => None + } + } yield li(marginLeft := offset)( + a( + color := "#f8f8f8", + WideStyles.tableOfContentsItem, + href := s"#${sanitize(header)}" + )( + header + ) + ), + narrowHide = true + ) + + div( + WideStyles.header, + NarrowStyles.header, + Styles.headerStyle, + headerBox, + hr(NarrowStyles.disappear, backgroundColor := "#f8f8f8", width := "80%"), + tableOfContents + ) +} + + +val currentTimeText = LocalDate.now.toString + + +def renderAdjacentLink(next: Boolean, name: String, url: String) = { + a(href := url)( + if(next) frag(name, " ", i(cls:="fa fa-arrow-right" , aria.hidden:=true)) + else frag(i(cls:="fa fa-arrow-left" , aria.hidden:=true), " ", name) + ) +} +def postContent(homePage: Boolean, post: PostInfo, adjacentLinks: Frag, posts: Seq[String]) = pageChrome( + Some(post.name), + homePage, + Seq[Frag]( + div(adjacentLinks, marginBottom := 10), + raw(post.rawHtmlContent), + adjacentLinks + ), + post.headers, + pageHeaders = posts +)
\ No newline at end of file diff --git a/docs/readme.md b/docs/pages/1 - Cask: a Scala HTTP micro-framework .md index 4add926..22d879e 100644 --- a/docs/readme.md +++ b/docs/pages/1 - Cask: a Scala HTTP micro-framework .md @@ -1,6 +1,3 @@ -Cask: a Scala HTTP micro-framework -================================== - ```scala object MinimalApplication extends cask.MainRoutes{ @cask.get("/") @@ -17,10 +14,10 @@ object MinimalApplication extends cask.MainRoutes{ } ``` -Cask is a simple Scala web framework inspired by Python's -[Flask](http://flask.pocoo.org/docs/1.0/) project. It aims to bring simplicity, -flexibility and ease-of-use to Scala webservers, avoiding cryptic DSLs or -complicated asynchrony. +[Cask](https://github.com/lihaoyi/cask) is a simple Scala web framework inspired +by Python's [Flask](http://flask.pocoo.org/docs/1.0/) project. It aims to bring +simplicity, flexibility and ease-of-use to Scala webservers, avoiding cryptic +DSLs or complicated asynchrony. Getting Started --------------- @@ -29,7 +26,8 @@ The easiest way to begin using Cask is by downloading the [Mill](http://www.lihaoyi.com/mill/) example project: - Install [Mill](http://www.lihaoyi.com/mill/) -- Unzip [XXX](XXX) into a folder. This should give you the following files: +- Unzip the $$$minimalApplication into a folder. This should + give you the following files: ```text build.sc app/src/MinimalExample.scala @@ -80,8 +78,11 @@ ivy"com.lihaoyi::cask:0.1.0" "com.lihaoyi" %% "cask" % "0.1.0" ``` -Minimal Example ---------------- +Example Projects +---------------- + +### Minimal Example + ```scala object MinimalApplication extends cask.MainRoutes{ @cask.get("/") @@ -98,6 +99,8 @@ object MinimalApplication extends cask.MainRoutes{ } ``` +- $$$minimalApplication + The rough outline of how the minimal example works should be easy to understand: - You define an object that inherits from `cask.MainRoutes` @@ -142,11 +145,13 @@ object MinimalRoutes extends cask.Routes{ object MinimalMain extends cask.Main(MinimalRoutes) ``` +- $$$minimalApplication2 +- You can split up your routes into separate `cask.Routes` objects as makes sense and pass them all into `cask.Main`. -Variable Routes ---------------- +### Variable Routes + ```scala object VariableRoutes extends cask.MainRoutes{ @@ -169,6 +174,8 @@ object VariableRoutes extends cask.MainRoutes{ } ``` +- $$$variableRoutes + You can bind variables to endpoints by declaring them as parameters: these are either taken from a path-segment matcher of the same name (e.g. `postId` above), or from query-parameters of the same name (e.g. `param` above). You can make @@ -181,8 +188,8 @@ If you need to capture the entire sub-path of the request, you can set the flag matter). This will make the route match any sub-path of the prefix given to the `@cask` decorator, and give you the remainder to use in your endpoint logic. -Receiving Form-encoded or JSON data ------------------------------------ +### Receiving Form-encoded or JSON data + ```scala object FormJsonPost extends cask.MainRoutes{ @@ -205,6 +212,8 @@ object FormJsonPost extends cask.MainRoutes{ } ``` +- $$$formJsonPost + If you need to handle a JSON-encoded POST request, you can use the `@cast.postJson` decorator. This assumes the posted request body is a JSON dict, and uses its keys to populate the endpoint's parameters, either as raw @@ -226,8 +235,8 @@ deserialization into Scala data-types fails, a 400 response is returned automatically with a helpful error message. -Processing Cookies ------------------- +### Processing Cookies + ```scala object Cookies extends cask.MainRoutes{ @@ -256,14 +265,16 @@ object Cookies extends cask.MainRoutes{ } ``` +- $$$cookies + Cookies are most easily read by declaring a `: cask.Cookie` parameter; the parameter name is used to fetch the cookie you are interested in. Cookies can be stored by setting the `cookie` attribute in the response, and deleted simply by setting `expires = java.time.Instant.EPOCH` (i.e. to have expired a long time ago) -Serving Static Files --------------------- +### Serving Static Files + ```scala object StaticFiles extends cask.MainRoutes{ @cask.get("/") @@ -278,13 +289,15 @@ object StaticFiles extends cask.MainRoutes{ } ``` +- $$$staticFiles + You can ask Cask to serve static files by defining a `@cask.static` endpoint. This will match any subpath of the value returned by the endpoint (e.g. above `/static/file.txt`, `/static/folder/file.txt`, etc.) and return the file contents from the corresponding file on disk (and 404 otherwise). -Redirects or Aborts -------------------- +### Redirects or Aborts + ```scala object RedirectAbort extends cask.MainRoutes{ @cask.get("/") @@ -301,12 +314,14 @@ object RedirectAbort extends cask.MainRoutes{ } ``` +- $$$redirectAbort + Cask provides some convenient helpers `cask.Redirect` and `cask.Abort` which you can return; these are simple wrappers around `cask.Request`, and simply set up the relevant headers or status code for you. -Extending Endpoints with Decorators ------------------------------------ +### Extending Endpoints with Decorators + ```scala object Decorated extends cask.MainRoutes{ @@ -354,6 +369,8 @@ object Decorated extends cask.MainRoutes{ } ``` +- $$$decorateds + You can write extra decorator annotations that stack on top of the existing `@cask.get`/`@cask.post` to provide additional arguments or validation. This is done by implementing the `cask.Decorator` interface and it's `getRawParams` @@ -430,12 +447,14 @@ object Decorated2 extends cask.MainRoutes{ } ``` +- $$$decorated2 + This is convenient for cases where you want a set of decorators to apply broadly across your web application, and do not want to repeat them over and over at every single endpoint. -Gzip & Deflated Responses -------------------------- +### Gzip & Deflated Responses + ```scala object Compress extends cask.MainRoutes{ @@ -451,6 +470,8 @@ object Compress extends cask.MainRoutes{ ``` +- $$$compress + Cask provides a useful `@cask.decorators.compress` decorator that gzips or deflates a response body if possible. This is useful if you don't have a proxy like Nginx or similar in front of your server to perform the compression for @@ -474,6 +495,8 @@ object Compress2 extends cask.Routes{ object Compress2Main extends cask.Main(Compress2) ``` +- $$$compress2 + Or globally, in your `cask.Main`: ```scala @@ -492,8 +515,10 @@ object Compress3Main extends cask.Main(Compress3){ } ``` -TodoMVC Api Server ------------------- +- $$$compress3 + +### TodoMVC Api Server + ```scala object TodoMvcApi extends cask.MainRoutes{ @@ -535,6 +560,8 @@ object TodoMvcApi extends cask.MainRoutes{ } ``` +- $$$todoApi + This is a simple self-contained example of using Cask to write an in-memory API server for the common [TodoMVC example app](http://todomvc.com/). @@ -543,8 +570,8 @@ etc.. Those can be managed via the normal mechanism for [Serving Static Files](#serving-static-files). -TodoMVC Database Integration ----------------------------- +### TodoMVC Database Integration + ```scala import cask.internal.Router import com.typesafe.config.ConfigFactory @@ -631,6 +658,8 @@ object TodoMvcDb extends cask.MainRoutes{ ``` +- $$$todoDb + This example demonstrates how to use Cask to write a TodoMVC API server that persists it's state in a database rather than in memory. We use the [Quill](http://getquill.io/) database access library to write a `@transactional` @@ -648,8 +677,8 @@ be passed into each endpoint function as an additional parameter list as described in [Extending Endpoints with Decorators](#extending-endpoints-with-decorators). -TodoMVC Full Stack Web ----------------------- +### TodoMVC Full Stack Web + The following code snippet is the complete code for a full-stack TodoMVC implementation: including HTML generation for the web UI via @@ -845,6 +874,8 @@ object Server extends cask.MainRoutes{ } ``` +- $$$todo + Main Customization ------------------ |