diff --git a/.gitignore b/.gitignore index 409baf6..4b17451 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ collected_docs.json db.sqlite -.env \ No newline at end of file +.env diff --git a/deno.lock b/deno.lock index 950f916..7256b57 100644 --- a/deno.lock +++ b/deno.lock @@ -295,6 +295,7 @@ "https://deno.land/x/importmap@0.2.1/mod.ts": "ae3d1cd7eabd18c01a4960d57db471126b020f23b37ef14e1359bbb949227ade", "https://deno.land/x/marked@1.0.1/mod.ts": "25a04e7c3512622293d84b7287711b990562ce41e44f7fb55af9ca1586e57b15", "https://deno.land/x/rutt@0.0.13/mod.ts": "af981cfb95131152bf50fc9140fc00cb3efb6563df2eded1d408361d8577df20", + "https://deno.land/x/rutt@0.0.14/mod.ts": "5027b8e8b12acca48b396a25aee74ad7ee94a25c24cda75571d7839cbd41113c", "https://deno.land/x/sqlite@v3.7.0/build/sqlite.d.ts": "12908ced1670f96d5dc39aebb0d659630136fa6523881e4712cfb20b122dd324", "https://deno.land/x/sqlite@v3.7.0/build/sqlite.js": "cc55fef9cd124b2acb624899a5fad413834f4701bcfc21ac275844b822466292", "https://deno.land/x/sqlite@v3.7.0/build/vfs.js": "08533cc78fb29b9d9bd62f6bb93e5ef333407013fed185776808f11223ba0e70", diff --git a/dev.ts b/dev.ts index 2d85d6c..13f60b9 100644 --- a/dev.ts +++ b/dev.ts @@ -1,5 +1,18 @@ #!/usr/bin/env -S deno run -A --watch=static/,routes/ -import dev from "$fresh/dev.ts"; +import { connectDB } from "./src/user/db.ts"; +import * as users from "./src/user/user.ts"; +import dev from "$fresh/dev.ts"; +await devUserAdd(); await dev(import.meta.url, "./main.ts"); + +async function devUserAdd() { + if(Deno.env.get("DB_PATH") === ":memory:") { + const db = await connectDB(); + const username = "admin"; + const password = "admin"; + const new_user = await users.createUser(username, password); + await users.addUser(db, new_user); + } +} \ No newline at end of file diff --git a/fresh-main/.gitignore b/fresh-main/.gitignore new file mode 100644 index 0000000..941fcf1 --- /dev/null +++ b/fresh-main/.gitignore @@ -0,0 +1 @@ +deno.lock \ No newline at end of file diff --git a/fresh-main/.vscode/extensions.json b/fresh-main/.vscode/extensions.json new file mode 100644 index 0000000..971c0ed --- /dev/null +++ b/fresh-main/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "denoland.vscode-deno", + "sastan.twind-intellisense" + ] +} diff --git a/fresh-main/.vscode/import_map.json b/fresh-main/.vscode/import_map.json new file mode 100644 index 0000000..e917bb7 --- /dev/null +++ b/fresh-main/.vscode/import_map.json @@ -0,0 +1,19 @@ +{ + "scopes": { + "THIS FILE EXISTS ONLY FOR VSCODE! IT IS NOT USED AT RUNTIME": {} + }, + "imports": { + "$fresh/": "../", + + "twind": "https://esm.sh/twind@0.16.17", + "twind/": "https://esm.sh/twind@0.16.17/", + + "preact": "https://esm.sh/preact@10.11.0", + "preact/": "https://esm.sh/preact@10.11.0/", + "preact-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4", + "@preact/signals": "https://esm.sh/*@preact/signals@1.0.3", + "@preact/signals-core": "https://esm.sh/@preact/signals-core@1.0.1", + + "$std/": "https://deno.land/std@0.150.0/" + } +} diff --git a/fresh-main/.vscode/settings.json b/fresh-main/.vscode/settings.json new file mode 100644 index 0000000..74b914b --- /dev/null +++ b/fresh-main/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "deno.enable": true, + "deno.lint": true, + "deno.importMap": "./.vscode/import_map.json", + "deno.codeLens.test": true, + "editor.defaultFormatter": "denoland.vscode-deno" +} diff --git a/fresh-main/CODE_OF_CONDUCT.md b/fresh-main/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..e47c9cd --- /dev/null +++ b/fresh-main/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at hello@lcas.dev. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/fresh-main/LICENSE b/fresh-main/LICENSE new file mode 100644 index 0000000..5e0fffb --- /dev/null +++ b/fresh-main/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Luca Casonato + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/fresh-main/README.md b/fresh-main/README.md new file mode 100644 index 0000000..b36baf6 --- /dev/null +++ b/fresh-main/README.md @@ -0,0 +1,106 @@ +[Documentation](#-documentation) | [Getting started](#-getting-started) + +# fresh + +the fresh logo: a sliced lemon dripping with juice + +**Fresh** is a next generation web framework, built for speed, reliability, and +simplicity. + +Some stand-out features: + +- Just-in-time rendering on the edge. +- Island based client hydration for maximum interactivity. +- Zero runtime overhead: no JS is shipped to the client by default. +- No build step. +- No configuration necessary. +- TypeScript support out of the box. +- File-system routing Γ  la Next.js. + +## πŸ“– Documentation + +The [documentation](https://fresh.deno.dev/docs/) is available on +[fresh.deno.dev](https://fresh.deno.dev/). + +## πŸš€ Getting started + +Install [Deno CLI](https://deno.land/) version 1.25.0 or higher. + +You can scaffold a new project by running the Fresh init script. To scaffold a +project in the `deno-fresh-demo` folder, run the following: + +```sh +deno run -A -r https://fresh.deno.dev deno-fresh-demo +``` + +Then navigate to the newly created project folder: + +``` +cd deno-fresh-demo +``` + +From within your project folder, start the development server using the +`deno task` command: + +``` +deno task start +``` + +Now open http://localhost:8000 in your browser to view the page. You make +changes to the project source code and see them reflected in your browser. + +To deploy the project to the live internet, you can use +[Deno Deploy](https://deno.com/deploy): + +1. Push your project to GitHub. +2. [Create a Deno Deploy project](https://dash.deno.com/new). +3. [Link](https://deno.com/deploy/docs/projects#enabling) the Deno Deploy + project to the **`main.ts`** file in the root of the created repository. +4. The project will be deployed to a public $project.deno.dev subdomain. + +For a more in-depth getting started guide, visit the +[Getting Started](https://fresh.deno.dev/docs/getting-started) page in the Fresh +docs. + +## Adding your project to the showcase + +If you feel that your project would be helpful to other fresh users, please +consider putting your project on the +[showcase](https://fresh.deno.dev/showcase). However, websites that are just for +promotional purposes may not be listed. + +To take a screenshot, run the following command. + +```sh +deno task screenshot [url] [your-app-name] +``` + +Then add your site to +[showcase.json](https://github.com/denoland/fresh/blob/main/www/data/showcase.json), +preferably with source code on GitHub, but not required. + +## Badges + +![Made with Fresh](./www/static/fresh-badge.svg) + +```md +[![Made with Fresh](https://fresh.deno.dev/fresh-badge.svg)](https://fresh.deno.dev) +``` + +```html + + Made with Fresh + +``` + +![Made with Fresh(dark)](./www/static/fresh-badge-dark.svg) + +```md +[![Made with Fresh](https://fresh.deno.dev/fresh-badge-dark.svg)](https://fresh.deno.dev) +``` + +```html + + Made with Fresh + +``` diff --git a/fresh-main/deno.json b/fresh-main/deno.json new file mode 100644 index 0000000..9adebb4 --- /dev/null +++ b/fresh-main/deno.json @@ -0,0 +1,18 @@ +{ + "tasks": { + "test": "deno test -A && deno check --config=www/deno.json www/main.ts www/dev.ts && deno check init.ts", + "fixture": "deno run -A --watch=static/,routes/ tests/fixture/dev.ts", + "www": "deno run -A --watch=www/static/,www/routes/,docs/ www/dev.ts", + "screenshot": "deno run -A www/utils/screenshot.ts" + }, + "importMap": "./.vscode/import_map.json", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "preact" + }, + "test": { + "files": { + "exclude": ["www/"] + } + } +} diff --git a/fresh-main/dev.ts b/fresh-main/dev.ts new file mode 100644 index 0000000..39539a2 --- /dev/null +++ b/fresh-main/dev.ts @@ -0,0 +1,2 @@ +import { dev } from "./src/dev/mod.ts"; +export default dev; diff --git a/fresh-main/init.ts b/fresh-main/init.ts new file mode 100644 index 0000000..78e1475 --- /dev/null +++ b/fresh-main/init.ts @@ -0,0 +1,377 @@ +import { join, parse, resolve } from "./src/dev/deps.ts"; +import { error } from "./src/dev/error.ts"; +import { collect, ensureMinDenoVersion, generate } from "./src/dev/mod.ts"; +import { freshImports, twindImports } from "./src/dev/imports.ts"; + +ensureMinDenoVersion(); + +const help = `fresh-init + +Initialize a new Fresh project. This will create all the necessary files for a +new project. + +To generate a project in the './foobar' subdirectory: + fresh-init ./foobar + +To generate a project in the current directory: + fresh-init . + +USAGE: + fresh-init + +OPTIONS: + --force Overwrite existing files + --twind Setup project to use 'twind' for styling + --vscode Setup project for VSCode +`; + +const CONFIRM_EMPTY_MESSAGE = + "The target directory is not empty (files could get overwritten). Do you want to continue anyway?"; + +const USE_TWIND_MESSAGE = + "Fresh has built in support for styling using Tailwind CSS. Do you want to use this?"; + +const USE_VSCODE_MESSAGE = "Do you use VS Code?"; + +const flags = parse(Deno.args, { + boolean: ["force", "twind", "vscode"], + default: { "force": null, "twind": null, "vscode": null }, +}); + +if (flags._.length !== 1) { + error(help); +} + +console.log( + `\n%c πŸ‹ Fresh: the next-gen web framework. %c\n`, + "background-color: #86efac; color: black; font-weight: bold", + "", +); + +const unresolvedDirectory = Deno.args[0]; +const resolvedDirectory = resolve(unresolvedDirectory); + +try { + const dir = [...Deno.readDirSync(resolvedDirectory)]; + const isEmpty = dir.length === 0 || + dir.length === 1 && dir[0].name === ".git"; + if ( + !isEmpty && + !(flags.force === null ? confirm(CONFIRM_EMPTY_MESSAGE) : flags.force) + ) { + error("Directory is not empty."); + } +} catch (err) { + if (!(err instanceof Deno.errors.NotFound)) { + throw err; + } +} +console.log("%cLet's set up your new Fresh project.\n", "font-weight: bold"); + +const useTwind = flags.twind === null + ? confirm(USE_TWIND_MESSAGE) + : flags.twind; + +const useVSCode = flags.vscode === null + ? confirm(USE_VSCODE_MESSAGE) + : flags.vscode; + +await Deno.mkdir(join(resolvedDirectory, "routes", "api"), { recursive: true }); +await Deno.mkdir(join(resolvedDirectory, "islands"), { recursive: true }); +await Deno.mkdir(join(resolvedDirectory, "static"), { recursive: true }); +await Deno.mkdir(join(resolvedDirectory, "components"), { recursive: true }); +if (useVSCode) { + await Deno.mkdir(join(resolvedDirectory, ".vscode"), { recursive: true }); +} + +const importMap = { imports: {} as Record }; +freshImports(importMap.imports); +if (useTwind) twindImports(importMap.imports); +const IMPORT_MAP_JSON = JSON.stringify(importMap, null, 2) + "\n"; +await Deno.writeTextFile( + join(resolvedDirectory, "import_map.json"), + IMPORT_MAP_JSON, +); + +const ROUTES_INDEX_TSX = `import { Head } from "$fresh/runtime.ts"; +import Counter from "../islands/Counter.tsx"; + +export default function Home() { + return ( + <> + + Fresh App + + + the fresh logo: a sliced lemon dripping with juice + + Welcome to \`fresh\`. Try updating this message in the ./routes/index.tsx + file, and refresh. +

+ + + + ); +} +`; +await Deno.writeTextFile( + join(resolvedDirectory, "routes", "index.tsx"), + ROUTES_INDEX_TSX, +); + +const COMPONENTS_BUTTON_TSX = `import { JSX } from "preact"; +import { IS_BROWSER } from "$fresh/runtime.ts"; + +export function Button(props: JSX.HTMLAttributes) { + return ( + + + + ); +} +`; +await Deno.writeTextFile( + join(resolvedDirectory, "islands", "Counter.tsx"), + ISLANDS_COUNTER_TSX, +); + +const ROUTES_GREET_TSX = `import { PageProps } from "$fresh/server.ts"; + +export default function Greet(props: PageProps) { + return
Hello {props.params.name}
; +} +`; +await Deno.writeTextFile( + join(resolvedDirectory, "routes", "[name].tsx"), + ROUTES_GREET_TSX, +); + +const ROUTES_API_JOKE_TS = `import { HandlerContext } from "$fresh/server.ts"; + +// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/ +const JOKES = [ + "Why do Java developers often wear glasses? They can't C#.", + "A SQL query walks into a bar, goes up to two tables and says β€œcan I join you?”", + "Wasn't hard to crack Forrest Gump's password. 1forrest1.", + "I love pressing the F5 key. It's refreshing.", + "Called IT support and a chap from Australia came to fix my network connection. I asked β€œDo you come from a LAN down under?”", + "There are 10 types of people in the world. Those who understand binary and those who don't.", + "Why are assembly programmers often wet? They work below C level.", + "My favourite computer based band is the Black IPs.", + "What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.", + "An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.", +]; + +export const handler = (_req: Request, _ctx: HandlerContext): Response => { + const randomIndex = Math.floor(Math.random() * JOKES.length); + const body = JOKES[randomIndex]; + return new Response(body); +}; +`; +await Deno.writeTextFile( + join(resolvedDirectory, "routes", "api", "joke.ts"), + ROUTES_API_JOKE_TS, +); + +const TWIND_CONFIG_TS = `import { Options } from "$fresh/plugins/twind.ts"; + +export default { + selfURL: import.meta.url, +} as Options; +`; +if (useTwind) { + await Deno.writeTextFile( + join(resolvedDirectory, "twind.config.ts"), + TWIND_CONFIG_TS, + ); +} + +const STATIC_LOGO = + ` + + + + +`; + +await Deno.writeTextFile( + join(resolvedDirectory, "static", "logo.svg"), + STATIC_LOGO, +); + +try { + const faviconArrayBuffer = await fetch("https://fresh.deno.dev/favicon.ico") + .then((d) => d.arrayBuffer()); + await Deno.writeFile( + join(resolvedDirectory, "static", "favicon.ico"), + new Uint8Array(faviconArrayBuffer), + ); +} catch { + // Skip this and be silent if there is a nework issue. +} + +let MAIN_TS = `/// +/// +/// +/// +/// + +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; +`; + +if (useTwind) { + MAIN_TS += ` +import twindPlugin from "$fresh/plugins/twind.ts"; +import twindConfig from "./twind.config.ts"; +`; +} + +MAIN_TS += ` +await start(manifest${ + useTwind ? ", { plugins: [twindPlugin(twindConfig)] }" : "" +});\n`; +const MAIN_TS_PATH = join(resolvedDirectory, "main.ts"); +await Deno.writeTextFile(MAIN_TS_PATH, MAIN_TS); + +const DEV_TS = `#!/usr/bin/env -S deno run -A --watch=static/,routes/ + +import dev from "$fresh/dev.ts"; + +await dev(import.meta.url, "./main.ts"); +`; +const DEV_TS_PATH = join(resolvedDirectory, "dev.ts"); +await Deno.writeTextFile(DEV_TS_PATH, DEV_TS); +try { + await Deno.chmod(DEV_TS_PATH, 0o777); +} catch { + // this throws on windows +} + +const config = { + tasks: { + start: "deno run -A --watch=static/,routes/ dev.ts", + }, + importMap: "./import_map.json", + compilerOptions: { + jsx: "react-jsx", + jsxImportSource: "preact", + }, +}; +const DENO_CONFIG = JSON.stringify(config, null, 2) + "\n"; + +await Deno.writeTextFile(join(resolvedDirectory, "deno.json"), DENO_CONFIG); + +const README_MD = `# fresh project + +### Usage + +Start the project: + +\`\`\` +deno task start +\`\`\` + +This will watch the project directory and restart as necessary. +`; +await Deno.writeTextFile( + join(resolvedDirectory, "README.md"), + README_MD, +); + +const vscodeSettings = { + "deno.enable": true, + "deno.lint": true, + "editor.defaultFormatter": "denoland.vscode-deno", +}; + +const VSCODE_SETTINGS = JSON.stringify(vscodeSettings, null, 2) + "\n"; + +if (useVSCode) { + await Deno.writeTextFile( + join(resolvedDirectory, ".vscode", "settings.json"), + VSCODE_SETTINGS, + ); +} + +const vscodeExtensions = { + recommendations: ["denoland.vscode-deno"], +}; + +if (useTwind) { + vscodeExtensions.recommendations.push("sastan.twind-intellisense"); +} + +const VSCODE_EXTENSIONS = JSON.stringify(vscodeExtensions, null, 2) + "\n"; + +if (useVSCode) { + await Deno.writeTextFile( + join(resolvedDirectory, ".vscode", "extensions.json"), + VSCODE_EXTENSIONS, + ); +} + +const manifest = await collect(resolvedDirectory); +await generate(resolvedDirectory, manifest); + +// Specifically print unresolvedDirectory, rather than resolvedDirectory in order to +// not leak personal info (e.g. `/Users/MyName`) +console.log("\n%cProject initialized!\n", "color: green; font-weight: bold"); + +console.log( + `Enter your project directory using %ccd ${unresolvedDirectory}%c.`, + "color: cyan", + "", +); +console.log( + "Run %cdeno task start%c to start the project. %cCTRL-C%c to stop.", + "color: cyan", + "", + "color: cyan", + "", +); +console.log(); +console.log( + "Stuck? Join our Discord %chttps://discord.gg/deno", + "color: cyan", + "", +); +console.log(); +console.log( + "%cHappy hacking! πŸ¦•", + "color: gray", +); diff --git a/fresh-main/plugins/twind.ts b/fresh-main/plugins/twind.ts new file mode 100644 index 0000000..da11ee8 --- /dev/null +++ b/fresh-main/plugins/twind.ts @@ -0,0 +1,50 @@ +import { virtualSheet } from "twind/sheets"; +import { Plugin } from "../server.ts"; + +import { Options, setup, STYLE_ELEMENT_ID } from "./twind/shared.ts"; +export type { Options }; + +export default function twind(options: Options): Plugin { + const sheet = virtualSheet(); + setup(options, sheet); + const main = `data:application/javascript,import hydrate from "${ + new URL("./twind/main.ts", import.meta.url).href + }"; +import options from "${options.selfURL}"; +export default function(state) { hydrate(options, state); }`; + return { + name: "twind", + entrypoints: { "main": main }, + render(ctx) { + sheet.reset(undefined); + const res = ctx.render(); + const cssTexts = [...sheet.target]; + const snapshot = sheet.reset(); + const scripts = []; + let cssText: string; + if (res.requiresHydration) { + const precedences = snapshot[1] as number[]; + cssText = cssTexts.map((cssText, i) => + `${cssText}/*${precedences[i].toString(36)}*/` + ).join("\n"); + const mappings: (string | [string, string])[] = []; + for ( + const [key, value] of (snapshot[3] as Map).entries() + ) { + if (key === value) { + mappings.push(key); + } else { + mappings.push([key, value]); + } + } + scripts.push({ entrypoint: "main", state: mappings }); + } else { + cssText = cssTexts.join("\n"); + } + return { + scripts, + styles: [{ cssText, id: STYLE_ELEMENT_ID }], + }; + }, + }; +} diff --git a/fresh-main/plugins/twind/main.ts b/fresh-main/plugins/twind/main.ts new file mode 100644 index 0000000..8943564 --- /dev/null +++ b/fresh-main/plugins/twind/main.ts @@ -0,0 +1,30 @@ +import { Sheet } from "twind"; +import { Options, setup, STYLE_ELEMENT_ID } from "./shared.ts"; + +type State = [string, string][]; + +export default function hydrate(options: Options, state: State) { + const el = document.getElementById(STYLE_ELEMENT_ID) as HTMLStyleElement; + const rules = new Set(); + const precedences: number[] = []; + const mappings = new Map( + state.map((v) => typeof v === "string" ? [v, v] : v), + ); + // deno-lint-ignore no-explicit-any + const sheetState: any[] = [precedences, rules, mappings, true]; + const target = el.sheet!; + const ruleText = Array.from(target.cssRules).map((r) => r.cssText); + for (const r of ruleText) { + const m = r.lastIndexOf("/*"); + const precedence = parseInt(r.slice(m + 2, -2), 36); + const rule = r.slice(0, m); + rules.add(rule); + precedences.push(precedence); + } + const sheet: Sheet = { + target, + insert: (rule, index) => target.insertRule(rule, index), + init: (cb) => cb(sheetState.shift()), + }; + setup(options, sheet); +} diff --git a/fresh-main/plugins/twind/shared.ts b/fresh-main/plugins/twind/shared.ts new file mode 100644 index 0000000..1285dfd --- /dev/null +++ b/fresh-main/plugins/twind/shared.ts @@ -0,0 +1,48 @@ +import { JSX, options as preactOptions, VNode } from "preact"; +import { Configuration, setup as twSetup, Sheet, tw } from "twind"; + +export const STYLE_ELEMENT_ID = "__FRSH_TWIND"; + +export interface Options extends Omit { + /** The import.meta.url of the module defining these options. */ + selfURL: string; +} + +declare module "preact" { + namespace JSX { + interface DOMAttributes { + class?: string; + className?: string; + } + } +} + +export function setup(options: Options, sheet: Sheet) { + const config: Configuration = { + ...options, + mode: "silent", + sheet, + }; + twSetup(config); + + const originalHook = preactOptions.vnode; + // deno-lint-ignore no-explicit-any + preactOptions.vnode = (vnode: VNode>) => { + if (typeof vnode.type === "string" && typeof vnode.props === "object") { + const { props } = vnode; + const classes: string[] = []; + if (props.class) { + classes.push(tw(props.class)); + props.class = undefined; + } + if (props.className) { + classes.push(tw(props.className)); + } + if (classes.length) { + props.class = classes.join(" "); + } + } + + originalHook?.(vnode); + }; +} diff --git a/fresh-main/runtime.ts b/fresh-main/runtime.ts new file mode 100644 index 0000000..3a8a6bb --- /dev/null +++ b/fresh-main/runtime.ts @@ -0,0 +1,3 @@ +export * from "./src/runtime/utils.ts"; +export * from "./src/runtime/head.ts"; +export * from "./src/runtime/csp.ts"; diff --git a/fresh-main/server.ts b/fresh-main/server.ts new file mode 100644 index 0000000..f519fbb --- /dev/null +++ b/fresh-main/server.ts @@ -0,0 +1 @@ +export * from "./src/server/mod.ts"; diff --git a/fresh-main/src/dev/deps.ts b/fresh-main/src/dev/deps.ts new file mode 100644 index 0000000..d3dc884 --- /dev/null +++ b/fresh-main/src/dev/deps.ts @@ -0,0 +1,15 @@ +// std +export { + dirname, + extname, + fromFileUrl, + join, + resolve, + toFileUrl, +} from "https://deno.land/std@0.150.0/path/mod.ts"; +export { walk } from "https://deno.land/std@0.150.0/fs/walk.ts"; +export { parse } from "https://deno.land/std@0.150.0/flags/mod.ts"; +export { gte } from "https://deno.land/std@0.150.0/semver/mod.ts"; + +// ts-morph +export { Node, Project } from "https://deno.land/x/ts_morph@16.0.0/mod.ts"; diff --git a/fresh-main/src/dev/error.ts b/fresh-main/src/dev/error.ts new file mode 100644 index 0000000..7f7d722 --- /dev/null +++ b/fresh-main/src/dev/error.ts @@ -0,0 +1,8 @@ +export function printError(message: string) { + console.error(`%cerror%c: ${message}`, "color: red; font-weight: bold", ""); +} + +export function error(message: string): never { + printError(message); + Deno.exit(1); +} diff --git a/fresh-main/src/dev/imports.ts b/fresh-main/src/dev/imports.ts new file mode 100644 index 0000000..326d5ee --- /dev/null +++ b/fresh-main/src/dev/imports.ts @@ -0,0 +1,22 @@ +export const RECOMMENDED_PREACT_VERSION = "10.11.0"; +export const RECOMMENDED_PREACT_RTS_VERSION = "5.2.4"; +export const RECOMMENDED_PREACT_SIGNALS_VERSION = "1.0.3"; +export const RECOMMENDED_PREACT_SIGNALS_CORE_VERSION = "1.0.1"; +export const RECOMMENDED_TWIND_VERSION = "0.16.17"; + +export function freshImports(imports: Record) { + imports["$fresh/"] = new URL("../../", import.meta.url).href; + imports["preact"] = `https://esm.sh/preact@${RECOMMENDED_PREACT_VERSION}`; + imports["preact/"] = `https://esm.sh/preact@${RECOMMENDED_PREACT_VERSION}/`; + imports["preact-render-to-string"] = + `https://esm.sh/*preact-render-to-string@${RECOMMENDED_PREACT_RTS_VERSION}`; + imports["@preact/signals"] = + `https://esm.sh/*@preact/signals@${RECOMMENDED_PREACT_SIGNALS_VERSION}`; + imports["@preact/signals-core"] = + `https://esm.sh/*@preact/signals-core@${RECOMMENDED_PREACT_SIGNALS_CORE_VERSION}`; +} + +export function twindImports(imports: Record) { + imports["twind"] = `https://esm.sh/twind@${RECOMMENDED_TWIND_VERSION}`; + imports["twind/"] = `https://esm.sh/twind@${RECOMMENDED_TWIND_VERSION}/`; +} diff --git a/fresh-main/src/dev/mod.ts b/fresh-main/src/dev/mod.ts new file mode 100644 index 0000000..d874977 --- /dev/null +++ b/fresh-main/src/dev/mod.ts @@ -0,0 +1,196 @@ +import { + dirname, + extname, + fromFileUrl, + gte, + join, + toFileUrl, + walk, +} from "./deps.ts"; +import { error } from "./error.ts"; + +const MIN_DENO_VERSION = "1.25.0"; + +export function ensureMinDenoVersion() { + // Check that the minimum supported Deno version is being used. + if (!gte(Deno.version.deno, MIN_DENO_VERSION)) { + let message = + `Deno version ${MIN_DENO_VERSION} or higher is required. Please update Deno.\n\n`; + + if (Deno.execPath().includes("homebrew")) { + message += + "You seem to have installed Deno via homebrew. To update, run: `brew upgrade deno`\n"; + } else { + message += "To update, run: `deno upgrade`\n"; + } + + error(message); + } +} + +interface Manifest { + routes: string[]; + islands: string[]; +} + +export async function collect(directory: string): Promise { + const routesDir = join(directory, "./routes"); + const islandsDir = join(directory, "./islands"); + + const routes = []; + try { + const routesUrl = toFileUrl(routesDir); + // TODO(lucacasonato): remove the extranious Deno.readDir when + // https://github.com/denoland/deno_std/issues/1310 is fixed. + for await (const _ of Deno.readDir(routesDir)) { + // do nothing + } + const routesFolder = walk(routesDir, { + includeDirs: false, + includeFiles: true, + exts: ["tsx", "jsx", "ts", "js"], + }); + for await (const entry of routesFolder) { + if (entry.isFile) { + const file = toFileUrl(entry.path).href.substring( + routesUrl.href.length, + ); + routes.push(file); + } + } + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + // Do nothing. + } else { + throw err; + } + } + routes.sort(); + + const islands = []; + try { + const islandsUrl = toFileUrl(islandsDir); + for await (const entry of Deno.readDir(islandsDir)) { + if (entry.isDirectory) { + error( + `Found subdirectory '${entry.name}' in islands/. The islands/ folder must not contain any subdirectories.`, + ); + } + if (entry.isFile) { + const ext = extname(entry.name); + if (![".tsx", ".jsx", ".ts", ".js"].includes(ext)) continue; + const path = join(islandsDir, entry.name); + const file = toFileUrl(path).href.substring(islandsUrl.href.length); + islands.push(file); + } + } + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + // Do nothing. + } else { + throw err; + } + } + islands.sort(); + + return { routes, islands }; +} + +export async function generate(directory: string, manifest: Manifest) { + const { routes, islands } = manifest; + + const output = `// DO NOT EDIT. This file is generated by fresh. +// This file SHOULD be checked into source version control. +// This file is automatically updated during development when running \`dev.ts\`. + +import config from "./deno.json" assert { type: "json" }; +${ + routes.map((file, i) => `import * as $${i} from "./routes${file}";`).join( + "\n", + ) + } +${ + islands.map((file, i) => `import * as $$${i} from "./islands${file}";`) + .join("\n") + } + +const manifest = { + routes: { + ${ + routes.map((file, i) => `${JSON.stringify(`./routes${file}`)}: $${i},`) + .join("\n ") + } + }, + islands: { + ${ + islands.map((file, i) => `${JSON.stringify(`./islands${file}`)}: $$${i},`) + .join("\n ") + } + }, + baseUrl: import.meta.url, + config, +}; + +export default manifest; +`; + + const proc = Deno.run({ + cmd: [Deno.execPath(), "fmt", "-"], + stdin: "piped", + stdout: "piped", + stderr: "null", + }); + const raw = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(output)); + controller.close(); + }, + }); + await raw.pipeTo(proc.stdin.writable); + const out = await proc.output(); + await proc.status(); + proc.close(); + + const manifestStr = new TextDecoder().decode(out); + const manifestPath = join(directory, "./fresh.gen.ts"); + + await Deno.writeTextFile(manifestPath, manifestStr); + console.log( + `%cThe manifest has been generated for ${routes.length} routes and ${islands.length} islands.`, + "color: blue; font-weight: bold", + ); +} + +export async function dev(base: string, entrypoint: string) { + ensureMinDenoVersion(); + + entrypoint = new URL(entrypoint, base).href; + + const dir = dirname(fromFileUrl(base)); + + let currentManifest: Manifest; + const prevManifest = Deno.env.get("FRSH_DEV_PREVIOUS_MANIFEST"); + if (prevManifest) { + currentManifest = JSON.parse(prevManifest); + } else { + currentManifest = { islands: [], routes: [] }; + } + const newManifest = await collect(dir); + Deno.env.set("FRSH_DEV_PREVIOUS_MANIFEST", JSON.stringify(newManifest)); + + const manifestChanged = + !arraysEqual(newManifest.routes, currentManifest.routes) || + !arraysEqual(newManifest.islands, currentManifest.islands); + + if (manifestChanged) await generate(dir, newManifest); + + await import(entrypoint); +} + +function arraysEqual(a: T[], b: T[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; ++i) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/fresh-main/src/runtime/csp.ts b/fresh-main/src/runtime/csp.ts new file mode 100644 index 0000000..1566331 --- /dev/null +++ b/fresh-main/src/runtime/csp.ts @@ -0,0 +1,140 @@ +import { createContext } from "preact"; +import { useContext } from "preact/hooks"; + +export const SELF = "'self'"; +export const UNSAFE_INLINE = "'unsafe-inline'"; +export const UNSAFE_EVAL = "'unsafe-eval'"; +export const UNSAFE_HASHES = "'unsafe-hashes'"; +export const NONE = "'none'"; +export const STRICT_DYNAMIC = "'strict-dynamic'"; + +export function nonce(val: string) { + return `'nonce-${val}'`; +} + +export interface ContentSecurityPolicy { + directives: ContentSecurityPolicyDirectives; + reportOnly: boolean; +} + +export interface ContentSecurityPolicyDirectives { + // Fetch directives + /** + * Defines the valid sources for web workers and nested browsing contexts + * loaded using elements such as and