almost implement views

This commit is contained in:
chloe caruso 2025-06-10 01:13:59 -07:00
parent a1d17a5d61
commit c8b5e91251
16 changed files with 514 additions and 115 deletions

View file

@ -50,7 +50,6 @@ export async function bundleClientJavaScript(
path.basename(file).replace(/\.client\.[tj]sx?/, "")
);
const { metafile } = bundle;
console.log(metafile);
const promises: Promise<void>[] = [];
// TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
for (const file of bundle.outputFiles) {
@ -85,25 +84,44 @@ export async function bundleClientJavaScript(
type ServerPlatform = "node" | "passthru";
export async function bundleServerJavaScript(
/** Has 'export default app;' */
backendEntryPoint: string,
_: string,
/** Views for dynamic loading */
viewEntryPoints: FileItem[],
platform: ServerPlatform = "node",
) {
const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION";
const viewModules = viewEntryPoints.map((view) => {
const module = require(view.file);
if (!module.meta) {
throw new Error(`${view.file} is missing 'export const meta'`);
}
if (!module.default) {
throw new Error(`${view.file} is missing a default export.`);
}
return { module, view };
});
const viewSource = [
...viewEntryPoints.map((view, i) =>
`import * as view${i} from ${JSON.stringify(view.file)}`
),
`const scripts = ${scriptMagic}[-1]`,
`const styles = ${scriptMagic}[-2]`,
`export const scripts = ${scriptMagic}[-1]`,
"export const views = {",
...viewEntryPoints.flatMap((view, i) => [
...viewModules.flatMap(({ view, module }, i) => [
` ${JSON.stringify(view.id)}: {`,
` component: view${i}.default,`,
` meta: view${i}.meta,`,
` layout: view${i}.layout?.default,`,
` theme: view${i}.layout?.theme ?? view${i}.theme,`,
` scripts: ${scriptMagic}[${i}]`,
` layout: ${
module.layout?.default ? `view${i}.layout?.default` : "undefined"
},`,
` theme: ${
module.layout?.theme
? `view${i}.layout?.theme`
: module.theme
? `view${i}.theme`
: "undefined"
},`,
` inlineCss: styles[${scriptMagic}[${i}]]`,
` },`,
]),
"}",
@ -112,55 +130,133 @@ export async function bundleServerJavaScript(
virtualFiles({
"$views": viewSource,
}),
banFiles([
"hot.ts",
"incremental.ts",
"bundle.ts",
"generate.ts",
"css.ts",
].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))),
// banFiles([
// "hot.ts",
// "incremental.ts",
// "bundle.ts",
// "generate.ts",
// "css.ts",
// ].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))),
projectRelativeResolution(),
{
name: "marko",
setup(b) {
b.onLoad({ filter: /\.marko$/ }, async ({ path }) => {
const src = await fs.readFile(path);
const result = await marko.compile(src, path, {
output: "html",
});
b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => {
return {
loader: "ts",
contents: result.code,
contents: hot.getSourceCode(file),
};
});
},
},
{
name: "mark css external",
setup(b) {
b.onResolve(
{ filter: /\.css$/ },
() => ({ path: ".", namespace: "dropped" }),
);
b.onLoad(
{ filter: /./, namespace: "dropped" },
() => ({ contents: "" }),
);
},
},
];
const bundle = await esbuild.build({
bundle: true,
chunkNames: "/js/c.[hash]",
entryNames: "/js/[name]",
assetNames: "/asset/[hash]",
entryPoints: [backendEntryPoint],
chunkNames: "c.[hash]",
entryNames: "[name]",
entryPoints: [
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
],
platform: "node",
format: "esm",
minify: false,
// outdir: "/out!",
outdir: ".clover/wah",
outdir: "/out!",
plugins: serverPlugins,
splitting: true,
write: true,
external: ["@babel/preset-typescript"],
write: false,
metafile: true,
});
console.log(bundle);
throw new Error("wahhh");
const viewData = viewModules.map(({ module, view }) => {
return {
id: view.id,
theme: module.theme,
cssImports: hot.getCssImports(view.file)
.concat("src/global.css")
.map((file) => path.resolve(file)),
clientRefs: hot.getClientScriptRefs(view.file),
};
});
return {
views: viewData,
bundle,
scriptMagic,
};
}
type Await<T> = T extends Promise<infer R> ? R : T;
export function finalizeServerJavaScript(
backend: Await<ReturnType<typeof bundleServerJavaScript>>,
viewCssBundles: css.Output[],
incr: Incremental,
) {
const { metafile, outputFiles } = backend.bundle;
// Only the reachable script files need to be inserted into the bundle.
const reachableScriptKeys = new Set(
backend.views.flatMap((view) => view.clientRefs),
);
const reachableScripts = Object.fromEntries(
Array.from(incr.out.script)
.filter(([k]) => reachableScriptKeys.has(k)),
);
// Deduplicate styles
const styleList = Array.from(new Set(viewCssBundles));
for (const output of outputFiles) {
const basename = output.path.replace(/^.*?!/, "");
const key = "out!" + basename.replaceAll("\\", "/");
// If this contains the generated "$views" file, then
// replace the IDs with the bundled results.
let text = output.text;
if (metafile.outputs[key].inputs["framework/lib/view.ts"]) {
text = text.replace(
/CLOVER_CLIENT_SCRIPTS_DEFINITION\[(-?\d)\]/gs,
(_, i) => {
i = Number(i);
// Inline the styling data
if (i === -2) {
return JSON.stringify(styleList.map((item) => item.text));
}
// Inline the script data
if (i === -1) {
return JSON.stringify(Object.fromEntries(incr.out.script));
}
// Reference an index into `styleList`
return `${styleList.indexOf(viewCssBundles[i])}`;
},
);
}
fs.writeMkdirSync(path.join(".clover/backend/" + basename), text);
}
}
import * as esbuild from "esbuild";
import * as path from "node:path";
import process from "node:process";
import * as hot from "./hot.ts";
import { banFiles, virtualFiles } from "./esbuild-support.ts";
import {
banFiles,
projectRelativeResolution,
virtualFiles,
} from "./esbuild-support.ts";
import { Incremental } from "./incremental.ts";
import type { FileItem } from "#sitegen";
import * as marko from "@marko/compiler";
import * as css from "./css.ts";
import * as fs from "./lib/fs.ts";

View file

@ -91,7 +91,7 @@ export async function bundleCssFiles(
return {
text: outputFiles[0].text,
sources: Object.keys(metafile.outputs["$input$.css"].inputs)
.filter((x) => x !== "input:."),
.filter((x) => !x.startsWith("vfs:")),
};
}
@ -100,4 +100,3 @@ import * as fs from "#sitegen/fs";
import * as hot from "./hot.ts";
import * as path from "node:path";
import { virtualFiles } from "./esbuild-support.ts";
import { Incremental } from "./incremental.ts";

View file

@ -7,14 +7,14 @@ export function virtualFiles(
b.onResolve(
{
filter: new RegExp(
// TODO: Proper Escape
`\\$`,
`^(?:${
Object.keys(map).map((file) => string.escapeRegExp(file)).join(
"|",
)
})\$`,
),
},
({ path }) => {
console.log({ path });
return ({ path, namespace: "vfs" });
},
({ path }) => ({ path, namespace: "vfs" }),
);
b.onLoad(
{ filter: /./, namespace: "vfs" },
@ -40,8 +40,9 @@ export function banFiles(
b.onResolve(
{
filter: new RegExp(
"^(?:" + files.map((file) => string.escapeRegExp(file)).join("|") +
")$",
`^(?:${
files.map((file) => string.escapeRegExp(file)).join("|")
})\$`,
),
},
({ path, importer }) => {
@ -54,5 +55,19 @@ export function banFiles(
} satisfies esbuild.Plugin;
}
export function projectRelativeResolution(root = process.cwd() + "/src") {
return {
name: "project relative resolution ('@/' prefix)",
setup(b) {
b.onResolve({ filter: /^@\// }, ({ path: id }) => {
return {
path: path.resolve(root, id.slice(2)),
};
});
},
} satisfies esbuild.Plugin;
}
import * as esbuild from "esbuild";
import * as string from "#sitegen/string";
import * as path from "node:path";

View file

@ -12,7 +12,8 @@ async function sitegen(status: Spinner) {
let root = path.resolve(import.meta.dirname, "../src");
const join = (...sub: string[]) => path.join(root, ...sub);
const incr = Incremental.fromDisk();
const incr = new Incremental();
// const incr = Incremental.fromDisk();
await incr.statAllFiles();
// Sitegen reviews every defined section for resources to process
@ -100,7 +101,7 @@ async function sitegen(status: Spinner) {
body: string;
head: string;
css: css.Output;
scriptFiles: string[];
clientRefs: string[];
item: FileItem;
}
const renderResults: RenderResult[] = [];
@ -164,7 +165,7 @@ async function sitegen(status: Spinner) {
body: text,
head: renderedMeta,
css: cssBundle,
scriptFiles: Array.from(addon.sitegen.scripts),
clientRefs: Array.from(addon.sitegen.scripts),
item: item,
});
}
@ -193,14 +194,26 @@ async function sitegen(status: Spinner) {
// -- bundle backend and views --
status.text = "Bundle backend code";
const {} = await bundle.bundleServerJavaScript(
const backend = await bundle.bundleServerJavaScript(
join("backend.ts"),
views,
);
const viewCssPromise = await Promise.all(
backend.views.map((view) =>
cssOnce.get(
view.cssImports.join(":") + JSON.stringify(view.theme),
() => cssQueue.add([view.id, view.cssImports, view.theme ?? {}]),
)
),
);
// -- bundle scripts --
const referencedScripts = Array.from(
new Set(renderResults.flatMap((r) => r.scriptFiles)),
new Set([
...renderResults.flatMap((r) => r.clientRefs),
...backend.views.flatMap((r) => r.clientRefs),
]),
(script) => path.join(hot.projectSrc, script),
);
const extraPublicScripts = scripts.map((entry) => entry.file);
const uniqueCount = new Set([
@ -214,6 +227,9 @@ async function sitegen(status: Spinner) {
incr,
);
// -- finalize backend bundle --
await bundle.finalizeServerJavaScript(backend, await viewCssPromise, incr);
// -- copy/compress static files --
async function doStaticFile(item: FileItem) {
const body = await fs.readFile(item.file);
@ -241,7 +257,7 @@ async function sitegen(status: Spinner) {
await Promise.all(
renderResults.map(
async (
{ item: page, body, head, css, scriptFiles },
{ item: page, body, head, css, clientRefs: scriptFiles },
) => {
const doc = wrapDocument({
body,

View file

@ -118,12 +118,17 @@ function loadEsbuildCode(
module.exports = self;
return;
}
let loader: any = "tsx";
if (filepath.endsWith(".ts")) loader = "ts";
else if (filepath.endsWith(".jsx")) loader = "jsx";
else if (filepath.endsWith(".js")) loader = "js";
module.cloverClientRefs = opt.scannedClientRefs ?? extractClientScripts(src);
if (opt.scannedClientRefs) {
module.cloverClientRefs = opt.scannedClientRefs;
} else {
let { code, refs } = resolveClientRefs(src, filepath);
module.cloverClientRefs = refs;
src = code;
}
if (src.includes("import.meta")) {
src = `
import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
@ -142,21 +147,43 @@ function loadEsbuildCode(
return module._compile(src, filepath, "commonjs");
}
function resolveClientRef(sourcePath: string, ref: string) {
const filePath = resolveFrom(sourcePath, ref);
if (
!filePath.endsWith(".client.ts") &&
!filePath.endsWith(".client.tsx")
) {
throw new Error("addScript must be a .client.ts or .client.tsx");
}
return path.relative(projectSrc, filePath);
}
function loadMarko(module: NodeJS.Module, filepath: string) {
let src = fs.readFileSync(filepath, "utf8");
// A non-standard thing here is Clover Sitegen implements
// its own client side scripting stuff, so it overrides
// bare client import statements to it's own usage.
const scannedClientRefs = new Set<string>();
if (src.match(/^\s*client\s+import\s+["']/m)) {
src = src.replace(
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
(_, src) => `<CloverScriptInclude src=${src} />`,
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n';
(_, src) => {
const ref = JSON.parse(`"${src.slice(1, -1)}"`);
const resolved = resolveClientRef(filepath, ref);
scannedClientRefs.add(resolved);
return `<CloverScriptInclude=${JSON.stringify(resolved)} />`;
},
) + '\nimport { addScript as CloverScriptInclude } from "#sitegen";\n';
}
src = marko.compileSync(src, filepath).code;
src = src.replace("marko/debug/html", "#ssr/marko");
return loadEsbuildCode(module, filepath, src);
module.cloverSourceCode = src;
return loadEsbuildCode(module, filepath, src, {
scannedClientRefs: Array.from(scannedClientRefs),
});
}
function loadMdx(module: NodeJS.Module, filepath: string) {
@ -197,6 +224,23 @@ export function getCssImports(filepath: string) {
?.cssImportsRecursive ?? [];
}
export function getClientScriptRefs(filepath: string) {
filepath = path.resolve(filepath);
const module = require.cache[filepath];
if (!module) throw new Error(filepath + " was never loaded");
return module.cloverClientRefs ?? [];
}
export function getSourceCode(filepath: string) {
filepath = path.resolve(filepath);
const module = require.cache[filepath];
if (!module) throw new Error(filepath + " was never loaded");
if (!module.cloverSourceCode) {
throw new Error(filepath + " did not record source code");
}
return module.cloverSourceCode;
}
export function resolveFrom(src: string, dest: string) {
try {
return createRequire(src).resolve(dest);
@ -210,16 +254,23 @@ export function resolveFrom(src: string, dest: string) {
const importRegExp =
/import\s+(\*\sas\s([a-zA-Z0-9$_]+)|{[^}]+})\s+from\s+(?:"#sitegen"|'#sitegen')/s;
const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))/;
export function extractClientScripts(source: string): string[] {
const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))?/;
interface ResolvedClientRefs {
code: string;
refs: string[];
}
export function resolveClientRefs(
code: string,
filepath: string,
): ResolvedClientRefs {
// This match finds a call to 'import ... from "#sitegen"'
const importMatch = source.match(importRegExp);
if (!importMatch) return [];
const importMatch = code.match(importRegExp);
if (!importMatch) return { code, refs: [] };
const items = importMatch[1];
let identifier = "";
if (items.startsWith("{")) {
const clauseMatch = items.match(getSitegenAddScriptRegExp);
if (!clauseMatch) return []; // did not import
if (!clauseMatch) return { code, refs: [] }; // did not import
identifier = clauseMatch[1] || "addScript";
} else if (items.startsWith("*")) {
identifier = importMatch[2] + "\\s*\\.\\s*addScript";
@ -228,19 +279,24 @@ export function extractClientScripts(source: string): string[] {
}
identifier = identifier.replaceAll("$", "\\$"); // only needed escape
const findCallsRegExp = new RegExp(
`\\b${identifier}\\s*\\(("[^"]+"|'[^']+')\\)`,
`\\b(${identifier})\\s*\\(("[^"]+"|'[^']+')\\)`,
"gs",
);
const calls = source.matchAll(findCallsRegExp);
return [...calls].map((call) => {
return JSON.parse(`"${call[1].slice(1, -1)}"`) as string;
const scannedClientRefs = new Set<string>();
code = code.replace(findCallsRegExp, (_, call, arg) => {
const ref = JSON.parse(`"${arg.slice(1, -1)}"`);
const resolved = resolveClientRef(filepath, ref);
scannedClientRefs.add(resolved);
return `${call}(${JSON.stringify(resolved)})`;
});
return { code, refs: Array.from(scannedClientRefs) };
}
declare global {
namespace NodeJS {
interface Module {
cloverClientRefs?: string[];
cloverSourceCode?: string;
_compile(
this: NodeJS.Module,

View file

@ -141,7 +141,7 @@ export class Incremental {
ASSERT(stat, "Updated stat on untracked file " + fileKey);
if (stat.lastModified < newLastModified) {
// Invalidate
console.log(fileKey + " updated");
console.info(fileKey + " updated");
const invalidQueue = [fileKey];
let currentInvalid;
while (currentInvalid = invalidQueue.pop()) {

View file

@ -41,6 +41,14 @@ export function readDirRecOptionalSync(dir: string) {
}
}
export async function readJson<T>(file: string) {
return JSON.parse(await readFile(file, "utf-8")) as T;
}
export function readJsonSync<T>(file: string) {
return JSON.parse(readFileSync(file, "utf-8")) as T;
}
import * as path from "node:path";
import {
existsSync,

View file

@ -10,10 +10,8 @@ export interface FileItem {
file: string;
}
const frameworkDir = path.dirname(import.meta.dirname);
export interface SitegenRender {
scripts: Set<ScriptId>;
scripts: Set<string>;
}
export function initRender(): SitegenRender {
@ -31,24 +29,8 @@ export function getRender() {
}
/** Add a client-side script to the page. */
export function addScript(id: ScriptId) {
const srcFile: string = util.getCallSites()
.find((site) => !site.scriptName.startsWith(frameworkDir))!
.scriptName;
const filePath = hot.resolveFrom(srcFile, id);
if (
!filePath.endsWith(".client.ts") &&
!filePath.endsWith(".client.tsx")
) {
throw new Error("addScript must be a .client.ts or .client.tsx");
}
getRender().scripts.add(filePath);
}
export function Script({ src }: { src: ScriptId }) {
if (!src) throw new Error("Missing 'src' attribute");
addScript(src);
return null;
export function addScript(id: ScriptId | { value: ScriptId }) {
getRender().scripts.add(typeof id === "string" ? id : id.value);
}
export interface Section {
@ -56,6 +38,3 @@ export interface Section {
}
import * as ssr from "../engine/ssr.ts";
import * as util from "node:util";
import * as hot from "../hot.ts";
import * as path from "node:path";

View file

@ -5,21 +5,19 @@ export interface View {
| meta.Meta
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
layout?: engine.Component;
theme?: css.Theme;
inlineCss: string;
scripts: Record<string, string>;
}
let views: Record<string, View> = {};
// An older version of the Clover Engine supported streaming suspense
// boundaries, but those were never used. Pages will wait until they
// are fully rendered before sending.
async function renderView(
export async function renderView(
c: hono.Context,
id: string,
props: Record<string, unknown>,
) {
views = require("$views").views;
const { views, scripts } = require("$views");
// The view contains pre-bundled CSS and scripts, but keeps the scripts
// separate for run-time dynamic scripts. For example, the file viewer
// includes the canvas for the current page, but only the current page.
@ -28,9 +26,7 @@ async function renderView(
inlineCss,
layout,
meta: metadata,
scripts,
theme,
}: View = UNWRAP(views[id]);
}: View = views[id];
// -- metadata --
const renderedMetaPromise = Promise.resolve(
@ -43,8 +39,10 @@ async function renderView(
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
sitegen: sg.initRender(),
});
console.log(sitegen);
// -- join document and send --
console.log(scripts);
return c.html(wrapDocument({
body,
head: await renderedMetaPromise,

View file

@ -1,5 +1,3 @@
import * as v from "#sitegen/view";
console.log(v);
const logHttp = scoped("http", { color: "magenta" });
const app = new Hono();

View file

@ -1,3 +1,233 @@
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
export const app = new Hono();
import { Hono } from "#hono";
// Main page
app.get("/q+a", async (c) => {
if (hasAdminToken(c)) {
return serveAsset(c, "/admin/q+a", 200);
}
return serveAsset(c, "/q+a", 200);
});
// Submit form
app.post("/q+a", async (c) => {
const form = await c.req.formData();
let text = form.get("text");
if (typeof text !== "string") {
return questionFailure(c, 400, "Bad Request");
}
text = text.trim();
const input = {
date: new Date(),
prompt: text,
sourceName: "unknown",
sourceLocation: "unknown",
sourceVPN: null,
};
input.date.setMilliseconds(0);
if (text.length <= 0) {
return questionFailure(c, 400, "Content is too short", text);
}
if (text.length > 16000) {
return questionFailure(c, 400, "Content is too long", text);
}
// Ban patterns
if (
text.includes("hams" + "terko" + "mbat" + ".expert") // 2025-02-18. 3 occurrences. All VPN
) {
// To prevent known automatic spam-bots from noticing something automatic is
// happening, pretend that the question was successfully submitted.
return sendSuccess(c, new Date());
}
const ipAddr = c.req.header("cf-connecting-ip");
if (ipAddr) {
input.sourceName = uniqueNamesGenerator({
dictionaries: [adjectives, colors, animals],
separator: "-",
seed: ipAddr + PROXYCHECK_API_KEY,
});
}
const cfIPCountry = c.req.header("cf-ipcountry");
if (cfIPCountry) {
input.sourceLocation = cfIPCountry;
}
if (ipAddr && PROXYCHECK_API_KEY) {
const proxyCheck = await fetch(
`https://proxycheck.io/v2/?key=${PROXYCHECK_API_KEY}&risk=1&vpn=1`,
{
method: "POST",
body: "ips=" + ipAddr,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
},
).then((res) => res.json());
if (ipAddr && proxyCheck[ipAddr]) {
if (proxyCheck[ipAddr].proxy === "yes") {
input.sourceVPN = proxyCheck[ipAddr].operator?.name ??
proxyCheck[ipAddr].organisation ??
proxyCheck[ipAddr].provider ?? "unknown";
}
if (Number(proxyCheck[ipAddr].risk) > 72) {
return questionFailure(
c,
403,
"This IP address has been flagged as a high risk IP address. If you are using a VPN/Proxy, please disable it and try again.",
text,
);
}
}
}
const date = Question.create(
QuestionType.pending,
JSON.stringify(input),
input.date,
);
await sendSuccess(c, date);
});
async function sendSuccess(c: Context, date: Date) {
if (c.req.header("Accept")?.includes("application/json")) {
return c.json({
success: true,
message: "ok",
date: date.getTime(),
id: formatQuestionId(date),
}, { status: 200 });
}
c.res = await renderView(c, "qa_success", {
permalink: `https://paperclover.net/q+a/${formatQuestionId(date)}`,
});
}
// Question Permalink
app.get("/q+a/:id", (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");
if (id.length === 12 && /^\d+$/.test(id)) {
return c.redirect(`/q+a/${id.slice(0, 10)}`);
}
let image = false;
if (id.endsWith(".png")) {
image = true;
id = id.slice(0, -4);
}
const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next();
const question = Question.getByDate(timestamp);
if (!question) return next();
// if (image) {
// return getQuestionImage(question, c.req.method === "HEAD");
// }
return renderView(c, "q+a/permalink", { question });
});
// Admin
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", {});
});
app.delete("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id");
const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next();
const question = Question.getByDate(timestamp);
if (!question) return next();
const deleteFull = c.req.header("X-Delete-Full") === "true";
if (deleteFull) {
Question.deleteByQmid(question.qmid);
} else {
Question.rejectByQmid(question.qmid);
}
return c.json({ success: true, message: "ok" });
});
app.patch("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id");
const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next();
const question = Question.getByDate(timestamp);
if (!question) return next();
const form = await c.req.raw.json();
if (typeof form.text !== "string" || typeof form.type !== "number") {
return questionFailure(c, 400, "Bad Request");
}
Question.updateByQmid(question.qmid, form.text, form.type);
return c.json({ success: true, message: "ok" });
});
app.get("/admin/q+a/:id", async (c, next) => {
const id = c.req.param("id");
const timestamp = questionIdToTimestamp(id);
if (!timestamp) return next();
const question = Question.getByDate(timestamp);
if (!question) return next();
let pendingInfo: null | PendingQuestionData = null;
if (question.type === QuestionType.pending) {
pendingInfo = JSON.parse(question.text) as PendingQuestionData;
question.text = pendingInfo.prompt.trim().split("\n").map((line) =>
line.trim().length === 0 ? "" : `q: ${line.trim()}`
).join("\n") + "\n\n";
question.type = QuestionType.normal;
}
return renderView(c, "q+a/editor", {
pendingInfo,
question,
});
});
app.get("/q+a/things/random", async (c) => {
c.res = await renderView(c, "q+a/things-random", {});
});
// 404
app.get("/q+a/*", async (c) => {
return serveAsset(c, "/q+a/404", 404);
});
async function questionFailure(
c: Context,
status: ContentfulStatusCode,
message: string,
content?: string,
) {
if (c.req.header("Accept")?.includes("application/json")) {
return c.json({ success: false, message, id: null }, { status });
}
return await renderView(c, "q+a/fail", {
error: message,
content,
});
}
import { type Context, Hono } from "#hono";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import {
adjectives,
animals,
colors,
uniqueNamesGenerator,
} from "unique-names-generator";
import { hasAdminToken } from "../admin.ts";
import { serveAsset } from "#sitegen/assets";
import {
PendingQuestion,
PendingQuestionData,
} from "./models/PendingQuestion.ts";
import { Question, QuestionType } from "./models/Question.ts";
import { renderView } from "#sitegen/view";
// import { getQuestionImage } from "./question_image";
import { formatQuestionId, questionIdToTimestamp } from "./format.ts";

View file

View file

@ -1,11 +1,10 @@
---
export { minimal as layout } from "../layouts/questions.tsx";
import { PendingQuestion } from "../db.ts";
import { useInlineScript } from "../framework/page-resources";
export { minimal as layout } from "../layout.tsx";
import { PendingQuestion } from "@/q+a/models/PendingQuestion.ts";
import {
formatQuestionISOTimestamp,
formatQuestionTimestamp,
} from "../q+a/QuestionRender";
} from "@/q+a/format.ts";
export const meta = { title: 'question answer inbox' };
<const/questions = PendingQuestion.getAll() />
@ -38,4 +37,4 @@ import {
</div>
</>
client import "backend-inbox.client.ts";
client import "./backend-inbox.client.ts";

View file

@ -4,6 +4,8 @@ export const theme = {
...layout.theme,
primary: "#58ffee",
};
export const meta = { title: 'oh no' };
<const/{ error, content }=input/>
<h2 style="color: red">:(</h2>

View file

@ -1,14 +1,9 @@
export interface Input {
permalink: string;
}
<const/{ permalink }=input/>
export const meta = { title: 'question submitted!!!' };
import * as layout from "../layout.tsx";
export { layout };
export const theme = {
...layout.theme,
primary: "#58ffee",
};
<const/{ permalink }=input/>
<h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>
<p style="color: #8c78ff">
@ -28,3 +23,11 @@ export const theme = {
<p>
<a href="/q+a">return to the questions list</a>
</p>
import * as layout from "../layout.tsx";
export { layout };
export const theme = {
...layout.theme,
primary: "#58ffee",
};

View file

@ -1,10 +1,10 @@
---
import { useInlineScript } from "../framework/page-resources";
export const meta = { title: 'random number' };
client import "./things-random.client.ts";
<const/number = Math.floor(Math.random() * 999999) + 1 />
const number = Math.floor(Math.random() * 999999) + 1;
useInlineScript("qa_things_random");
---
<main>
<div>{number}</div>
<div>${number}</div>
<button>another</button>
</main>