| 1 | export const blog: BlogMeta = { |
| 2 | title: "Marko is the coziest HTML templating language", |
| 3 | desc: "...todo...", |
| 4 | created: "2025-06-13", |
| 5 | draft: true, |
| 6 | }; |
| 7 | export const meta = formatBlogMeta(blob); |
| 8 | export * as layout from "#src/blog/layout.tsx"; |
| 9 | |
| 10 | I've been recently playing around [Marko], and after adding limited support |
| 11 | for it in my website generator, [sitegen], I instantly fell in love with how |
| 12 | minimalistic it is in comparison to JSX, Astro components, and Svelte. |
| 13 | |
| 14 | [Marko]: https://next.markojs.com |
| 15 | [sitegen]: https://paperclover.dev/clo/sitegen |
| 16 | |
| 17 | ## Introduction to Marko |
| 18 | |
| 19 | If JSX was taking HTML and shoving its syntax into JavaScript, Marko is shoving |
| 20 | JavaScript into HTML. Attributes are JavaScript expressions. |
| 21 | |
| 22 | ```marko |
| 23 | <div> |
| 24 | // `input` is like props, but given in the top-level scope |
| 25 | <time datetime=input.date.toISOString()> |
| 26 | // Interpolation with JS template string syntax |
| 27 | ${formatTimeNicely(input.date)} |
| 28 | </time> |
| 29 | <div> |
| 30 | <a href=`/users/${input.user.id}`>${input.user.name}</a> |
| 31 | </div> |
| 32 | |
| 33 | // Capital letter variables for imported components |
| 34 | <MarkdownContent message=input.message /> |
| 35 | |
| 36 | // Components also can be auto-imported by lowercase. |
| 37 | // This will look upwards for a `tags/` folder containing |
| 38 | // "custom-footer.marko", similar to how Node.js finds |
| 39 | // package names in all upwards `node_modules` folders. |
| 40 | <custom-footer /> |
| 41 | </div> |
| 42 | |
| 43 | // ESM `import` / `export` just work as expected. |
| 44 | // I prefer my imports at the end, to highlight the markup. |
| 45 | import MarkdownContent from "./MarkdownContent.marko"; |
| 46 | import { formatTimeNicely } from "../date-helpers.ts"; |
| 47 | ``` |
| 48 | |
| 49 | Tags with the `value` attribute have a shorthand, which is used by the built-in |
| 50 | `<if>` for conditional rendering. |
| 51 | |
| 52 | ```marko |
| 53 | // Sugar for <input value="string" /> |
| 54 | <input="string" /> |
| 55 | |
| 56 | // and it composes amazingly to the 'if' built-in |
| 57 | <if=input.user> |
| 58 | <UserProfile=input.user /> |
| 59 | </if> |
| 60 | ``` |
| 61 | |
| 62 | Tags can also return values into the scope for use in the template using `/`, such as `<id>` for unique ID generation. This is available to components that `<return=output/>`. |
| 63 | |
| 64 | ``` |
| 65 | <id/uniqueId /> |
| 66 | |
| 67 | <input id=uniqueId type="checkbox" name="allow_trans_rights" /> |
| 68 | <label for=uniqueId>click me!</> |
| 69 | // ^ oh, you can also omit the |
| 70 | // closing tag name if you want. |
| 71 | ``` |
| 72 | |
| 73 | It's important that I started with the two forms of "Tag I/O": `=` for input |
| 74 | and `/` for output. With those building blocks, we introduce local variables |
| 75 | with `const` |
| 76 | |
| 77 | ``` |
| 78 | <const/rendered = markdownToHtml(input.value) /> |
| 79 | |
| 80 | // This is how you insert raw HTML to the document |
| 81 | <inline-html=rendered /> |
| 82 | |
| 83 | // It supports all of the cozy destructuring syntax JS has |
| 84 | <const/{ id, name } = user /> |
| 85 | ``` |
| 86 | |
| 87 | Unlike JSX, when you pass content within a tag (`input.content` instead of |
| 88 | JSX's `children`), instead of it being a JSX element, it is actually a |
| 89 | function. This means that the `for` tag can render the content multiple times. |
| 90 | |
| 91 | ``` |
| 92 | <ul> |
| 93 | <for from=1 to=10> |
| 94 | // Renders a new random number for each iteration. |
| 95 | <li>${Math.random()}</li> |
| 96 | </> |
| 97 | </ul> |
| 98 | ``` |
| 99 | |
| 100 | Since `content` is a function, it can take arguments. This is done with `|` |
| 101 | |
| 102 | ``` |
| 103 | <h1>my friends</h1> |
| 104 | <ul> |
| 105 | // I tend to omit the closing tag names for the built-in control |
| 106 | // flow tags, but I keep them for HTML tags. It's kinda like how |
| 107 | // in JavaScript you just write `}` to close your `if`s and loops. |
| 108 | // |
| 109 | // Anyways <for> also has 'of' |
| 110 | <for|item| of=user.friends> |
| 111 | <li class="friend">${item.name}</li> |
| 112 | </> |
| 113 | |
| 114 | // They support the same syntax JavaScript function params allows, |
| 115 | // so you can have destructuring here too, and multiple params. |
| 116 | <for|{ name }, index| of=user.friends> |
| 117 | // By the way you can also use emmet-style class and ID shorthands. |
| 118 | <li.friend>My #${index + 1} friend is ${name}</li> |
| 119 | </> |
| 120 | </ul> |
| 121 | ``` |
| 122 | |
| 123 | Instead of named slots, Marko has attribute tags. These are more powerful than |
| 124 | slots since they are functions, and can also act as sugar for more complicated |
| 125 | attributes. |
| 126 | |
| 127 | ``` |
| 128 | <Layout title="Welcome"> |
| 129 | <@header variant="big"> |
| 130 | <h1>the next big thing</h1> |
| 131 | </@header> |
| 132 | |
| 133 | <p>body text...</p> |
| 134 | </Layout> |
| 135 | |
| 136 | // The `input` variable inside of <Layout /> is: |
| 137 | // |
| 138 | // { |
| 139 | // title: "Welcome", |
| 140 | // header: { |
| 141 | // content: /* function rendering "<h1>the next big thing</h1>" */, |
| 142 | // variant: "big", |
| 143 | // }, |
| 144 | // content: /* function rendering "<p>body text</p>" */ |
| 145 | // } |
| 146 | ``` |
| 147 | |
| 148 | This layout could be implemented as such: |
| 149 | |
| 150 | ```marko |
| 151 | <main> |
| 152 | <if=input.header /> |
| 153 | <const/{ ...headerProps, content }=input.header /> |
| 154 | <header ...headerProps> |
| 155 | // Instead of assigning to a variable with a capital letter, |
| 156 | // template interpolation works on tag names. This can also |
| 157 | // be a string to render the native HTML tag of that kind. |
| 158 | <${content} /> |
| 159 | </header> |
| 160 | <hr /> |
| 161 | </> |
| 162 | |
| 163 | <${input.content} /> |
| 164 | </main> |
| 165 | ``` |
| 166 | |
| 167 | The last syntax feature missing is calling a tag with parameters. That is done |
| 168 | just like a regular function call, with '('. |
| 169 | |
| 170 | ``` |
| 171 | <Something(item, index) /> |
| 172 | ``` |
| 173 | |
| 174 | In fact, attributes can just be sugar over this syntax. (this technically isn't |
| 175 | true but it's close enough for the example) |
| 176 | |
| 177 | ``` |
| 178 | <SpecialButton type="submit" class="red" /> |
| 179 | |
| 180 | // is equal to |
| 181 | |
| 182 | <SpecialButton({ type: "submit", class: "red" }) /> |
| 183 | ``` |
| 184 | |
| 185 | All of the above is about how Marko's syntax works, and how it performs HTML |
| 186 | generation with components. Marko also allows interactive components, but an |
| 187 | explaination of that is beyond the scope of this page, mostly since I have not |
| 188 | used it. A brief example of it, modified from their documentation. |
| 189 | |
| 190 | ```marko |
| 191 | // Reactive variables with <let/> just work... |
| 192 | <let/basicCounter=0 /> |
| 193 | <button onClick() { basicCounter += 1 }>${basicCounter}</button> |
| 194 | // ...but a counter is boring. |
| 195 | |
| 196 | <let/todos=[ |
| 197 | { id: 0, text: "Learn Marko" }, |
| 198 | { id: 1, text: "Make a Website" }, |
| 199 | ]/> |
| 200 | |
| 201 | // 'by' is like React JSX's "key" property, but it's optional. |
| 202 | <ul><for|todo, i| of=todos by=(todo => todo.id)> |
| 203 | <li.todo> |
| 204 | // this variable remains stable even if the list |
| 205 | // re-orders, because 'by' was specified. |
| 206 | <let/done=false/> |
| 207 | <label> |
| 208 | <span>${todo.text}</span> |
| 209 | // ':=' creates a two-way reactive binding, |
| 210 | // (it passes a callback for `checkedChanged`) |
| 211 | <input type="checkbox" checked:=done /> |
| 212 | </label> |
| 213 | <button |
| 214 | title="delete" |
| 215 | disabled=!done |
| 216 | onClick() { |
| 217 | todos = todos.toSpliced(i, 1); |
| 218 | } |
| 219 | > &times; </button> |
| 220 | </li> |
| 221 | </></ul> |
| 222 | |
| 223 | // Form example |
| 224 | <let/nextId=2/> |
| 225 | <form onSubmit(e) { |
| 226 | e.preventDefault(); |
| 227 | todos = todos.concat({ |
| 228 | id: nextId++, |
| 229 | // HTMLFormElement exposes all its named input |
| 230 | // elements as extra properties on the object. |
| 231 | text: e.target.text.value, |
| 232 | }); |
| 233 | // And you can clear it with 'reset()' |
| 234 | e.target.reset(); |
| 235 | }> |
| 236 | // We don't 'onChange' like a React loser. The form |
| 237 | // value can be read in the submit event like normal. |
| 238 | <input name="text" placeholder="Another Item"> |
| 239 | <button type="submit">Add</button> |
| 240 | </form> |
| 241 | ``` |
| 242 | |
| 243 | <SectionHeader updated="2025-08-11">Usage on `paperclover.net`</Section> |
| 244 | |
| 245 | Using Marko for HTML generation is quite easy. `.marko` files can be compiled |
| 246 | into `.js` using the `@marko/compiler` library. |
| 247 | |
| 248 | ```ts |
| 249 | const src = fs.readFileSync("page.marko", "utf8"); |
| 250 | const compile = marko.compileSync(src, filepath); |
| 251 | fs.writeFileSync("page.js", compile.code); |
| 252 | |
| 253 | const page = require("./page.js"); |
| 254 | console.info(page); |
| 255 | |
| 256 | import * as fs from "node:fs"; |
| 257 | import * as marko from "@marko/compiler"; |
| 258 | ``` |
| 259 | |
| 260 | To get client side JavaScript, an option can be passed to the Marko compiler to |
| 261 | generate the client side code. While it is a big selling point of Marko, I do |
| 262 | not use any of their client side features, instead deferring to manually-written |
| 263 | frontend scripts. This is because that is how my website has been for years, |
| 264 | statically generated. And for websites like mine that are content focused, this |
| 265 | is the correct way to do things. |
| 266 | |
| 267 | Since I have a custom HTML generation library (built on JSX and some React-like |
| 268 | patterns), I have written a simple integration for it to utilize Marko |
| 269 | components, which is loaded by replacing the generated import to `marko/html`, |
| 270 | which lets me overwrite functions like `createTemplate` (to change the signature |
| 271 | of a component), `dynamicTag` (to allow Marko to render non-Marko components), |
| 272 | and `fork` (to enable async integration with the rendering framework). An |
| 273 | additional feature of this is I have a Node.js loader hook to allow importing |
| 274 | these files directly. |
| 275 | |
| 276 | ```tsx |
| 277 | function Page() { |
| 278 | const q = Question.getByDate(new Date("2025-06-07 12:12 EST")); |
| 279 | return <div> |
| 280 | <h1>example question</h1> |
| 281 | <QuestionRender question={q} /> |
| 282 | </div>; |
| 283 | } |
| 284 | |
| 285 | // The synchronous render can be used because `Page` and `question.marko` |
| 286 | // do not await any promises (SQLite runs synchronously) |
| 287 | console.info(render.sync(<Page />).text); |
| 288 | |
| 289 | import * as render from "#engine/render"; |
| 290 | import QuestionRender from "#src/q+a/tags/question.marko"; |
| 291 | import { Question } from "#src/q+a/models/Question.ts"; |
| 292 | ``` |
| 293 | |
| 294 | Here is the `question.marko` tag used to render [questions on the clover q+a](/q+a). |
| 295 | |
| 296 | ```marko |
| 297 | // Renders a `Question` entry including its markdown body. |
| 298 | export interface Input { |
| 299 | question: Question; |
| 300 | admin?: boolean; |
| 301 | } |
| 302 | |
| 303 | // 2024-12-31 05:00:00 EST |
| 304 | export const transitionDate = 1735639200000; |
| 305 | |
| 306 | <const/{ question, admin } = input /> |
| 307 | <const/{ id, date, text } = question/> |
| 308 | |
| 309 | <${"e-"} |
| 310 | f=(date > transitionDate ? true : undefined) |
| 311 | id=admin ? `q${id}` : undefined |
| 312 | > |
| 313 | <if=admin> |
| 314 | <a |
| 315 | style="margin-right: 0.5rem" |
| 316 | href=`/admin/q+a/${id}` |
| 317 | >[EDIT]</a> |
| 318 | </> |
| 319 | <a> |
| 320 | <time |
| 321 | datetime=formatQuestionISOTimestamp(date) |
| 322 | >${formatQuestionTimestamp(date)}</time> |
| 323 | </a> |
| 324 | |
| 325 | <QuestionMarkdown=text/> |
| 326 | </> |
| 327 | |
| 328 | // this singleton script will make all the '<time>' tags clickable. |
| 329 | client import "./clickable-links.client.ts"; |
| 330 | |
| 331 | import type { Question } from "#src/q+a/models/Question.ts"; |
| 332 | import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "#src/q+a/format.ts"; |
| 333 | import { QuestionMarkdown } from "#src/q+a/markdown.ts"; |
| 334 | ``` |
| 335 | |
| 336 | The integration is great, `client import` is quite a magical concept, and I've |
| 337 | tuned it to do the expected thing in my framework. |
| 338 | |
| 339 | import { type BlogMeta, formatBlogMeta } from '#src/blog/helpers.ts'; |