work on porting paperclover.net and also some tests

This commit is contained in:
chloe caruso 2025-06-15 11:35:28 -07:00
parent c5113954a8
commit db244583d7
36 changed files with 2252 additions and 305 deletions

View file

@ -1,4 +1,5 @@
import "@paperclover/console/inject";
import "#debug";
const protocol = "http";

View file

@ -39,6 +39,9 @@ export async function bundleClientJavaScript(
write: false,
metafile: true,
external: ["node_modules/"],
jsx: "automatic",
jsxImportSource: "#ssr",
jsxDev: dev,
});
if (bundle.errors.length || bundle.warnings.length) {
throw new AggregateError(
@ -135,11 +138,22 @@ export async function bundleServerJavaScript(
return ({
loader: "ts",
contents: cacheEntry.src,
resolveDir: path.dirname(file),
});
},
);
},
},
{
name: "replace client references",
setup(b) {
b.onLoad({ filter: /\.tsx?$/ }, async ({ path: file }) => ({
contents:
hot.resolveClientRefs(await fs.readFile(file, "utf-8"), file).code,
loader: path.extname(file).slice(1) as esbuild.Loader,
}));
},
},
{
name: "mark css external",
setup(b) {
@ -154,7 +168,7 @@ export async function bundleServerJavaScript(
},
},
];
const { metafile, outputFiles, warnings } = await esbuild.build({
const { metafile, outputFiles } = await esbuild.build({
bundle: true,
chunkNames: "c.[hash]",
entryNames: "server",
@ -169,6 +183,9 @@ export async function bundleServerJavaScript(
splitting: true,
write: false,
metafile: true,
jsx: "automatic",
jsxImportSource: "#ssr",
jsxDev: false,
});
const files: Record<string, Buffer> = {};
@ -273,3 +290,4 @@ import {
} from "./esbuild-support.ts";
import { Incremental } from "./incremental.ts";
import * as css from "./css.ts";
import * as fs from "#sitegen/fs";

17
framework/debug.safe.ts Normal file
View file

@ -0,0 +1,17 @@
globalThis.UNWRAP = (t, ...args) => {
if (t == null) {
throw new Error(
args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
);
}
return t;
};
globalThis.ASSERT = (t, ...args) => {
if (!t) {
throw new Error(
args.length > 0 ? util.format(...args) : "Assertion Failed",
);
}
};
import * as util from "node:util";

View file

@ -18,3 +18,24 @@ test("simple tree", (t) =>
).text,
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
));
test("unescaped/escaped html", (t) =>
t.assert.equal(
engine.ssrSync(<div>{engine.html("<fuck>")}{"\"&'`<>"}</div>).text,
"<div><fuck>&quot;&amp;&#x27;&#x60;&lt;&gt;</div>",
));
test("clsx built-in", (t) =>
t.assert.equal(
engine.ssrSync(
<>
<a class="a" />
<b class={null} />
<c class={undefined} />
<d class={["a", "b", null]} />
<e class={{ a: true, b: false }} />
<e
class={[null, "x", { z: true }, [{ m: true }, null, { v: false }]]}
/>
</>,
).text,
'<a class=a></a><b></b><c></c><d class="a b"></d><e class=a></e><e class="x z m"></e>',
));

View file

@ -64,6 +64,11 @@ export function projectRelativeResolution(root = process.cwd() + "/src") {
path: path.resolve(root, id.slice(2)),
};
});
b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => {
return {
path: hot.resolveFrom(importer, id),
};
});
},
} satisfies esbuild.Plugin;
}
@ -71,3 +76,4 @@ export function projectRelativeResolution(root = process.cwd() + "/src") {
import * as esbuild from "esbuild";
import * as string from "#sitegen/string";
import * as path from "node:path";
import * as hot from "./hot.ts";

View file

@ -74,26 +74,40 @@ export async function sitegen(
dir: sectionPath("pages"),
list: pages,
prefix: "/",
exclude: [".css", ".client.ts", ".client.tsx"],
include: [".tsx", ".mdx", ".marko"],
exclude: [".client.ts", ".client.tsx"],
},
{
dir: sectionPath("static"),
list: staticFiles,
prefix: "/",
ext: true,
},
{
dir: sectionPath("scripts"),
list: scripts,
prefix: rootPrefix,
include: [".client.ts", ".client.tsx"],
},
{ dir: sectionPath("static"), list: staticFiles, prefix: "/", ext: true },
{ dir: sectionPath("scripts"), list: scripts, prefix: rootPrefix },
{
dir: sectionPath("views"),
list: views,
prefix: rootPrefix,
exclude: [".css", ".client.ts", ".client.tsx"],
include: [".tsx", ".mdx", ".marko"],
exclude: [".client.ts", ".client.tsx"],
},
];
for (const { dir, list, prefix, exclude = [], ext = false } of kinds) {
for (
const { dir, list, prefix, include = [""], exclude = [], ext = false }
of kinds
) {
const items = fs.readDirRecOptionalSync(dir);
item: for (const subPath of items) {
for (const subPath of items) {
const file = path.join(dir, subPath);
const stat = fs.statSync(file);
if (stat.isDirectory()) continue;
for (const e of exclude) {
if (subPath.endsWith(e)) continue item;
}
if (!include.some((e) => subPath.endsWith(e))) continue;
if (exclude.some((e) => subPath.endsWith(e))) continue;
const trim = ext
? subPath
: subPath.slice(0, -path.extname(subPath).length).replaceAll(
@ -216,31 +230,33 @@ export async function sitegen(
},
});
}
async function prepareView(view: FileItem) {
const module = require(view.file);
async function prepareView(item: FileItem) {
const module = require(item.file);
if (!module.meta) {
throw new Error(`${view.file} is missing 'export const meta'`);
throw new Error(`${item.file} is missing 'export const meta'`);
}
if (!module.default) {
throw new Error(`${view.file} is missing a default export.`);
throw new Error(`${item.file} is missing a default export.`);
}
const pageTheme = module.layout?.theme ?? module.theme;
const theme: css.Theme = {
...css.defaultTheme,
...pageTheme,
};
const cssImports = hot.getCssImports(view.file)
.concat("src/global.css")
.map((file) => path.relative(hot.projectSrc, path.resolve(file)));
const cssImports = Array.from(
new Set([globalCssPath, ...hot.getCssImports(item.file)]),
(file) => path.relative(hot.projectSrc, file),
);
ensureCssGetsBuilt(cssImports, theme, item.id);
incr.put({
kind: "viewMetadata",
key: view.id,
sources: [view.file],
key: item.id,
sources: [item.file],
value: {
file: path.relative(hot.projectRoot, view.file),
file: path.relative(hot.projectRoot, item.file),
cssImports,
theme,
clientRefs: hot.getClientScriptRefs(view.file),
clientRefs: hot.getClientScriptRefs(item.file),
hasLayout: !!module.layout?.default,
},
});
@ -422,7 +438,7 @@ function getItemText({ file }: FileItem) {
return path.relative(hot.projectSrc, file).replaceAll("\\", "/");
}
import { OnceMap, Queue } from "./queue.ts";
import { OnceMap, Queue } from "#sitegen/async";
import { Incremental } from "./incremental.ts";
import * as bundle from "./bundle.ts";
import * as css from "./css.ts";

View file

@ -148,7 +148,7 @@ export class Incremental {
for (const key of map.keys()) {
if (!this.round.referenced.has(`${kind}\0${key}`)) {
unreferenced.push({ kind: kind as ArtifactKind, key });
console.warn("unreferened " + kind + " : " + key);
this.out[kind as ArtifactKind].delete(key);
}
}
}
@ -652,7 +652,7 @@ const zstd = util.promisify(zlib.zstdCompress);
import * as fs from "#sitegen/fs";
import * as zlib from "node:zlib";
import * as util from "node:util";
import { Queue } from "./queue.ts";
import { Queue } from "#sitegen/async";
import * as hot from "./hot.ts";
import * as mime from "#sitegen/mime";
import * as path from "node:path";

View file

@ -1,3 +1,5 @@
const five_minutes = 5 * 60 * 1000;
interface QueueOptions<T, R> {
name: string;
fn: (item: T, spin: Spinner) => Promise<R>;
@ -199,11 +201,78 @@ export class OnceMap<T> {
const result = compute();
this.ongoing.set(key, result);
result.finally(() => this.ongoing.delete(key));
return result;
}
}
interface ARCEValue<T> {
value: T;
[Symbol.dispose]: () => void;
}
export function RefCountedExpirable<T>(
init: () => Promise<T>,
deinit: (value: T) => void,
expire: number = five_minutes,
): () => Promise<ARCEValue<T>> {
let refs = 0;
let item: ARCEValue<T> | null = null;
let loading: Promise<ARCEValue<T>> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function deref() {
ASSERT(item !== null);
if (--refs !== 0) return;
ASSERT(timer === null);
timer = setTimeout(() => {
ASSERT(refs === 0);
ASSERT(loading === null);
ASSERT(item !== null);
deinit(item.value);
item = null;
timer = null;
}, expire);
}
return async function () {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (item !== null) {
refs++;
return item;
}
if (loading !== null) {
refs++;
return loading;
}
const p = Promise.withResolvers<ARCEValue<T>>();
loading = p.promise;
try {
const value = await init();
item = { value, [Symbol.dispose]: deref };
refs++;
p.resolve(item);
return item;
} catch (e) {
p.reject(e);
throw e;
} finally {
loading = null;
}
};
}
export function once<T>(fn: () => Promise<T>): () => Promise<T> {
let result: T | Promise<T> | null = null;
return async () => {
if (result) return result;
result = await fn();
return result;
};
}
import { Progress } from "@paperclover/console/Progress";
import { Spinner } from "@paperclover/console/Spinner";
import * as path from "node:path";

View file

@ -16,7 +16,14 @@ let scripts: Record<string, string> = null!;
// boundaries, but those were never used. Pages will wait until they
// are fully rendered before sending.
export async function renderView(
c: hono.Context,
context: hono.Context,
id: string,
props: Record<string, unknown>,
) {
return context.html(await renderViewToString(id, { context, ...props }));
}
export async function renderViewToString(
id: string,
props: Record<string, unknown>,
) {
@ -29,11 +36,11 @@ export async function renderView(
inlineCss,
layout,
meta: metadata,
}: View = views[id];
}: View = UNWRAP(views[id], `Missing view ${id}`);
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata({ context: c }) : metadata,
typeof metadata === "function" ? metadata(props) : metadata,
).then((m) => meta.renderMeta(m));
// -- html --
@ -44,14 +51,17 @@ export async function renderView(
});
// -- join document and send --
return c.html(wrapDocument({
return wrapDocument({
body,
head: await renderedMetaPromise,
inlineCss,
scripts: joinScripts(
Array.from(sitegen.scripts, (script) => scripts[script]),
Array.from(
sitegen.scripts,
(id) => UNWRAP(scripts[id], `Missing script ${id}`),
),
}));
),
});
}
export function provideViewData(v: typeof views, s: typeof scripts) {
@ -87,5 +97,4 @@ export function wrapDocument({
import * as meta from "./meta.ts";
import type * as hono from "#hono";
import * as engine from "../engine/ssr.ts";
import type * as css from "../css.ts";
import * as sg from "./sitegen.ts";

View file

@ -0,0 +1,4 @@
<div meow=null />
<div>
wait(${null})
</div>

View file

@ -0,0 +1,6 @@
import Component from './Component.marko';
<h1>web page</h1>
<if=!false>
<Component=null/>
</>

View file

@ -11,7 +11,7 @@ export async function main() {
const watch = new Watch(rebuild);
watch.add(...incr.invals.keys());
statusLine();
// ... an
// ... and then serve it!
serve();
function serve() {
@ -41,7 +41,13 @@ export async function main() {
files = files.map((file) => path.relative(hot.projectRoot, file));
const changed: string[] = [];
for (const file of files) {
if (incr.updateStat(file, fs.statSync(file).mtimeMs)) changed.push(file);
let mtimeMs: number | null = null;
try {
mtimeMs = fs.statSync(file).mtimeMs;
} catch (err: any) {
if (err?.code !== "ENOENT") throw err;
}
if (incr.updateStat(file, mtimeMs)) changed.push(file);
}
if (changed.length === 0) {
console.warn("Files were modified but the 'modify' time did not change.");

1051
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,6 +8,7 @@
"hls.js": "^1.6.5",
"hono": "^4.7.11",
"marko": "^6.0.20",
"puppeteer": "^24.10.1",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
@ -16,6 +17,7 @@
},
"imports": {
"#backend": "./src/backend.ts",
"#debug": "./framework/debug.safe.ts",
"#sitegen": "./framework/lib/sitegen.ts",
"#sitegen/*": "./framework/lib/*.ts",
"#ssr": "./framework/engine/ssr.ts",

View file

@ -3,26 +3,29 @@
this repository contains clover's "sitegen" framework, which is a set of tools
that assist building websites. these tools power https://paperclover.net.
- HTML "Server Side Rendering") engine written from scratch.
- A more practical JSX runtime (`class` instead of `className`, etc).
- Transparent integration with [Marko][1] to mix component types.
- MDX support for text-heavy content pages.
- Incremental static site generator and build system
- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)
- A more practical JSX runtime (`class` instead of `className`, built-in
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
- Integration with [Marko][1] for concisely written components.
- TODO: MDX-like compiler for content-heavy pages like blogs.
- Different languages can be used at the same time. Supports
`async function` components, `<Suspense />`, and custom extensions.
- **Incremental static site generator and build system.**
- Build entire production site at start, incremental updates when pages
change; Build system state survives coding sessions.
- The only difference in development and production mode is hidden
source-maps and stripped assertions and `console.debug` calls. The site
you see locally is the site you see deployed.
source-maps and stripped `console.debug` calls. The site you
see locally is the same site you see deployed.
- (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs
checks when the files change. For example, changing a component re-tests
only pages that use that component and re-lints only the changed file.
- Integrated libraries for building complex, content heavy web sites.
- **Integrated libraries for building complex, content heavy web sites.**
- Static asset serving with ETag and build-time compression.
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
- TODO: Meta and Open Graph generation. (`export const meta`)
- TODO: Font subsetting tools to reduce
- Built on the battle-tested Node.js runtime. Partial support for Deno and Bun.
- TODO: Font subsetting tools to reduce bytes downloaded by fonts.
- **Built on the battle-tested Node.js runtime.** Partial support for Deno and Bun.
[1]: https://next.markojs.com

33
run.js
View file

@ -4,18 +4,17 @@ import * as util from "node:util";
import process from "node:process";
// Disable experimental warnings (Type Stripping, etc)
{
const { emit: originalEmit } = process;
const warnings = ["ExperimentalWarning"];
process.emit = function (event, error) {
const { emit: originalEmit } = process;
const warnings = ["ExperimentalWarning"];
process.emit = function (event, error) {
return event === "warning" && warnings.includes(error.name)
? false
: originalEmit.apply(process, arguments);
};
}
};
// Init hooks
const hot = await import("./framework/hot.ts");
await import("#debug");
const console = hot.load("@paperclover/console");
globalThis.console["log"] = console.info;
@ -24,22 +23,6 @@ globalThis.console.warn = console.warn;
globalThis.console.error = console.error;
globalThis.console.debug = console.scoped("dbg");
globalThis.UNWRAP = (t, ...args) => {
if (t == null) {
throw new Error(
args.length > 0 ? util.format(...args) : "UNWRAP(" + t + ")",
);
}
return t;
};
globalThis.ASSERT = (t, ...args) => {
if (!t) {
throw new Error(
args.length > 0 ? util.format(...args) : "Assertion Failed",
);
}
};
// Load with hooks
if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
if (process.argv.length == 2) {
@ -60,7 +43,11 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
process.exit(1);
}
process.argv = [process.argv[0], ...process.argv.slice(2)];
hot.load(found).main?.();
try {
await hot.load(found).main?.();
} catch (e) {
console.error(util.inspect(e));
}
}
export { hot };

View file

@ -59,6 +59,17 @@ export function hasAdminToken(c: Context) {
return token && compareToken(token);
}
import * as fs from "node:fs";
export async function main() {
const key = crypto.randomUUID();
await fs.writeMkdir(".clover/admin-token.txt", key);
const start = ({
win32: "start",
darwin: "open",
} as Record<string, string>)[process.platform] ?? "xdg-open";
child_process.exec(`${start} http://[::1]:3000/admin/login?key=${key}`);
}
import * as fs from "#sitegen/fs";
import type { Context, Next } from "hono";
import { serveAsset } from "#sitegen/assets";
import * as child_process from "node:child_process";

View file

@ -15,6 +15,17 @@ app.route("", require("./q+a/backend.ts").app);
app.use(assets.middleware);
if (process.argv.includes("--development")) {
app.onError((err, c) => {
if (err instanceof HTTPException) {
// Get the custom response
return err.getResponse();
}
return c.text(util.inspect(err), 500);
});
}
export default app;
async function removeDuplicateSlashes(c: Context, next: Next) {
@ -31,8 +42,10 @@ async function removeDuplicateSlashes(c: Context, next: Next) {
}
import { type Context, Hono, type Next } from "#hono";
import { HTTPException } from "hono/http-exception";
import { logger } from "hono/logger";
import { trimTrailingSlash } from "hono/trailing-slash";
import * as assets from "#sitegen/assets";
import * as admin from "./admin.ts";
import { scoped } from "@paperclover/console";
import * as util from "node:util";

View file

@ -0,0 +1,283 @@
export const blog: BlogMeta = {
title: "Marko is the coziest HTML templating language",
desc: "...todo...",
date: "2025-06-13",
draft: true,
};
export const meta = formatBlogMeta(blob);
I've been recently playing around [Marko][1], and after adding limited support
for it in my website generator, [sitegen][2], I instantly fell in love with how
minimalistic it is in comparison to JSX, Astro components, and Svelte.
## Introduction
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 in global 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 />
</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>
```
## Usage on `paperclover.net`
TODO: document a lot of feedback, how i embedded Marko
My website uses statically generated HTML. That is why I have not needed to use
reactive variables. My generator doesn't even try compiling components
client-side.
Here is the actual component 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";
```
import { type BlogMeta, formatBlogMeta } from '@/blog/helpers.ts';

View file

@ -1,6 +0,0 @@
export const meta = { title: 'oh no,,,' };
# oh dear
sound the alarms

View file

@ -104,12 +104,12 @@ async function sendSuccess(c: Context, date: Date) {
id: formatQuestionId(date),
}, { status: 200 });
}
c.res = await renderView(c, "qa_success", {
c.res = await renderView(c, "q+a/success", {
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
});
}
// Question Permalink
app.get("/q+a/:id", (c, next) => {
app.get("/q+a/:id", async (c, next) => {
// from deadname era, the seconds used to be in the url.
// this was removed so that the url can be crafted by hand.
let id = c.req.param("id");
@ -138,7 +138,7 @@ app.get("/admin/q+a", async (c) => {
return serveAsset(c, "/admin/q+a", 200);
});
app.get("/admin/q+a/inbox", async (c) => {
return renderView(c, "qa_backend_inbox", {});
return renderView(c, "q+a/backend-inbox", {});
});
app.delete("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id");

82
src/q+a/image.tsx Normal file
View file

@ -0,0 +1,82 @@
const width = 768;
const cacheImageDir = path.resolve(".clover/question_images");
// Cached browser session
const getBrowser = RefCountedExpirable(
() =>
puppeteer.launch({
// headless: false,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
}),
(b) => b.close(),
);
export async function renderQuestionImage(question: Question) {
const html = await renderViewToString("q+a/embed-image", { question });
// this browser session will be reused if multiple images are generated
// either at the same time or within a 5-minute time span. the dispose
// symbol
using sharedBrowser = await getBrowser();
const b = sharedBrowser.value;
const p = await b.newPage();
await p.setViewport({ width, height: 400 });
await p.setContent(html);
try {
await p.waitForNetworkIdle({ idleTime: 100, timeout: 500 });
} catch (e) {}
const height = await p.evaluate(() => {
const e = document.querySelector("main")!;
return e.getBoundingClientRect().height;
});
const buf = await p.screenshot({
path: "screenshot.png",
type: "png",
captureBeyondViewport: true,
clip: { x: 0, width, y: 0, height: height, scale: 1.5 },
});
await p.close();
return Buffer.from(buf);
}
export async function getQuestionImage(
question: Question,
headOnly: boolean,
): Promise<Response> {
const hash = crypto.createHash("sha1")
.update(question.qmid + question.type + question.text)
.digest("hex");
const headers = {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=31536000",
"ETag": `"${hash}"`,
"Last-Modified": question.date.toUTCString(),
};
if (headOnly) {
return new Response(null, { headers });
}
const cachedFilePath = path.join(cacheImageDir, `/${hash}.png`);
let buf: Buffer;
try {
buf = await fs.readFile(cachedFilePath);
} catch (e: any) {
if (e.code !== "ENOENT") throw e;
buf = await renderQuestionImage(question);
fs.writeMkdir(cachedFilePath, buf).catch(() => {});
}
return new Response(buf, { headers });
}
import * as crypto from "node:crypto";
import * as fs from "#sitegen/fs";
import * as path from "node:path";
import * as puppeteer from "puppeteer";
import { Question } from "@/q+a/models/Question.ts";
import { RefCountedExpirable } from "#sitegen/async";
import { renderViewToString } from "#sitegen/view";

View file

@ -12,15 +12,13 @@ export const meta: Metadata = {
<const/questions = [...Question.getAll()] />
<if=!admin>
<QuestionForm />
<question-form />
</>
<for|q| of=questions>
<QuestionRender question=q admin=admin />
<for|question| of=questions>
<question ...{ question, admin } />
</>
<footer>
fun fact: clover has answered ${questions.length} questions
</footer>
import { Question } from "@/q+a/models/Question.ts";
import { QuestionForm } from "@/q+a/components/QuestionForm.marko";
import QuestionRender from '@/q+a/components/Question.marko';

View file

@ -0,0 +1,115 @@
import { basicSetup, EditorState, EditorView } from "codemirror";
import { ssrSync } from "#ssr";
// @ts-ignore
import type { ScriptPayload } from "../views/editor.marko";
// @ts-ignore
import QuestionRender from "@/q+a/components/Question.marko";
declare const payload: ScriptPayload;
const date = new Date(payload.date);
const main = document.getElementById("edit-grid")! as HTMLDivElement;
const preview = document.getElementById("preview")! as HTMLDivElement;
function updatePreview(text: string) {
preview.innerHTML = ssrSync(
<QuestionRender
question={{
id: payload.id,
qmid: payload.qmid,
text: text,
type: payload.type,
date,
}}
/>,
).text;
}
const startState = EditorState.create({
doc: payload.text,
extensions: [
basicSetup,
EditorView.darkTheme.of(true),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
updatePreview(update.state.doc.toString());
}
}),
EditorView.lineWrapping,
],
// selection: EditorSelection.create([
// EditorSelection.cursor(0),
// ], 0),
});
const view = new EditorView({
state: startState,
parent: document.getElementById("editor")!,
});
view.focus();
(globalThis as any).onCommitQuestion = wrapAction(async () => {
const text = view.state.doc.toString();
const res = await fetch(`/admin/q+a/${payload.id}`, {
method: "PATCH",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
text,
type: payload.type,
}),
});
if (!res.ok) {
throw new Error("Failed to update question, status: " + res.status);
}
if (location.search.includes("return=inbox")) {
location.href = "/admin/q+a/inbox";
} else {
location.href = "/q+a#q" + payload.id;
}
});
(globalThis as any).onDelete = wrapAction(async () => {
if (confirm("Are you sure you want to delete this question?")) {
const res = await fetch(`/admin/q+a/${payload.id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
},
});
if (!res.ok) {
throw new Error("Failed to delete question, status: " + res.status);
}
location.href = document.referrer || "/admin/q+a";
}
});
(globalThis as any).onTypeChange = () => {
payload.type = parseInt(
(document.getElementById("type") as HTMLSelectElement).value,
);
updatePreview(view.state.doc.toString());
};
function wrapAction(cb: () => Promise<void>) {
return async () => {
main.style.opacity = "0.5";
main.style.pointerEvents = "none";
const inputs = main.querySelectorAll("button,select,input") as NodeListOf<
HTMLButtonElement
>;
inputs.forEach((b) => {
b.disabled = true;
});
try {
await cb();
} catch (e: any) {
main.style.opacity = "1";
main.style.pointerEvents = "auto";
inputs.forEach((b) => {
b.disabled = false;
});
alert(e.message);
}
};
}

View file

@ -5,7 +5,7 @@ export interface Input {
}
// 2024-12-31 05:00:00 EST
static const transitionDate = 1735639200000;
export const transitionDate = 1735639200000;
<const/{ question, admin } = input />
<const/{ id, date } = question/>
@ -34,4 +34,4 @@ 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";
import { CloverMarkdown } from "@/q+a/clover-markdown.tsx";

View file

@ -0,0 +1,74 @@
// @ts-ignore
globalThis.onReply = (id: string) => {
location.href = `/admin/q+a/${id}?return=inbox`;
};
// @ts-ignore
globalThis.onDelete = async (id: string) => {
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
if (!div) return alert("Question not found");
// Pending State
div.style.opacity = "0.5";
div.style.pointerEvents = "none";
div?.querySelectorAll("button").forEach((b) => {
b.disabled = true;
});
try {
const resp = await fetch(`/admin/q+a/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
},
});
if (resp.status !== 200) {
throw new Error("Failed to delete question, status: " + resp.status);
}
} catch (e: any) {
div.style.opacity = "1";
div.style.pointerEvents = "auto";
div?.querySelectorAll("button").forEach((b) => {
b.disabled = false;
});
return alert(e.message);
}
div.remove();
};
// @ts-ignore
globalThis.onDeleteFull = async (id: string) => {
const div = document.querySelector(`[data-q="${id}"]`) as HTMLDivElement;
if (!div) return alert("Question not found");
// Confirmation
if (!confirm("Are you sure you want to delete this question?")) return;
// Pending State
div.style.opacity = "0.5";
div.style.pointerEvents = "none";
div?.querySelectorAll("button").forEach((b) => {
b.disabled = true;
});
try {
const resp = await fetch(`/admin/q+a/${id}`, {
method: "DELETE",
headers: {
Accept: "application/json",
"X-Delete-Full": "true",
},
});
if (resp.status !== 200) {
throw new Error("Failed to delete question, status: " + resp.status);
}
} catch (e: any) {
div.style.opacity = "1";
div.style.pointerEvents = "auto";
div?.querySelectorAll("button").forEach((b) => {
b.disabled = false;
});
return alert(e.message);
}
div.remove();
};

39
src/q+a/views/editor.css Normal file
View file

@ -0,0 +1,39 @@
#edit-grid {
display: grid;
grid-template-columns: 1fr 80ch;
grid-template-rows: 3rem 1fr;
grid-gap: 1em;
height: 100vh;
button {
margin-right: 1rem;
}
main {
padding: 0;
margin: 0;
}
}
#topleft, #topright {
padding: 1rem;
}
#topleft {
padding-right: 0rem;
}
#topright {
padding-left: 0rem;
}
#preview {
overflow-y: auto;
e- {
margin-top: 0;
}
}
#editor {
background-color: #303030;
overflow-y: scroll;
height: 100%;
}
.cm-scroller {
overflow-y: auto;
height: 100%;
}

View file

@ -0,0 +1,65 @@
import "./editor.css";
export { theme } from "@/q+a/layout.tsx";
export const meta = { title: "question editor" };
export interface Input {
question: Question;
pendingInfo: PendingQuestion.JSON | null;
}
export interface ScriptPayload {
id: string;
qmid: string;
text: string;
type: Question.Type;
date: string;
}
<const/{ question } = input />
<const/payload= {
id: question.id,
qmid: question.qmid,
text: question.text,
type: question.type,
date: question.date.toISOString(),
} satisfies ScriptPayload/>
<div#edit-grid.qa>
<div#topleft>
<button
onclick="location.href='/admin/q+a'"
style={ color: "magenta" }
>
all
</button>
<button
onclick="location.href='/admin/q+a/inbox'"
style={ color: "dodgerblue" }
>
inbox
</button>
</div>
<div#topright>
<button onclick="onCommitQuestion()" style="color:lime">
update
</button>
<button onclick="onDelete()" style={ color: "red" }>
reject
</button>
<select id="type" onchange="onTypeChange()" value=question.type>
<option value=QuestionType.normal>normal</>
<option value=QuestionType.annotation>annotation</>
<option value=QuestionType.reject>reject</>
</select>
</div>
<div#editor />
<main#preview>
<question ...{question} />
</main>
</div>
<html-script src="/js/edit_frontend.js" type="module" />
import { type PendingQuestion } from "@/q+a/models/PendingQuestion.ts";
import { Question, QuestionType } from "@/q+a/models/Question.ts";

View file

@ -0,0 +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';

View file

@ -0,0 +1,47 @@
export interface Input {
question: Question;
}
server export function meta({ context: { req }, question }) {
const isDiscord = req.get("user-agent")
?.toLowerCase()
.includes("discordbot");
if (question.type === QuestionType.normal) {
return {
title: "question permalink",
openGraph: {
images: [{ url: `https://paperclover.net/q+a/${q.id}.png` }],
},
twitter: { card: "summary_large_image" },
themeColor: isDiscord
? q.date.getTime() > transitionDate ? "#8c78ff" : "#58ff71"
: undefined,
};
}
}
<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';

View file

@ -3,17 +3,16 @@
// sub-projects like the file viewer in 'file', or the question answer system
// in 'q+a'. Each section can define configuration, pages, backend routes, and
// contain other files.
interface Section {
root: string;
}
const join = (...paths: string[]) => path.join(import.meta.dirname, ...paths);
export const siteSections: Section[] = [
{ root: join("./") },
{ root: join(".") },
{ root: join("q+a/") },
{ root: join("file-viewer/") },
{ root: join("friends/") },
// { root: join("blog/"), pageBase: "/blog" },
// { root: join("fiction/"), pageBase: "/fiction" },
];
import * as path from "node:path";
import { Section } from "#sitegen";

View file

@ -15,5 +15,6 @@
"strict": true,
"verbaitimModuleSyntax": true,
"target": "es2022"
}
},
"include": ["framework/**/*", "src/**/*"]
}