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?/, "")
|
path.basename(file).replace(/\.client\.[tj]sx?/, "")
|
||||||
);
|
);
|
||||||
const { metafile } = bundle;
|
const { metafile } = bundle;
|
||||||
console.log(metafile);
|
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
// TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
|
// TODO: add a shared build hash to entrypoints, derived from all the chunk hashes.
|
||||||
for (const file of bundle.outputFiles) {
|
for (const file of bundle.outputFiles) {
|
||||||
|
@ -85,25 +84,44 @@ export async function bundleClientJavaScript(
|
||||||
type ServerPlatform = "node" | "passthru";
|
type ServerPlatform = "node" | "passthru";
|
||||||
export async function bundleServerJavaScript(
|
export async function bundleServerJavaScript(
|
||||||
/** Has 'export default app;' */
|
/** Has 'export default app;' */
|
||||||
backendEntryPoint: string,
|
_: string,
|
||||||
/** Views for dynamic loading */
|
/** Views for dynamic loading */
|
||||||
viewEntryPoints: FileItem[],
|
viewEntryPoints: FileItem[],
|
||||||
platform: ServerPlatform = "node",
|
platform: ServerPlatform = "node",
|
||||||
) {
|
) {
|
||||||
const scriptMagic = "CLOVER_CLIENT_SCRIPTS_DEFINITION";
|
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 = [
|
const viewSource = [
|
||||||
...viewEntryPoints.map((view, i) =>
|
...viewEntryPoints.map((view, i) =>
|
||||||
`import * as view${i} from ${JSON.stringify(view.file)}`
|
`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 = {",
|
"export const views = {",
|
||||||
...viewEntryPoints.flatMap((view, i) => [
|
...viewModules.flatMap(({ view, module }, i) => [
|
||||||
` ${JSON.stringify(view.id)}: {`,
|
` ${JSON.stringify(view.id)}: {`,
|
||||||
` component: view${i}.default,`,
|
` component: view${i}.default,`,
|
||||||
` meta: view${i}.meta,`,
|
` meta: view${i}.meta,`,
|
||||||
` layout: view${i}.layout?.default,`,
|
` layout: ${
|
||||||
` theme: view${i}.layout?.theme ?? view${i}.theme,`,
|
module.layout?.default ? `view${i}.layout?.default` : "undefined"
|
||||||
` scripts: ${scriptMagic}[${i}]`,
|
},`,
|
||||||
|
` 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({
|
virtualFiles({
|
||||||
"$views": viewSource,
|
"$views": viewSource,
|
||||||
}),
|
}),
|
||||||
banFiles([
|
// banFiles([
|
||||||
"hot.ts",
|
// "hot.ts",
|
||||||
"incremental.ts",
|
// "incremental.ts",
|
||||||
"bundle.ts",
|
// "bundle.ts",
|
||||||
"generate.ts",
|
// "generate.ts",
|
||||||
"css.ts",
|
// "css.ts",
|
||||||
].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))),
|
// ].map((subPath) => path.join(hot.projectRoot, "framework/" + subPath))),
|
||||||
|
projectRelativeResolution(),
|
||||||
{
|
{
|
||||||
name: "marko",
|
name: "marko",
|
||||||
setup(b) {
|
setup(b) {
|
||||||
b.onLoad({ filter: /\.marko$/ }, async ({ path }) => {
|
b.onLoad({ filter: /\.marko$/ }, async ({ path: file }) => {
|
||||||
const src = await fs.readFile(path);
|
|
||||||
const result = await marko.compile(src, path, {
|
|
||||||
output: "html",
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
loader: "ts",
|
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({
|
const bundle = await esbuild.build({
|
||||||
bundle: true,
|
bundle: true,
|
||||||
chunkNames: "/js/c.[hash]",
|
chunkNames: "c.[hash]",
|
||||||
entryNames: "/js/[name]",
|
entryNames: "[name]",
|
||||||
assetNames: "/asset/[hash]",
|
entryPoints: [
|
||||||
entryPoints: [backendEntryPoint],
|
path.join(import.meta.dirname, "backend/entry-" + platform + ".ts"),
|
||||||
|
],
|
||||||
platform: "node",
|
platform: "node",
|
||||||
format: "esm",
|
format: "esm",
|
||||||
minify: false,
|
minify: false,
|
||||||
// outdir: "/out!",
|
outdir: "/out!",
|
||||||
outdir: ".clover/wah",
|
|
||||||
plugins: serverPlugins,
|
plugins: serverPlugins,
|
||||||
splitting: true,
|
splitting: true,
|
||||||
write: true,
|
write: false,
|
||||||
external: ["@babel/preset-typescript"],
|
metafile: true,
|
||||||
});
|
});
|
||||||
console.log(bundle);
|
const viewData = viewModules.map(({ module, view }) => {
|
||||||
throw new Error("wahhh");
|
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 esbuild from "esbuild";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
import * as hot from "./hot.ts";
|
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 { Incremental } from "./incremental.ts";
|
||||||
import type { FileItem } from "#sitegen";
|
import type { FileItem } from "#sitegen";
|
||||||
import * as marko from "@marko/compiler";
|
import * as marko from "@marko/compiler";
|
||||||
|
import * as css from "./css.ts";
|
||||||
import * as fs from "./lib/fs.ts";
|
import * as fs from "./lib/fs.ts";
|
||||||
|
|
|
@ -91,7 +91,7 @@ export async function bundleCssFiles(
|
||||||
return {
|
return {
|
||||||
text: outputFiles[0].text,
|
text: outputFiles[0].text,
|
||||||
sources: Object.keys(metafile.outputs["$input$.css"].inputs)
|
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 hot from "./hot.ts";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { virtualFiles } from "./esbuild-support.ts";
|
import { virtualFiles } from "./esbuild-support.ts";
|
||||||
import { Incremental } from "./incremental.ts";
|
|
||||||
|
|
|
@ -7,14 +7,14 @@ export function virtualFiles(
|
||||||
b.onResolve(
|
b.onResolve(
|
||||||
{
|
{
|
||||||
filter: new RegExp(
|
filter: new RegExp(
|
||||||
// TODO: Proper Escape
|
`^(?:${
|
||||||
`\\$`,
|
Object.keys(map).map((file) => string.escapeRegExp(file)).join(
|
||||||
|
"|",
|
||||||
|
)
|
||||||
|
})\$`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
({ path }) => {
|
({ path }) => ({ path, namespace: "vfs" }),
|
||||||
console.log({ path });
|
|
||||||
return ({ path, namespace: "vfs" });
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
b.onLoad(
|
b.onLoad(
|
||||||
{ filter: /./, namespace: "vfs" },
|
{ filter: /./, namespace: "vfs" },
|
||||||
|
@ -40,8 +40,9 @@ export function banFiles(
|
||||||
b.onResolve(
|
b.onResolve(
|
||||||
{
|
{
|
||||||
filter: new RegExp(
|
filter: new RegExp(
|
||||||
"^(?:" + files.map((file) => string.escapeRegExp(file)).join("|") +
|
`^(?:${
|
||||||
")$",
|
files.map((file) => string.escapeRegExp(file)).join("|")
|
||||||
|
})\$`,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
({ path, importer }) => {
|
({ path, importer }) => {
|
||||||
|
@ -54,5 +55,19 @@ export function banFiles(
|
||||||
} satisfies esbuild.Plugin;
|
} 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 esbuild from "esbuild";
|
||||||
import * as string from "#sitegen/string";
|
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");
|
let root = path.resolve(import.meta.dirname, "../src");
|
||||||
const join = (...sub: string[]) => path.join(root, ...sub);
|
const join = (...sub: string[]) => path.join(root, ...sub);
|
||||||
const incr = Incremental.fromDisk();
|
const incr = new Incremental();
|
||||||
|
// const incr = Incremental.fromDisk();
|
||||||
await incr.statAllFiles();
|
await incr.statAllFiles();
|
||||||
|
|
||||||
// Sitegen reviews every defined section for resources to process
|
// Sitegen reviews every defined section for resources to process
|
||||||
|
@ -100,7 +101,7 @@ async function sitegen(status: Spinner) {
|
||||||
body: string;
|
body: string;
|
||||||
head: string;
|
head: string;
|
||||||
css: css.Output;
|
css: css.Output;
|
||||||
scriptFiles: string[];
|
clientRefs: string[];
|
||||||
item: FileItem;
|
item: FileItem;
|
||||||
}
|
}
|
||||||
const renderResults: RenderResult[] = [];
|
const renderResults: RenderResult[] = [];
|
||||||
|
@ -164,7 +165,7 @@ async function sitegen(status: Spinner) {
|
||||||
body: text,
|
body: text,
|
||||||
head: renderedMeta,
|
head: renderedMeta,
|
||||||
css: cssBundle,
|
css: cssBundle,
|
||||||
scriptFiles: Array.from(addon.sitegen.scripts),
|
clientRefs: Array.from(addon.sitegen.scripts),
|
||||||
item: item,
|
item: item,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -193,14 +194,26 @@ async function sitegen(status: Spinner) {
|
||||||
|
|
||||||
// -- bundle backend and views --
|
// -- bundle backend and views --
|
||||||
status.text = "Bundle backend code";
|
status.text = "Bundle backend code";
|
||||||
const {} = await bundle.bundleServerJavaScript(
|
const backend = await bundle.bundleServerJavaScript(
|
||||||
join("backend.ts"),
|
join("backend.ts"),
|
||||||
views,
|
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 --
|
// -- bundle scripts --
|
||||||
const referencedScripts = Array.from(
|
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 extraPublicScripts = scripts.map((entry) => entry.file);
|
||||||
const uniqueCount = new Set([
|
const uniqueCount = new Set([
|
||||||
|
@ -214,6 +227,9 @@ async function sitegen(status: Spinner) {
|
||||||
incr,
|
incr,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// -- finalize backend bundle --
|
||||||
|
await bundle.finalizeServerJavaScript(backend, await viewCssPromise, incr);
|
||||||
|
|
||||||
// -- copy/compress static files --
|
// -- copy/compress static files --
|
||||||
async function doStaticFile(item: FileItem) {
|
async function doStaticFile(item: FileItem) {
|
||||||
const body = await fs.readFile(item.file);
|
const body = await fs.readFile(item.file);
|
||||||
|
@ -241,7 +257,7 @@ async function sitegen(status: Spinner) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
renderResults.map(
|
renderResults.map(
|
||||||
async (
|
async (
|
||||||
{ item: page, body, head, css, scriptFiles },
|
{ item: page, body, head, css, clientRefs: scriptFiles },
|
||||||
) => {
|
) => {
|
||||||
const doc = wrapDocument({
|
const doc = wrapDocument({
|
||||||
body,
|
body,
|
||||||
|
|
|
@ -118,12 +118,17 @@ function loadEsbuildCode(
|
||||||
module.exports = self;
|
module.exports = self;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let loader: any = "tsx";
|
let loader: any = "tsx";
|
||||||
if (filepath.endsWith(".ts")) loader = "ts";
|
if (filepath.endsWith(".ts")) loader = "ts";
|
||||||
else if (filepath.endsWith(".jsx")) loader = "jsx";
|
else if (filepath.endsWith(".jsx")) loader = "jsx";
|
||||||
else if (filepath.endsWith(".js")) loader = "js";
|
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")) {
|
if (src.includes("import.meta")) {
|
||||||
src = `
|
src = `
|
||||||
import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
|
import.meta.url = ${JSON.stringify(pathToFileURL(filepath).toString())};
|
||||||
|
@ -142,21 +147,43 @@ function loadEsbuildCode(
|
||||||
return module._compile(src, filepath, "commonjs");
|
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) {
|
function loadMarko(module: NodeJS.Module, filepath: string) {
|
||||||
let src = fs.readFileSync(filepath, "utf8");
|
let src = fs.readFileSync(filepath, "utf8");
|
||||||
// A non-standard thing here is Clover Sitegen implements
|
// A non-standard thing here is Clover Sitegen implements
|
||||||
// its own client side scripting stuff, so it overrides
|
// its own client side scripting stuff, so it overrides
|
||||||
// bare client import statements to it's own usage.
|
// bare client import statements to it's own usage.
|
||||||
|
const scannedClientRefs = new Set<string>();
|
||||||
if (src.match(/^\s*client\s+import\s+["']/m)) {
|
if (src.match(/^\s*client\s+import\s+["']/m)) {
|
||||||
src = src.replace(
|
src = src.replace(
|
||||||
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
|
/^\s*client\s+import\s+("[^"]+"|'[^']+')[^\n]+/m,
|
||||||
(_, src) => `<CloverScriptInclude src=${src} />`,
|
(_, src) => {
|
||||||
) + '\nimport { Script as CloverScriptInclude } from "#sitegen";\n';
|
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 = marko.compileSync(src, filepath).code;
|
||||||
src = src.replace("marko/debug/html", "#ssr/marko");
|
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) {
|
function loadMdx(module: NodeJS.Module, filepath: string) {
|
||||||
|
@ -197,6 +224,23 @@ export function getCssImports(filepath: string) {
|
||||||
?.cssImportsRecursive ?? [];
|
?.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) {
|
export function resolveFrom(src: string, dest: string) {
|
||||||
try {
|
try {
|
||||||
return createRequire(src).resolve(dest);
|
return createRequire(src).resolve(dest);
|
||||||
|
@ -210,16 +254,23 @@ export function resolveFrom(src: string, dest: string) {
|
||||||
|
|
||||||
const importRegExp =
|
const importRegExp =
|
||||||
/import\s+(\*\sas\s([a-zA-Z0-9$_]+)|{[^}]+})\s+from\s+(?:"#sitegen"|'#sitegen')/s;
|
/import\s+(\*\sas\s([a-zA-Z0-9$_]+)|{[^}]+})\s+from\s+(?:"#sitegen"|'#sitegen')/s;
|
||||||
const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))/;
|
const getSitegenAddScriptRegExp = /addScript(?:\s+as\s+([a-zA-Z0-9$_]+))?/;
|
||||||
export function extractClientScripts(source: string): string[] {
|
interface ResolvedClientRefs {
|
||||||
|
code: string;
|
||||||
|
refs: string[];
|
||||||
|
}
|
||||||
|
export function resolveClientRefs(
|
||||||
|
code: string,
|
||||||
|
filepath: string,
|
||||||
|
): ResolvedClientRefs {
|
||||||
// This match finds a call to 'import ... from "#sitegen"'
|
// This match finds a call to 'import ... from "#sitegen"'
|
||||||
const importMatch = source.match(importRegExp);
|
const importMatch = code.match(importRegExp);
|
||||||
if (!importMatch) return [];
|
if (!importMatch) return { code, refs: [] };
|
||||||
const items = importMatch[1];
|
const items = importMatch[1];
|
||||||
let identifier = "";
|
let identifier = "";
|
||||||
if (items.startsWith("{")) {
|
if (items.startsWith("{")) {
|
||||||
const clauseMatch = items.match(getSitegenAddScriptRegExp);
|
const clauseMatch = items.match(getSitegenAddScriptRegExp);
|
||||||
if (!clauseMatch) return []; // did not import
|
if (!clauseMatch) return { code, refs: [] }; // did not import
|
||||||
identifier = clauseMatch[1] || "addScript";
|
identifier = clauseMatch[1] || "addScript";
|
||||||
} else if (items.startsWith("*")) {
|
} else if (items.startsWith("*")) {
|
||||||
identifier = importMatch[2] + "\\s*\\.\\s*addScript";
|
identifier = importMatch[2] + "\\s*\\.\\s*addScript";
|
||||||
|
@ -228,19 +279,24 @@ export function extractClientScripts(source: string): string[] {
|
||||||
}
|
}
|
||||||
identifier = identifier.replaceAll("$", "\\$"); // only needed escape
|
identifier = identifier.replaceAll("$", "\\$"); // only needed escape
|
||||||
const findCallsRegExp = new RegExp(
|
const findCallsRegExp = new RegExp(
|
||||||
`\\b${identifier}\\s*\\(("[^"]+"|'[^']+')\\)`,
|
`\\b(${identifier})\\s*\\(("[^"]+"|'[^']+')\\)`,
|
||||||
"gs",
|
"gs",
|
||||||
);
|
);
|
||||||
const calls = source.matchAll(findCallsRegExp);
|
const scannedClientRefs = new Set<string>();
|
||||||
return [...calls].map((call) => {
|
code = code.replace(findCallsRegExp, (_, call, arg) => {
|
||||||
return JSON.parse(`"${call[1].slice(1, -1)}"`) as string;
|
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 {
|
declare global {
|
||||||
namespace NodeJS {
|
namespace NodeJS {
|
||||||
interface Module {
|
interface Module {
|
||||||
cloverClientRefs?: string[];
|
cloverClientRefs?: string[];
|
||||||
|
cloverSourceCode?: string;
|
||||||
|
|
||||||
_compile(
|
_compile(
|
||||||
this: NodeJS.Module,
|
this: NodeJS.Module,
|
||||||
|
|
|
@ -141,7 +141,7 @@ export class Incremental {
|
||||||
ASSERT(stat, "Updated stat on untracked file " + fileKey);
|
ASSERT(stat, "Updated stat on untracked file " + fileKey);
|
||||||
if (stat.lastModified < newLastModified) {
|
if (stat.lastModified < newLastModified) {
|
||||||
// Invalidate
|
// Invalidate
|
||||||
console.log(fileKey + " updated");
|
console.info(fileKey + " updated");
|
||||||
const invalidQueue = [fileKey];
|
const invalidQueue = [fileKey];
|
||||||
let currentInvalid;
|
let currentInvalid;
|
||||||
while (currentInvalid = invalidQueue.pop()) {
|
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 * as path from "node:path";
|
||||||
import {
|
import {
|
||||||
existsSync,
|
existsSync,
|
||||||
|
|
|
@ -10,10 +10,8 @@ export interface FileItem {
|
||||||
file: string;
|
file: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const frameworkDir = path.dirname(import.meta.dirname);
|
|
||||||
|
|
||||||
export interface SitegenRender {
|
export interface SitegenRender {
|
||||||
scripts: Set<ScriptId>;
|
scripts: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initRender(): SitegenRender {
|
export function initRender(): SitegenRender {
|
||||||
|
@ -31,24 +29,8 @@ export function getRender() {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a client-side script to the page. */
|
/** Add a client-side script to the page. */
|
||||||
export function addScript(id: ScriptId) {
|
export function addScript(id: ScriptId | { value: ScriptId }) {
|
||||||
const srcFile: string = util.getCallSites()
|
getRender().scripts.add(typeof id === "string" ? id : id.value);
|
||||||
.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 interface Section {
|
export interface Section {
|
||||||
|
@ -56,6 +38,3 @@ export interface Section {
|
||||||
}
|
}
|
||||||
|
|
||||||
import * as ssr from "../engine/ssr.ts";
|
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
|
| meta.Meta
|
||||||
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
|
| ((props: { context?: hono.Context }) => Promise<meta.Meta> | meta.Meta);
|
||||||
layout?: engine.Component;
|
layout?: engine.Component;
|
||||||
theme?: css.Theme;
|
|
||||||
inlineCss: string;
|
inlineCss: string;
|
||||||
scripts: Record<string, string>;
|
scripts: Record<string, string>;
|
||||||
}
|
}
|
||||||
let views: Record<string, View> = {};
|
|
||||||
|
|
||||||
// An older version of the Clover Engine supported streaming suspense
|
// An older version of the Clover Engine supported streaming suspense
|
||||||
// boundaries, but those were never used. Pages will wait until they
|
// boundaries, but those were never used. Pages will wait until they
|
||||||
// are fully rendered before sending.
|
// are fully rendered before sending.
|
||||||
async function renderView(
|
export async function renderView(
|
||||||
c: hono.Context,
|
c: hono.Context,
|
||||||
id: string,
|
id: string,
|
||||||
props: Record<string, unknown>,
|
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
|
// The view contains pre-bundled CSS and scripts, but keeps the scripts
|
||||||
// separate for run-time dynamic scripts. For example, the file viewer
|
// separate for run-time dynamic scripts. For example, the file viewer
|
||||||
// includes the canvas for the current page, but only the current page.
|
// includes the canvas for the current page, but only the current page.
|
||||||
|
@ -28,9 +26,7 @@ async function renderView(
|
||||||
inlineCss,
|
inlineCss,
|
||||||
layout,
|
layout,
|
||||||
meta: metadata,
|
meta: metadata,
|
||||||
scripts,
|
}: View = views[id];
|
||||||
theme,
|
|
||||||
}: View = UNWRAP(views[id]);
|
|
||||||
|
|
||||||
// -- metadata --
|
// -- metadata --
|
||||||
const renderedMetaPromise = Promise.resolve(
|
const renderedMetaPromise = Promise.resolve(
|
||||||
|
@ -43,8 +39,10 @@ async function renderView(
|
||||||
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
|
const { text: body, addon: { sitegen } } = await engine.ssrAsync(page, {
|
||||||
sitegen: sg.initRender(),
|
sitegen: sg.initRender(),
|
||||||
});
|
});
|
||||||
|
console.log(sitegen);
|
||||||
|
|
||||||
// -- join document and send --
|
// -- join document and send --
|
||||||
|
console.log(scripts);
|
||||||
return c.html(wrapDocument({
|
return c.html(wrapDocument({
|
||||||
body,
|
body,
|
||||||
head: await renderedMetaPromise,
|
head: await renderedMetaPromise,
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import * as v from "#sitegen/view";
|
|
||||||
console.log(v);
|
|
||||||
const logHttp = scoped("http", { color: "magenta" });
|
const logHttp = scoped("http", { color: "magenta" });
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
|
@ -1,3 +1,233 @@
|
||||||
|
const PROXYCHECK_API_KEY = process.env.PROXYCHECK_API_KEY;
|
||||||
|
|
||||||
export const app = new Hono();
|
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 "../layout.tsx";
|
||||||
export { minimal as layout } from "../layouts/questions.tsx";
|
import { PendingQuestion } from "@/q+a/models/PendingQuestion.ts";
|
||||||
import { PendingQuestion } from "../db.ts";
|
|
||||||
import { useInlineScript } from "../framework/page-resources";
|
|
||||||
import {
|
import {
|
||||||
formatQuestionISOTimestamp,
|
formatQuestionISOTimestamp,
|
||||||
formatQuestionTimestamp,
|
formatQuestionTimestamp,
|
||||||
} from "../q+a/QuestionRender";
|
} from "@/q+a/format.ts";
|
||||||
|
export const meta = { title: 'question answer inbox' };
|
||||||
|
|
||||||
<const/questions = PendingQuestion.getAll() />
|
<const/questions = PendingQuestion.getAll() />
|
||||||
|
|
||||||
|
@ -38,4 +37,4 @@ import {
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
client import "backend-inbox.client.ts";
|
client import "./backend-inbox.client.ts";
|
||||||
|
|
|
@ -4,6 +4,8 @@ export const theme = {
|
||||||
...layout.theme,
|
...layout.theme,
|
||||||
primary: "#58ffee",
|
primary: "#58ffee",
|
||||||
};
|
};
|
||||||
|
export const meta = { title: 'oh no' };
|
||||||
|
|
||||||
<const/{ error, content }=input/>
|
<const/{ error, content }=input/>
|
||||||
|
|
||||||
<h2 style="color: red">:(</h2>
|
<h2 style="color: red">:(</h2>
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
export interface Input {
|
export interface Input {
|
||||||
permalink: string;
|
permalink: string;
|
||||||
}
|
}
|
||||||
<const/{ permalink }=input/>
|
export const meta = { title: 'question submitted!!!' };
|
||||||
|
|
||||||
import * as layout from "../layout.tsx";
|
<const/{ permalink }=input/>
|
||||||
export { layout };
|
|
||||||
export const theme = {
|
|
||||||
...layout.theme,
|
|
||||||
primary: "#58ffee",
|
|
||||||
};
|
|
||||||
|
|
||||||
<h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>
|
<h2 style="color: #8c78ff; margin-bottom: 0">thank you</h2>
|
||||||
<p style="color: #8c78ff">
|
<p style="color: #8c78ff">
|
||||||
|
@ -28,3 +23,11 @@ export const theme = {
|
||||||
<p>
|
<p>
|
||||||
<a href="/q+a">return to the questions list</a>
|
<a href="/q+a">return to the questions list</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
import * as layout from "../layout.tsx";
|
||||||
|
export { layout };
|
||||||
|
export const theme = {
|
||||||
|
...layout.theme,
|
||||||
|
primary: "#58ffee",
|
||||||
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
---
|
export const meta = { title: 'random number' };
|
||||||
import { useInlineScript } from "../framework/page-resources";
|
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>
|
<main>
|
||||||
<div>{number}</div>
|
<div>${number}</div>
|
||||||
<button>another</button>
|
<button>another</button>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue