diff --git a/dev.ts b/dev.ts index 8216797a1..f0f52e19d 100644 --- a/dev.ts +++ b/dev.ts @@ -1,5 +1,5 @@ import dev from "./server/dev.ts"; if (import.meta.main) { - dev(Deno.args[0]); + dev(Deno.args); } diff --git a/examples/react-app/main.ts b/examples/react-app/main.ts index 81010ad7e..96199776a 100644 --- a/examples/react-app/main.ts +++ b/examples/react-app/main.ts @@ -1,3 +1,5 @@ +/** @format */ + import { bootstrap } from "aleph/react"; bootstrap(); diff --git a/examples/react-app/routes/todos.tsx b/examples/react-app/routes/todos.tsx index c43e2b5ed..fa224e4b3 100644 --- a/examples/react-app/routes/todos.tsx +++ b/examples/react-app/routes/todos.tsx @@ -1,119 +1,149 @@ +/** @format */ + import type { FormEvent } from "react"; import { Head, useData } from "aleph/react"; type Todo = { - id: number; - message: string; - completed: boolean; + id: number; + message: string; + completed: boolean; }; const store = { - todos: JSON.parse(window.localStorage?.getItem("todos") || "[]") as Todo[], - save() { - localStorage?.setItem("todos", JSON.stringify(this.todos)); - }, + todos: JSON.parse(window.localStorage?.getItem("todos") || "[]") as Todo[], + save() { + localStorage?.setItem("todos", JSON.stringify(this.todos)); + }, }; -export function data() { - return Response.json(store); -} +export const data = { + defer: false, + fetch() { + return Response.json(store); + }, +}; export async function mutation(req: Request): Promise { - const { id, message, completed } = await req.json(); - switch (req.method) { - case "PUT": { - store.todos.push({ id: Date.now(), message, completed: false }); - store.save(); - break; - } - case "PATCH": { - const todo = store.todos.find((todo) => todo.id === id); - if (todo) { - todo.completed = completed; - store.save(); - } - break; - } - case "DELETE": { - store.todos = store.todos.filter((todo) => todo.id !== id); - store.save(); - } - } - return Response.json(store); + const { id, message, completed } = await req.json(); + switch (req.method) { + case "PUT": { + store.todos.push({ id: Date.now(), message, completed: false }); + store.save(); + break; + } + case "PATCH": { + const todo = store.todos.find(todo => todo.id === id); + if (todo) { + todo.completed = completed; + store.save(); + } + break; + } + case "DELETE": { + store.todos = store.todos.filter(todo => todo.id !== id); + store.save(); + } + } + return Response.json(store); } export default function Todos() { - const { data: { todos }, isMutating, mutation } = useData<{ todos: Todo[] }>(); + const { + data: { todos }, + isMutating, + mutation, + } = useData<{ todos: Todo[] }>(); - const onSubmit = async (e: FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - const fd = new FormData(form); - const message = fd.get("message")?.toString().trim(); - if (message) { - await mutation.put({ message }, { - // optimistic update data without waiting for the server response - optimisticUpdate: (data) => { - return { - todos: [...data.todos, { id: 0, message, completed: false }], - }; - }, - // replace the data with the new data that is from the server response - replace: true, - }); - setTimeout(() => form.querySelector("input")?.focus(), 0); - form.reset(); - } - }; + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const fd = new FormData(form); + const message = fd.get("message")?.toString().trim(); + if (message) { + await mutation.put( + { message }, + { + // optimistic update data without waiting for the server response + optimisticUpdate: data => { + return { + todos: [ + ...data.todos, + { id: 0, message, completed: false }, + ], + }; + }, + // replace the data with the new data that is from the server response + replace: true, + } + ); + setTimeout(() => form.querySelector("input")?.focus(), 0); + form.reset(); + } + }; - return ( -
- - Todos - - -

- Todos - {todos.length > 0 && {todos.filter((todo) => todo.completed).length}/{todos.length}} -

-
    - {todos.map((todo) => ( -
  • - mutation.patch({ id: todo.id, completed: !todo.completed }, "replace")} - /> - - {todo.id > 0 && ( - - )} -
  • - ))} -
-
- -
-
- ); + return ( +
+ + Todos + + +

+ Todos + {todos.length > 0 && ( + + {todos.filter(todo => todo.completed).length}/ + {todos.length} + + )} +

+
    + {todos.map(todo => ( +
  • + + mutation.patch( + { id: todo.id, completed: !todo.completed }, + "replace" + ) + } + /> + + {todo.id > 0 && ( + + )} +
  • + ))} +
+
+ +
+
+ ); } diff --git a/examples/react-app/server.ts b/examples/react-app/server.ts index daf5e7313..815aa7b86 100644 --- a/examples/react-app/server.ts +++ b/examples/react-app/server.ts @@ -1,11 +1,13 @@ +/** @format */ + import { serve } from "aleph/server"; import react from "aleph/plugins/react"; import denoDeploy from "aleph/plugins/deploy"; import modules from "./routes/_export.ts"; serve({ - plugins: [ - denoDeploy({ moduleMain: import.meta.url, modules }), - react({ ssr: true }), - ], + plugins: [ + denoDeploy({ moduleMain: import.meta.url, modules }), + react({ ssr: true }), + ], }); diff --git a/framework/react/markup.ts b/framework/react/markup.ts new file mode 100644 index 000000000..4e91a3855 --- /dev/null +++ b/framework/react/markup.ts @@ -0,0 +1,23 @@ +/** @format */ + +const ESCAPE_LOOKUP: { [match: string]: string } = { + "&": "\\u0026", + ">": "\\u003e", + "<": "\\u003c", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +export function escapeHtml(html: string) { + return html.replace(ESCAPE_REGEX, match => ESCAPE_LOOKUP[match]); +} + +export interface SafeHtml { + __html: string; +} + +export function createHtml(html: string): SafeHtml { + return { __html: html }; +} diff --git a/init.ts b/init.ts index b7f552a24..79477d3d9 100644 --- a/init.ts +++ b/init.ts @@ -1,343 +1,379 @@ -import { Untar } from "https://deno.land/std@0.180.0/archive/untar.ts"; -import { parse } from "https://deno.land/std@0.180.0/flags/mod.ts"; -import { blue, bold, cyan, dim, green, red } from "https://deno.land/std@0.180.0/fmt/colors.ts"; -import { copy as copyDir } from "https://deno.land/std@0.180.0/fs/copy.ts"; -import { copy } from "https://deno.land/std@0.180.0/streams/copy.ts"; -import { readerFromStreamReader } from "https://deno.land/std@0.180.0/streams/reader_from_stream_reader.ts"; -import { ensureDir } from "https://deno.land/std@0.180.0/fs/ensure_dir.ts"; -import { basename, join } from "https://deno.land/std@0.180.0/path/mod.ts"; +/** @format */ -const templates = [ - "react", - "react-mdx", - "yew", - "leptos", - "api", -]; +import { Untar } from "https://deno.land/std@0.192.0/archive/untar.ts"; +import { parse } from "https://deno.land/std@0.192.0/flags/mod.ts"; +import { + blue, + bold, + cyan, + dim, + green, + red, +} from "https://deno.land/std@0.192.0/fmt/colors.ts"; +import { copy as copyDir } from "https://deno.land/std@0.192.0/fs/copy.ts"; +import { copy } from "https://deno.land/std@0.192.0/streams/copy.ts"; +import { readerFromStreamReader } from "https://deno.land/std@0.192.0/streams/reader_from_stream_reader.ts"; +import { ensureDir } from "https://deno.land/std@0.192.0/fs/ensure_dir.ts"; +import { basename, join } from "https://deno.land/std@0.192.0/path/mod.ts"; -const rsApps = [ - "yew", - "leptos", -]; +const templates = ["react", "react-mdx", "yew", "leptos", "api"]; -const unocssApps = [ - "react", - "yew", - "leptos", -]; +const rsApps = ["yew", "leptos"]; + +const unocssApps = ["react", "yew", "leptos"]; const versions = { - react: "18.2.0", + react: "18.2.0", }; type Options = { - template?: string; + template?: string; }; export default async function init(nameArg?: string, options?: Options) { - let { template } = options || {}; + let { template } = options || {}; - // get and check the project name - const name = nameArg ?? await ask("Project Name:"); - if (!name) { - console.error(`${red("!")} Please entry project name.`); - Deno.exit(1); - } + // get and check the project name + const name = nameArg ?? (await ask("Project Name:")); + if (!name) { + console.error(`${red("!")} Please entry project name.`); + Deno.exit(1); + } - if (template && !(templates.includes(template))) { - console.error( - `${red("!")} Invalid template name ${red(template)}, must be one of [${blue(templates.join(","))}]`, - ); - Deno.exit(1); - } + if (template && !templates.includes(template)) { + console.error( + `${red("!")} Invalid template name ${red( + template + )}, must be one of [${blue(templates.join(","))}]` + ); + Deno.exit(1); + } - // check the dir is clean - if ( - !(await isFolderEmpty(Deno.cwd(), name)) && - !(await confirm(`Folder ${blue(name)} already exists, continue?`)) - ) { - Deno.exit(1); - } + // check the dir is clean + if ( + !(await isFolderEmpty(Deno.cwd(), name)) && + !(await confirm(`Folder ${blue(name)} already exists, continue?`)) + ) { + Deno.exit(1); + } - if (!template) { - const answer = await ask([ - "Select a framework:", - ...templates.map((name, i) => ` ${bold((i + 1).toString())}. ${getTemplateDisplayName(name)}`), - dim(`[1-${templates.length}]`), - ].join("\n")); - const n = parseInt(answer); - if (!isNaN(n) && n > 0 && n <= templates.length) { - template = templates[n - 1]; - } else { - console.error(`${red("!")} Please entry ${cyan(`[1-${templates.length}]`)}.`); - Deno.exit(1); - } - } + if (!template) { + const answer = await ask( + [ + "Select a framework:", + ...templates.map( + (name, i) => + ` ${bold( + (i + 1).toString() + )}. ${getTemplateDisplayName(name)}` + ), + dim(`[1-${templates.length}]`), + ].join("\n") + ); + const n = parseInt(answer); + if (!isNaN(n) && n > 0 && n <= templates.length) { + template = templates[n - 1]; + } else { + console.error( + `${red("!")} Please entry ${cyan(`[1-${templates.length}]`)}.` + ); + Deno.exit(1); + } + } - const appDir = join(Deno.cwd(), name); - const withUnocss = unocssApps.includes(template!) && await confirm("Use Atomic CSS (powered by Unocss)?"); - const withVscode = await confirm("Initialize VS Code workspace configuration?"); - const deploy = !rsApps.includes(template) ? await confirm("Deploy to Deno Deploy?") : false; - const isRsApp = rsApps.includes(template); + const appDir = join(Deno.cwd(), name); + const withUnocss = + unocssApps.includes(template!) && + (await confirm("Use Atomic CSS (powered by Unocss)?")); + const withVscode = await confirm( + "Initialize VS Code workspace configuration?" + ); + const deploy = !rsApps.includes(template) + ? await confirm("Deploy to Deno Deploy?") + : false; + const isRsApp = rsApps.includes(template); - let alephPkgUri: string; - if (import.meta.url.startsWith("file://")) { - const src = `examples/${withUnocss ? "with-unocss/" : ""}${template}-app/`; - await copyDir(src, name); - alephPkgUri = ".."; - } else { - console.log(`${dim("↓")} Downloading template(${blue(template!)}), this might take a moment...`); - const res = await fetch("https://cdn.deno.land/aleph/meta/versions.json"); - if (res.status !== 200) { - console.error(await res.text()); - Deno.exit(1); - } - const { latest: VERSION } = await res.json(); - const repo = "alephjs/aleph.js"; - const resp = await fetch( - `https://codeload.github.com/${repo}/tar.gz/refs/tags/${VERSION}`, - ); - if (resp.status !== 200) { - console.error(await resp.text()); - Deno.exit(1); - } - // deno-lint-ignore ban-ts-comment - // @ts-ignore - const gz = new DecompressionStream("gzip"); - const entryList = new Untar( - readerFromStreamReader(resp.body!.pipeThrough(gz).getReader()), - ); - const prefix = `${basename(repo)}-${VERSION}/examples/${withUnocss ? "with-unocss/" : ""}${template}-app/`; - for await (const entry of entryList) { - if (entry.fileName.startsWith(prefix) && !entry.fileName.endsWith("/README.md")) { - const fp = join(appDir, trimPrefix(entry.fileName, prefix)); - if (entry.type === "directory") { - await ensureDir(fp); - continue; - } - const file = await Deno.open(fp, { write: true, create: true }); - await copy(entry, file); - } - } - alephPkgUri = `https://deno.land/x/aleph@${VERSION}`; - } + let alephPkgUri: string; + if (import.meta.url.startsWith("file://")) { + const src = `examples/${ + withUnocss ? "with-unocss/" : "" + }${template}-app/`; + await copyDir(src, name); + alephPkgUri = ".."; + } else { + console.log( + `${dim("↓")} Downloading template(${blue( + template! + )}), this might take a moment...` + ); + const res = await fetch( + "https://cdn.deno.land/aleph/meta/versions.json" + ); + if (res.status !== 200) { + console.error(await res.text()); + Deno.exit(1); + } + const { latest: VERSION } = await res.json(); + const repo = "alephjs/aleph.js"; + const resp = await fetch( + `https://codeload.github.com/${repo}/tar.gz/refs/tags/${VERSION}` + ); + if (resp.status !== 200) { + console.error(await resp.text()); + Deno.exit(1); + } + // deno-lint-ignore ban-ts-comment + // @ts-ignore + const gz = new DecompressionStream("gzip"); + const entryList = new Untar( + readerFromStreamReader( + resp.body!.pipeThrough(gz).getReader() + ) + ); + const prefix = `${basename(repo)}-${VERSION}/examples/${ + withUnocss ? "with-unocss/" : "" + }${template}-app/`; + for await (const entry of entryList) { + if ( + entry.fileName.startsWith(prefix) && + !entry.fileName.endsWith("/README.md") + ) { + const fp = join(appDir, trimPrefix(entry.fileName, prefix)); + if (entry.type === "directory") { + await ensureDir(fp); + continue; + } + const file = await Deno.open(fp, { write: true, create: true }); + await copy(entry, file); + } + } + alephPkgUri = `https://deno.land/x/aleph@${VERSION}`; + } - const serverCode = await Deno.readTextFile(join(appDir, "server.ts")); - if (!isRsApp && !deploy) { - await Deno.writeTextFile( - join(appDir, "server.ts"), - serverCode - .replace('import modules from "./routes/_export.ts";\n', "") - .replace('import denoDeploy from "aleph/plugins/deploy";\n', "") - .replace(" denoDeploy({ moduleMain: import.meta.url, modules }),\n", "") - .replace(" denoDeploy({ modules }),\n", ""), - ); - await Deno.remove(join(appDir, "routes/_export.ts")); - } else { - await Deno.writeTextFile( - join(appDir, "server.ts"), - serverCode.replace( - "denoDeploy({ moduleMain: import.meta.url, modules })", - "denoDeploy({ modules })", - ), - ); - } + const serverCode = await Deno.readTextFile(join(appDir, "server.ts")); + if (!isRsApp && !deploy) { + await Deno.writeTextFile( + join(appDir, "server.ts"), + serverCode + .replace('import modules from "./routes/_export.ts";\n', "") + .replace('import denoDeploy from "aleph/plugins/deploy";\n', "") + .replace( + " denoDeploy({ moduleMain: import.meta.url, modules }),\n", + "" + ) + .replace(" denoDeploy({ modules }),\n", "") + ); + await Deno.remove(join(appDir, "routes/_export.ts")); + } else { + await Deno.writeTextFile( + join(appDir, "server.ts"), + serverCode.replace( + "denoDeploy({ moduleMain: import.meta.url, modules })", + "denoDeploy({ modules })" + ) + ); + } - const res = await fetch("https://esm.sh/status.json"); - if (res.status !== 200) { - console.error(await res.text()); - Deno.exit(1); - } - const { version: ESM_VERSION } = await res.json(); - const denoConfig = { - "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "dom.extras", - "deno.ns", - ], - "types": [ - `${alephPkgUri}/types.d.ts`, - ], - }, - "importMap": "import_map.json", - "tasks": { - "dev": await existsFile(join(appDir, "dev.ts")) ? "deno run -A dev.ts" : `deno run -A ${alephPkgUri}/dev.ts`, - "start": "deno run -A server.ts", - "build": "deno run -A server.ts --build", - "esm:add": `deno run -A https://esm.sh/v${ESM_VERSION} add`, - "esm:update": `deno run -A https://esm.sh/v${ESM_VERSION} update`, - "esm:remove": `deno run -A https://esm.sh/v${ESM_VERSION} remove`, - }, - }; - const importMap = { - imports: { - "~/": "./", - "std/": "https://deno.land/std@0.180.0/", - "aleph/": `${alephPkgUri}/`, - "aleph/server": `${alephPkgUri}/server/mod.ts`, - "aleph/dev": `${alephPkgUri}/server/dev.ts`, - } as Record, - scopes: {}, - }; - if (deploy) { - Object.assign(importMap.imports, { - "aleph/plugins/deploy": `${alephPkgUri}/plugins/deploy.ts`, - }); - } - if (withUnocss) { - Object.assign(importMap.imports, { - "aleph/plugins/unocss": `${alephPkgUri}/plugins/unocss.ts`, - "@unocss/core": `https://esm.sh/v${ESM_VERSION}/@unocss/core@0.50.6`, - "@unocss/preset-uno": `https://esm.sh/v${ESM_VERSION}/@unocss/preset-uno@0.50.6`, - }); - } - switch (template) { - case "react-mdx": - Object.assign(importMap.imports, { - "aleph/plugins/mdx": `${alephPkgUri}/plugins/mdx.ts`, - "@mdx-js/react": `https://esm.sh/v${ESM_VERSION}/@mdx-js/react@2.3.0`, - }); - /* falls through */ - case "react": { - Object.assign(denoConfig.compilerOptions, { - "jsx": "react-jsx", - "jsxImportSource": `https://esm.sh/v${ESM_VERSION}/react@${versions.react}`, - }); - Object.assign(importMap.imports, { - "aleph/react": `${alephPkgUri}/framework/react/mod.ts`, - "aleph/plugins/react": `${alephPkgUri}/framework/react/plugin.ts`, - "react": `https://esm.sh/v${ESM_VERSION}/react@${versions.react}`, - "react-dom": `https://esm.sh/v${ESM_VERSION}/react-dom@${versions.react}`, - "react-dom/": `https://esm.sh/v${ESM_VERSION}/react-dom@${versions.react}/`, - }); - break; - } - } + const res = await fetch("https://esm.sh/status.json"); + if (res.status !== 200) { + console.error(await res.text()); + Deno.exit(1); + } + const { version: ESM_VERSION } = await res.json(); + const denoConfig = { + compilerOptions: { + lib: ["dom", "dom.iterable", "dom.extras", "deno.ns"], + types: [`${alephPkgUri}/types.d.ts`], + }, + importMap: "import_map.json", + tasks: { + dev: (await existsFile(join(appDir, "dev.ts"))) + ? "deno run -A dev.ts" + : `deno run -A ${alephPkgUri}/dev.ts`, + start: "deno run -A server.ts", + build: "deno run -A server.ts --build", + "esm:add": `deno run -A https://esm.sh/v${ESM_VERSION} add`, + "esm:update": `deno run -A https://esm.sh/v${ESM_VERSION} update`, + "esm:remove": `deno run -A https://esm.sh/v${ESM_VERSION} remove`, + }, + }; + const importMap = { + imports: { + "~/": "./", + "std/": "https://deno.land/std@0.180.0/", + "aleph/": `${alephPkgUri}/`, + "aleph/server": `${alephPkgUri}/server/mod.ts`, + "aleph/dev": `${alephPkgUri}/server/dev.ts`, + } as Record, + scopes: {}, + }; + if (deploy) { + Object.assign(importMap.imports, { + "aleph/plugins/deploy": `${alephPkgUri}/plugins/deploy.ts`, + }); + } + if (withUnocss) { + Object.assign(importMap.imports, { + "aleph/plugins/unocss": `${alephPkgUri}/plugins/unocss.ts`, + "@unocss/core": `https://esm.sh/v${ESM_VERSION}/@unocss/core@0.50.6`, + "@unocss/preset-uno": `https://esm.sh/v${ESM_VERSION}/@unocss/preset-uno@0.50.6`, + }); + } + switch (template) { + case "react-mdx": + Object.assign(importMap.imports, { + "aleph/plugins/mdx": `${alephPkgUri}/plugins/mdx.ts`, + "@mdx-js/react": `https://esm.sh/v${ESM_VERSION}/@mdx-js/react@2.3.0`, + }); + /* falls through */ + case "react": { + Object.assign(denoConfig.compilerOptions, { + jsx: "react-jsx", + jsxImportSource: `https://esm.sh/v${ESM_VERSION}/react@${versions.react}`, + }); + Object.assign(importMap.imports, { + "aleph/react": `${alephPkgUri}/framework/react/mod.ts`, + "aleph/plugins/react": `${alephPkgUri}/framework/react/plugin.ts`, + react: `https://esm.sh/v${ESM_VERSION}/react@${versions.react}`, + "react-dom": `https://esm.sh/v${ESM_VERSION}/react-dom@${versions.react}`, + "react-dom/": `https://esm.sh/v${ESM_VERSION}/react-dom@${versions.react}/`, + }); + break; + } + } - await ensureDir(appDir); - await Promise.all([ - Deno.writeTextFile( - join(appDir, "deno.json"), - JSON.stringify(denoConfig, undefined, 2), - ), - Deno.writeTextFile( - join(appDir, "import_map.json"), - JSON.stringify(importMap, undefined, 2), - ), - ]); + await ensureDir(appDir); + await Promise.all([ + Deno.writeTextFile( + join(appDir, "deno.json"), + JSON.stringify(denoConfig, undefined, 2) + ), + Deno.writeTextFile( + join(appDir, "import_map.json"), + JSON.stringify(importMap, undefined, 2) + ), + ]); - if (withVscode) { - const settings = { - "deno.enable": true, - "deno.lint": true, - "deno.config": "./deno.json", - }; - await ensureDir(join(appDir, ".vscode")); - await Deno.writeTextFile( - join(appDir, ".vscode", "settings.json"), - JSON.stringify(settings, undefined, 2), - ); - } + if (withVscode) { + const settings = { + "deno.enable": true, + "deno.lint": true, + "deno.config": "./deno.json", + }; + await ensureDir(join(appDir, ".vscode")); + await Deno.writeTextFile( + join(appDir, ".vscode", "settings.json"), + JSON.stringify(settings, undefined, 2) + ); + } - await Deno.run({ - cmd: [Deno.execPath(), "cache", "--no-lock", "server.ts"], - cwd: appDir, - stderr: "inherit", - stdout: "inherit", - }).status(); + await Deno.run({ + cmd: [Deno.execPath(), "cache", "--no-lock", "server.ts"], + cwd: appDir, + stderr: "inherit", + stdout: "inherit", + }).status(); - console.log([ - "", - green("▲ Aleph.js is ready to go!"), - "", - `${dim("$")} cd ${name}`, - `${dim("$")} deno task dev ${dim("# Start the server in `development` mode")}`, - `${dim("$")} deno task start ${dim("# Start the server in `production` mode")}`, - `${dim("$")} deno task build ${dim("# Build & Optimize the app (bundling, SSG, etc.)")}`, - "", - `Docs: ${cyan("https://alephjs.org/docs")}`, - `Bugs: ${cyan("https://github.com/alephjs/aleph.js/issues")}`, - "", - ].join("\n")); - Deno.exit(0); + console.log( + [ + "", + green("▲ Aleph.js is ready to go!"), + "", + `${dim("$")} cd ${name}`, + `${dim("$")} deno task dev ${dim( + "# Start the server in `development` mode" + )}`, + `${dim("$")} deno task start ${dim( + "# Start the server in `production` mode" + )}`, + `${dim("$")} deno task build ${dim( + "# Build & Optimize the app (bundling, SSG, etc.)" + )}`, + "", + `Docs: ${cyan("https://alephjs.org/docs")}`, + `Bugs: ${cyan("https://github.com/alephjs/aleph.js/issues")}`, + "", + ].join("\n") + ); + Deno.exit(0); } async function isFolderEmpty(root: string, name: string): Promise { - const dir = join(root, name); - if (await existsFile(dir)) { - throw new Error(`Folder ${name} already exists as a file.`); - } - if (await existsDir(dir)) { - for await (const file of Deno.readDir(dir)) { - if (file.name !== ".DS_Store") { - return false; - } - } - } - return true; + const dir = join(root, name); + if (await existsFile(dir)) { + throw new Error(`Folder ${name} already exists as a file.`); + } + if (await existsDir(dir)) { + for await (const file of Deno.readDir(dir)) { + if (file.name !== ".DS_Store") { + return false; + } + } + } + return true; } async function ask(question = ":") { - await Deno.stdout.write(new TextEncoder().encode(cyan("? ") + question + " ")); - const buf = new Uint8Array(1024); - const n = await Deno.stdin.read(buf); - const answer = new TextDecoder().decode(buf.subarray(0, n)); - return answer.trim(); + await Deno.stdout.write( + new TextEncoder().encode(cyan("? ") + question + " ") + ); + const buf = new Uint8Array(1024); + const n = await Deno.stdin.read(buf); + const answer = new TextDecoder().decode(buf.subarray(0, n)); + return answer.trim(); } async function confirm(question = "are you sure?") { - let a: string; - // deno-lint-ignore no-empty - while (!/^(y|n|)$/i.test(a = await ask(question + dim(" [y/N]")))) {} - return a.toLowerCase() === "y"; + let a: string; + // deno-lint-ignore no-empty + while (!/^(y|n|)$/i.test((a = await ask(question + dim(" [y/N]"))))) {} + return a.toLowerCase() === "y"; } function trimPrefix(s: string, prefix: string): string { - if (prefix !== "" && s.startsWith(prefix)) { - return s.slice(prefix.length); - } - return s; + if (prefix !== "" && s.startsWith(prefix)) { + return s.slice(prefix.length); + } + return s; } function getTemplateDisplayName(name: string) { - if (name === "api") { - return "REST API"; - } - if (name === "react-mdx") { - return "React with MDX"; - } - return name.at(0)?.toUpperCase() + name.slice(1); + if (name === "api") { + return "REST API"; + } + if (name === "react-mdx") { + return "React with MDX"; + } + return name.at(0)?.toUpperCase() + name.slice(1); } /** Check whether or not the given path exists as a directory. */ export async function existsDir(path: string): Promise { - try { - const stat = await Deno.lstat(path); - return stat.isDirectory; - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - throw err; - } + try { + const stat = await Deno.lstat(path); + return stat.isDirectory; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return false; + } + throw err; + } } /** Check whether or not the given path exists as regular file. */ export async function existsFile(path: string): Promise { - try { - const stat = await Deno.lstat(path); - return stat.isFile; - } catch (err) { - if (err instanceof Deno.errors.NotFound) { - return false; - } - throw err; - } + try { + const stat = await Deno.lstat(path); + return stat.isFile; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return false; + } + throw err; + } } if (import.meta.main) { - const { _: args, ...options } = parse(Deno.args); - await init(args[0] as string | undefined, options); + const { _: args, ...options } = parse(Deno.args); + await init(args[0] as string | undefined, options); } diff --git a/server/deps.ts b/server/deps.ts index f72a3dd06..630de9559 100644 --- a/server/deps.ts +++ b/server/deps.ts @@ -1,19 +1,28 @@ +/** @format */ + // deno std -export { concat as concatBytes } from "https://deno.land/std@0.180.0/bytes/mod.ts"; -export { encode as btoa } from "https://deno.land/std@0.180.0/encoding/base64.ts"; -export * as colors from "https://deno.land/std@0.180.0/fmt/colors.ts"; -export { ensureDir } from "https://deno.land/std@0.180.0/fs/ensure_dir.ts"; -export { serve, serveTls } from "https://deno.land/std@0.180.0/http/server.ts"; -export * as path from "https://deno.land/std@0.180.0/path/mod.ts"; -export * as jsonc from "https://deno.land/std@0.180.0/jsonc/mod.ts"; +export { concat as concatBytes } from "https://deno.land/std@0.192.0/bytes/mod.ts"; +export { encode as btoa } from "https://deno.land/std@0.192.0/encoding/base64.ts"; +export * as colors from "https://deno.land/std@0.192.0/fmt/colors.ts"; +export { ensureDir } from "https://deno.land/std@0.192.0/fs/ensure_dir.ts"; +export { serve, serveTls } from "https://deno.land/std@0.192.0/http/server.ts"; +export * as path from "https://deno.land/std@0.192.0/path/mod.ts"; +export * as jsonc from "https://deno.land/std@0.192.0/jsonc/mod.ts"; +export { parse as parseCliArgs } from "https://deno.land/std@0.192.0/flags/mod.ts"; // third-party // @deno-types="https://deno.land/x/esbuild@v0.17.12/mod.d.ts" export * as esbuild from "https://deno.land/x/esbuild@v0.17.12/mod.js"; export * from "https://deno.land/x/aleph_compiler@0.9.3/mod.ts"; export * from "https://deno.land/x/aleph_compiler@0.9.3/types.ts"; -export { default as initLolHtml, HTMLRewriter } from "https://deno.land/x/lol_html@0.0.6/mod.ts"; +export { + default as initLolHtml, + HTMLRewriter, +} from "https://deno.land/x/lol_html@0.0.6/mod.ts"; export { default as lolHtmlWasm } from "https://deno.land/x/lol_html@0.0.6/wasm.js"; // npm -export { default as mitt, type Emitter } from "https://esm.sh/mitt@3.0.0?pin=v110"; +export { + default as mitt, + type Emitter, +} from "https://esm.sh/mitt@3.0.0?pin=v110"; diff --git a/server/dev.ts b/server/dev.ts index 6df2741ec..269f1e837 100644 --- a/server/dev.ts +++ b/server/dev.ts @@ -1,191 +1,231 @@ +/** @format */ + import { isFilledString } from "../shared/util.ts"; -import { colors, Emitter, ensureDir, mitt, parseDeps, path } from "./deps.ts"; +import { + colors, + Emitter, + ensureDir, + mitt, + parseDeps, + path, + parseCliArgs, +} from "./deps.ts"; import depGraph from "./graph.ts"; -import { builtinModuleExts, findFile, getAlephConfig, getImportMap, watchFs } from "./helpers.ts"; +import { + builtinModuleExts, + findFile, + getAlephConfig, + getImportMap, + watchFs, +} from "./helpers.ts"; import log from "./log.ts"; import { initRouter, toRouterRegExp } from "./router.ts"; import type { AlephConfig } from "./types.ts"; type WatchFsEvents = { - [key in "create" | "remove" | "modify" | `modify:${string}` | `hotUpdate:${string}`]: { - specifier: string; - }; + [key in + | "create" + | "remove" + | "modify" + | `modify:${string}` + | `hotUpdate:${string}`]: { + specifier: string; + }; }; const watchFsEmitters = new Set>(); /** Create a `watchFs` emitter. */ export function createWatchFsEmitter() { - const e = mitt(); - watchFsEmitters.add(e); - return e; + const e = mitt(); + watchFsEmitters.add(e); + return e; } /** Remove the emitter. */ export function removeWatchFsEmitter(e: Emitter) { - e.all.clear(); - watchFsEmitters.delete(e); + e.all.clear(); + watchFsEmitters.delete(e); } /** Watch for file changes. */ export function watch(appDir: string, onRouterChange?: () => void) { - const config = getAlephConfig(); - const emitter = createWatchFsEmitter(); - - emitter.on("*", async (kind, { specifier }) => { - if (kind === "create" || kind === "remove") { - // reload router when fs changess - const reg = toRouterRegExp(config?.router); - if (reg.test(specifier)) { - const router = await initRouter(appDir, config?.router); - Reflect.set(globalThis, "__ALEPH_ROUTER", router); - onRouterChange?.(); - } - } - }); - - if (onRouterChange) { - initRouter(appDir, config?.router).then((router) => { - Reflect.set(globalThis, "__ALEPH_ROUTER", router); - onRouterChange(); - }); - } - - watchFs(appDir, (kind: "create" | "remove" | "modify", pathname: string) => { - const specifier = "./" + path.relative(appDir, pathname).replaceAll("\\", "/"); - // delete global cached index html - if (specifier === "./index.html") { - Reflect.deleteProperty(globalThis, "__ALEPH_INDEX_HTML"); - } - if (kind === "remove") { - depGraph.unmark(specifier); - } else { - depGraph.update(specifier); - } - if (kind === "modify") { - watchFsEmitters.forEach((e) => { - e.emit("modify", { specifier }); - e.emit(`modify:${specifier}`, { specifier }); - if (e.all.has(`hotUpdate:${specifier}`)) { - e.emit(`hotUpdate:${specifier}`, { specifier }); - } else if (specifier !== "./routes/_export.ts") { - depGraph.lookup(specifier, (specifier) => { - if (e.all.has(`hotUpdate:${specifier}`)) { - e.emit(`hotUpdate:${specifier}`, { specifier }); - return false; - } - }); - } - }); - } else { - watchFsEmitters.forEach((e) => e.emit(kind, { specifier })); - } - }); + const config = getAlephConfig(); + const emitter = createWatchFsEmitter(); + + emitter.on("*", async (kind, { specifier }) => { + if (kind === "create" || kind === "remove") { + // reload router when fs changess + const reg = toRouterRegExp(config?.router); + if (reg.test(specifier)) { + const router = await initRouter(appDir, config?.router); + Reflect.set(globalThis, "__ALEPH_ROUTER", router); + onRouterChange?.(); + } + } + }); + + if (onRouterChange) { + initRouter(appDir, config?.router).then(router => { + Reflect.set(globalThis, "__ALEPH_ROUTER", router); + onRouterChange(); + }); + } + + watchFs( + appDir, + (kind: "create" | "remove" | "modify", pathname: string) => { + const specifier = + "./" + path.relative(appDir, pathname).replaceAll("\\", "/"); + // delete global cached index html + if (specifier === "./index.html") { + Reflect.deleteProperty(globalThis, "__ALEPH_INDEX_HTML"); + } + if (kind === "remove") { + depGraph.unmark(specifier); + } else { + depGraph.update(specifier); + } + if (kind === "modify") { + watchFsEmitters.forEach(e => { + e.emit("modify", { specifier }); + e.emit(`modify:${specifier}`, { specifier }); + if (e.all.has(`hotUpdate:${specifier}`)) { + e.emit(`hotUpdate:${specifier}`, { specifier }); + } else if (specifier !== "./routes/_export.ts") { + depGraph.lookup(specifier, specifier => { + if (e.all.has(`hotUpdate:${specifier}`)) { + e.emit(`hotUpdate:${specifier}`, { specifier }); + return false; + } + }); + } + }); + } else { + watchFsEmitters.forEach(e => e.emit(kind, { specifier })); + } + } + ); } let devProcess: Deno.Process | null = null; let watched = false; -export default async function dev(serverEntry?: string) { - serverEntry = serverEntry - ? serverEntry.startsWith("file://") ? path.fromFileUrl(serverEntry) : path.resolve(serverEntry) - : await findFile(builtinModuleExts.map((ext) => `server.${ext}`)); - if (!serverEntry) { - log.fatal("[dev] No server entry found."); - return; - } - - const appDir = path.dirname(serverEntry); - if (!watched) { - log.info(colors.dim("[dev]"), "Watching for file changes..."); - watch(appDir); - watched = true; - } - - const entry = `./${path.basename(serverEntry)}`; - const code = await Deno.readTextFile(serverEntry); - const importMap = await getImportMap(); - const deps = await parseDeps(entry, code, { - importMap: JSON.stringify(importMap), - }); - const exportTs = deps.find((dep) => dep.specifier.startsWith("./") && dep.specifier.endsWith("/_export.ts")); - - // reset the `_export.ts` module - if (exportTs) { - const fp = path.join(appDir, exportTs.specifier); - await ensureDir(path.dirname(fp)); - await Deno.writeTextFile(fp, "export default {}"); - } - - // watch server entry and its deps to restart the dev server - const emitter = createWatchFsEmitter(); - emitter.on("*", (kind, { specifier }) => { - if ( - kind === "modify" && !specifier.endsWith("/_export.ts") && ( - specifier === entry || - deps.some((dep) => dep.specifier === specifier) - ) - ) { - console.clear(); - console.info(colors.dim("[dev] Restarting the server...")); - devProcess?.kill("SIGTERM"); - dev(serverEntry); - } - }); - - const cmd = [Deno.execPath(), "run", "-A", "--no-lock", serverEntry, "--dev"]; - devProcess = Deno.run({ cmd, stderr: "inherit", stdout: "inherit" }); - await devProcess.status(); - removeWatchFsEmitter(emitter); +export default async function dev(args?: string[]) { + const flags = parseCliArgs(args || [], { + boolean: ["unstable", "A"], + }); + const serverEntry = + typeof flags._[0] === "string" + ? flags._[0].startsWith("file://") + ? path.fromFileUrl(flags._[0]) + : path.resolve(flags._[0]) + : await findFile(builtinModuleExts.map(ext => `server.${ext}`)); + + if (!serverEntry) { + log.fatal("[dev] No server entry found."); + return; + } + + const appDir = path.dirname(serverEntry); + if (!watched) { + log.info(colors.dim("[dev]"), "Watching for file changes..."); + watch(appDir); + watched = true; + } + + const entry = `./${path.basename(serverEntry)}`; + const code = await Deno.readTextFile(serverEntry); + const importMap = await getImportMap(); + const deps = await parseDeps(entry, code, { + importMap: JSON.stringify(importMap), + }); + const exportTs = deps.find( + dep => + dep.specifier.startsWith("./") && + dep.specifier.endsWith("/_export.ts") + ); + + // reset the `_export.ts` module + if (exportTs) { + const fp = path.join(appDir, exportTs.specifier); + await ensureDir(path.dirname(fp)); + await Deno.writeTextFile(fp, "export default {}"); + } + + // watch server entry and its deps to restart the dev server + const emitter = createWatchFsEmitter(); + emitter.on("*", (kind, { specifier }) => { + if ( + kind === "modify" && + !specifier.endsWith("/_export.ts") && + (specifier === entry || + deps.some(dep => dep.specifier === specifier)) + ) { + console.clear(); + console.info(colors.dim("[dev] Restarting the server...")); + devProcess?.kill("SIGTERM"); + dev(args); + } + }); + + const cmd = [Deno.execPath(), "run", "-A", "--no-lock"]; + + flags.unstable && cmd.push("--unstable"); + cmd.push(serverEntry), cmd.push("--dev"); + console.log(cmd); + devProcess = Deno.run({ cmd, stderr: "inherit", stdout: "inherit" }); + await devProcess.status(); + removeWatchFsEmitter(emitter); } export function handleHMR(req: Request): Response { - const { socket, response } = Deno.upgradeWebSocket(req); - const emitter = createWatchFsEmitter(); - const send = (message: Record) => { - try { - socket.send(JSON.stringify(message)); - } catch (err) { - log.warn("socket.send:", err.message); - } - }; - socket.addEventListener("close", () => { - removeWatchFsEmitter(emitter); - }); - socket.addEventListener("open", () => { - emitter.on("create", ({ specifier }) => { - const config: AlephConfig | undefined = Reflect.get( - globalThis, - "__ALEPH_CONFIG", - ); - if (config?.router) { - const reg = toRouterRegExp(config.router); - const routePattern = reg.exec(specifier); - if (routePattern) { - send({ type: "create", specifier, routePattern }); - return; - } - } - send({ type: "create", specifier }); - }); - emitter.on("remove", ({ specifier }) => { - emitter.off(`hotUpdate:${specifier}`); - send({ type: "remove", specifier }); - }); - }); - socket.addEventListener("message", (e) => { - if (isFilledString(e.data)) { - try { - const { type, specifier } = JSON.parse(e.data); - if (type === "hotAccept" && isFilledString(specifier)) { - emitter.on(`hotUpdate:${specifier}`, () => { - send({ type: "modify", specifier }); - }); - } - } catch (_e) { - log.error("invlid socket message:", e.data); - } - } - }); - return response; + const { socket, response } = Deno.upgradeWebSocket(req); + const emitter = createWatchFsEmitter(); + const send = (message: Record) => { + try { + socket.send(JSON.stringify(message)); + } catch (err) { + log.warn("socket.send:", err.message); + } + }; + socket.addEventListener("close", () => { + removeWatchFsEmitter(emitter); + }); + socket.addEventListener("open", () => { + emitter.on("create", ({ specifier }) => { + const config: AlephConfig | undefined = Reflect.get( + globalThis, + "__ALEPH_CONFIG" + ); + if (config?.router) { + const reg = toRouterRegExp(config.router); + const routePattern = reg.exec(specifier); + if (routePattern) { + send({ type: "create", specifier, routePattern }); + return; + } + } + send({ type: "create", specifier }); + }); + emitter.on("remove", ({ specifier }) => { + emitter.off(`hotUpdate:${specifier}`); + send({ type: "remove", specifier }); + }); + }); + socket.addEventListener("message", e => { + if (isFilledString(e.data)) { + try { + const { type, specifier } = JSON.parse(e.data); + if (type === "hotAccept" && isFilledString(specifier)) { + emitter.on(`hotUpdate:${specifier}`, () => { + send({ type: "modify", specifier }); + }); + } + } catch (_e) { + log.error("invlid socket message:", e.data); + } + } + }); + return response; }