Page MenuHomeSealhub

No OneTemporary

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

Mime Type
text/x-diff
Expires
Sun, Sep 21, 00:30 (1 d, 11 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
949757
Default Alt Text
(32 KB)

Event Timeline