almost implement views
This commit is contained in:
parent
a1d17a5d61
commit
c8b5e91251
16 changed files with 514 additions and 115 deletions
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import * as v from "#sitegen/view";
|
||||
console.log(v);
|
||||
const logHttp = scoped("http", { color: "magenta" });
|
||||
|
||||
const app = new Hono();
|
||||
|
|
|
@ -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";
|
||||
|
|
0
src/q+a/views/backend-inbox.client.ts
Normal file
0
src/q+a/views/backend-inbox.client.ts
Normal 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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
Loading…
Reference in a new issue