Some more type fixes around images

This commit is contained in:
Kuba Orlik 2024-03-21 11:55:14 +01:00
parent be4d4e3168
commit 318f8df49f
12 changed files with 522 additions and 484 deletions

493
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "docker-compose up -d db && node .", "start": "docker-compose up -d db && node .",
"typecheck:back": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/back", "typecheck:back": "npx tsc --noEmit --target es6 --lib es2021,dom -p src/back",
"typecheck:front": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/front", "typecheck:front": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/front",
"build": "sealgen build", "build": "sealgen build",
"watch": "multiple-scripts-tmux -p watch", "watch": "multiple-scripts-tmux -p watch",
@ -35,19 +35,20 @@
"@hotwired/turbo": "^8.0.2", "@hotwired/turbo": "^8.0.2",
"@koa/router": "^12.0.1", "@koa/router": "^12.0.1",
"@playwright/test": "^1.36.1", "@playwright/test": "^1.36.1",
"@sealcode/jdd": "^0.2.18", "@sealcode/jdd": "^0.3.0",
"@sealcode/sealgen": "^0.12.13", "@sealcode/sealgen": "^0.13.0",
"@sealcode/ts-predicates": "^0.4.3", "@sealcode/ts-predicates": "^0.4.3",
"@types/kill-port": "^2.0.0", "@types/kill-port": "^2.0.0",
"get-port": "^7.0.0", "get-port": "^7.0.0",
"js-convert-case": "^4.2.0", "js-convert-case": "^4.2.0",
"koa-responsive-image-router": "^0.2.18",
"locreq": "^3.0.0", "locreq": "^3.0.0",
"multiple-scripts-tmux": "^1.0.4", "multiple-scripts-tmux": "^1.0.4",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"object-path": "^0.11.8", "object-path": "^0.11.8",
"sealious": "^0.17.48", "sealious": "^0.17.48",
"stimulus": "^2.0.0", "stimulus": "^2.0.0",
"tempstream": "^0.3.12", "tempstream": "^0.3.15",
"unplugin-auto-import": "^0.17.5", "unplugin-auto-import": "^0.17.5",
"vitest": "^1.1.0" "vitest": "^1.1.0"
}, },
@ -57,6 +58,7 @@
"@types/node": "^20.8.4", "@types/node": "^20.8.4",
"@types/object-path": "^0.11.4", "@types/object-path": "^0.11.4",
"@types/tedious": "^4.0.7", "@types/tedious": "^4.0.7",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^5.10.0", "@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.2", "@typescript-eslint/parser": "^5.10.2",
"@vitest/coverage-istanbul": "^1.1.0", "@vitest/coverage-istanbul": "^1.1.0",

View File

@ -1,3 +1,6 @@
import _locreq from "locreq";
import { module_dirname } from "./util.js";
const locreq = _locreq(module_dirname(import.meta.url));
export const SEALIOUS_SANITY = Boolean(process.env.SEALIOUS_SANITY); export const SEALIOUS_SANITY = Boolean(process.env.SEALIOUS_SANITY);
export const PORT = process.env.SEALIOUS_PORT export const PORT = process.env.SEALIOUS_PORT
? parseInt(process.env.SEALIOUS_PORT) ? parseInt(process.env.SEALIOUS_PORT)
@ -16,3 +19,8 @@ export const MAILCATCHER_API_PORT = parseInt(
); );
export const MAILER = process.env.SEALIOUS_MAILER; export const MAILER = process.env.SEALIOUS_MAILER;
export const DEFAULT_HTML_LANG = "pl"; export const DEFAULT_HTML_LANG = "pl";
export const IMAGE_CACHE_FS_DIR =
process.env.IMAGE_CACHE_FS_DIR || locreq.resolve("cache/images");
export const SMARTCROP_CACHE_FS_DIR =
process.env.IMAGE_CACHE_FS_DIR || locreq.resolve("cache/smartcrop");

View File

@ -1,5 +1,7 @@
import kill from "kill-port"; import kill from "kill-port";
import type { KoaResponsiveImageRouter } from "koa-responsive-image-router";
import _locreq from "locreq"; import _locreq from "locreq";
import { FlatTemplatable } from "tempstream";
import TheApp from "./app.js"; import TheApp from "./app.js";
import { PORT, SEALIOUS_SANITY } from "./config.js"; import { PORT, SEALIOUS_SANITY } from "./config.js";
import { mainRouter } from "./routes/index.js"; import { mainRouter } from "./routes/index.js";

View File

@ -3,5 +3,8 @@ import { Registry } from "@sealcode/jdd";
export const registry = new Registry(); export const registry = new Registry();
import { ImageDemo } from "./image-demo/image-demo.jdd.js";
registry.add("image-demo", ImageDemo);
import { NiceBox } from "./nice-box/nice-box.jdd.js"; import { NiceBox } from "./nice-box/nice-box.jdd.js";
registry.add("nice-box", NiceBox); registry.add("nice-box", NiceBox);

View File

@ -29,8 +29,7 @@ export class ImageDemo extends Component<typeof component_arguments> {
return ( return (
<div class="image-demo"> <div class="image-demo">
<h2>Image with alt text</h2> <h2>Image with alt text</h2>
{" " || {render_image(image_with_alt.image, {
render_image(image_with_alt.image, {
container: { width: 200, height: 200 }, container: { width: 200, height: 200 },
alt: image_with_alt.alt, alt: image_with_alt.alt,
})} })}
@ -39,7 +38,11 @@ export class ImageDemo extends Component<typeof component_arguments> {
<div class="image-grid"> <div class="image-grid">
{multiple_images.map((image) => {multiple_images.map((image) =>
render_image(image, { render_image(image, {
container: { width: 200, height: 200, objectFit: "cover" }, container: {
width: 200,
height: 200,
objectFit: "cover",
},
}) })
)} )}
</div> </div>

View File

@ -5,10 +5,17 @@ import {
ExtractStructuredComponentArgumentsValues, ExtractStructuredComponentArgumentsValues,
JDDContext, JDDContext,
} from "@sealcode/jdd"; } from "@sealcode/jdd";
import { Readable } from "stream";
const component_arguments = { const component_arguments = {
title: new ComponentArguments.ShortText(), title: new ComponentArguments.ShortText(),
content: new ComponentArguments.Markdown(), content: new ComponentArguments.Markdown(),
images: new ComponentArguments.List(
new ComponentArguments.Structured({
image: new ComponentArguments.Image(),
alt: new ComponentArguments.ShortText(),
})
),
} as const; } as const;
export class NiceBox extends Component<typeof component_arguments> { export class NiceBox extends Component<typeof component_arguments> {
@ -16,17 +23,28 @@ export class NiceBox extends Component<typeof component_arguments> {
return component_arguments; return component_arguments;
} }
toHTML( async toHTML(
{ {
title, title,
content, content,
images,
}: ExtractStructuredComponentArgumentsValues<typeof component_arguments>, }: ExtractStructuredComponentArgumentsValues<typeof component_arguments>,
{ render_markdown }: JDDContext { render_markdown, decode_file, render_image }: JDDContext
): FlatTemplatable { ): Promise<Readable> {
return ( return (
<div class="nice-box"> <div class="nice-box">
<h2>{title}</h2> <h2>{title}</h2>
<div>{render_markdown(content)}</div> <div>{render_markdown(content)}</div>
{images.map((image) =>
render_image(image.image, {
container: {
width: 200,
height: 200,
objectFit: "contain",
},
alt: image?.alt || "",
})
)}
</div> </div>
); );
} }

View File

@ -1,82 +1,157 @@
import { TempstreamJSX, Templatable, FlatTemplatable, tempstream } from "tempstream";
import { BaseContext } from "koa";
import { StatefulPage } from "@sealcode/sealgen";
import html from "../html.js";
import { registry } from "../jdd-components/components.js";
import { import {
ComponentArgument, ComponentArgument,
Enum,
List, List,
render, render,
simpleJDDContext, simpleJDDContext,
Structured, Structured,
} from "@sealcode/jdd"; } from "@sealcode/jdd";
import objectPath from "object-path"; import { StateAndMetadata, StatefulPage, to_base64 } from "@sealcode/sealgen";
import { hasFieldOfType, hasShape, is, predicates } from "@sealcode/ts-predicates";
import { BaseContext } from "koa";
import { Templatable, TempstreamJSX } from "tempstream";
import html from "../html.js";
import { registry } from "../jdd-components/components.js";
import { ComponentInput } from "./component-preview/component-input.js";
import { ComponentPreviewActions } from "./component-preview/component-preview-actions.js";
import { jdd_context } from "./jdd-context.js";
export const actionName = "Components"; export const actionName = "Components";
const actions = { function id<X>(_: any, __: any, x: X): X {
add_array_item: ( return x;
state: State, }
_: Record<string, string>,
arg_path: string[],
empty_value: unknown
) => {
const args = state.args;
objectPath.insert(
args,
arg_path,
empty_value,
((objectPath.get(args, arg_path) as unknown[]) || []).length
);
return {
...state,
args,
};
},
remove_array_item: (
state: State,
_: Record<string, string>,
arg_path: string[],
index_to_remove: number
) => {
const args = state.args;
objectPath.del(args, [...arg_path, index_to_remove]);
return {
...state,
args,
};
},
change_component: (state: State, inputs: Record<string, string>) => {
const component_name = inputs.component;
const component = registry.get(component_name);
return {
...state,
component: component_name,
args: component?.getExampleValues() || {},
};
},
randomize_args: (state: State, inputs: Record<string, string>) => {
const component_name = inputs.component;
const component = registry.get(component_name);
return {
...state,
args: component?.getExampleValues() || {},
};
},
} as const;
type State = { async function encodeSealiousFile(maybe_file: Record<string, unknown>) {
if (maybe_file?.getDataPath) {
return simpleJDDContext.encode_file(
{
type: "path",
// asserting that this is an instance of sealious' FileFromPath
path: (maybe_file as unknown as { data: { path: string } }).data
.path as string,
},
false
);
}
}
const componentArgToRequestProcessor = {
list: async function (arg, arg_name, value: unknown) {
if (
!is(value, predicates.array(predicates.object)) &&
!is(value, predicates.object)
) {
throw new Error(`$.${arg_name} is not a list or object`);
}
const values = Array.isArray(value) ? value : Object.values(value);
const nested_arg_type = (arg as List<ComponentArgument<unknown>>).item_type;
let array_result: Array<unknown> = await Promise.all(
values.map((value, index) => {
return (
componentArgToRequestProcessor[nested_arg_type.getTypeName()] || id
)(nested_arg_type, `${arg_name}[${index}]`, value);
})
);
if (nested_arg_type.getTypeName() != "list") {
array_result = array_result.flat();
}
return array_result;
},
structured: async function (arg, arg_name, value) {
if (!is(value, predicates.object)) {
throw new Error(`${arg_name} is not an object`);
}
let result = Object.fromEntries(
await Promise.all(
Object.entries(value).map(async ([obj_key, obj_value]) => {
const nested_arg_type: ComponentArgument<unknown> = (
arg as Structured<Record<string, ComponentArgument<unknown>>>
).structure[obj_key];
if (!nested_arg_type) {
return [obj_key, null];
}
const new_value = await (
componentArgToRequestProcessor[nested_arg_type.getTypeName()] ||
id
)(arg, `${arg_name}[${obj_key}]`, obj_value);
return [obj_key, new_value];
})
)
);
// if we're in a list and any of the values return an array, we will multiply the object
if (arg.hasParent("list")) {
const keys_with_unexpected_arrays = Object.entries(result)
.filter(([key, value]) => {
const nested_arg_type: ComponentArgument<unknown> = (
arg as Structured<Record<string, ComponentArgument<unknown>>>
).structure[key];
return (
nested_arg_type.getTypeName() !== "list" && Array.isArray(value)
);
})
.map(([key]) => key);
if (keys_with_unexpected_arrays.length > 1) {
throw new Error(
"Multiplying on multiple fields at the same time is not implemented yet"
);
}
if (keys_with_unexpected_arrays.length == 1) {
const key = keys_with_unexpected_arrays[0];
const old_result = result;
result = (old_result[key] as Array<unknown>).map((value) => ({
...old_result,
[key]: value,
}));
}
}
return result;
},
image: async function (arg, _, value: unknown) {
if (
!hasShape(
{
new: predicates.maybe(predicates.array(predicates.object)),
old: predicates.string,
},
value
)
) {
return null;
}
const files = (value.new || []).filter((e) => e);
if (files.length == 0) {
return value.old;
} else if (files.length == 1) {
return encodeSealiousFile(files[0]);
} else if (arg.hasParent("list")) {
return Promise.all(files.map(encodeSealiousFile));
}
},
} as Record<
string,
(arg: ComponentArgument<any>, arg_name: string, value: unknown) => Promise<unknown>
>;
export type ComponentPreviewState = {
component: string; component: string;
args: Record<string, unknown>; component_args: Record<string, unknown>;
}; };
export default new (class ComponentsPage extends StatefulPage<State, typeof actions> { export default new (class ComponentsPage extends StatefulPage<
actions = actions; ComponentPreviewState,
typeof ComponentPreviewActions
> {
actions = ComponentPreviewActions;
getInitialState() { async getInitialState() {
const [component_name, component] = Object.entries(registry.getAll())[0]; const [component_name, component] = Object.entries(registry.getAll())[0];
return { component: component_name, args: component.getExampleValues() }; const initial_state = {
component: component_name,
component_args: await component.getExampleValues(jdd_context),
};
return initial_state;
} }
wrapInLayout(ctx: BaseContext, content: Templatable): Templatable { wrapInLayout(ctx: BaseContext, content: Templatable): Templatable {
@ -88,149 +163,68 @@ export default new (class ComponentsPage extends StatefulPage<State, typeof acti
}); });
} }
renderListArgument<T>( wrapInForm(state: ComponentPreviewState, content: Templatable): Templatable {
state: State, // overwriting this method in order to add enctype to form
arg_path: string[],
arg: List<ComponentArgument<T>>,
value: T[] = []
): FlatTemplatable {
return ( return (
<fieldset> <form action="./" method="POST" enctype="multipart/form-data">
<legend>{arg_path.at(-1)}</legend>
{value.map((e, i) => (
<div style="display: flex">
{this.renderArgumentInput(
state,
[...arg_path, i.toString()],
arg.item_type,
e
)}
{this.makeActionButton(
state,
{ action: "remove_array_item", label: "❌" },
arg_path,
i
)}
</div>
))}
{this.makeActionButton(
state,
{
action: "add_array_item",
label: "",
},
arg_path,
arg.item_type.getExampleValue()
)}
</fieldset>
);
}
renderStructuredArgument<
T extends Structured<Record<string, ComponentArgument<unknown>>>
>(
state: State,
arg_path: string[],
arg: T,
value: Record<string, unknown>
): FlatTemplatable {
return (
<fieldset>
<legend>{arg_path.at(-1)}</legend>
{Object.entries(arg.structure).map(([arg_name, arg]) => (
<div>
{this.renderArgumentInput(
state,
[...arg_path, arg_name],
arg,
(value as Record<string, unknown>)[arg_name]
)}
</div>
))}
</fieldset>
);
}
printArgPath(path: string[]): string {
return path.map((e) => `[${e}]`).join("");
}
renderEnumArgument<T extends Enum<any>>(
state: State,
arg_path: string[],
arg: T,
value: string
): FlatTemplatable {
return (
<div>
<label>
{arg_path.at(-1) || ""}
<select
name={`$.args${this.printArgPath(arg_path)}`}
onchange={this.rerender()}
>
{arg.values.map((v) => (
<option value={v} selected={value == v}>
{v}
</option>
))}
</select>
</label>
</div>
);
}
renderArgumentInput<T>(
state: State,
arg_path: string[],
arg: ComponentArgument<T>,
value: T
): FlatTemplatable {
if (value === undefined) {
value = arg.getEmptyValue();
}
if (arg instanceof List) {
return this.renderListArgument(state, arg_path, arg, value as T[]);
}
if (arg instanceof Structured) {
return this.renderStructuredArgument(
state,
arg_path,
arg,
value as Record<string, unknown>
);
}
if (arg instanceof Enum) {
return this.renderEnumArgument(state, arg_path, arg, value as string);
}
return (
<div>
<label>
{arg_path.at(-1) || ""}
{arg.getTypeName() == "markdown" ? (
<textarea
name={`$.args${this.printArgPath(arg_path)}`}
onblur={this.rerender()}
cols="70"
>
{value as string}
</textarea>
) : (
<input <input
type="text" name="state"
name={`$.args${this.printArgPath(arg_path)}`} type="hidden"
value={value as string} value={to_base64(JSON.stringify(state))}
size="70"
/> />
)} {content}
</label> </form>
</div>
); );
} }
render(ctx: BaseContext, state: State, inputs: Record<string, string>) { async preprocessRequestBody<
T extends StateAndMetadata<ComponentPreviewState, typeof ComponentPreviewActions>
>(values: Record<string, unknown>): Promise<T> {
let old_component = hasFieldOfType(values, "component", predicates.string)
? values.component
: null;
const new_component = hasShape(
{ $: predicates.shape({ component: predicates.string }) },
values
)
? values.$.component
: null;
const component_name = new_component || old_component;
if (!component_name) {
throw new Error("Unspecified component name");
}
const component = registry.get(component_name);
if (!component) {
throw new Error(`Unknown component: ${component_name}`);
}
if (
!hasShape(
{ $: predicates.shape({ component_args: predicates.object }) },
values
)
) {
// no component args to overwrite
return values as T;
}
for (const [arg_name, arg] of Object.entries(component.getArguments())) {
let value = values.$.component_args[arg_name];
if (value) {
const new_value = await (
componentArgToRequestProcessor[arg.getTypeName()] || id
)(arg, arg_name, value);
values.$.component_args[arg_name] = new_value;
}
}
return values as T;
}
render(
ctx: BaseContext,
state: ComponentPreviewState,
inputs: Record<string, string>
) {
const all_components = registry.getAll(); const all_components = registry.getAll();
const component = const component =
registry.get(state.component) || Object.values(all_components)[0]; registry.get(state.component) || Object.values(all_components)[0];
@ -253,19 +247,26 @@ export default new (class ComponentsPage extends StatefulPage<State, typeof acti
{this.makeActionButton(state, "randomize_args")} {this.makeActionButton(state, "randomize_args")}
<fieldset class="component-preview-parameters"> <fieldset class="component-preview-parameters">
<legend>Parameters</legend> <legend>Parameters</legend>
{Object.entries(component.getArguments()).map(([arg_name, arg]) => {Object.entries(component.getArguments()).map(
this.renderArgumentInput( async ([arg_name, arg]) => (
<ComponentInput
{...{
state, state,
[arg_name], arg_path: [arg_name],
arg, arg,
state.args[arg_name] === undefined value:
? arg.getExampleValue() state.component_args[arg_name] === undefined
: state.args[arg_name] ? await arg.getExampleValue(jdd_context)
: state.component_args[arg_name],
onblur: this.rerender(),
page: this,
}}
/>
) )
)} )}
<input type="submit" value="Preview" /> <input type="submit" value="Preview" />
</fieldset> </fieldset>
<div>{JSON.stringify(state)}</div> <code>{JSON.stringify(state)}</code>
</div> </div>
<div class="resize-gutter"></div> <div class="resize-gutter"></div>
{ {
@ -307,18 +308,30 @@ export default new (class ComponentsPage extends StatefulPage<State, typeof acti
<legend>Preview</legend> <legend>Preview</legend>
{render( {render(
registry, registry,
[{ component_name: state.component, args: state.args }], [
simpleJDDContext {
component_name: state.component,
args: state.component_args,
},
],
jdd_context
)} )}
</fieldset> </fieldset>
</div> </div>
{ {
/* HTML */ `<script> /* HTML */ `<script>
document.documentElement.addEventListener("ts-rebuilt", () => { const main_form = document
document
.querySelector("#component-debugger") .querySelector("#component-debugger")
.closest("form") .closest("form");
.requestSubmit(); document.documentElement.addEventListener("ts-rebuilt", () => {
main_form.requestSubmit();
});
main_form.addEventListener("turbo:submit-end", () => {
// this clears the values of file inputs, so they don't get unecessarily
// re-uploaded on future submissions - the file is alreade there on the server
main_form
.querySelectorAll("input[type=file]")
.forEach((input) => (input.value = ""));
}); });
</script>` </script>`
} }

View File

@ -1,5 +1,6 @@
import Router from "@koa/router"; import Router from "@koa/router";
import { Middlewares } from "sealious"; import { Middlewares } from "sealious";
import { imageRouter, RESPONSIVE_IMAGES_URL_PATH } from "../image-router.js";
import { MainView } from "./common/main-view.js"; import { MainView } from "./common/main-view.js";
import mountAutoRoutes from "./routes.js"; import mountAutoRoutes from "./routes.js";
@ -15,5 +16,7 @@ export const mainRouter = (router: Router): void => {
ctx.body = { status: ctx.$app.status, started_at }; ctx.body = { status: ctx.$app.status, started_at };
}); });
router.use(RESPONSIVE_IMAGES_URL_PATH, imageRouter.getRoutes());
mountAutoRoutes(router); mountAutoRoutes(router);
}; };

View File

@ -7,7 +7,7 @@
"strictNullChecks": true, "strictNullChecks": true,
"target": "ES2019", "target": "ES2019",
"esModuleInterop": true, "esModuleInterop": true,
"lib": ["es6", "esnext"], "lib": ["es2021"],
"outDir": "../../dist/back", "outDir": "../../dist/back",
"keyofStringsOnly": true, "keyofStringsOnly": true,
"jsx": "react", "jsx": "react",

View File

@ -1,8 +1,10 @@
import * as Turbo from "@hotwired/turbo"; import * as Turbo from "@hotwired/turbo";
import { Application } from "stimulus"; import { Application } from "stimulus";
import InputImagePreview from "./controllers/input-image-preview";
import TaskController from "./controllers/task-controller"; import TaskController from "./controllers/task-controller";
export { Turbo }; export { Turbo };
const application = Application.start(); const application = Application.start();
application.register("task", TaskController); application.register("task", TaskController);
application.register("input-image-preview", InputImagePreview);

View File

@ -1,6 +1,7 @@
/* DO NOT EDIT! This file is generated automaticaly with npx sealgen generate-css-includes */ /* DO NOT EDIT! This file is generated automaticaly with npx sealgen generate-css-includes */
@import "../node_modules/@sealcode/sealgen/src/forms/forms.css"; @import "../node_modules/@sealcode/sealgen/src/forms/forms.css";
@import "back/jdd-components/image-demo/image-demo.css";
@import "back/routes/common/ui/input.css"; @import "back/routes/common/ui/input.css";
@import "back/routes/components.css"; @import "back/routes/components.css";
@import "colors.css"; @import "colors.css";