diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd8f30c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM denoland/deno:alpine + +WORKDIR /app + +COPY . . +RUN deno cache main.ts +RUN deno task build + +USER deno +EXPOSE 80 +EXPOSE 443 + +CMD ["run", "-A", "main.ts"] \ No newline at end of file diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..530640a --- /dev/null +++ b/compose.yml @@ -0,0 +1,10 @@ +services: + app: + container_name: deno_fresh_app + build: . + ports: + - "80:80" + - "443:443" + volumes: + - .:/app + command: deno run -A main.ts diff --git a/databases/ensure.ts b/databases/ensure.ts index 993fba3..1c56375 100644 --- a/databases/ensure.ts +++ b/databases/ensure.ts @@ -1,6 +1,8 @@ import { Database } from "@db/sqlite"; export default async function ensureDatabases() { + await Deno.mkdir("databases/data", { recursive: true }); + for await (const file of Deno.readDir("databases/init")) { if (!file.isFile) { console.warn(`[WARN] Path ${file.name} is not a file.`); diff --git a/deno.json b/deno.json index 9061960..e9dcaca 100644 --- a/deno.json +++ b/deno.json @@ -3,6 +3,7 @@ "tasks": { "check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx", "cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -", + "pmpr": "deno run -A toolbox/cli.ts", "manifest": "deno task cli manifest $(pwd)", "start": "deno run -A --unstable-ffi --watch=static/,routes/ dev.ts", "build": "deno run -A --unstable-ffi dev.ts build", @@ -25,6 +26,7 @@ "@db/sqlite": "jsr:@db/sqlite@^0.12.0", "@melvdouc/xml-parser": "jsr:@melvdouc/xml-parser@^0.1.1", "@popov/jwt": "jsr:@popov/jwt@^1.0.1", + "@std/cli": "jsr:@std/cli@^1.0.10", "preact": "https://esm.sh/preact@10.22.0", "preact/": "https://esm.sh/preact@10.22.0/", "@preact/signals": "https://esm.sh/*@preact/signals@1.2.2", diff --git a/toolbox/cli.ts b/toolbox/cli.ts new file mode 100644 index 0000000..91f91f1 --- /dev/null +++ b/toolbox/cli.ts @@ -0,0 +1,104 @@ +import { parseArgs, type ParseOptions } from "@std/cli/parse-args"; +import { createModule } from "$root/toolbox/module/create.ts"; +import { listModules } from "$root/toolbox/module/list.ts"; + +type CLICommand = (...args: Array) => void; +type CLIAsyncCommand = (...args: Array) => Promise; +interface CLI { + [command: string]: CLI | CLICommand | CLIAsyncCommand; +} + +const argSpec: ParseOptions = {}; + +const cli: CLI = { + help: () => displayHelp(cli), + module: { + list: () => listModules(), + create: (name: string) => createModule(name), + }, +}; + +function displayHelp( + cli: CLI | CLICommand | CLIAsyncCommand, + errorMessage?: string, +): never { + const loggingFunction = errorMessage ? console.error : console.log; + if (errorMessage) { + console.error(errorMessage); + } + + if (typeof cli == "function") { + loggingFunction("Usage:"); + displayFunctionHelp(cli, loggingFunction); + } else { + loggingFunction("Commands:"); + displayObjectHelp(cli, loggingFunction); + } + + Deno.exit(errorMessage ? 1 : 0); +} + +function displayObjectHelp( + cli: CLI, + loggingFunction: typeof console.log, + level: number = 1, +) { + for (const [key, value] of Object.entries(cli)) { + if (typeof value == "function") { + displayFunctionHelp(value, loggingFunction, level); + } else { + loggingFunction(`${" ".repeat(level * 2)}${key}`); + displayObjectHelp(value, loggingFunction, level + 1); + } + } +} + +function displayFunctionHelp( + cli: CLICommand | CLIAsyncCommand, + loggingFunction: typeof console.log, + level: number = 1, +) { + const command = cli.name; + const matched = cli.toString().match(/(?<=^\()[^\)]+(?=\))/); + const args = matched?.[0].split(",").map((arg) => `<${arg.trim()}>`); + loggingFunction( + `${" ".repeat(level * 2)}${command} ${args?.join(" ") ?? ""}`, + ); +} + +function runCommand( + commands: Array, + cli: CLI, +): never | { + command: CLICommand | CLIAsyncCommand; + args: Array; +} { + if (commands.length == 0) { + displayHelp(cli, `No command provided.`); + } + + const command = commands.shift()!.toString(); + + if (cli[command] == undefined) { + displayHelp(cli, `Command "${command}" doesn't exist.`); + } + + if (typeof cli[command] == "object") { + return runCommand(commands, cli[command]); + } + + if (cli[command].length != commands.length) { + displayHelp(cli[command], `Wrong usage of command "${command}".`); + } + + return { command: cli[command], args: commands }; +} + +function main() { + const argv = parseArgs(Deno.args, argSpec); + const { command, args } = runCommand(argv._, cli); + + command(...args.map((element) => element.toString())); +} + +main(); diff --git a/toolbox/module/create.ts b/toolbox/module/create.ts new file mode 100644 index 0000000..f289d98 --- /dev/null +++ b/toolbox/module/create.ts @@ -0,0 +1,140 @@ +export async function createModule(name: string): Promise { + if (!name.match(/^[a-zA-Z0-9](?:(?:\-(?!\-))?[a-zA-Z0-9]*)*[a-zA-Z0-9]$/)) { + console.error("Module names must be in kebab case."); + Deno.exit(1); + } + + console.log(`Checking for module ${name}...`); + + try { + await Deno.mkdir(`routes/(apps)/${name}`); + } catch (error) { + if (!(error instanceof Deno.errors.AlreadyExists)) { + throw error; + } + + console.error(`Some module is already named ${name}, aborting.`); + Deno.exit(1); + } + + const capitalizedName = name.match(/(^\w)|(\-\w)/g)!.reduce( + (word, pattern) => word.replace(pattern, pattern.at(-1)!.toUpperCase()), + name, + ); + + Promise.allSettled([ + createDir(`routes/(apps)/${name}/(_props)`), + createDir(`routes/(apps)/${name}/partials`), + createDir(`routes/(apps)/${name}/(_islands)`), + createDir(`routes/(apps)/${name}/(_components)`), + createDir(`routes/(apps)/${name}/api`), + ]); + + Promise.allSettled([ + createFile( + `routes/(apps)/${name}/index.tsx`, + getIndexContent(capitalizedName), + ), + createFile( + `routes/(apps)/${name}/(_props)/props.ts`, + getPropsContent(capitalizedName), + ), + createFile( + `routes/(apps)/${name}/partials/index.tsx`, + getPartialIndexContent(capitalizedName), + ), + createFile( + `routes/(apps)/${name}/api/example.ts`, + getApiExampleContent(capitalizedName), + ), + ]); + + const formatter = new Deno.Command(Deno.execPath(), { + args: [ + "fmt", + `routes/(apps)/${name}`, + ], + }); + formatter.output(); +} + +function createFile(path: string, content: string): Promise { + console.log(`Creating file ${path}...`); + return Deno.writeTextFile(path, content); +} + +function createDir(path: string): Promise { + console.log(`Creating directory ${path}...`); + return Deno.mkdir(path); +} + +function getIndexContent(_name: string) { + return ` + import makeIndex from "$root/defaults/makeIndex.ts"; + export default makeIndex(import.meta.dirname!); + `; +} + +function getPartialIndexContent(name: string) { + return ` + import { EmptyObject } from "$root/defaults/interfaces.ts"; + import { + getPartialsConfig, + makePartials, + } from "$root/defaults/makePartials.tsx"; + + type ${name}IndexProps = EmptyObject; + + export function Index(_props: ${name}IndexProps) { + return

Welcome to ${name}.

; + } + + export const config = getPartialsConfig(); + export default makePartials(Index); + `; +} + +function getPropsContent(name: string) { + return ` + import { AppProperties } from "$root/defaults/interfaces.ts"; + + const properties: AppProperties = { + name: "${name}", + icon: "school", + pages: { + index: "Homepage", + }, + adminOnly: [], + hint: "PolyMPR module", + }; + + export default properties; + `; +} + +function getApiExampleContent(_name: string) { + return ` + import { Handlers } from "$fresh/server.ts"; + + export const handler: Handlers = { + async POST(request, context) { + if (request.headers.get("content-type") != "application/json") { + return new Response(null, { + status: 400 + }); + } + + const responseBody = { + requestBody: await request.json(), + context, + }; + + return new Response(JSON.stringify(responseBody), { + headers: { + "content-type": "application/json", + }, + }); + }, + }; + `; +} diff --git a/toolbox/module/list.ts b/toolbox/module/list.ts new file mode 100644 index 0000000..a348047 --- /dev/null +++ b/toolbox/module/list.ts @@ -0,0 +1,7 @@ +export async function listModules(): Promise { + for await (const path of Deno.readDir("routes/(apps)")) { + if (path.isDirectory) { + console.log(path.name); + } + } +}