feat: dynamic page regeneration #24

Merged
clo merged 3 commits from regen into master 2025-08-11 22:43:27 -07:00
2 changed files with 426 additions and 0 deletions
Showing only changes of commit f6cbe9537b - Show all commits

View file

@ -0,0 +1,87 @@
- imports at the bottom
- order your file by importance.
- 'G' to jump to imports, etc
- prefer namespace imports
- easier to type and refactor. easier to read.
- large files are okay
- all files are their own library
- split files up by making components modular, not by "oh it's too big"
- engine/render.ts is a standalone library, in order to split JSX, Suspense,
and Marko out, the main file was made modular.
- lowercase
- name objects ultra-concisely
- filenames are often one word describing what they contain
- avoid useless descriptors like "utils", "helpers", and "data"
- examples
- async.ts contains all the async library functions.
- watch.ts contains the file watcher and watch-reload mode.
- render.*, Io
- be ultra-concise in comments
- no "discarded" variables, embrace `void x`
- makes code more readable
- note how i want to write a lint for this
- note the one proposal i want about void
- push the ts inference engine (as const, ReturnType, etc)
- reduces how much you repeat yourself making it easier to refactor things
- use the code as the source of truth
- push the ts inference engine (generics)
- do not implement crazy things with the TS engine, instead use generic input
types, and then use regular control to narrow and transform the return type.
source of truth is your code.
- UNWRAP, ASSERT utility globals are amazing
- ban postfix '!'
- stripped for production frontend builds
- destructure often
- use the one example from work lol
- package.json "imports" are amazing
- remapping
- implementation switching
- testing
- embrace the web and node.js APIs
- sitegen relies on so many node features that bun and deno fail to run it.
- overlay modules are great
- avoid dependencies
- once you build your own mini standard library you win
- talk about regrets with mdx
## imports at the bottom
Here is an abridged version of my website's `backend.ts`. When reading it from
top to bottom it is immediately obvious that it is a Hono web server.
```ts
// This is the main file for paperclover.net's server.
const app = new Hono();
const logHttp = console.scoped("http", { color: "magenta" });
// Middleware
app.use(...);
...
// Backends
app.route("", require("./q+a/backend.ts").app);
...
export default app;
...
import { type Context, Hono, type Next } from "#hono";
import { logger } from "hono/logger";
import { trimTrailingSlash } from "hono/trailing-slash";
import * as assets from "#sitegen/assets";
import * as admin from "./admin.ts";
import * as console from "@paperclover/console";
```
Since `import`/`export` statements are hoisted like `var` and `function`, the
position of these statements within the file does not matter. The imported
modules have to be loaded first before this file can start. With this, I've
found it nicer to sort the file by _importance_ rather than by arbitrary rules
dictated by how C-style `#include`s worked.
Start with a documentation comment, then the most important
functions/variables/types, sort the file by importance. Imports are not really
important since you very quickly get to know where common namespaces come from.
And since they're at the bottom, you can just press `G` in Vim or `CMD+Down` on
the Mac to scroll to the end of the file.

View file

@ -0,0 +1,339 @@
export const blog: BlogMeta = {
title: "Marko is the coziest HTML templating language",
desc: "...todo...",
created: "2025-06-13",
draft: true,
};
export const meta = formatBlogMeta(blob);
export * as layout from "@/blog/layout.tsx";
I've been recently playing around [Marko], and after adding limited support
for it in my website generator, [sitegen], I instantly fell in love with how
minimalistic it is in comparison to JSX, Astro components, and Svelte.
[Marko]: https://next.markojs.com
[sitegen]: https://paperclover.dev/clo/sitegen
## Introduction to Marko
If JSX was taking HTML and shoving its syntax into JavaScript, Marko is shoving
JavaScript into HTML. Attributes are JavaScript expressions.
```marko
<div>
// `input` is like props, but given in the top-level scope
<time datetime=input.date.toISOString()>
// Interpolation with JS template string syntax
${formatTimeNicely(input.date)}
</time>
<div>
<a href=`/users/${input.user.id}`>${input.user.name}</a>
</div>
// Capital letter variables for imported components
<MarkdownContent message=input.message />
// Components also can be auto-imported by lowercase.
// This will look upwards for a `tags/` folder containing
// "custom-footer.marko", similar to how Node.js finds
// package names in all upwards `node_modules` folders.
<custom-footer />
</div>
// ESM `import` / `export` just work as expected.
// I prefer my imports at the end, to highlight the markup.
import MarkdownContent from "./MarkdownContent.marko";
import { formatTimeNicely } from "../date-helpers.ts";
```
Tags with the `value` attribute have a shorthand, which is used by the built-in
`<if>` for conditional rendering.
```marko
// Sugar for <input value="string" />
<input="string" />
// and it composes amazingly to the 'if' built-in
<if=input.user>
<UserProfile=input.user />
</if>
```
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/>`.
```
<id/uniqueId />
<input id=uniqueId type="checkbox" name="allow_trans_rights" />
<label for=uniqueId>click me!</>
// ^ oh, you can also omit the
// closing tag name if you want.
```
It's important that I started with the two forms of "Tag I/O": `=` for input
and `/` for output. With those building blocks, we introduce local variables
with `const`
```
<const/rendered = markdownToHtml(input.value) />
// This is how you insert raw HTML to the document
<inline-html=rendered />
// It supports all of the cozy destructuring syntax JS has
<const/{ id, name } = user />
```
Unlike JSX, when you pass content within a tag (`input.content` instead of
JSX's `children`), instead of it being a JSX element, it is actually a
function. This means that the `for` tag can render the content multiple times.
```
<ul>
<for from=1 to=10>
// Renders a new random number for each iteration.
<li>${Math.random()}</li>
</>
</ul>
```
Since `content` is a function, it can take arguments. This is done with `|`
```
<h1>my friends</h1>
<ul>
// I tend to omit the closing tag names for the built-in control
// flow tags, but I keep them for HTML tags. It's kinda like how
// in JavaScript you just write `}` to close your `if`s and loops.
//
// Anyways <for> also has 'of'
<for|item| of=user.friends>
<li class="friend">${item.name}</li>
</>
// They support the same syntax JavaScript function params allows,
// so you can have destructuring here too, and multiple params.
<for|{ name }, index| of=user.friends>
// By the way you can also use emmet-style class and ID shorthands.
<li.friend>My #${index + 1} friend is ${name}</li>
</>
</ul>
```
Instead of named slots, Marko has attribute tags. These are more powerful than
slots since they are functions, and can also act as sugar for more complicated
attributes.
```
<Layout title="Welcome">
<@header variant="big">
<h1>the next big thing</h1>
</@header>
<p>body text...</p>
</Layout>
// The `input` variable inside of <Layout /> is:
//
// {
// title: "Welcome",
// header: {
// content: /* function rendering "<h1>the next big thing</h1>" */,
// variant: "big",
// },
// content: /* function rendering "<p>body text</p>" */
// }
```
This layout could be implemented as such:
```marko
<main>
<if=input.header />
<const/{ ...headerProps, content }=input.header />
<header ...headerProps>
// Instead of assigning to a variable with a capital letter,
// template interpolation works on tag names. This can also
// be a string to render the native HTML tag of that kind.
<${content} />
</header>
<hr />
</>
<${input.content} />
</main>
```
The last syntax feature missing is calling a tag with parameters. That is done
just like a regular function call, with '('.
```
<Something(item, index) />
```
In fact, attributes can just be sugar over this syntax. (this technically isn't
true but it's close enough for the example)
```
<SpecialButton type="submit" class="red" />
// is equal to
<SpecialButton({ type: "submit", class: "red" }) />
```
All of the above is about how Marko's syntax works, and how it performs HTML
generation with components. Marko also allows interactive components, but an
explaination of that is beyond the scope of this page, mostly since I have not
used it. A brief example of it, modified from their documentation.
```marko
// Reactive variables with <let/> just work...
<let/basicCounter=0 />
<button onClick() { basicCounter += 1 }>${basicCounter}</button>
// ...but a counter is boring.
<let/todos=[
{ id: 0, text: "Learn Marko" },
{ id: 1, text: "Make a Website" },
]/>
// 'by' is like React JSX's "key" property, but it's optional.
<ul><for|todo, i| of=todos by=(todo => todo.id)>
<li.todo>
// this variable remains stable even if the list
// re-orders, because 'by' was specified.
<let/done=false/>
<label>
<span>${todo.text}</span>
// ':=' creates a two-way reactive binding,
// (it passes a callback for `checkedChanged`)
<input type="checkbox" checked:=done />
</label>
<button
title="delete"
disabled=!done
onClick() {
todos = todos.toSpliced(i, 1);
}
> &times; </button>
</li>
</></ul>
// Form example
<let/nextId=2/>
<form onSubmit(e) {
e.preventDefault();
todos = todos.concat({
id: nextId++,
// HTMLFormElement exposes all its named input
// elements as extra properties on the object.
text: e.target.text.value,
});
// And you can clear it with 'reset()'
e.target.reset();
}>
// We don't 'onChange' like a React loser. The form
// value can be read in the submit event like normal.
<input name="text" placeholder="Another Item">
<button type="submit">Add</button>
</form>
```
<SectionHeader updated="2025-08-11">Usage on `paperclover.net`</Section>
Using Marko for HTML generation is quite easy. `.marko` files can be compiled
into `.js` using the `@marko/compiler` library.
```ts
const src = fs.readFileSync("page.marko", "utf8");
const compile = marko.compileSync(src, filepath);
fs.writeFileSync("page.js", compile.code);
const page = require("./page.js");
console.info(page);
import * as fs from "node:fs";
import * as marko from "@marko/compiler";
```
To get client side JavaScript, an option can be passed to the Marko compiler to
generate the client side code. While it is a big selling point of Marko, I do
not use any of their client side features, instead deferring to manually-written
frontend scripts. This is because that is how my website has been for years,
statically generated. And for websites like mine that are content focused, this
is the correct way to do things.
Since I have a custom HTML generation library (built on JSX and some React-like
patterns), I have written a simple integration for it to utilize Marko
components, which is loaded by replacing the generated import to `marko/html`,
which lets me overwrite functions like `createTemplate` (to change the signature
of a component), `dynamicTag` (to allow Marko to render non-Marko components),
and `fork` (to enable async integration with the rendering framework). An
additional feature of this is I have a Node.js loader hook to allow importing
these files directly.
```tsx
function Page() {
const q = Question.getByDate(new Date("2025-06-07 12:12 EST"));
return <div>
<h1>example question</h1>
<QuestionRender question={q} />
</div>;
}
// The synchronous render can be used because `Page` and `question.marko`
// do not await any promises (SQLite runs synchronously)
console.info(render.sync(<Page />).text);
import * as render from "#engine/render";
import QuestionRender from "@/q+a/tags/question.marko";
import { Question } from "@/q+a/models/Question.ts";
```
Here is the `question.marko` tag used to render [questions on the clover q+a](/q+a).
```marko
// Renders a `Question` entry including its markdown body.
export interface Input {
question: Question;
admin?: boolean;
}
// 2024-12-31 05:00:00 EST
export const transitionDate = 1735639200000;
<const/{ question, admin } = input />
<const/{ id, date, text } = question/>
<${"e-"}
f=(date > transitionDate ? true : undefined)
id=admin ? `q${id}` : undefined
>
<if=admin>
<a
style="margin-right: 0.5rem"
href=`/admin/q+a/${id}`
>[EDIT]</a>
</>
<a>
<time
datetime=formatQuestionISOTimestamp(date)
>${formatQuestionTimestamp(date)}</time>
</a>
<CloverMarkdown ...{ text } />
</>
// this singleton script will make all the '<time>' tags clickable.
client import "./clickable-links.client.ts";
import type { Question } from "@/q+a/models/Question.ts";
import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts";
import { CloverMarkdown } from "@/q+a/clover-markdown.tsx";
```
The integration is great, `client import` is quite a magical concept, and I've
tuned it to do the expected thing in my framework.
import { type BlogMeta, formatBlogMeta } from '@/blog/helpers.ts';