From c8b5e912518e32e7751d89e7fb1b804a8cdf2ccb Mon Sep 17 00:00:00 2001 From: chloe caruso Date: Tue, 10 Jun 2025 01:13:59 -0700 Subject: [PATCH] almost implement views --- framework/bundle.ts | 158 ++++++++++++++---- framework/css.ts | 3 +- framework/esbuild-support.ts | 31 +++- framework/generate.ts | 28 +++- framework/hot.ts | 84 ++++++++-- framework/incremental.ts | 2 +- framework/lib/fs.ts | 8 + framework/lib/sitegen.ts | 27 +-- framework/lib/view.ts | 12 +- src/backend.ts | 2 - src/q+a/backend.ts | 232 +++++++++++++++++++++++++- src/q+a/views/backend-inbox.client.ts | 0 src/q+a/views/backend-inbox.marko | 11 +- src/q+a/views/fail.marko | 2 + src/q+a/views/success.marko | 17 +- src/q+a/views/things-random.marko | 12 +- 16 files changed, 514 insertions(+), 115 deletions(-) create mode 100644 src/q+a/views/backend-inbox.client.ts diff --git a/framework/bundle.ts b/framework/bundle.ts index 78f72d8..f1cd71a 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -50,7 +50,6 @@ export async function bundleClientJavaScript( path.basename(file).replace(/\.client\.[tj]sx?/, "") ); const { metafile } = bundle; - console.log(metafile); const promises: Promise[] = []; // 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 extends Promise ? R : T; + +export function finalizeServerJavaScript( + backend: Await>, + 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"; diff --git a/framework/css.ts b/framework/css.ts index 2192456..3e82022 100644 --- a/framework/css.ts +++ b/framework/css.ts @@ -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"; diff --git a/framework/esbuild-support.ts b/framework/esbuild-support.ts index db91ef5..88faff5 100644 --- a/framework/esbuild-support.ts +++ b/framework/esbuild-support.ts @@ -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"; diff --git a/framework/generate.ts b/framework/generate.ts index 66e0b4c..6f17654 100644 --- a/framework/generate.ts +++ b/framework/generate.ts @@ -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, diff --git a/framework/hot.ts b/framework/hot.ts index 666701c..eaeb2fc 100644 --- a/framework/hot.ts +++ b/framework/hot.ts @@ -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(); if (src.match(/^\s*client\s+import\s+["']/m)) { src = src.replace( /^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m, - (_, 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 ``; + }, + ) + '\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(); + 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, diff --git a/framework/incremental.ts b/framework/incremental.ts index 196a7a7..97c236e 100644 --- a/framework/incremental.ts +++ b/framework/incremental.ts @@ -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()) { diff --git a/framework/lib/fs.ts b/framework/lib/fs.ts index 632093e..974ded3 100644 --- a/framework/lib/fs.ts +++ b/framework/lib/fs.ts @@ -41,6 +41,14 @@ export function readDirRecOptionalSync(dir: string) { } } +export async function readJson(file: string) { + return JSON.parse(await readFile(file, "utf-8")) as T; +} + +export function readJsonSync(file: string) { + return JSON.parse(readFileSync(file, "utf-8")) as T; +} + import * as path from "node:path"; import { existsSync, diff --git a/framework/lib/sitegen.ts b/framework/lib/sitegen.ts index dbe30f1..6bfaa59 100644 --- a/framework/lib/sitegen.ts +++ b/framework/lib/sitegen.ts @@ -10,10 +10,8 @@ export interface FileItem { file: string; } -const frameworkDir = path.dirname(import.meta.dirname); - export interface SitegenRender { - scripts: Set; + scripts: Set; } 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"; diff --git a/framework/lib/view.ts b/framework/lib/view.ts index a133529..87677a2 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -5,21 +5,19 @@ export interface View { | meta.Meta | ((props: { context?: hono.Context }) => Promise | meta.Meta); layout?: engine.Component; - theme?: css.Theme; inlineCss: string; scripts: Record; } -let views: Record = {}; // 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, ) { - 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, diff --git a/src/backend.ts b/src/backend.ts index 0252399..3e6c20f 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -1,5 +1,3 @@ -import * as v from "#sitegen/view"; -console.log(v); const logHttp = scoped("http", { color: "magenta" }); const app = new Hono(); diff --git a/src/q+a/backend.ts b/src/q+a/backend.ts index 95dec0c..419874b 100644 --- a/src/q+a/backend.ts +++ b/src/q+a/backend.ts @@ -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"; diff --git a/src/q+a/views/backend-inbox.client.ts b/src/q+a/views/backend-inbox.client.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/q+a/views/backend-inbox.marko b/src/q+a/views/backend-inbox.marko index aa3d62a..005a3a8 100644 --- a/src/q+a/views/backend-inbox.marko +++ b/src/q+a/views/backend-inbox.marko @@ -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' }; @@ -38,4 +37,4 @@ import { -client import "backend-inbox.client.ts"; +client import "./backend-inbox.client.ts"; diff --git a/src/q+a/views/fail.marko b/src/q+a/views/fail.marko index 1c45e39..178471f 100644 --- a/src/q+a/views/fail.marko +++ b/src/q+a/views/fail.marko @@ -4,6 +4,8 @@ export const theme = { ...layout.theme, primary: "#58ffee", }; +export const meta = { title: 'oh no' }; +

:(

diff --git a/src/q+a/views/success.marko b/src/q+a/views/success.marko index f9b1238..2207d5a 100644 --- a/src/q+a/views/success.marko +++ b/src/q+a/views/success.marko @@ -1,14 +1,9 @@ export interface Input { permalink: string; } - +export const meta = { title: 'question submitted!!!' }; -import * as layout from "../layout.tsx"; -export { layout }; -export const theme = { - ...layout.theme, - primary: "#58ffee", -}; +

thank you

@@ -28,3 +23,11 @@ export const theme = {

return to the questions list

+ +import * as layout from "../layout.tsx"; +export { layout }; +export const theme = { + ...layout.theme, + primary: "#58ffee", +}; + diff --git a/src/q+a/views/things-random.marko b/src/q+a/views/things-random.marko index 197bfff..f88e137 100644 --- a/src/q+a/views/things-random.marko +++ b/src/q+a/views/things-random.marko @@ -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; -useInlineScript("qa_things_random"); ----
-
{number}
+
${number}
+