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 "#debug";
|
||||
|
||||
const protocol = "http";
|
||||
|
||||
|
|
|
@ -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
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,
|
||||
'<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)),
|
||||
};
|
||||
});
|
||||
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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
|
@ -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";
|
||||
|
|
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);
|
||||
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
1051
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
23
readme.md
23
readme.md
|
@ -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
33
run.js
|
@ -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 };
|
||||
|
|
13
src/admin.ts
13
src/admin.ts
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
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),
|
||||
}, { 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
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()] />
|
||||
|
||||
<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';
|
||||
|
|
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
|
||||
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";
|
|
@ -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
|
||||
// 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";
|
||||
|
|
|
@ -15,5 +15,6 @@
|
|||
"strict": true,
|
||||
"verbaitimModuleSyntax": true,
|
||||
"target": "es2022"
|
||||
}
|
||||
},
|
||||
"include": ["framework/**/*", "src/**/*"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue