Page Menu
Home
Sealhub
Search
Configure Global Search
Log In
Files
F8929851
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
32 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/builder.ts b/src/builder.ts
index 5a24467..9d37c06 100644
--- a/src/builder.ts
+++ b/src/builder.ts
@@ -1,132 +1,146 @@
import { build } from "esbuild";
import glob from "tiny-glob";
+import { promises as fs } from "fs";
import { embeddable_file_extensions } from "./embeddable-file-extensions.js";
import { load_assets_plugin } from "./esbuild-plugins/load-assets.js";
import { rewrite_asset_imports_plugin } from "./esbuild-plugins/rewrite-asset-imports.js";
import { generateCollections } from "./generate-collections.js";
import { generateComponents } from "./generate-components.js";
import { generateRoutes } from "./generate-routes.js";
import { generateStimulusControllers } from "./generate-stimulus.js";
import { FONTS_CONFIG_PATH, getFonts } from "./get-fonts.js";
+import { relative } from "node:path";
export abstract class Builder {
abstract ownsFile(file_path: string): boolean;
abstract getName(): string;
abstract _build(): Promise<void>;
abstract dispose(): Promise<void>;
constructor(public project_dir: string) {}
public ongoing_build: Promise<void> | null = null;
public build(notifier?: (message: string) => void) {
if (!this.ongoing_build) {
const build = this._build()
.catch((err) => {
console.error(err);
})
.then(() => {
notifier?.(this.getName());
this.ongoing_build = null;
});
this.ongoing_build = build;
}
return this.ongoing_build;
}
}
export class FontsBuilder extends Builder {
getName(): string {
return "fonts";
}
ownsFile(file_path: string) {
return file_path == FONTS_CONFIG_PATH;
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
async dispose(): Promise<void> {}
async _build() {
return getFonts();
}
}
export class BackendTSBuilder extends Builder {
getName(): string {
return "backend-ts";
}
ownsFile(file_path: string) {
return (
(file_path.endsWith(".ts") || file_path.endsWith(".tsx")) &&
!file_path.endsWith("src/back/collections/collections.ts") &&
!file_path.endsWith("src/back/routes/routes.ts") &&
!file_path.endsWith("src/back/routes/urls.ts") &&
!file_path.endsWith("stimulus.ts") &&
!file_path.startsWith("src/front")
);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
async dispose(): Promise<void> {}
async _build(): Promise<void> {
await Promise.all([
generateCollections(),
generateRoutes(),
generateComponents(),
]);
const entryPoints = (
await glob(
`./src/back/**/*.{ts,tsx,${embeddable_file_extensions.join(
","
)}}`
)
).filter((path) => !path.includes(".#"));
- await build({
+ const { metafile } = await build({
entryPoints,
sourcemap: true,
bundle: false,
jsxFactory: "TempstreamJSX.createElement",
jsxFragment: "TempstreamJSX.Fragment",
outdir: "./dist/back",
logLevel: "info",
platform: "node",
target: "es2022",
format: "esm",
loader: Object.fromEntries(
embeddable_file_extensions.map((ext) => ["." + ext, "js"])
),
plugins: [rewrite_asset_imports_plugin, load_assets_plugin],
+ metafile: true,
});
+
+ await fs.writeFile(
+ relative(this.project_dir, "dist") + "/" + "back" + ".meta.json",
+ JSON.stringify(metafile)
+ );
}
}
export class FrontendTSBuilder extends Builder {
getName(): string {
return "frontend-ts";
}
ownsFile(file_path: string) {
return (
file_path.startsWith("src/front") ||
file_path.endsWith(".stimulus.ts")
);
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
async dispose(): Promise<void> {}
async _build(): Promise<void> {
await generateStimulusControllers();
- await build({
+ const { metafile } = await build({
entryPoints: ["./src/front/index.ts"],
sourcemap: true,
outfile: "./public/dist/bundle.js",
logLevel: "info",
bundle: true,
minify: true,
+ metafile: true,
+ treeShaking: true,
});
+ await fs.writeFile(
+ relative(this.project_dir, "dist") + "/" + "front" + ".meta.json",
+ JSON.stringify(metafile)
+ );
}
}
diff --git a/src/forms/form.test.ts b/src/forms/form.test.ts
index 7cbfdf5..7a94387 100644
--- a/src/forms/form.test.ts
+++ b/src/forms/form.test.ts
@@ -1,403 +1,403 @@
import Router from "@koa/router";
import axios from "axios";
-import Koa, { BaseContext } from "koa";
+import Koa, { Context } from "koa";
import { Page } from "playwright";
import { Controls, Fields, Form, Mountable } from "../index.js";
import { mount } from "../mount.js";
import { locator_is_visible } from "../test_utils/locator_is_visible.js";
import { getBrowser } from "../utils/browser-creator.js";
import { assertThrowsAsync } from "../utils/utils.js";
import { FormDataValue } from "./form-types.js";
import getPort from "get-port";
import { SimpleFormField } from "./fields/simple-form-field.js";
import { expect as PlaywritghtExpect } from "@playwright/test";
const fields = {
text: new Fields.SimpleFormField(true),
};
export function form_factory(canAccessFun?: Mountable["canAccess"]): Form<typeof fields, void> {
const result = new (class extends Form<typeof fields, void> {
fields = fields;
submitButtonText = "Submit";
controls = [
new Controls.SimpleInput(fields.text, {
label: "This is a test:",
type: "password",
}),
];
async onSubmit() {
return;
}
})();
if (canAccessFun) result.canAccess = canAccessFun;
return result;
}
const port = await getPort();
console.log("Using port " + port + " for form.test.ts");
describe("form test", () => {
let page: Page;
before(async () => {
const browser = await getBrowser();
const context = await browser.newContext();
page = await context.newPage();
});
describe("basic tests", async () => {
let server: ReturnType<Koa["listen"]>;
before(async () => {
const app = new Koa();
const router = new Router();
mount(router, "/", form_factory(), true);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
await page.goto(`http://localhost:${port}`);
});
after(async () => {
server.close();
});
it("does not allow to submit an empty form when there's a required field", async () => {
await page.getByRole("button", { name: "Submit", exact: true }).click();
await assertThrowsAsync(async () => {
return page.getByText("Done").click({ timeout: 500 });
});
});
it("allows to submit a form when all required fields have a value", async () => {
await page.getByPlaceholder("password").click();
await page.getByPlaceholder("password").fill("testpasswd");
await page.getByRole("button", { name: "Submit", exact: true }).click();
await page.getByText("Done").click();
});
it("does not allow submitting an empty form by circumventing HTML-based validation", async () => {
const res_axios = await axios.post(
`http://localhost:${port}`,
{
text: "",
},
{
validateStatus: (status: number) => {
if (status == 422) return true;
return false;
},
}
);
console.log(res_axios.data);
if (!res_axios.data.includes("Some fields are invalid")) {
throw new Error("when sending a empty request with axios, the error didnt appear");
}
});
});
describe("canAccess tests", async () => {
let server: ReturnType<Koa["listen"]>;
afterEach(async () => {
server.close();
});
it("allows visit when configured when canAccess returns true", async () => {
const app = new Koa();
const router = new Router();
mount(
router,
"/",
form_factory(
async (ctx: Koa.Context): Promise<{ canAccess: boolean; message: string }> => {
return { canAccess: true, message: "" };
}
),
true
);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
const response = await page.goto(`http://localhost:${port}`);
if (response?.status() != 200) {
throw new Error(`Should return 200 status and it returns ${response?.status()}`);
}
});
describe("declines access when canAccess returns false", async () => {
const app = new Koa();
const router = new Router();
before(async () => {
mount(
router,
"/",
form_factory(
async (
ctx: Koa.Context
): Promise<{ canAccess: boolean; message: string }> => {
console.log("CAN ACCESS? FALSE");
return { canAccess: false, message: "" };
}
),
true
);
app.use(router.routes()).use(router.allowedMethods());
});
beforeEach(async () => {
server = app.listen(port);
});
it("prevents the form from rendering", async () => {
const response = await page.goto(`http://localhost:${port}`);
if (response?.status() != 403) {
throw new Error(
`Should return 403 status and it returns ${response?.status()}`
);
}
});
it("does not allow submitting of the form through axios", async () => {
await axios.post(
`http://localhost:${port}`,
{
text: "sample",
},
{
validateStatus: (status: number) => {
if (status == 403) return true;
return false;
},
}
);
});
});
it("passes the context to canAccess (false case)", async () => {
const app = new Koa();
const router = new Router();
mount(
router,
"/",
form_factory(
async (ctx: Koa.Context): Promise<{ canAccess: boolean; message: string }> => {
return ctx.$context && ctx.$context.user_id
? { canAccess: true, message: "" }
: { canAccess: false, message: "" };
}
),
true
);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
const response = await page.goto(`http://localhost:${port}`);
if (response?.status() != 403) {
throw new Error(`Should return 403 status and it returns ${response?.status()}`);
}
});
it("passes the context to canAccess (true case)", async () => {
const app = new Koa();
const router = new Router();
- router.use(async (ctx: BaseContext, next: any) => {
+ router.use(async (ctx: Context, next: any) => {
ctx.$context = {
user_id: "miguel",
} as any;
await next();
});
mount(
router,
"/",
form_factory(
async (ctx: Koa.Context): Promise<{ canAccess: boolean; message: string }> => {
return ctx.$context && ctx.$context.user_id
? { canAccess: true, message: "" }
: { canAccess: false, message: "" };
}
),
true
);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
const response = await page.goto(`http://localhost:${port}`);
if (response?.status() != 200) {
throw new Error(`Should return 200 status and it returns ${response?.status()}`);
}
});
});
describe("validation e2e", async () => {
describe("validation message", async () => {
let server: ReturnType<Koa["listen"]>;
before(async () => {
const app = new Koa();
const router = new Router();
mount(
router,
"/",
new (class extends Form<typeof fields, void> {
fields = fields;
submitButtonText = "Submit";
controls = [
new Controls.SimpleInput(fields.text, {
label: "This is a test:",
type: "password",
}),
];
async onSubmit() {
return;
}
async validateValues(
ctx: Koa.Context,
data: Record<string, FormDataValue>
): Promise<{ valid: boolean; error: string }> {
if (data.text === "incorrect")
return {
valid: false,
error: "Incorrect input",
};
return { valid: true, error: "" };
}
})(),
true
);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
await page.goto(`http://localhost:${port}`);
});
after(async () => {
server.close();
});
it("shows up when incorrect input is given", async () => {
await page.getByPlaceholder("password").click();
await page.getByPlaceholder("password").fill("incorrect");
await page.getByRole("button", { name: "Submit" }).click();
if (!(await locator_is_visible(page.getByText("Incorrect input"))))
throw new Error("validation message doens't show up when input is incorrect");
});
it("doesn't show when correct input is given", async () => {
await page.getByPlaceholder("password").click();
await page.getByPlaceholder("password").fill("correct");
await page.getByRole("button", { name: "Submit" }).click();
await assertThrowsAsync(async () => {
return page.getByText("Incorrect input").click({ timeout: 500 });
});
});
});
describe("field specific validation message", async () => {
let server: ReturnType<Koa["listen"]>;
before(async () => {
const app = new Koa();
const router = new Router();
const fields = {
text: new Fields.EmailField(true),
};
mount(
router,
"/",
new (class extends Form<typeof fields, void> {
fields = fields;
submitButtonText = "Submit";
controls = [
new Controls.SimpleInput(fields.text, {
label: "This is a test:",
type: "text",
}),
];
async onSubmit() {
return;
}
})(),
true
);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
await page.goto(`http://localhost:${port}`);
});
after(async () => {
server.close();
});
it("shows up when incorrect input is given", async () => {
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill("notanemail");
await page.getByRole("button", { name: "Submit" }).click();
await page.getByText("Please enter a proper email address").click();
});
it("doesn't show up when correct input is given", async () => {
await page.getByPlaceholder("text").click();
await page.getByPlaceholder("text").fill("yes@an.email");
await page.getByRole("button", { name: "Submit" }).click();
assertThrowsAsync(async () => {
return page
.getByText("Please enter a proper email address")
.click({ timeout: 500 });
});
});
});
});
describe("getInitialValues tests", async () => {
let server: ReturnType<Koa["listen"]>;
afterEach(async () => {
server.close();
});
it("fills the form with values from getInitialValues", async () => {
const app = new Koa();
const router = new Router();
const fields = {
title: new SimpleFormField(true),
};
mount(
router,
"/",
new (class extends Form<typeof fields, null> {
fields = fields;
controls = [new Controls.SimpleInput(fields.title)];
async getInitialValues() {
return { title: "Hello" };
}
onSubmit() {
return null;
}
})(),
true
);
app.use(router.routes()).use(router.allowedMethods());
server = app.listen(port);
const response = await page.goto(`http://localhost:${port}`);
if (response?.status() != 200) {
throw new Error(`Should return 200 status and it returns ${response?.status()}`);
}
await PlaywritghtExpect(page.getByLabel("title")).toHaveValue("Hello");
});
});
});
diff --git a/src/get-fonts.ts b/src/get-fonts.ts
index 72b1e17..2451ec9 100644
--- a/src/get-fonts.ts
+++ b/src/get-fonts.ts
@@ -1,27 +1,38 @@
-import { readFile } from "fs/promises";
+import { readFile, writeFile } from "fs/promises";
import { constructURL, download } from "google-fonts-helper";
+import { resolve } from "node:path";
import { target_locreq } from "./target-locreq.js";
export const FONTS_CONFIG_PATH = "src/fonts.json";
export async function getFonts(_: Record<string, string | boolean> = {}) {
const fonts_config_path = target_locreq.resolve(FONTS_CONFIG_PATH);
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const fonts_config = JSON.parse(await readFile(fonts_config_path, "utf8"));
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access
const fonts_url = constructURL(fonts_config.googleFonts);
if (!fonts_url) {
throw new Error("Invalid font config");
}
const downloader = download(fonts_url, {
base64: false,
overwriting: false,
outputDir: target_locreq.resolve("public/dist/fonts"),
stylePath: "fonts.css",
fontsDir: "./",
fontsPath: "/dist/fonts",
});
await downloader.execute();
+ const fonts_css_file_path = resolve(
+ target_locreq.resolve("public/dist/fonts"),
+ "fonts.css"
+ );
+ const content = await readFile(fonts_css_file_path, "utf-8");
+ await writeFile(
+ fonts_css_file_path,
+ content.replace(/@font-face {/g, "@font-face {\nfont-display: swap;")
+ );
+
console.log("Downloaded new fonts pack");
}
diff --git a/src/mount.ts b/src/mount.ts
index ace4840..ac7f2cf 100644
--- a/src/mount.ts
+++ b/src/mount.ts
@@ -1,91 +1,91 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Router from "@koa/router";
import { FileManager } from "@sealcode/file-manager";
-import Koa, { BaseContext, Context } from "koa";
+import Koa, { Context } from "koa";
import { Middlewares as SealiousMiddlewares } from "sealious";
import { Mountable } from "./page/mountable.js";
async function handleHtmlPromise(ctx: Context, next: Koa.Next) {
await next();
if (ctx.body instanceof Promise) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
ctx.body = await ctx.body;
}
ctx.set("content-type", "text/html;charset=utf-8");
}
export function mount(
router: Router,
url: string | { rawURL: string },
mountable: Mountable,
use_dummy_app = false,
file_manager: FileManager | undefined = use_dummy_app
? new FileManager("/tmp", "/uploaded_files")
: undefined
): void {
const raw_url = typeof url === "string" ? url : url.rawURL;
const args = use_dummy_app
? [
- async (ctx: BaseContext, next: any) => {
+ async (ctx: Context, next: any) => {
// this dummy is a temporary solution it's ilustrating the ways that
// sealgen is coupled with sealious. Definitely room for improvement in this
// regard
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
ctx.$app = {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
Logger: new Proxy(
{},
{
get: (_target: any, prop: any) => {
if (prop === "info") {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
} else {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}
},
}
),
getString: (x: string) => x,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await next();
},
SealiousMiddlewares.parseBody(file_manager),
handleHtmlPromise,
]
: [
SealiousMiddlewares.extractContext(),
SealiousMiddlewares.parseBody(file_manager),
handleHtmlPromise,
];
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
router.use(raw_url, ...args);
mountable.init();
// to automatically add trailing slashes:
router.get(raw_url.slice(0, -1), async (ctx, next) => {
const url2 = ctx.URL;
if (!url2.pathname.endsWith("/")) {
url2.pathname += "/";
ctx.redirect(url2.href);
}
await next();
});
router.use(raw_url, async (ctx, next) => {
ctx.set("content-type", "text/html;charset=utf-8");
const access_result = await mountable.canAccess(ctx);
if (!access_result.canAccess) {
ctx.status = 403;
ctx.body = access_result.message || "no access";
return;
}
await next();
});
mountable.mount(router, raw_url);
}
diff --git a/src/page/stateful-page.test.ts b/src/page/stateful-page.test.ts
index f92c3c4..3394392 100644
--- a/src/page/stateful-page.test.ts
+++ b/src/page/stateful-page.test.ts
@@ -1,38 +1,38 @@
-import { BaseContext } from "koa";
+import { Context } from "koa";
import { Templatable, tempstream } from "tempstream";
import {
ExtractStatefulPageActionArgs,
StatefulPage,
StatefulPageActionArgument,
} from "./stateful-page.js";
describe("stateful page", () => {
it("has types that allow for extracting action argument types", () => {
type TestState = {};
const action = async ({}: StatefulPageActionArgument<TestState, [number]>) => {};
type Args = ExtractStatefulPageActionArgs<typeof action>;
const a = [2] as Args; // should not throw a typescript error;
});
it("handles a basic case with action buttons", () => {
type TestState = {};
const actions = <const>{
some_action: async ({}: StatefulPageActionArgument<TestState, [number]>) => {},
};
new (class extends StatefulPage<TestState, typeof actions> {
actions = actions;
- getInitialState(_ctx: BaseContext) {
+ getInitialState(_ctx: Context) {
return {};
}
- wrapInLayout(_ctx: BaseContext, content: Templatable, _state: TestState): Templatable {
+ wrapInLayout(_ctx: Context, content: Templatable, _state: TestState): Templatable {
return content;
}
- render(_ctx: BaseContext, state: TestState) {
+ render(_ctx: Context, state: TestState) {
return tempstream`<div>${this.makeActionButton(state, "some_action", 2)}</div>`;
}
})();
});
});
diff --git a/src/page/stateful-page.ts b/src/page/stateful-page.ts
index 23d93b6..baf0512 100644
--- a/src/page/stateful-page.ts
+++ b/src/page/stateful-page.ts
@@ -1,328 +1,328 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import Router from "@koa/router";
import { predicates, hasShape, is } from "@sealcode/ts-predicates";
import deepmerge, { ArrayMergeOptions } from "deepmerge";
-import { BaseContext, Context } from "koa";
+import { Context } from "koa";
import { Templatable, tempstream } from "tempstream";
import { from_base64, to_base64 } from "../utils/base64.js";
import { Mountable } from "./mountable.js";
import { isPlainObject } from "is-what";
export type StatefulPageActionDescription<ActionName> =
| {
action: ActionName;
label?: string;
content?: string;
disabled?: boolean;
}
| ActionName;
export type StateAndMetadata<State, Actions> =
| {
state: State;
inputs: Record<string, string>;
action: keyof Actions;
action_args: string;
}
| {
state: State;
inputs: Record<string, string>;
action: null;
action_args: null;
$: Record<string, unknown>;
};
export type StatefulPageActionArgument<
State extends Record<string, unknown>,
Args extends unknown[] = unknown[]
> = {
- ctx: BaseContext;
+ ctx: Context;
state: State;
inputs: Record<string, string>;
args: Args;
page: StatefulPage<any, any>;
};
export type StatefulPageAction<
State extends Record<string, unknown>,
Args extends unknown[] = unknown[]
> = (obj: StatefulPageActionArgument<State, Args>) => State | Promise<State>;
export type ExtractStatefulPageActionArgs<X> = X extends StatefulPageAction<
any,
infer Args
>
? Args
: never;
export abstract class StatefulPage<
State extends Record<string, unknown>,
Actions extends Record<string, StatefulPageAction<State>>
> extends Mountable {
abstract actions: Actions;
- abstract getInitialState(ctx: BaseContext): State | Promise<State>;
+ abstract getInitialState(ctx: Context): State | Promise<State>;
abstract render(
- ctx: BaseContext,
+ ctx: Context,
state: State,
inputs: Record<string, string>
): Templatable | Promise<Templatable>;
async canAccess() {
return <const>{ canAccess: true, message: "" };
}
constructor() {
super();
const original_render = this.render.bind(this) as typeof this.render;
this.render = async (
- ctx: BaseContext,
+ ctx: Context,
state: State,
inputs: Record<string, string>
) => {
return this.wrapInLayout(
ctx,
await this.wrapInForm(
- ctx as Context,
+ ctx,
state,
await original_render(ctx, state, inputs)
),
state
);
};
}
abstract wrapInLayout(
- ctx: BaseContext,
+ ctx: Context,
content: Templatable,
state: State
): Templatable;
async wrapInForm(
context: Context,
state: State,
content: Templatable
): Promise<Templatable> {
return tempstream/* HTML */ `<form
action="./"
method="POST"
enctype="multipart/form-data"
>
<input
name="state"
type="hidden"
value="${to_base64(await this.serializeState(context, state))}"
/>
${content}
</form>`;
}
extractActionAndLabel<ActionName extends keyof Actions>(
action_description: StatefulPageActionDescription<ActionName>
): { action: string; label: string; content: string; disabled: boolean } {
let label, action, content: string;
let disabled: boolean;
if (is(action_description, predicates.object)) {
action = action_description.action.toString();
label = action_description.label || action;
content = action_description.content || label;
disabled = action_description.disabled || false;
} else {
action = action_description.toString();
label = action;
content = label;
disabled = false;
}
return { action, label, content, disabled };
}
makeActionURL<ActionName extends keyof Actions>(
action_description: StatefulPageActionDescription<ActionName>,
...args: ExtractStatefulPageActionArgs<Actions[ActionName]>
) {
const { action } = this.extractActionAndLabel(action_description);
return `./?action=${action}&action_args=${encodeURIComponent(
// encoding as URI Component because sometimes it can contain a "+" which is treated as a space
to_base64(JSON.stringify(args))
)}`;
}
makeActionButton<ActionName extends keyof Actions>(
_state: State,
action_description: StatefulPageActionDescription<ActionName>,
...args: ExtractStatefulPageActionArgs<Actions[ActionName]>
) {
const { label, content, disabled } =
this.extractActionAndLabel(action_description);
return /* HTML */ `
<button
type="submit"
formaction="${this.makeActionURL(action_description, ...args)}"
title="${label}"
${disabled ? "disabled" : ""}
>
${content}
</button>
`;
}
makeActionCallback<ActionName extends keyof Actions>(
action_description:
| {
action: ActionName;
label?: string;
}
| ActionName,
...args: ExtractStatefulPageActionArgs<Actions[ActionName]>
) {
return `(()=>{const form = this.closest('form'); form.action='${this.makeActionURL(
action_description,
...args
)}'; form.requestSubmit()})()`;
}
rerender() {
return "this.closest('form').requestSubmit()";
}
async preprocessState(values: State): Promise<State> {
return values;
}
async preprocessOverrides(
- _context: BaseContext,
+ _context: Context,
_state: State,
values: Record<string, unknown>
): Promise<Record<string, unknown>> {
return values;
}
- async serializeState(_context: BaseContext, state: State): Promise<string> {
+ async serializeState(_context: Context, state: State): Promise<string> {
return JSON.stringify(state);
}
- async deserializeState(_context: BaseContext, s: string): Promise<State> {
+ async deserializeState(_context: Context, s: string): Promise<State> {
const deserialized = JSON.parse(s) as unknown;
return deserialized as State;
}
async extractState(
- ctx: BaseContext
+ ctx: Context
): Promise<StateAndMetadata<State, Actions>> {
if (
!hasShape(
{
action: predicates.maybe(predicates.string),
state: predicates.string,
action_args: predicates.maybe(predicates.string),
$: predicates.maybe(predicates.object),
},
ctx.$body
)
) {
console.error("Wrong data: ", ctx.$body);
throw new Error("wrong formdata shape");
}
const inputs = Object.fromEntries(
Object.entries(ctx.$body).filter(
([key]) => !["action", "state", "args", "$"].includes(key)
)
) as Record<string, string>;
// the "$" key is parsed as dot notation and overrides the state
const original_state_string = ctx.$body.state;
const original_state = await this.deserializeState(
ctx,
typeof original_state_string == "string"
? from_base64(original_state_string)
: "{}"
);
const $body = ctx.$body;
let state_overrides = $body.$ || {};
state_overrides = await this.preprocessOverrides(
ctx,
original_state,
state_overrides
);
let modified_state = deepmerge(original_state, state_overrides, {
isMergeableObject: (v) => isPlainObject(v) || Array.isArray(v),
arrayMerge: (
target: any[],
source: any[],
options: ArrayMergeOptions
) => {
// https://github.com/TehShrike/deepmerge?tab=readme-ov-file#arraymerge-example-combine-arrays
const destination = target.slice();
/* eslint-disable @typescript-eslint/no-unsafe-argument */
source.forEach((item, index) => {
if (typeof destination[index] === "undefined") {
destination[index] =
options.cloneUnlessOtherwiseSpecified(
item,
options
);
} else if (options.isMergeableObject(item)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
destination[index] = deepmerge(
target[index],
item,
options
);
} else if (target.indexOf(item) === -1) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
destination[index] = item;
}
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return destination;
},
}) as State;
// giving extending classes a change to modify the state before furhter processing
modified_state = await this.preprocessState(modified_state);
if (ctx.$body.action && ctx.$body.action_args) {
return {
state: modified_state,
action: ctx.$body.action,
inputs,
action_args: ctx.$body.action_args,
};
} else {
return {
state: modified_state,
action: null,
inputs,
action_args: null,
$: ctx.$body.$ || {},
};
}
}
mount(router: Router, path: string) {
router.get(path, async (ctx) => {
ctx.body = this.render(ctx, await this.getInitialState(ctx), {});
});
router.post(path, async (ctx) => {
const { action, state, inputs, action_args } =
await this.extractState(ctx);
if (action) {
const new_state = await this.actions[action]({
ctx,
state,
inputs,
args: JSON.parse(
from_base64(action_args as string)
) as unknown[],
page: this,
});
ctx.body = this.render(ctx, new_state, inputs);
} else {
ctx.body = this.render(ctx, state, inputs);
}
ctx.status = 433;
});
}
}
diff --git a/src/templates/stateful-page.ts b/src/templates/stateful-page.ts
index 7e1cc35..baae3d7 100644
--- a/src/templates/stateful-page.ts
+++ b/src/templates/stateful-page.ts
@@ -1,68 +1,68 @@
import { curryImportPath } from "../utils/import-path.js";
export async function statefulPageTemplate(
action_name: string,
newfilefullpath: string
): Promise<string> {
const rel = curryImportPath(newfilefullpath);
return `import { TempstreamJSX, Templatable } from "tempstream";
-import { BaseContext } from "koa";
+import { Context } from "koa";
import { StatefulPage } from "@sealcode/sealgen";
import html from "${rel("src/back/html.js")}";
export const actionName = "${action_name}";
const actions = {
add: (state: State, inputs: Record<string, string>) => {
return {
...state,
elements: [...state.elements, inputs.element_to_add || "new element"],
};
},
remove: (state: State, _: unknown, index_to_remove: number) => {
return {
...state,
elements: state.elements.filter((_, index) => index != index_to_remove),
};
},
} as const;
type State = {
elements: string[];
};
export default new (class ${action_name}Page extends StatefulPage<State, typeof actions> {
actions = actions;
getInitialState() {
return { elements: ["one", "two", "three"] };
}
- wrapInLayout(ctx: BaseContext, content: Templatable): Templatable {
+ wrapInLayout(ctx: Context, content: Templatable): Templatable {
return html(ctx, "${action_name}", content);
}
- render(ctx: BaseContext, state: State, inputs: Record<string, string>) {
+ render(ctx: Context, state: State, inputs: Record<string, string>) {
return (
<div>
<ul>
{state.elements.map((e, index) => (
<li>
{e} {this.makeActionButton(state, "remove", index)}
</li>
))}
</ul>
<div>
<input
name="element_to_add"
type="text"
value={inputs.element_to_add || ""}
/>
{this.makeActionButton(state, "add")}
</div>
</div>
);
}
})();
`;
}
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Sep 21, 00:30 (1 d, 13 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
949757
Default Alt Text
(32 KB)
Attached To
Mode
rSGEN sealgen
Attached
Detach File
Event Timeline
Log In to Comment