work on porting paperclover.net and also some tests
This commit is contained in:
parent
c5113954a8
commit
db244583d7
36 changed files with 2252 additions and 305 deletions
|
@ -1,4 +1,5 @@
|
||||||
import "@paperclover/console/inject";
|
import "@paperclover/console/inject";
|
||||||
|
import "#debug";
|
||||||
|
|
||||||
const protocol = "http";
|
const protocol = "http";
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,9 @@ export async function bundleClientJavaScript(
|
||||||
write: false,
|
write: false,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
external: ["node_modules/"],
|
external: ["node_modules/"],
|
||||||
|
jsx: "automatic",
|
||||||
|
jsxImportSource: "#ssr",
|
||||||
|
jsxDev: dev,
|
||||||
});
|
});
|
||||||
if (bundle.errors.length || bundle.warnings.length) {
|
if (bundle.errors.length || bundle.warnings.length) {
|
||||||
throw new AggregateError(
|
throw new AggregateError(
|
||||||
|
@ -135,11 +138,22 @@ export async function bundleServerJavaScript(
|
||||||
return ({
|
return ({
|
||||||
loader: "ts",
|
loader: "ts",
|
||||||
contents: cacheEntry.src,
|
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",
|
name: "mark css external",
|
||||||
setup(b) {
|
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,
|
bundle: true,
|
||||||
chunkNames: "c.[hash]",
|
chunkNames: "c.[hash]",
|
||||||
entryNames: "server",
|
entryNames: "server",
|
||||||
|
@ -169,6 +183,9 @@ export async function bundleServerJavaScript(
|
||||||
splitting: true,
|
splitting: true,
|
||||||
write: false,
|
write: false,
|
||||||
metafile: true,
|
metafile: true,
|
||||||
|
jsx: "automatic",
|
||||||
|
jsxImportSource: "#ssr",
|
||||||
|
jsxDev: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const files: Record<string, Buffer> = {};
|
const files: Record<string, Buffer> = {};
|
||||||
|
@ -273,3 +290,4 @@ import {
|
||||||
} from "./esbuild-support.ts";
|
} from "./esbuild-support.ts";
|
||||||
import { Incremental } from "./incremental.ts";
|
import { Incremental } from "./incremental.ts";
|
||||||
import * as css from "./css.ts";
|
import * as css from "./css.ts";
|
||||||
|
import * as fs from "#sitegen/fs";
|
||||||
|
|
17
framework/debug.safe.ts
Normal file
17
framework/debug.safe.ts
Normal 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";
|
|
@ -18,3 +18,24 @@ test("simple tree", (t) =>
|
||||||
).text,
|
).text,
|
||||||
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
|
'<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>"&'`<></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>',
|
||||||
|
));
|
||||||
|
|
|
@ -64,6 +64,11 @@ export function projectRelativeResolution(root = process.cwd() + "/src") {
|
||||||
path: path.resolve(root, id.slice(2)),
|
path: path.resolve(root, id.slice(2)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
b.onResolve({ filter: /^#/ }, ({ path: id, importer }) => {
|
||||||
|
return {
|
||||||
|
path: hot.resolveFrom(importer, id),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
} satisfies esbuild.Plugin;
|
} satisfies esbuild.Plugin;
|
||||||
}
|
}
|
||||||
|
@ -71,3 +76,4 @@ export function projectRelativeResolution(root = process.cwd() + "/src") {
|
||||||
import * as esbuild from "esbuild";
|
import * as esbuild from "esbuild";
|
||||||
import * as string from "#sitegen/string";
|
import * as string from "#sitegen/string";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import * as hot from "./hot.ts";
|
||||||
|
|
|
@ -74,26 +74,40 @@ export async function sitegen(
|
||||||
dir: sectionPath("pages"),
|
dir: sectionPath("pages"),
|
||||||
list: pages,
|
list: pages,
|
||||||
prefix: "/",
|
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"),
|
dir: sectionPath("views"),
|
||||||
list: views,
|
list: views,
|
||||||
prefix: rootPrefix,
|
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);
|
const items = fs.readDirRecOptionalSync(dir);
|
||||||
item: for (const subPath of items) {
|
for (const subPath of items) {
|
||||||
const file = path.join(dir, subPath);
|
const file = path.join(dir, subPath);
|
||||||
const stat = fs.statSync(file);
|
const stat = fs.statSync(file);
|
||||||
if (stat.isDirectory()) continue;
|
if (stat.isDirectory()) continue;
|
||||||
for (const e of exclude) {
|
if (!include.some((e) => subPath.endsWith(e))) continue;
|
||||||
if (subPath.endsWith(e)) continue item;
|
if (exclude.some((e) => subPath.endsWith(e))) continue;
|
||||||
}
|
|
||||||
const trim = ext
|
const trim = ext
|
||||||
? subPath
|
? subPath
|
||||||
: subPath.slice(0, -path.extname(subPath).length).replaceAll(
|
: subPath.slice(0, -path.extname(subPath).length).replaceAll(
|
||||||
|
@ -216,31 +230,33 @@ export async function sitegen(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function prepareView(view: FileItem) {
|
async function prepareView(item: FileItem) {
|
||||||
const module = require(view.file);
|
const module = require(item.file);
|
||||||
if (!module.meta) {
|
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) {
|
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 pageTheme = module.layout?.theme ?? module.theme;
|
||||||
const theme: css.Theme = {
|
const theme: css.Theme = {
|
||||||
...css.defaultTheme,
|
...css.defaultTheme,
|
||||||
...pageTheme,
|
...pageTheme,
|
||||||
};
|
};
|
||||||
const cssImports = hot.getCssImports(view.file)
|
const cssImports = Array.from(
|
||||||
.concat("src/global.css")
|
new Set([globalCssPath, ...hot.getCssImports(item.file)]),
|
||||||
.map((file) => path.relative(hot.projectSrc, path.resolve(file)));
|
(file) => path.relative(hot.projectSrc, file),
|
||||||
|
);
|
||||||
|
ensureCssGetsBuilt(cssImports, theme, item.id);
|
||||||
incr.put({
|
incr.put({
|
||||||
kind: "viewMetadata",
|
kind: "viewMetadata",
|
||||||
key: view.id,
|
key: item.id,
|
||||||
sources: [view.file],
|
sources: [item.file],
|
||||||
value: {
|
value: {
|
||||||
file: path.relative(hot.projectRoot, view.file),
|
file: path.relative(hot.projectRoot, item.file),
|
||||||
cssImports,
|
cssImports,
|
||||||
theme,
|
theme,
|
||||||
clientRefs: hot.getClientScriptRefs(view.file),
|
clientRefs: hot.getClientScriptRefs(item.file),
|
||||||
hasLayout: !!module.layout?.default,
|
hasLayout: !!module.layout?.default,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -422,7 +438,7 @@ function getItemText({ file }: FileItem) {
|
||||||
return path.relative(hot.projectSrc, file).replaceAll("\\", "/");
|
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 { Incremental } from "./incremental.ts";
|
||||||
import * as bundle from "./bundle.ts";
|
import * as bundle from "./bundle.ts";
|
||||||
import * as css from "./css.ts";
|
import * as css from "./css.ts";
|
||||||
|
|
|
@ -148,7 +148,7 @@ export class Incremental {
|
||||||
for (const key of map.keys()) {
|
for (const key of map.keys()) {
|
||||||
if (!this.round.referenced.has(`${kind}\0${key}`)) {
|
if (!this.round.referenced.has(`${kind}\0${key}`)) {
|
||||||
unreferenced.push({ kind: kind as ArtifactKind, 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 fs from "#sitegen/fs";
|
||||||
import * as zlib from "node:zlib";
|
import * as zlib from "node:zlib";
|
||||||
import * as util from "node:util";
|
import * as util from "node:util";
|
||||||
import { Queue } from "./queue.ts";
|
import { Queue } from "#sitegen/async";
|
||||||
import * as hot from "./hot.ts";
|
import * as hot from "./hot.ts";
|
||||||
import * as mime from "#sitegen/mime";
|
import * as mime from "#sitegen/mime";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
|
@ -1,210 +1,279 @@
|
||||||
interface QueueOptions<T, R> {
|
const five_minutes = 5 * 60 * 1000;
|
||||||
name: string;
|
|
||||||
fn: (item: T, spin: Spinner) => Promise<R>;
|
interface QueueOptions<T, R> {
|
||||||
getItemText?: (item: T) => string;
|
name: string;
|
||||||
maxJobs?: number;
|
fn: (item: T, spin: Spinner) => Promise<R>;
|
||||||
passive?: boolean;
|
getItemText?: (item: T) => string;
|
||||||
}
|
maxJobs?: number;
|
||||||
|
passive?: boolean;
|
||||||
// Process multiple items in parallel, queue up as many.
|
}
|
||||||
export class Queue<T, R> {
|
|
||||||
#name: string;
|
// Process multiple items in parallel, queue up as many.
|
||||||
#fn: (item: T, spin: Spinner) => Promise<R>;
|
export class Queue<T, R> {
|
||||||
#maxJobs: number;
|
#name: string;
|
||||||
#getItemText: (item: T) => string;
|
#fn: (item: T, spin: Spinner) => Promise<R>;
|
||||||
#passive: boolean;
|
#maxJobs: number;
|
||||||
|
#getItemText: (item: T) => string;
|
||||||
#active: Spinner[] = [];
|
#passive: boolean;
|
||||||
#queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = [];
|
|
||||||
|
#active: Spinner[] = [];
|
||||||
#cachedProgress: Progress<{ active: Spinner[] }> | null = null;
|
#queue: Array<[T] | [T, (result: R) => void, (err: unknown) => void]> = [];
|
||||||
#done: number = 0;
|
|
||||||
#total: number = 0;
|
#cachedProgress: Progress<{ active: Spinner[] }> | null = null;
|
||||||
#onComplete: (() => void) | null = null;
|
#done: number = 0;
|
||||||
#estimate: number | null = null;
|
#total: number = 0;
|
||||||
#errors: unknown[] = [];
|
#onComplete: (() => void) | null = null;
|
||||||
|
#estimate: number | null = null;
|
||||||
constructor(options: QueueOptions<T, R>) {
|
#errors: unknown[] = [];
|
||||||
this.#name = options.name;
|
|
||||||
this.#fn = options.fn;
|
constructor(options: QueueOptions<T, R>) {
|
||||||
this.#maxJobs = options.maxJobs ?? 5;
|
this.#name = options.name;
|
||||||
this.#getItemText = options.getItemText ?? defaultGetItemText;
|
this.#fn = options.fn;
|
||||||
this.#passive = options.passive ?? false;
|
this.#maxJobs = options.maxJobs ?? 5;
|
||||||
}
|
this.#getItemText = options.getItemText ?? defaultGetItemText;
|
||||||
|
this.#passive = options.passive ?? false;
|
||||||
get bar() {
|
}
|
||||||
const cached = this.#cachedProgress;
|
|
||||||
if (!cached) {
|
get bar() {
|
||||||
const bar = this.#cachedProgress = new Progress({
|
const cached = this.#cachedProgress;
|
||||||
spinner: null,
|
if (!cached) {
|
||||||
text: ({ active }) => {
|
const bar = this.#cachedProgress = new Progress({
|
||||||
const now = performance.now();
|
spinner: null,
|
||||||
let text = `[${this.#done}/${this.#total}] ${this.#name}`;
|
text: ({ active }) => {
|
||||||
let n = 0;
|
const now = performance.now();
|
||||||
for (const item of active) {
|
let text = `[${this.#done}/${this.#total}] ${this.#name}`;
|
||||||
let itemText = "- " + item.format(now);
|
let n = 0;
|
||||||
text += `\n` +
|
for (const item of active) {
|
||||||
itemText.slice(0, Math.max(0, process.stdout.columns - 1));
|
let itemText = "- " + item.format(now);
|
||||||
if (n > 10) {
|
text += `\n` +
|
||||||
text += `\n ... + ${active.length - n} more`;
|
itemText.slice(0, Math.max(0, process.stdout.columns - 1));
|
||||||
break;
|
if (n > 10) {
|
||||||
}
|
text += `\n ... + ${active.length - n} more`;
|
||||||
n++;
|
break;
|
||||||
}
|
}
|
||||||
return text;
|
n++;
|
||||||
},
|
}
|
||||||
props: {
|
return text;
|
||||||
active: [] as Spinner[],
|
},
|
||||||
},
|
props: {
|
||||||
});
|
active: [] as Spinner[],
|
||||||
bar.value = 0;
|
},
|
||||||
return bar;
|
});
|
||||||
}
|
bar.value = 0;
|
||||||
return cached;
|
return bar;
|
||||||
}
|
}
|
||||||
|
return cached;
|
||||||
add(args: T) {
|
}
|
||||||
this.#total += 1;
|
|
||||||
this.updateTotal();
|
add(args: T) {
|
||||||
if (this.#active.length > this.#maxJobs) {
|
this.#total += 1;
|
||||||
const { promise, resolve, reject } = Promise.withResolvers<R>();
|
this.updateTotal();
|
||||||
this.#queue.push([args, resolve, reject]);
|
if (this.#active.length > this.#maxJobs) {
|
||||||
return promise;
|
const { promise, resolve, reject } = Promise.withResolvers<R>();
|
||||||
}
|
this.#queue.push([args, resolve, reject]);
|
||||||
return this.#run(args);
|
return promise;
|
||||||
}
|
}
|
||||||
|
return this.#run(args);
|
||||||
addMany(items: T[]) {
|
}
|
||||||
this.#total += items.length;
|
|
||||||
this.updateTotal();
|
addMany(items: T[]) {
|
||||||
|
this.#total += items.length;
|
||||||
const runNowCount = this.#maxJobs - this.#active.length;
|
this.updateTotal();
|
||||||
const runNow = items.slice(0, runNowCount);
|
|
||||||
const runLater = items.slice(runNowCount);
|
const runNowCount = this.#maxJobs - this.#active.length;
|
||||||
this.#queue.push(...runLater.reverse().map<[T]>((x) => [x]));
|
const runNow = items.slice(0, runNowCount);
|
||||||
runNow.map((item) => this.#run(item).catch(() => {}));
|
const runLater = items.slice(runNowCount);
|
||||||
}
|
this.#queue.push(...runLater.reverse().map<[T]>((x) => [x]));
|
||||||
|
runNow.map((item) => this.#run(item).catch(() => {}));
|
||||||
async #run(args: T): Promise<R> {
|
}
|
||||||
const bar = this.bar;
|
|
||||||
const itemText = this.#getItemText(args);
|
async #run(args: T): Promise<R> {
|
||||||
const spinner = new Spinner(itemText);
|
const bar = this.bar;
|
||||||
spinner.stop();
|
const itemText = this.#getItemText(args);
|
||||||
const active = this.#active;
|
const spinner = new Spinner(itemText);
|
||||||
try {
|
spinner.stop();
|
||||||
active.unshift(spinner);
|
const active = this.#active;
|
||||||
bar.props = { active };
|
try {
|
||||||
const result = await this.#fn(args, spinner);
|
active.unshift(spinner);
|
||||||
this.#done++;
|
bar.props = { active };
|
||||||
return result;
|
const result = await this.#fn(args, spinner);
|
||||||
} catch (err) {
|
this.#done++;
|
||||||
if (err && typeof err === "object") {
|
return result;
|
||||||
(err as any).job = itemText;
|
} catch (err) {
|
||||||
}
|
if (err && typeof err === "object") {
|
||||||
this.#errors.push(err);
|
(err as any).job = itemText;
|
||||||
throw err;
|
}
|
||||||
} finally {
|
this.#errors.push(err);
|
||||||
active.splice(active.indexOf(spinner), 1);
|
throw err;
|
||||||
bar.props = { active };
|
} finally {
|
||||||
bar.value = this.#done;
|
active.splice(active.indexOf(spinner), 1);
|
||||||
|
bar.props = { active };
|
||||||
// Process next item
|
bar.value = this.#done;
|
||||||
const next = this.#queue.shift();
|
|
||||||
if (next) {
|
// Process next item
|
||||||
const args = next[0];
|
const next = this.#queue.shift();
|
||||||
this.#run(args)
|
if (next) {
|
||||||
.then((result) => next[1]?.(result))
|
const args = next[0];
|
||||||
.catch((err) => next[2]?.(err));
|
this.#run(args)
|
||||||
} else if (this.#active.length === 0) {
|
.then((result) => next[1]?.(result))
|
||||||
if (this.#passive) {
|
.catch((err) => next[2]?.(err));
|
||||||
this.bar.stop();
|
} else if (this.#active.length === 0) {
|
||||||
this.#cachedProgress = null;
|
if (this.#passive) {
|
||||||
}
|
this.bar.stop();
|
||||||
this.#onComplete?.();
|
this.#cachedProgress = null;
|
||||||
}
|
}
|
||||||
}
|
this.#onComplete?.();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
updateTotal() {
|
}
|
||||||
const bar = this.bar;
|
|
||||||
bar.total = Math.max(this.#total, this.#estimate ?? 0);
|
updateTotal() {
|
||||||
}
|
const bar = this.bar;
|
||||||
|
bar.total = Math.max(this.#total, this.#estimate ?? 0);
|
||||||
set estimate(e: number) {
|
}
|
||||||
this.#estimate = e;
|
|
||||||
if (this.#cachedProgress) {
|
set estimate(e: number) {
|
||||||
this.updateTotal();
|
this.#estimate = e;
|
||||||
}
|
if (this.#cachedProgress) {
|
||||||
}
|
this.updateTotal();
|
||||||
|
}
|
||||||
async done(o: { method: "success" | "stop" }) {
|
}
|
||||||
if (this.#active.length === 0) {
|
|
||||||
this.#end(o);
|
async done(o: { method: "success" | "stop" }) {
|
||||||
return;
|
if (this.#active.length === 0) {
|
||||||
}
|
this.#end(o);
|
||||||
|
return;
|
||||||
const { promise, resolve } = Promise.withResolvers<void>();
|
}
|
||||||
this.#onComplete = resolve;
|
|
||||||
await promise;
|
const { promise, resolve } = Promise.withResolvers<void>();
|
||||||
this.#end(o);
|
this.#onComplete = resolve;
|
||||||
}
|
await promise;
|
||||||
|
this.#end(o);
|
||||||
#end(
|
}
|
||||||
{ method = this.#passive ? "stop" : "success" }: {
|
|
||||||
method: "success" | "stop";
|
#end(
|
||||||
},
|
{ method = this.#passive ? "stop" : "success" }: {
|
||||||
) {
|
method: "success" | "stop";
|
||||||
const bar = this.#cachedProgress;
|
},
|
||||||
if (this.#errors.length > 0) {
|
) {
|
||||||
if (bar) bar.stop();
|
const bar = this.#cachedProgress;
|
||||||
throw new AggregateError(
|
if (this.#errors.length > 0) {
|
||||||
this.#errors,
|
if (bar) bar.stop();
|
||||||
this.#errors.length + " jobs failed in '" + this.#name + "'",
|
throw new AggregateError(
|
||||||
);
|
this.#errors,
|
||||||
}
|
this.#errors.length + " jobs failed in '" + this.#name + "'",
|
||||||
|
);
|
||||||
if (bar) bar[method]();
|
}
|
||||||
}
|
|
||||||
|
if (bar) bar[method]();
|
||||||
get active(): boolean {
|
}
|
||||||
return this.#active.length !== 0;
|
|
||||||
}
|
get active(): boolean {
|
||||||
}
|
return this.#active.length !== 0;
|
||||||
|
}
|
||||||
const cwd = process.cwd();
|
}
|
||||||
function defaultGetItemText(item: unknown) {
|
|
||||||
let itemText = "";
|
const cwd = process.cwd();
|
||||||
if (typeof item === "string") {
|
function defaultGetItemText(item: unknown) {
|
||||||
itemText = item;
|
let itemText = "";
|
||||||
} else if (typeof item === "object" && item !== null) {
|
if (typeof item === "string") {
|
||||||
const { path, label, id } = item as any;
|
itemText = item;
|
||||||
itemText = label ?? path ?? id ?? JSON.stringify(item);
|
} else if (typeof item === "object" && item !== null) {
|
||||||
} else {
|
const { path, label, id } = item as any;
|
||||||
itemText = JSON.stringify(item);
|
itemText = label ?? path ?? id ?? JSON.stringify(item);
|
||||||
}
|
} else {
|
||||||
|
itemText = JSON.stringify(item);
|
||||||
if (itemText.startsWith(cwd)) {
|
}
|
||||||
itemText = path.relative(cwd, itemText);
|
|
||||||
}
|
if (itemText.startsWith(cwd)) {
|
||||||
return itemText;
|
itemText = path.relative(cwd, itemText);
|
||||||
}
|
}
|
||||||
|
return itemText;
|
||||||
export class OnceMap<T> {
|
}
|
||||||
private ongoing = new Map<string, Promise<T>>();
|
|
||||||
|
export class OnceMap<T> {
|
||||||
get(key: string, compute: () => Promise<T>) {
|
private ongoing = new Map<string, Promise<T>>();
|
||||||
if (this.ongoing.has(key)) {
|
|
||||||
return this.ongoing.get(key)!;
|
get(key: string, compute: () => Promise<T>) {
|
||||||
}
|
if (this.ongoing.has(key)) {
|
||||||
|
return this.ongoing.get(key)!;
|
||||||
const result = compute();
|
}
|
||||||
this.ongoing.set(key, result);
|
|
||||||
result.finally(() => this.ongoing.delete(key));
|
const result = compute();
|
||||||
return result;
|
this.ongoing.set(key, result);
|
||||||
}
|
return result;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
import { Progress } from "@paperclover/console/Progress";
|
|
||||||
import { Spinner } from "@paperclover/console/Spinner";
|
interface ARCEValue<T> {
|
||||||
import * as path from "node:path";
|
value: T;
|
||||||
import process from "node:process";
|
[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";
|
||||||
|
import process from "node:process";
|
|
@ -16,7 +16,14 @@ let scripts: Record<string, string> = null!;
|
||||||
// boundaries, but those were never used. Pages will wait until they
|
// boundaries, but those were never used. Pages will wait until they
|
||||||
// are fully rendered before sending.
|
// are fully rendered before sending.
|
||||||
export async function renderView(
|
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,
|
id: string,
|
||||||
props: Record<string, unknown>,
|
props: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
|
@ -29,11 +36,11 @@ export async function renderView(
|
||||||
inlineCss,
|
inlineCss,
|
||||||
layout,
|
layout,
|
||||||
meta: metadata,
|
meta: metadata,
|
||||||
}: View = views[id];
|
}: View = UNWRAP(views[id], `Missing view ${id}`);
|
||||||
|
|
||||||
// -- metadata --
|
// -- metadata --
|
||||||
const renderedMetaPromise = Promise.resolve(
|
const renderedMetaPromise = Promise.resolve(
|
||||||
typeof metadata === "function" ? metadata({ context: c }) : metadata,
|
typeof metadata === "function" ? metadata(props) : metadata,
|
||||||
).then((m) => meta.renderMeta(m));
|
).then((m) => meta.renderMeta(m));
|
||||||
|
|
||||||
// -- html --
|
// -- html --
|
||||||
|
@ -44,14 +51,17 @@ export async function renderView(
|
||||||
});
|
});
|
||||||
|
|
||||||
// -- join document and send --
|
// -- join document and send --
|
||||||
return c.html(wrapDocument({
|
return wrapDocument({
|
||||||
body,
|
body,
|
||||||
head: await renderedMetaPromise,
|
head: await renderedMetaPromise,
|
||||||
inlineCss,
|
inlineCss,
|
||||||
scripts: joinScripts(
|
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) {
|
export function provideViewData(v: typeof views, s: typeof scripts) {
|
||||||
|
@ -87,5 +97,4 @@ export function wrapDocument({
|
||||||
import * as meta from "./meta.ts";
|
import * as meta from "./meta.ts";
|
||||||
import type * as hono from "#hono";
|
import type * as hono from "#hono";
|
||||||
import * as engine from "../engine/ssr.ts";
|
import * as engine from "../engine/ssr.ts";
|
||||||
import type * as css from "../css.ts";
|
|
||||||
import * as sg from "./sitegen.ts";
|
import * as sg from "./sitegen.ts";
|
||||||
|
|
4
framework/test-fixture/Component.marko
Normal file
4
framework/test-fixture/Component.marko
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<div meow=null />
|
||||||
|
<div>
|
||||||
|
wait(${null})
|
||||||
|
</div>
|
6
framework/test-fixture/UseComponent.marko
Normal file
6
framework/test-fixture/UseComponent.marko
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import Component from './Component.marko';
|
||||||
|
|
||||||
|
<h1>web page</h1>
|
||||||
|
<if=!false>
|
||||||
|
<Component=null/>
|
||||||
|
</>
|
|
@ -11,7 +11,7 @@ export async function main() {
|
||||||
const watch = new Watch(rebuild);
|
const watch = new Watch(rebuild);
|
||||||
watch.add(...incr.invals.keys());
|
watch.add(...incr.invals.keys());
|
||||||
statusLine();
|
statusLine();
|
||||||
// ... an
|
// ... and then serve it!
|
||||||
serve();
|
serve();
|
||||||
|
|
||||||
function serve() {
|
function serve() {
|
||||||
|
@ -41,7 +41,13 @@ export async function main() {
|
||||||
files = files.map((file) => path.relative(hot.projectRoot, file));
|
files = files.map((file) => path.relative(hot.projectRoot, file));
|
||||||
const changed: string[] = [];
|
const changed: string[] = [];
|
||||||
for (const file of files) {
|
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) {
|
if (changed.length === 0) {
|
||||||
console.warn("Files were modified but the 'modify' time did not change.");
|
console.warn("Files were modified but the 'modify' time did not change.");
|
||||||
|
|
1051
package-lock.json
generated
1051
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -8,6 +8,7 @@
|
||||||
"hls.js": "^1.6.5",
|
"hls.js": "^1.6.5",
|
||||||
"hono": "^4.7.11",
|
"hono": "^4.7.11",
|
||||||
"marko": "^6.0.20",
|
"marko": "^6.0.20",
|
||||||
|
"puppeteer": "^24.10.1",
|
||||||
"unique-names-generator": "^4.7.1"
|
"unique-names-generator": "^4.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -16,6 +17,7 @@
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"#backend": "./src/backend.ts",
|
"#backend": "./src/backend.ts",
|
||||||
|
"#debug": "./framework/debug.safe.ts",
|
||||||
"#sitegen": "./framework/lib/sitegen.ts",
|
"#sitegen": "./framework/lib/sitegen.ts",
|
||||||
"#sitegen/*": "./framework/lib/*.ts",
|
"#sitegen/*": "./framework/lib/*.ts",
|
||||||
"#ssr": "./framework/engine/ssr.ts",
|
"#ssr": "./framework/engine/ssr.ts",
|
||||||
|
|
23
readme.md
23
readme.md
|
@ -3,26 +3,29 @@
|
||||||
this repository contains clover's "sitegen" framework, which is a set of tools
|
this repository contains clover's "sitegen" framework, which is a set of tools
|
||||||
that assist building websites. these tools power https://paperclover.net.
|
that assist building websites. these tools power https://paperclover.net.
|
||||||
|
|
||||||
- HTML "Server Side Rendering") engine written from scratch.
|
- **HTML "Server Side Rendering") engine written from scratch.** (~500 lines)
|
||||||
- A more practical JSX runtime (`class` instead of `className`, etc).
|
- A more practical JSX runtime (`class` instead of `className`, built-in
|
||||||
- Transparent integration with [Marko][1] to mix component types.
|
`clsx`, `html()` helper over `dangerouslySetInnerHTML` prop, etc).
|
||||||
- MDX support for text-heavy content pages.
|
- Integration with [Marko][1] for concisely written components.
|
||||||
- Incremental static site generator and build system
|
- 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
|
- Build entire production site at start, incremental updates when pages
|
||||||
change; Build system state survives coding sessions.
|
change; Build system state survives coding sessions.
|
||||||
- The only difference in development and production mode is hidden
|
- The only difference in development and production mode is hidden
|
||||||
source-maps and stripped assertions and `console.debug` calls. The site
|
source-maps and stripped `console.debug` calls. The site you
|
||||||
you see locally is the site you see deployed.
|
see locally is the same site you see deployed.
|
||||||
- (TODO) Tests, Lints, and Type-checking is run alongside, and only re-runs
|
- (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
|
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.
|
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.
|
- Static asset serving with ETag and build-time compression.
|
||||||
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
|
- Dynamicly rendered pages with static client. (`#import "#sitegen/view"`)
|
||||||
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
|
- Databases with a typed SQLite wrapper. (`import "#sitegen/sqlite"`)
|
||||||
- TODO: Meta and Open Graph generation. (`export const meta`)
|
- TODO: Meta and Open Graph generation. (`export const meta`)
|
||||||
- TODO: Font subsetting tools to reduce
|
- TODO: Font subsetting tools to reduce bytes downloaded by fonts.
|
||||||
- Built on the battle-tested Node.js runtime. Partial support for Deno and Bun.
|
- **Built on the battle-tested Node.js runtime.** Partial support for Deno and Bun.
|
||||||
|
|
||||||
[1]: https://next.markojs.com
|
[1]: https://next.markojs.com
|
||||||
|
|
||||||
|
|
39
run.js
39
run.js
|
@ -4,18 +4,17 @@ import * as util from "node:util";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
|
||||||
// Disable experimental warnings (Type Stripping, etc)
|
// Disable experimental warnings (Type Stripping, etc)
|
||||||
{
|
const { emit: originalEmit } = process;
|
||||||
const { emit: originalEmit } = process;
|
const warnings = ["ExperimentalWarning"];
|
||||||
const warnings = ["ExperimentalWarning"];
|
process.emit = function (event, error) {
|
||||||
process.emit = function (event, error) {
|
return event === "warning" && warnings.includes(error.name)
|
||||||
return event === "warning" && warnings.includes(error.name)
|
? false
|
||||||
? false
|
: originalEmit.apply(process, arguments);
|
||||||
: originalEmit.apply(process, arguments);
|
};
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init hooks
|
// Init hooks
|
||||||
const hot = await import("./framework/hot.ts");
|
const hot = await import("./framework/hot.ts");
|
||||||
|
await import("#debug");
|
||||||
|
|
||||||
const console = hot.load("@paperclover/console");
|
const console = hot.load("@paperclover/console");
|
||||||
globalThis.console["log"] = console.info;
|
globalThis.console["log"] = console.info;
|
||||||
|
@ -24,22 +23,6 @@ globalThis.console.warn = console.warn;
|
||||||
globalThis.console.error = console.error;
|
globalThis.console.error = console.error;
|
||||||
globalThis.console.debug = console.scoped("dbg");
|
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
|
// Load with hooks
|
||||||
if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
|
if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
|
||||||
if (process.argv.length == 2) {
|
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.exit(1);
|
||||||
}
|
}
|
||||||
process.argv = [process.argv[0], ...process.argv.slice(2)];
|
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 };
|
export { hot };
|
||||||
|
|
13
src/admin.ts
13
src/admin.ts
|
@ -59,6 +59,17 @@ export function hasAdminToken(c: Context) {
|
||||||
return token && compareToken(token);
|
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 type { Context, Next } from "hono";
|
||||||
import { serveAsset } from "#sitegen/assets";
|
import { serveAsset } from "#sitegen/assets";
|
||||||
|
import * as child_process from "node:child_process";
|
||||||
|
|
|
@ -15,6 +15,17 @@ app.route("", require("./q+a/backend.ts").app);
|
||||||
|
|
||||||
app.use(assets.middleware);
|
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;
|
export default app;
|
||||||
|
|
||||||
async function removeDuplicateSlashes(c: Context, next: Next) {
|
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 { type Context, Hono, type Next } from "#hono";
|
||||||
|
import { HTTPException } from "hono/http-exception";
|
||||||
import { logger } from "hono/logger";
|
import { logger } from "hono/logger";
|
||||||
import { trimTrailingSlash } from "hono/trailing-slash";
|
import { trimTrailingSlash } from "hono/trailing-slash";
|
||||||
import * as assets from "#sitegen/assets";
|
import * as assets from "#sitegen/assets";
|
||||||
import * as admin from "./admin.ts";
|
import * as admin from "./admin.ts";
|
||||||
import { scoped } from "@paperclover/console";
|
import { scoped } from "@paperclover/console";
|
||||||
|
import * as util from "node:util";
|
||||||
|
|
283
src/blog/pages/25/marko-intro.markodown
Normal file
283
src/blog/pages/25/marko-intro.markodown
Normal 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);
|
||||||
|
}
|
||||||
|
> × </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';
|
|
@ -1,6 +0,0 @@
|
||||||
export const meta = { title: 'oh no,,,' };
|
|
||||||
|
|
||||||
# oh dear
|
|
||||||
|
|
||||||
sound the alarms
|
|
||||||
|
|
|
@ -104,12 +104,12 @@ async function sendSuccess(c: Context, date: Date) {
|
||||||
id: formatQuestionId(date),
|
id: formatQuestionId(date),
|
||||||
}, { status: 200 });
|
}, { 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)}`,
|
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Question Permalink
|
// 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.
|
// from deadname era, the seconds used to be in the url.
|
||||||
// this was removed so that the url can be crafted by hand.
|
// this was removed so that the url can be crafted by hand.
|
||||||
let id = c.req.param("id");
|
let id = c.req.param("id");
|
||||||
|
@ -138,7 +138,7 @@ app.get("/admin/q+a", async (c) => {
|
||||||
return serveAsset(c, "/admin/q+a", 200);
|
return serveAsset(c, "/admin/q+a", 200);
|
||||||
});
|
});
|
||||||
app.get("/admin/q+a/inbox", async (c) => {
|
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) => {
|
app.delete("/admin/q+a/:id", async (c, next) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
|
|
82
src/q+a/image.tsx
Normal file
82
src/q+a/image.tsx
Normal 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";
|
|
@ -12,15 +12,13 @@ export const meta: Metadata = {
|
||||||
<const/questions = [...Question.getAll()] />
|
<const/questions = [...Question.getAll()] />
|
||||||
|
|
||||||
<if=!admin>
|
<if=!admin>
|
||||||
<QuestionForm />
|
<question-form />
|
||||||
</>
|
</>
|
||||||
<for|q| of=questions>
|
<for|question| of=questions>
|
||||||
<QuestionRender question=q admin=admin />
|
<question ...{ question, admin } />
|
||||||
</>
|
</>
|
||||||
<footer>
|
<footer>
|
||||||
fun fact: clover has answered ${questions.length} questions
|
fun fact: clover has answered ${questions.length} questions
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
import { Question } from "@/q+a/models/Question.ts";
|
import { Question } from "@/q+a/models/Question.ts";
|
||||||
import { QuestionForm } from "@/q+a/components/QuestionForm.marko";
|
|
||||||
import QuestionRender from '@/q+a/components/Question.marko';
|
|
||||||
|
|
115
src/q+a/scripts/editor.client.tsx
Normal file
115
src/q+a/scripts/editor.client.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -5,7 +5,7 @@ export interface Input {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2024-12-31 05:00:00 EST
|
// 2024-12-31 05:00:00 EST
|
||||||
static const transitionDate = 1735639200000;
|
export const transitionDate = 1735639200000;
|
||||||
|
|
||||||
<const/{ question, admin } = input />
|
<const/{ question, admin } = input />
|
||||||
<const/{ id, date } = question/>
|
<const/{ id, date } = question/>
|
||||||
|
@ -34,4 +34,4 @@ client import "./clickable-links.client.ts";
|
||||||
|
|
||||||
import type { Question } from "@/q+a/models/Question.ts";
|
import type { Question } from "@/q+a/models/Question.ts";
|
||||||
import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts";
|
import { formatQuestionTimestamp, formatQuestionISOTimestamp } from "@/q+a/format.ts";
|
||||||
import { CloverMarkdown } from "@/q+a/clover-markdown";
|
import { CloverMarkdown } from "@/q+a/clover-markdown.tsx";
|
|
@ -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
39
src/q+a/views/editor.css
Normal 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%;
|
||||||
|
}
|
65
src/q+a/views/editor.marko
Normal file
65
src/q+a/views/editor.marko
Normal 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";
|
16
src/q+a/views/image-embed.marko
Normal file
16
src/q+a/views/image-embed.marko
Normal 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';
|
47
src/q+a/views/permalink.marko
Normal file
47
src/q+a/views/permalink.marko
Normal 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';
|
|
@ -3,17 +3,16 @@
|
||||||
// sub-projects like the file viewer in 'file', or the question answer system
|
// 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
|
// in 'q+a'. Each section can define configuration, pages, backend routes, and
|
||||||
// contain other files.
|
// contain other files.
|
||||||
interface Section {
|
|
||||||
root: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const join = (...paths: string[]) => path.join(import.meta.dirname, ...paths);
|
const join = (...paths: string[]) => path.join(import.meta.dirname, ...paths);
|
||||||
|
|
||||||
export const siteSections: Section[] = [
|
export const siteSections: Section[] = [
|
||||||
{ root: join("./") },
|
{ root: join(".") },
|
||||||
{ root: join("q+a/") },
|
{ root: join("q+a/") },
|
||||||
{ root: join("file-viewer/") },
|
{ root: join("file-viewer/") },
|
||||||
{ root: join("friends/") },
|
{ root: join("friends/") },
|
||||||
|
// { root: join("blog/"), pageBase: "/blog" },
|
||||||
|
// { root: join("fiction/"), pageBase: "/fiction" },
|
||||||
];
|
];
|
||||||
|
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
|
import { Section } from "#sitegen";
|
||||||
|
|
|
@ -15,5 +15,6 @@
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"verbaitimModuleSyntax": true,
|
"verbaitimModuleSyntax": true,
|
||||||
"target": "es2022"
|
"target": "es2022"
|
||||||
}
|
},
|
||||||
|
"include": ["framework/**/*", "src/**/*"]
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue