chore: convert all files to unix-style line ends
This commit is contained in:
parent
c9d24a4fdd
commit
3a36e53635
10 changed files with 574 additions and 574 deletions
|
@ -1,4 +1,4 @@
|
|||
<div meow=null />
|
||||
<div>
|
||||
wait(${null})
|
||||
</div>
|
||||
<div meow=null />
|
||||
<div>
|
||||
wait(${null})
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import Component from './Component.marko';
|
||||
|
||||
<h1>web page</h1>
|
||||
<if=!false>
|
||||
<Component=null/>
|
||||
</>
|
||||
import Component from './Component.marko';
|
||||
|
||||
<h1>web page</h1>
|
||||
<if=!false>
|
||||
<Component=null/>
|
||||
</>
|
||||
|
|
|
@ -1,339 +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);
|
||||
}
|
||||
> × </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';
|
||||
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);
|
||||
}
|
||||
> × </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';
|
||||
|
|
|
@ -1,71 +1,71 @@
|
|||
import "./lofi.css";
|
||||
export interface Input {
|
||||
file: MediaFile;
|
||||
hasCotyledonCookie: boolean;
|
||||
}
|
||||
export { meta, theme } from "./clofi.tsx";
|
||||
|
||||
<const/{ file: dir, hasCotyledonCookie } = input />
|
||||
<const/{ path: fullPath, dirname, basename, kind } = dir />
|
||||
<const/isRoot = fullPath == '/'>
|
||||
|
||||
<div#lofi>
|
||||
|
||||
<define/ListItem|{ value: file }|>
|
||||
<const/dir=file.kind === MediaFileKind.directory/>
|
||||
<const/meta=(
|
||||
dir
|
||||
? formatSize(file.size)
|
||||
: (file.duration ?? 0) > 0
|
||||
? formatDuration(file.duration!)
|
||||
: null
|
||||
)/>
|
||||
<li class={ dir }>
|
||||
<a href=`/file${escapeUri(file.path)}` >
|
||||
<code>${formatDate(file.date)}</>${" "}
|
||||
${file.basenameWithoutExt}<span class="ext">${file.extension}</>${dir ? '/' : ''}
|
||||
<if=meta><span class='meta'>(${meta})</></>
|
||||
</a>
|
||||
</li>
|
||||
</define>
|
||||
|
||||
<h1>
|
||||
<if=isRoot>clo's files</>
|
||||
<else>${fullPath}</>
|
||||
</h1>
|
||||
|
||||
<if=isRoot>
|
||||
<const/{ readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie) />
|
||||
<if=readme><ul><ListItem=readme/></ul></>
|
||||
<for|{key, files, titleColor }| of=sections>
|
||||
<h2 style={color: titleColor }>${key}</>
|
||||
<ul>
|
||||
<for|item| of=files><ListItem=item /></>
|
||||
</ul>
|
||||
</>
|
||||
<if=!hasCotyledonCookie>
|
||||
<br><br><br><br><br><br>
|
||||
<p style={ opacity: 0.3 }>
|
||||
would you like to
|
||||
<a
|
||||
href="/file/cotyledon"
|
||||
style={
|
||||
color: 'white',
|
||||
'text-decoration': 'underline',
|
||||
}
|
||||
>dive deeper?</a>
|
||||
</p>
|
||||
</>
|
||||
</><else>
|
||||
<ul>
|
||||
<li><a href=`/file${escapeUri(path.posix.dirname(fullPath))}`>[up one...]</a></li>
|
||||
<for|item| of=dir.getChildren()> <ListItem=item /> </>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
</div>
|
||||
|
||||
import * as path from "node:path";
|
||||
import { escapeUri, formatDuration, formatSize, formatDate } from "@/file-viewer/format.ts";
|
||||
import { MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
|
||||
import * as sort from "@/file-viewer/sort.ts";
|
||||
import "./lofi.css";
|
||||
export interface Input {
|
||||
file: MediaFile;
|
||||
hasCotyledonCookie: boolean;
|
||||
}
|
||||
export { meta, theme } from "./clofi.tsx";
|
||||
|
||||
<const/{ file: dir, hasCotyledonCookie } = input />
|
||||
<const/{ path: fullPath, dirname, basename, kind } = dir />
|
||||
<const/isRoot = fullPath == '/'>
|
||||
|
||||
<div#lofi>
|
||||
|
||||
<define/ListItem|{ value: file }|>
|
||||
<const/dir=file.kind === MediaFileKind.directory/>
|
||||
<const/meta=(
|
||||
dir
|
||||
? formatSize(file.size)
|
||||
: (file.duration ?? 0) > 0
|
||||
? formatDuration(file.duration!)
|
||||
: null
|
||||
)/>
|
||||
<li class={ dir }>
|
||||
<a href=`/file${escapeUri(file.path)}` >
|
||||
<code>${formatDate(file.date)}</>${" "}
|
||||
${file.basenameWithoutExt}<span class="ext">${file.extension}</>${dir ? '/' : ''}
|
||||
<if=meta><span class='meta'>(${meta})</></>
|
||||
</a>
|
||||
</li>
|
||||
</define>
|
||||
|
||||
<h1>
|
||||
<if=isRoot>clo's files</>
|
||||
<else>${fullPath}</>
|
||||
</h1>
|
||||
|
||||
<if=isRoot>
|
||||
<const/{ readme, sections } = sort.splitRootDirFiles(dir, hasCotyledonCookie) />
|
||||
<if=readme><ul><ListItem=readme/></ul></>
|
||||
<for|{key, files, titleColor }| of=sections>
|
||||
<h2 style={color: titleColor }>${key}</>
|
||||
<ul>
|
||||
<for|item| of=files><ListItem=item /></>
|
||||
</ul>
|
||||
</>
|
||||
<if=!hasCotyledonCookie>
|
||||
<br><br><br><br><br><br>
|
||||
<p style={ opacity: 0.3 }>
|
||||
would you like to
|
||||
<a
|
||||
href="/file/cotyledon"
|
||||
style={
|
||||
color: 'white',
|
||||
'text-decoration': 'underline',
|
||||
}
|
||||
>dive deeper?</a>
|
||||
</p>
|
||||
</>
|
||||
</><else>
|
||||
<ul>
|
||||
<li><a href=`/file${escapeUri(path.posix.dirname(fullPath))}`>[up one...]</a></li>
|
||||
<for|item| of=dir.getChildren()> <ListItem=item /> </>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
</div>
|
||||
|
||||
import * as path from "node:path";
|
||||
import { escapeUri, formatDuration, formatSize, formatDate } from "@/file-viewer/format.ts";
|
||||
import { MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
|
||||
import * as sort from "@/file-viewer/sort.ts";
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
export interface Input {
|
||||
stage: number
|
||||
}
|
||||
export const meta = { title: 'C O T Y L E D O N' };
|
||||
export const theme = {
|
||||
bg: '#ff00ff',
|
||||
fg: '#000000',
|
||||
};
|
||||
|
||||
<h1>co<br>ty<br>le<br>don</h1>
|
||||
|
||||
<if=input.stage==0>
|
||||
|
||||
<p>
|
||||
this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all the artifacts.
|
||||
</p><p>
|
||||
by entering our museum, you agree not to use your camera. flash off isn't enough; the bits and bytes are alergic even to a camera's sensor
|
||||
</p><p>
|
||||
<sub>(in english: please do not store downloads after you're done viewing them)</sub>
|
||||
</p>
|
||||
|
||||
</>
|
||||
export interface Input {
|
||||
stage: number
|
||||
}
|
||||
export const meta = { title: 'C O T Y L E D O N' };
|
||||
export const theme = {
|
||||
bg: '#ff00ff',
|
||||
fg: '#000000',
|
||||
};
|
||||
|
||||
<h1>co<br>ty<br>le<br>don</h1>
|
||||
|
||||
<if=input.stage==0>
|
||||
|
||||
<p>
|
||||
this place is sacred, but dangerous. i have to keep visitors to an absolute minimum; you'll get dust on all the artifacts.
|
||||
</p><p>
|
||||
by entering our museum, you agree not to use your camera. flash off isn't enough; the bits and bytes are alergic even to a camera's sensor
|
||||
</p><p>
|
||||
<sub>(in english: please do not store downloads after you're done viewing them)</sub>
|
||||
</p>
|
||||
|
||||
</>
|
||||
|
|
8
src/friends/.gitignore
vendored
8
src/friends/.gitignore
vendored
|
@ -1,4 +1,4 @@
|
|||
# this directory is private, and not checked into the git repository
|
||||
# instead, it is version-controlled via computer clover's backup system.
|
||||
*
|
||||
!.gitignore
|
||||
# this directory is private, and not checked into the git repository
|
||||
# instead, it is version-controlled via computer clover's backup system.
|
||||
*
|
||||
!.gitignore
|
||||
|
|
|
@ -1,50 +1,50 @@
|
|||
import "./resume.css";
|
||||
|
||||
export const meta = { title: 'clover\'s resume' };
|
||||
|
||||
<main>
|
||||
<h1>clover's resume</h1>
|
||||
<div>last updated: 2025</>
|
||||
|
||||
<article.job>
|
||||
<header>
|
||||
<h2>web/backend engineer</h2>
|
||||
<em>2025-now</em>
|
||||
</>
|
||||
<ul>
|
||||
<i>(more details added as time goes on...)</i>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
<article.job>
|
||||
<header>
|
||||
<h2>runtime/systems engineer</h2>
|
||||
<em>2023-2025</em>
|
||||
<p>developer tools company</p>
|
||||
</>
|
||||
<ul>
|
||||
<li>hardcore engineering, elegant solutions</>
|
||||
<li>platform compatibility & stability</>
|
||||
<li>debugging and profiling across platforms</>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
<article.job>
|
||||
<header>
|
||||
<h2>technician</h2>
|
||||
<em>2023; part time</em>
|
||||
<p>automotive maintainance company</p>
|
||||
</>
|
||||
<ul>
|
||||
<li>pressed buttons on a computer</>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
<footer>
|
||||
<h2>eduation</h2> <em>2004-now</em>
|
||||
<p>
|
||||
my life on earth has taught me more than i expected. i <br/>
|
||||
continue to learn new things daily, as if it was magic.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
import "./resume.css";
|
||||
|
||||
export const meta = { title: 'clover\'s resume' };
|
||||
|
||||
<main>
|
||||
<h1>clover's resume</h1>
|
||||
<div>last updated: 2025</>
|
||||
|
||||
<article.job>
|
||||
<header>
|
||||
<h2>web/backend engineer</h2>
|
||||
<em>2025-now</em>
|
||||
</>
|
||||
<ul>
|
||||
<i>(more details added as time goes on...)</i>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
<article.job>
|
||||
<header>
|
||||
<h2>runtime/systems engineer</h2>
|
||||
<em>2023-2025</em>
|
||||
<p>developer tools company</p>
|
||||
</>
|
||||
<ul>
|
||||
<li>hardcore engineering, elegant solutions</>
|
||||
<li>platform compatibility & stability</>
|
||||
<li>debugging and profiling across platforms</>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
<article.job>
|
||||
<header>
|
||||
<h2>technician</h2>
|
||||
<em>2023; part time</em>
|
||||
<p>automotive maintainance company</p>
|
||||
</>
|
||||
<ul>
|
||||
<li>pressed buttons on a computer</>
|
||||
</ul>
|
||||
</>
|
||||
|
||||
<footer>
|
||||
<h2>eduation</h2> <em>2004-now</em>
|
||||
<p>
|
||||
my life on earth has taught me more than i expected. i <br/>
|
||||
continue to learn new things daily, as if it was magic.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
<form action="/q+a" method="POST">
|
||||
<textarea
|
||||
name="text"
|
||||
placeholder="ask clover a question..."
|
||||
required
|
||||
minlength="1"
|
||||
maxlength="10000"
|
||||
/>
|
||||
<div aria-hidden class="title">ask a question</div>
|
||||
<button type="submit">send</button>
|
||||
<div class="disabled-button">send</div>
|
||||
</form>
|
||||
|
||||
<form action="/q+a" method="POST">
|
||||
<textarea
|
||||
name="text"
|
||||
placeholder="ask clover a question..."
|
||||
required
|
||||
minlength="1"
|
||||
maxlength="10000"
|
||||
/>
|
||||
<div aria-hidden class="title">ask a question</div>
|
||||
<button type="submit">send</button>
|
||||
<div class="disabled-button">send</div>
|
||||
</form>
|
||||
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
export const meta = { title: "embed image" };
|
||||
export interface Input {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
<html-style>
|
||||
main { padding-top: 11px; }
|
||||
e- { margin: 0!important }
|
||||
e- > :first-child { margin-top: 0!important }
|
||||
e- > :last-child { margin-bottom: 0!important }
|
||||
</html-style>
|
||||
<main.qa>
|
||||
<question question=input.question />
|
||||
</main>
|
||||
|
||||
import { Question } from '@/q+a/models/Question.ts';
|
||||
export const meta = { title: "embed image" };
|
||||
export interface Input {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
<html-style>
|
||||
main { padding-top: 11px; }
|
||||
e- { margin: 0!important }
|
||||
e- > :first-child { margin-top: 0!important }
|
||||
e- > :last-child { margin-bottom: 0!important }
|
||||
</html-style>
|
||||
<main.qa>
|
||||
<question question=input.question />
|
||||
</main>
|
||||
|
||||
import { Question } from '@/q+a/models/Question.ts';
|
||||
|
|
|
@ -1,49 +1,49 @@
|
|||
export * as layout from "@/q+a/layout.tsx";
|
||||
export interface Input {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
server export function meta({ context, question }) {
|
||||
const isDiscord = context.get("user-agent")
|
||||
?.toLowerCase()
|
||||
.includes("discordbot");
|
||||
if (question.type === QuestionType.normal) {
|
||||
return {
|
||||
title: "question permalink",
|
||||
openGraph: {
|
||||
images: [{ url: `https://paperclover.net/q+a/${question.id}.png` }],
|
||||
},
|
||||
twitter: { card: "summary_large_image" },
|
||||
themeColor: isDiscord
|
||||
? question.date.getTime() > transitionDate ? "#8c78ff" : "#58ff71"
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { title: 'question permalink' };
|
||||
}
|
||||
|
||||
<const/{ question }=input/>
|
||||
<const/{ type }=question/>
|
||||
<if=type==QuestionType.normal>
|
||||
<p>this page is a permalink to the following question:</p>
|
||||
<question ...{question} />
|
||||
</><else if=type==QuestionType.pending>
|
||||
<p>
|
||||
this page is a permalink to a question that
|
||||
has not yet been answered.
|
||||
</p>
|
||||
<p><a href="/q+a">read questions with existing responses</a>.</p>
|
||||
</><else if=type==QuestionType.reject>
|
||||
<p>
|
||||
this page is a permalink to a question, but the question
|
||||
was deleted instead of answered. maybe it was sent multiple
|
||||
times, or maybe the question was not a question. who knows.
|
||||
</p>
|
||||
<p>sorry, sister</p>
|
||||
<p><a href="/q+a">all questions</a></p>
|
||||
</><else>
|
||||
<p>oh dear, this question is in an invalid state</p>
|
||||
<pre>${JSON.stringify(question, null, 2)}</pre>
|
||||
</>
|
||||
|
||||
import { Question, QuestionType } from '@/q+a/models/Question.ts';
|
||||
export * as layout from "@/q+a/layout.tsx";
|
||||
export interface Input {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
server export function meta({ context, question }) {
|
||||
const isDiscord = context.get("user-agent")
|
||||
?.toLowerCase()
|
||||
.includes("discordbot");
|
||||
if (question.type === QuestionType.normal) {
|
||||
return {
|
||||
title: "question permalink",
|
||||
openGraph: {
|
||||
images: [{ url: `https://paperclover.net/q+a/${question.id}.png` }],
|
||||
},
|
||||
twitter: { card: "summary_large_image" },
|
||||
themeColor: isDiscord
|
||||
? question.date.getTime() > transitionDate ? "#8c78ff" : "#58ff71"
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return { title: 'question permalink' };
|
||||
}
|
||||
|
||||
<const/{ question }=input/>
|
||||
<const/{ type }=question/>
|
||||
<if=type==QuestionType.normal>
|
||||
<p>this page is a permalink to the following question:</p>
|
||||
<question ...{question} />
|
||||
</><else if=type==QuestionType.pending>
|
||||
<p>
|
||||
this page is a permalink to a question that
|
||||
has not yet been answered.
|
||||
</p>
|
||||
<p><a href="/q+a">read questions with existing responses</a>.</p>
|
||||
</><else if=type==QuestionType.reject>
|
||||
<p>
|
||||
this page is a permalink to a question, but the question
|
||||
was deleted instead of answered. maybe it was sent multiple
|
||||
times, or maybe the question was not a question. who knows.
|
||||
</p>
|
||||
<p>sorry, sister</p>
|
||||
<p><a href="/q+a">all questions</a></p>
|
||||
</><else>
|
||||
<p>oh dear, this question is in an invalid state</p>
|
||||
<pre>${JSON.stringify(question, null, 2)}</pre>
|
||||
</>
|
||||
|
||||
import { Question, QuestionType } from '@/q+a/models/Question.ts';
|
||||
|
|
Loading…
Reference in a new issue