init 3
This commit is contained in:
parent
6ce9767e3b
commit
e2ee2c6375
@ -295,6 +295,7 @@
|
|||||||
"https://deno.land/x/importmap@0.2.1/mod.ts": "ae3d1cd7eabd18c01a4960d57db471126b020f23b37ef14e1359bbb949227ade",
|
"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/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.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.d.ts": "12908ced1670f96d5dc39aebb0d659630136fa6523881e4712cfb20b122dd324",
|
||||||
"https://deno.land/x/sqlite@v3.7.0/build/sqlite.js": "cc55fef9cd124b2acb624899a5fad413834f4701bcfc21ac275844b822466292",
|
"https://deno.land/x/sqlite@v3.7.0/build/sqlite.js": "cc55fef9cd124b2acb624899a5fad413834f4701bcfc21ac275844b822466292",
|
||||||
"https://deno.land/x/sqlite@v3.7.0/build/vfs.js": "08533cc78fb29b9d9bd62f6bb93e5ef333407013fed185776808f11223ba0e70",
|
"https://deno.land/x/sqlite@v3.7.0/build/vfs.js": "08533cc78fb29b9d9bd62f6bb93e5ef333407013fed185776808f11223ba0e70",
|
||||||
|
15
dev.ts
15
dev.ts
@ -1,5 +1,18 @@
|
|||||||
#!/usr/bin/env -S deno run -A --watch=static/,routes/
|
#!/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");
|
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);
|
||||||
|
}
|
||||||
|
}
|
1
fresh-main/.gitignore
vendored
Normal file
1
fresh-main/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
deno.lock
|
6
fresh-main/.vscode/extensions.json
vendored
Normal file
6
fresh-main/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"denoland.vscode-deno",
|
||||||
|
"sastan.twind-intellisense"
|
||||||
|
]
|
||||||
|
}
|
19
fresh-main/.vscode/import_map.json
vendored
Normal file
19
fresh-main/.vscode/import_map.json
vendored
Normal file
@ -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/"
|
||||||
|
}
|
||||||
|
}
|
7
fresh-main/.vscode/settings.json
vendored
Normal file
7
fresh-main/.vscode/settings.json
vendored
Normal file
@ -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"
|
||||||
|
}
|
128
fresh-main/CODE_OF_CONDUCT.md
Normal file
128
fresh-main/CODE_OF_CONDUCT.md
Normal file
@ -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.
|
21
fresh-main/LICENSE
Normal file
21
fresh-main/LICENSE
Normal file
@ -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.
|
106
fresh-main/README.md
Normal file
106
fresh-main/README.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
[Documentation](#-documentation) | [Getting started](#-getting-started)
|
||||||
|
|
||||||
|
# fresh
|
||||||
|
|
||||||
|
<img align="right" src="https://fresh.deno.dev/logo.svg" height="150px" alt="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
|
||||||
|
<a href="https://fresh.deno.dev">
|
||||||
|
<img width="197" height="37" src="https://fresh.deno.dev/fresh-badge.svg" alt="Made with Fresh" />
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
![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
|
||||||
|
<a href="https://fresh.deno.dev">
|
||||||
|
<img width="197" height="37" src="https://fresh.deno.dev/fresh-badge-dark.svg" alt="Made with Fresh" />
|
||||||
|
</a>
|
||||||
|
```
|
18
fresh-main/deno.json
Normal file
18
fresh-main/deno.json
Normal file
@ -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/"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
2
fresh-main/dev.ts
Normal file
2
fresh-main/dev.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import { dev } from "./src/dev/mod.ts";
|
||||||
|
export default dev;
|
377
fresh-main/init.ts
Normal file
377
fresh-main/init.ts
Normal file
@ -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 <DIRECTORY>
|
||||||
|
|
||||||
|
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<string, string> };
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Fresh App</title>
|
||||||
|
</Head>
|
||||||
|
<div${useTwind ? ` class="p-4 mx-auto max-w-screen-md"` : ""}>
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
${
|
||||||
|
useTwind ? `class="w-32 h-32"` : `width="128"\n height="128"`
|
||||||
|
}
|
||||||
|
alt="the fresh logo: a sliced lemon dripping with juice"
|
||||||
|
/>
|
||||||
|
<p${useTwind ? ` class="my-6"` : ""}>
|
||||||
|
Welcome to \`fresh\`. Try updating this message in the ./routes/index.tsx
|
||||||
|
file, and refresh.
|
||||||
|
</p>
|
||||||
|
<Counter start={3} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
disabled={!IS_BROWSER || props.disabled}
|
||||||
|
${
|
||||||
|
useTwind
|
||||||
|
? ' class="px-2 py-1 border(gray-100 2) hover:bg-gray-200"\n'
|
||||||
|
: ""
|
||||||
|
} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(resolvedDirectory, "components", "Button.tsx"),
|
||||||
|
COMPONENTS_BUTTON_TSX,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ISLANDS_COUNTER_TSX = `import { useState } from "preact/hooks";
|
||||||
|
import { Button } from "../components/Button.tsx";
|
||||||
|
|
||||||
|
interface CounterProps {
|
||||||
|
start: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Counter(props: CounterProps) {
|
||||||
|
const [count, setCount] = useState(props.start);
|
||||||
|
return (
|
||||||
|
<div${useTwind ? ' class="flex gap-2 w-full"' : ""}>
|
||||||
|
<p${useTwind ? ' class="flex-grow-1 font-bold text-xl"' : ""}>{count}</p>
|
||||||
|
<Button onClick={() => setCount(count - 1)}>-1</Button>
|
||||||
|
<Button onClick={() => setCount(count + 1)}>+1</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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 <div>Hello {props.params.name}</div>;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
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 =
|
||||||
|
`<svg width="40" height="40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M34.092 8.845C38.929 20.652 34.092 27 30 30.5c1 3.5-2.986 4.222-4.5 2.5-4.457 1.537-13.512 1.487-20-5C2 24.5 4.73 16.714 14 11.5c8-4.5 16-7 20.092-2.655Z" fill="#FFDB1E"/>
|
||||||
|
<path d="M14 11.5c6.848-4.497 15.025-6.38 18.368-3.47C37.5 12.5 21.5 22.612 15.5 25c-6.5 2.587-3 8.5-6.5 8.5-3 0-2.5-4-5.183-7.75C2.232 23.535 6.16 16.648 14 11.5Z" fill="#fff" stroke="#FFDB1E"/>
|
||||||
|
<path d="M28.535 8.772c4.645 1.25-.365 5.695-4.303 8.536-3.732 2.692-6.606 4.21-7.923 4.83-.366.173-1.617-2.252-1.617-1 0 .417-.7 2.238-.934 2.326-1.365.512-4.223 1.29-5.835 1.29-3.491 0-1.923-4.754 3.014-9.122.892-.789 1.478-.645 2.283-.645-.537-.773-.534-.917.403-1.546C17.79 10.64 23 8.77 25.212 8.42c.366.014.82.35.82.629.41-.14 2.095-.388 2.503-.278Z" fill="#FFE600"/>
|
||||||
|
<path d="M14.297 16.49c.985-.747 1.644-1.01 2.099-2.526.566.121.841-.08 1.29-.701.324.466 1.657.608 2.453.701-.715.451-1.057.852-1.452 2.106-1.464-.611-3.167-.302-4.39.42Z" fill="#fff"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
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 = `/// <reference no-default-lib="true" />
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
/// <reference lib="dom.iterable" />
|
||||||
|
/// <reference lib="dom.asynciterable" />
|
||||||
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
|
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",
|
||||||
|
);
|
50
fresh-main/plugins/twind.ts
Normal file
50
fresh-main/plugins/twind.ts
Normal file
@ -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<string, string>).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 }],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
30
fresh-main/plugins/twind/main.ts
Normal file
30
fresh-main/plugins/twind/main.ts
Normal file
@ -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<string>();
|
||||||
|
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);
|
||||||
|
}
|
48
fresh-main/plugins/twind/shared.ts
Normal file
48
fresh-main/plugins/twind/shared.ts
Normal file
@ -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<Configuration, "mode" | "sheet"> {
|
||||||
|
/** The import.meta.url of the module defining these options. */
|
||||||
|
selfURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "preact" {
|
||||||
|
namespace JSX {
|
||||||
|
interface DOMAttributes<Target extends EventTarget> {
|
||||||
|
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<JSX.DOMAttributes<any>>) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
3
fresh-main/runtime.ts
Normal file
3
fresh-main/runtime.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from "./src/runtime/utils.ts";
|
||||||
|
export * from "./src/runtime/head.ts";
|
||||||
|
export * from "./src/runtime/csp.ts";
|
1
fresh-main/server.ts
Normal file
1
fresh-main/server.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./src/server/mod.ts";
|
15
fresh-main/src/dev/deps.ts
Normal file
15
fresh-main/src/dev/deps.ts
Normal file
@ -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";
|
8
fresh-main/src/dev/error.ts
Normal file
8
fresh-main/src/dev/error.ts
Normal file
@ -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);
|
||||||
|
}
|
22
fresh-main/src/dev/imports.ts
Normal file
22
fresh-main/src/dev/imports.ts
Normal file
@ -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<string, string>) {
|
||||||
|
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<string, string>) {
|
||||||
|
imports["twind"] = `https://esm.sh/twind@${RECOMMENDED_TWIND_VERSION}`;
|
||||||
|
imports["twind/"] = `https://esm.sh/twind@${RECOMMENDED_TWIND_VERSION}/`;
|
||||||
|
}
|
196
fresh-main/src/dev/mod.ts
Normal file
196
fresh-main/src/dev/mod.ts
Normal file
@ -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<Manifest> {
|
||||||
|
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<T>(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;
|
||||||
|
}
|
140
fresh-main/src/runtime/csp.ts
Normal file
140
fresh-main/src/runtime/csp.ts
Normal file
@ -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 <frame> and <iframe>.
|
||||||
|
*/
|
||||||
|
childSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Restricts the URLs which can be loaded using script interfaces.
|
||||||
|
*/
|
||||||
|
connectSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Serves as a fallback for the other fetch directives.
|
||||||
|
*/
|
||||||
|
defaultSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for fonts loaded using @font-face.
|
||||||
|
*/
|
||||||
|
fontSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for nested browsing contexts loading using elements
|
||||||
|
* such as <frame> and <iframe>.
|
||||||
|
*/
|
||||||
|
frameSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources of images and favicons.
|
||||||
|
*/
|
||||||
|
imgSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources of application manifest files.
|
||||||
|
*/
|
||||||
|
manifestSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for loading media using the <audio> , <video> and
|
||||||
|
* <track> elements.
|
||||||
|
*/
|
||||||
|
mediaSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for the <object>, <embed>, and <applet> elements.
|
||||||
|
*/
|
||||||
|
objectSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources to be prefetched or prerendered.
|
||||||
|
*/
|
||||||
|
prefetchSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for JavaScript.
|
||||||
|
*/
|
||||||
|
scriptSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for JavaScript <script> elements.
|
||||||
|
*/
|
||||||
|
scriptSrcElem?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for JavaScript inline event handlers.
|
||||||
|
*/
|
||||||
|
scriptSrcAttr?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for stylesheets.
|
||||||
|
*/
|
||||||
|
styleSrc?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for stylesheets <style> elements and <link>
|
||||||
|
* elements with rel="stylesheet".
|
||||||
|
*/
|
||||||
|
styleSrcElem?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for inline styles applied to individual DOM
|
||||||
|
* elements.
|
||||||
|
*/
|
||||||
|
styleSrcAttr?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid sources for Worker, SharedWorker, or ServiceWorker scripts.
|
||||||
|
*/
|
||||||
|
workerSrc?: string[];
|
||||||
|
|
||||||
|
// Document directives
|
||||||
|
/**
|
||||||
|
* Restricts the URLs which can be used in a document's <base> element.
|
||||||
|
*/
|
||||||
|
baseUri?: string[];
|
||||||
|
/**
|
||||||
|
* Enables a sandbox for the requested resource similar to the <iframe>
|
||||||
|
* sandbox attribute.
|
||||||
|
*/
|
||||||
|
sandbox?: string[];
|
||||||
|
|
||||||
|
// Navigation directives
|
||||||
|
/**
|
||||||
|
* Restricts the URLs which can be used as the target of a form submissions
|
||||||
|
* from a given context.
|
||||||
|
*/
|
||||||
|
formAction?: string[];
|
||||||
|
/**
|
||||||
|
* Specifies valid parents that may embed a page using <frame>, <iframe>,
|
||||||
|
* <object>, <embed>, or <applet>.
|
||||||
|
*/
|
||||||
|
frameAncestors?: string[];
|
||||||
|
/**
|
||||||
|
* Restricts the URLs to which a document can initiate navigation by any
|
||||||
|
* means, including <form> (if form-action is not specified), <a>,
|
||||||
|
* window.location, window.open, etc.
|
||||||
|
*/
|
||||||
|
navigateTo?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The URI to report CSP violations to.
|
||||||
|
*/
|
||||||
|
reportUri?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CSP_CONTEXT = createContext<ContentSecurityPolicy | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useCSP(mutator: (csp: ContentSecurityPolicy) => void) {
|
||||||
|
const csp = useContext(CSP_CONTEXT);
|
||||||
|
if (csp) {
|
||||||
|
mutator(csp);
|
||||||
|
}
|
||||||
|
}
|
22
fresh-main/src/runtime/head.ts
Normal file
22
fresh-main/src/runtime/head.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { ComponentChildren, createContext } from "preact";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
export interface HeadProps {
|
||||||
|
children: ComponentChildren;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HEAD_CONTEXT = createContext<ComponentChildren[]>([]);
|
||||||
|
|
||||||
|
export function Head(props: HeadProps) {
|
||||||
|
let context: ComponentChildren[];
|
||||||
|
try {
|
||||||
|
context = useContext(HEAD_CONTEXT);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
"<Head> component is not supported in the browser, or during suspense renders.",
|
||||||
|
{ cause: err },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
context.push(props.children);
|
||||||
|
return null;
|
||||||
|
}
|
71
fresh-main/src/runtime/main.ts
Normal file
71
fresh-main/src/runtime/main.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { ComponentType, h, options, render } from "preact";
|
||||||
|
import { assetHashingHook } from "./utils.ts";
|
||||||
|
|
||||||
|
function createRootFragment(
|
||||||
|
parent: Element,
|
||||||
|
replaceNode: Node | Node[],
|
||||||
|
) {
|
||||||
|
replaceNode = ([] as Node[]).concat(replaceNode);
|
||||||
|
const s = replaceNode[replaceNode.length - 1].nextSibling;
|
||||||
|
function insert(c: Node, r: Node) {
|
||||||
|
parent.insertBefore(c, r || s);
|
||||||
|
}
|
||||||
|
// @ts-ignore this is fine
|
||||||
|
return parent.__k = {
|
||||||
|
nodeType: 1,
|
||||||
|
parentNode: parent,
|
||||||
|
firstChild: replaceNode[0],
|
||||||
|
childNodes: replaceNode,
|
||||||
|
insertBefore: insert,
|
||||||
|
appendChild: insert,
|
||||||
|
removeChild: function (c: Node) {
|
||||||
|
parent.removeChild(c);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export function revive(islands: Record<string, ComponentType>, props: any[]) {
|
||||||
|
function walk(node: Node | null) {
|
||||||
|
const tag = node!.nodeType === 8 &&
|
||||||
|
((node as Comment).data.match(/^\s*frsh-(.*)\s*$/) || [])[1];
|
||||||
|
let endNode: Node | null = null;
|
||||||
|
if (tag) {
|
||||||
|
const startNode = node!;
|
||||||
|
const children = [];
|
||||||
|
const parent = node!.parentNode;
|
||||||
|
// collect all children of the island
|
||||||
|
while ((node = node!.nextSibling) && node.nodeType !== 8) {
|
||||||
|
children.push(node);
|
||||||
|
}
|
||||||
|
startNode.parentNode!.removeChild(startNode); // remove start tag node
|
||||||
|
|
||||||
|
const [id, n] = tag.split(":");
|
||||||
|
render(
|
||||||
|
h(islands[id], props[Number(n)]),
|
||||||
|
createRootFragment(
|
||||||
|
parent! as HTMLElement,
|
||||||
|
children,
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
) as any as HTMLElement,
|
||||||
|
);
|
||||||
|
endNode = node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sib = node!.nextSibling;
|
||||||
|
const fc = node!.firstChild;
|
||||||
|
if (endNode) {
|
||||||
|
endNode.parentNode?.removeChild(endNode); // remove end tag node
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sib) walk(sib);
|
||||||
|
if (fc) walk(fc);
|
||||||
|
}
|
||||||
|
walk(document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalHook = options.vnode;
|
||||||
|
options.vnode = (vnode) => {
|
||||||
|
assetHashingHook(vnode);
|
||||||
|
if (originalHook) originalHook(vnode);
|
||||||
|
};
|
2
fresh-main/src/runtime/main_dev.ts
Normal file
2
fresh-main/src/runtime/main_dev.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
import "preact/debug";
|
||||||
|
export { revive } from "./main.ts";
|
70
fresh-main/src/runtime/utils.ts
Normal file
70
fresh-main/src/runtime/utils.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { VNode } from "preact";
|
||||||
|
|
||||||
|
export const INTERNAL_PREFIX = "/_frsh";
|
||||||
|
export const ASSET_CACHE_BUST_KEY = "__frsh_c";
|
||||||
|
|
||||||
|
export const IS_BROWSER = typeof document !== "undefined";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a "locked" asset path. This differs from a plain path in that it is
|
||||||
|
* specific to the current version of the application, and as such can be safely
|
||||||
|
* served with a very long cache lifetime (1 year).
|
||||||
|
*/
|
||||||
|
export function asset(path: string) {
|
||||||
|
if (!path.startsWith("/") || path.startsWith("//")) return path;
|
||||||
|
try {
|
||||||
|
const url = new URL(path, "https://freshassetcache.local");
|
||||||
|
if (
|
||||||
|
url.protocol !== "https:" || url.host !== "freshassetcache.local" ||
|
||||||
|
url.searchParams.has(ASSET_CACHE_BUST_KEY)
|
||||||
|
) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
url.searchParams.set(ASSET_CACHE_BUST_KEY, __FRSH_BUILD_ID);
|
||||||
|
return url.pathname + url.search + url.hash;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to create asset() URL, falling back to regular path ('${path}'):`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply the `asset` function to urls in a `srcset` attribute. */
|
||||||
|
export function assetSrcSet(srcset: string): string {
|
||||||
|
if (srcset.includes("(")) return srcset; // Bail if the srcset contains complicated syntax.
|
||||||
|
const parts = srcset.split(",");
|
||||||
|
const constructed = [];
|
||||||
|
for (const part of parts) {
|
||||||
|
const trimmed = part.trimStart();
|
||||||
|
const leadingWhitespace = part.length - trimmed.length;
|
||||||
|
if (trimmed === "") return srcset; // Bail if the srcset is malformed.
|
||||||
|
let urlEnd = trimmed.indexOf(" ");
|
||||||
|
if (urlEnd === -1) urlEnd = trimmed.length;
|
||||||
|
const leading = part.substring(0, leadingWhitespace);
|
||||||
|
const url = trimmed.substring(0, urlEnd);
|
||||||
|
const trailing = trimmed.substring(urlEnd);
|
||||||
|
constructed.push(leading + asset(url) + trailing);
|
||||||
|
}
|
||||||
|
return constructed.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetHashingHook(
|
||||||
|
vnode: VNode<{
|
||||||
|
src?: string;
|
||||||
|
srcset?: string;
|
||||||
|
["data-fresh-disable-lock"]?: boolean;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
if (vnode.type === "img" || vnode.type === "source") {
|
||||||
|
const { props } = vnode;
|
||||||
|
if (props["data-fresh-disable-lock"]) return;
|
||||||
|
if (typeof props.src === "string") {
|
||||||
|
props.src = asset(props.src);
|
||||||
|
}
|
||||||
|
if (typeof props.srcset === "string") {
|
||||||
|
props.srcset = assetSrcSet(props.srcset);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
60
fresh-main/src/runtime/utils_test.ts
Normal file
60
fresh-main/src/runtime/utils_test.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { assertEquals } from "../../tests/deps.ts";
|
||||||
|
import { asset, assetSrcSet } from "./utils.ts";
|
||||||
|
|
||||||
|
globalThis.__FRSH_BUILD_ID = "ID123";
|
||||||
|
|
||||||
|
Deno.test("asset", () => {
|
||||||
|
assertEquals(asset("/test.png"), "/test.png?__frsh_c=ID123");
|
||||||
|
assertEquals(asset("/test?f=1"), "/test?f=1&__frsh_c=ID123");
|
||||||
|
assertEquals(asset("/test#foo"), "/test?__frsh_c=ID123#foo");
|
||||||
|
assertEquals(asset("/test?f=1#foo"), "/test?f=1&__frsh_c=ID123#foo");
|
||||||
|
|
||||||
|
assertEquals(asset("./test.png"), "./test.png");
|
||||||
|
assertEquals(asset("//example.com/logo.png"), "//example.com/logo.png");
|
||||||
|
assertEquals(asset("/test.png?__frsh_c=1"), "/test.png?__frsh_c=1");
|
||||||
|
assertEquals(
|
||||||
|
asset("https://example.com/logo.png"),
|
||||||
|
"https://example.com/logo.png",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("assetSrcSet", () => {
|
||||||
|
assertEquals(assetSrcSet("/img.png"), "/img.png?__frsh_c=ID123");
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img.png, /img.png 2x"),
|
||||||
|
"/img.png?__frsh_c=ID123, /img.png?__frsh_c=ID123 2x",
|
||||||
|
);
|
||||||
|
assertEquals(assetSrcSet("/img.png 1x"), "/img.png?__frsh_c=ID123 1x");
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img.png 1x, /img.png 2x"),
|
||||||
|
"/img.png?__frsh_c=ID123 1x, /img.png?__frsh_c=ID123 2x",
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img.png 1.5x, /img.png 3x"),
|
||||||
|
"/img.png?__frsh_c=ID123 1.5x, /img.png?__frsh_c=ID123 3x",
|
||||||
|
);
|
||||||
|
|
||||||
|
//test with queries
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img.png?w=140, /img.png?w=200 2x"),
|
||||||
|
"/img.png?w=140&__frsh_c=ID123, /img.png?w=200&__frsh_c=ID123 2x",
|
||||||
|
);
|
||||||
|
|
||||||
|
// test with extra spaces
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img-s.png 300w, /img-l.png 600w , /img-xl.png 900w"),
|
||||||
|
"/img-s.png?__frsh_c=ID123 300w, /img-l.png?__frsh_c=ID123 600w , /img-xl.png?__frsh_c=ID123 900w",
|
||||||
|
);
|
||||||
|
|
||||||
|
// test with ( syntax
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img.png ( 140,0w)"),
|
||||||
|
"/img.png ( 140,0w)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// test with invalid parts
|
||||||
|
assertEquals(
|
||||||
|
assetSrcSet("/img.png,, /img-s.png 300w"),
|
||||||
|
"/img.png,, /img-s.png 300w",
|
||||||
|
);
|
||||||
|
});
|
150
fresh-main/src/server/bundle.ts
Normal file
150
fresh-main/src/server/bundle.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { BuildOptions } from "https://deno.land/x/esbuild@v0.14.51/mod.js";
|
||||||
|
import { BUILD_ID } from "./constants.ts";
|
||||||
|
import { denoPlugin, esbuild, toFileUrl } from "./deps.ts";
|
||||||
|
import { Island, Plugin } from "./types.ts";
|
||||||
|
|
||||||
|
export interface JSXConfig {
|
||||||
|
jsx: "react" | "react-jsx";
|
||||||
|
jsxImportSource?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let esbuildInitialized: boolean | Promise<void> = false;
|
||||||
|
async function ensureEsbuildInitialized() {
|
||||||
|
if (esbuildInitialized === false) {
|
||||||
|
if (Deno.run === undefined) {
|
||||||
|
const wasmURL = new URL("./esbuild_v0.14.51.wasm", import.meta.url).href;
|
||||||
|
esbuildInitialized = fetch(wasmURL).then(async (r) => {
|
||||||
|
const resp = new Response(r.body, {
|
||||||
|
headers: { "Content-Type": "application/wasm" },
|
||||||
|
});
|
||||||
|
const wasmModule = await WebAssembly.compileStreaming(resp);
|
||||||
|
await esbuild.initialize({
|
||||||
|
wasmModule,
|
||||||
|
worker: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
esbuild.initialize({});
|
||||||
|
}
|
||||||
|
await esbuildInitialized;
|
||||||
|
esbuildInitialized = true;
|
||||||
|
} else if (esbuildInitialized instanceof Promise) {
|
||||||
|
await esbuildInitialized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const JSX_RUNTIME_MODE = {
|
||||||
|
"react": "transform",
|
||||||
|
"react-jsx": "automatic",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export class Bundler {
|
||||||
|
#importMapURL: URL;
|
||||||
|
#jsxConfig: JSXConfig;
|
||||||
|
#islands: Island[];
|
||||||
|
#plugins: Plugin[];
|
||||||
|
#cache: Map<string, Uint8Array> | Promise<void> | undefined = undefined;
|
||||||
|
#dev: boolean;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
islands: Island[],
|
||||||
|
plugins: Plugin[],
|
||||||
|
importMapURL: URL,
|
||||||
|
jsxConfig: JSXConfig,
|
||||||
|
dev: boolean,
|
||||||
|
) {
|
||||||
|
this.#islands = islands;
|
||||||
|
this.#plugins = plugins;
|
||||||
|
this.#importMapURL = importMapURL;
|
||||||
|
this.#jsxConfig = jsxConfig;
|
||||||
|
this.#dev = dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
async bundle() {
|
||||||
|
const entryPoints: Record<string, string> = {
|
||||||
|
main: this.#dev
|
||||||
|
? new URL("../../src/runtime/main_dev.ts", import.meta.url).href
|
||||||
|
: new URL("../../src/runtime/main.ts", import.meta.url).href,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const island of this.#islands) {
|
||||||
|
entryPoints[`island-${island.id}`] = island.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plugin of this.#plugins) {
|
||||||
|
for (const [name, url] of Object.entries(plugin.entrypoints ?? {})) {
|
||||||
|
entryPoints[`plugin-${plugin.name}-${name}`] = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const absWorkingDir = Deno.cwd();
|
||||||
|
await ensureEsbuildInitialized();
|
||||||
|
// In dev-mode we skip identifier minification to be able to show proper
|
||||||
|
// component names in Preact DevTools instead of single characters.
|
||||||
|
const minifyOptions: Partial<BuildOptions> = this.#dev
|
||||||
|
? { minifyIdentifiers: false, minifySyntax: true, minifyWhitespace: true }
|
||||||
|
: { minify: true };
|
||||||
|
const bundle = await esbuild.build({
|
||||||
|
bundle: true,
|
||||||
|
define: { __FRSH_BUILD_ID: `"${BUILD_ID}"` },
|
||||||
|
entryPoints,
|
||||||
|
format: "esm",
|
||||||
|
metafile: true,
|
||||||
|
...minifyOptions,
|
||||||
|
outdir: ".",
|
||||||
|
// This is requried to ensure the format of the outputFiles path is the same
|
||||||
|
// between windows and linux
|
||||||
|
absWorkingDir,
|
||||||
|
outfile: "",
|
||||||
|
platform: "neutral",
|
||||||
|
plugins: [denoPlugin({ importMapURL: this.#importMapURL })],
|
||||||
|
sourcemap: this.#dev ? "linked" : false,
|
||||||
|
splitting: true,
|
||||||
|
target: ["chrome99", "firefox99", "safari15"],
|
||||||
|
treeShaking: true,
|
||||||
|
write: false,
|
||||||
|
jsx: JSX_RUNTIME_MODE[this.#jsxConfig.jsx],
|
||||||
|
jsxImportSource: this.#jsxConfig.jsxImportSource,
|
||||||
|
});
|
||||||
|
// const metafileOutputs = bundle.metafile!.outputs;
|
||||||
|
|
||||||
|
// for (const path in metafileOutputs) {
|
||||||
|
// const meta = metafileOutputs[path];
|
||||||
|
// const imports = meta.imports
|
||||||
|
// .filter(({ kind }) => kind === "import-statement")
|
||||||
|
// .map(({ path }) => `/${path}`);
|
||||||
|
// this.#preloads.set(`/${path}`, imports);
|
||||||
|
// }
|
||||||
|
|
||||||
|
const cache = new Map<string, Uint8Array>();
|
||||||
|
const absDirUrlLength = toFileUrl(absWorkingDir).href.length;
|
||||||
|
for (const file of bundle.outputFiles) {
|
||||||
|
cache.set(
|
||||||
|
toFileUrl(file.path).href.substring(absDirUrlLength),
|
||||||
|
file.contents,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.#cache = cache;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cache(): Promise<Map<string, Uint8Array>> {
|
||||||
|
if (this.#cache === undefined) {
|
||||||
|
this.#cache = this.bundle();
|
||||||
|
}
|
||||||
|
if (this.#cache instanceof Promise) {
|
||||||
|
await this.#cache;
|
||||||
|
}
|
||||||
|
return this.#cache as Map<string, Uint8Array>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(path: string): Promise<Uint8Array | null> {
|
||||||
|
const cache = await this.cache();
|
||||||
|
return cache.get(path) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPreloads(path: string): string[] {
|
||||||
|
// return this.#preloads.get(path) ?? [];
|
||||||
|
// }
|
||||||
|
}
|
23
fresh-main/src/server/constants.ts
Normal file
23
fresh-main/src/server/constants.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { INTERNAL_PREFIX } from "../runtime/utils.ts";
|
||||||
|
|
||||||
|
export const REFRESH_JS_URL = `${INTERNAL_PREFIX}/refresh.js`;
|
||||||
|
export const ALIVE_URL = `${INTERNAL_PREFIX}/alive`;
|
||||||
|
export const BUILD_ID = Deno.env.get("DENO_DEPLOYMENT_ID") ||
|
||||||
|
crypto.randomUUID();
|
||||||
|
export const JS_PREFIX = `/js`;
|
||||||
|
export const DEBUG = !Deno.env.get("DENO_DEPLOYMENT_ID");
|
||||||
|
|
||||||
|
export function bundleAssetUrl(path: string) {
|
||||||
|
return `${INTERNAL_PREFIX}${JS_PREFIX}/${BUILD_ID}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.__FRSH_BUILD_ID = BUILD_ID;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Crypto {
|
||||||
|
randomUUID(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-var
|
||||||
|
var __FRSH_BUILD_ID: string;
|
||||||
|
}
|
776
fresh-main/src/server/context.ts
Normal file
776
fresh-main/src/server/context.ts
Normal file
@ -0,0 +1,776 @@
|
|||||||
|
import {
|
||||||
|
ConnInfo,
|
||||||
|
extname,
|
||||||
|
fromFileUrl,
|
||||||
|
RequestHandler,
|
||||||
|
rutt,
|
||||||
|
Status,
|
||||||
|
toFileUrl,
|
||||||
|
typeByExtension,
|
||||||
|
walk,
|
||||||
|
} from "./deps.ts";
|
||||||
|
import { h } from "preact";
|
||||||
|
import { Manifest } from "./mod.ts";
|
||||||
|
import { Bundler, JSXConfig } from "./bundle.ts";
|
||||||
|
import { ALIVE_URL, BUILD_ID, JS_PREFIX, REFRESH_JS_URL } from "./constants.ts";
|
||||||
|
import DefaultErrorHandler from "./default_error_page.ts";
|
||||||
|
import {
|
||||||
|
AppModule,
|
||||||
|
ErrorPage,
|
||||||
|
ErrorPageModule,
|
||||||
|
FreshOptions,
|
||||||
|
Handler,
|
||||||
|
Island,
|
||||||
|
Middleware,
|
||||||
|
MiddlewareModule,
|
||||||
|
MiddlewareRoute,
|
||||||
|
Plugin,
|
||||||
|
RenderFunction,
|
||||||
|
Route,
|
||||||
|
RouteModule,
|
||||||
|
UnknownPage,
|
||||||
|
UnknownPageModule,
|
||||||
|
} from "./types.ts";
|
||||||
|
import { render as internalRender } from "./render.ts";
|
||||||
|
import { ContentSecurityPolicyDirectives, SELF } from "../runtime/csp.ts";
|
||||||
|
import { ASSET_CACHE_BUST_KEY, INTERNAL_PREFIX } from "../runtime/utils.ts";
|
||||||
|
interface RouterState {
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StaticFile {
|
||||||
|
/** The URL to the static file on disk. */
|
||||||
|
localUrl: URL;
|
||||||
|
/** The path to the file as it would be in the incoming request. */
|
||||||
|
path: string;
|
||||||
|
/** The size of the file. */
|
||||||
|
size: number;
|
||||||
|
/** The content-type of the file. */
|
||||||
|
contentType: string;
|
||||||
|
/** Hash of the file contents. */
|
||||||
|
etag: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerContext {
|
||||||
|
#dev: boolean;
|
||||||
|
#routes: Route[];
|
||||||
|
#islands: Island[];
|
||||||
|
#staticFiles: StaticFile[];
|
||||||
|
#bundler: Bundler;
|
||||||
|
#renderFn: RenderFunction;
|
||||||
|
#middlewares: MiddlewareRoute[];
|
||||||
|
#app: AppModule;
|
||||||
|
#notFound: UnknownPage;
|
||||||
|
#error: ErrorPage;
|
||||||
|
#plugins: Plugin[];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
routes: Route[],
|
||||||
|
islands: Island[],
|
||||||
|
staticFiles: StaticFile[],
|
||||||
|
renderfn: RenderFunction,
|
||||||
|
middlewares: MiddlewareRoute[],
|
||||||
|
app: AppModule,
|
||||||
|
notFound: UnknownPage,
|
||||||
|
error: ErrorPage,
|
||||||
|
plugins: Plugin[],
|
||||||
|
importMapURL: URL,
|
||||||
|
jsxConfig: JSXConfig,
|
||||||
|
) {
|
||||||
|
this.#routes = routes;
|
||||||
|
this.#islands = islands;
|
||||||
|
this.#staticFiles = staticFiles;
|
||||||
|
this.#renderFn = renderfn;
|
||||||
|
this.#middlewares = middlewares;
|
||||||
|
this.#app = app;
|
||||||
|
this.#notFound = notFound;
|
||||||
|
this.#error = error;
|
||||||
|
this.#plugins = plugins;
|
||||||
|
this.#dev = typeof Deno.env.get("DENO_DEPLOYMENT_ID") !== "string"; // Env var is only set in prod (on Deploy).
|
||||||
|
this.#bundler = new Bundler(
|
||||||
|
this.#islands,
|
||||||
|
this.#plugins,
|
||||||
|
importMapURL,
|
||||||
|
jsxConfig,
|
||||||
|
this.#dev,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the manifest into individual components and pages.
|
||||||
|
*/
|
||||||
|
static async fromManifest(
|
||||||
|
manifest: Manifest,
|
||||||
|
opts: FreshOptions,
|
||||||
|
): Promise<ServerContext> {
|
||||||
|
// Get the manifest' base URL.
|
||||||
|
const baseUrl = new URL("./", manifest.baseUrl).href;
|
||||||
|
|
||||||
|
const config = manifest.config || { importMap: "./import_map.json" };
|
||||||
|
if (typeof config.importMap !== "string") {
|
||||||
|
throw new Error("deno.json must contain an 'importMap' property.");
|
||||||
|
}
|
||||||
|
const importMapURL = new URL(config.importMap, manifest.baseUrl);
|
||||||
|
|
||||||
|
config.compilerOptions ??= {};
|
||||||
|
|
||||||
|
let jsx: "react" | "react-jsx";
|
||||||
|
switch (config.compilerOptions.jsx) {
|
||||||
|
case "react":
|
||||||
|
case undefined:
|
||||||
|
jsx = "react";
|
||||||
|
break;
|
||||||
|
case "react-jsx":
|
||||||
|
jsx = "react-jsx";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown jsx option: " + config.compilerOptions.jsx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsxConfig: JSXConfig = {
|
||||||
|
jsx,
|
||||||
|
jsxImportSource: config.compilerOptions.jsxImportSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract all routes, and prepare them into the `Page` structure.
|
||||||
|
const routes: Route[] = [];
|
||||||
|
const islands: Island[] = [];
|
||||||
|
const middlewares: MiddlewareRoute[] = [];
|
||||||
|
let app: AppModule = DEFAULT_APP;
|
||||||
|
let notFound: UnknownPage = DEFAULT_NOT_FOUND;
|
||||||
|
let error: ErrorPage = DEFAULT_ERROR;
|
||||||
|
for (const [self, module] of Object.entries(manifest.routes)) {
|
||||||
|
const url = new URL(self, baseUrl).href;
|
||||||
|
if (!url.startsWith(baseUrl + "routes")) {
|
||||||
|
throw new TypeError("Page is not a child of the basepath.");
|
||||||
|
}
|
||||||
|
const path = url.substring(baseUrl.length).substring("routes".length);
|
||||||
|
const baseRoute = path.substring(1, path.length - extname(path).length);
|
||||||
|
const name = baseRoute.replace("/", "-");
|
||||||
|
const isMiddleware = path.endsWith("/_middleware.tsx") ||
|
||||||
|
path.endsWith("/_middleware.ts") || path.endsWith("/_middleware.jsx") ||
|
||||||
|
path.endsWith("/_middleware.js");
|
||||||
|
if (!path.startsWith("/_") && !isMiddleware) {
|
||||||
|
const { default: component, config } = module as RouteModule;
|
||||||
|
let pattern = pathToPattern(baseRoute) + "{/}?";
|
||||||
|
if (config?.routeOverride) {
|
||||||
|
pattern = String(config.routeOverride);
|
||||||
|
}
|
||||||
|
let { handler } = module as RouteModule;
|
||||||
|
handler ??= {};
|
||||||
|
if (
|
||||||
|
component &&
|
||||||
|
typeof handler === "object" && handler.GET === undefined
|
||||||
|
) {
|
||||||
|
handler.GET = (_req, { render }) => render();
|
||||||
|
}
|
||||||
|
const route: Route = {
|
||||||
|
pattern,
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
component,
|
||||||
|
handler,
|
||||||
|
csp: Boolean(config?.csp ?? false),
|
||||||
|
};
|
||||||
|
routes.push(route);
|
||||||
|
} else if (isMiddleware) {
|
||||||
|
middlewares.push({
|
||||||
|
...middlewarePathToPattern(baseRoute),
|
||||||
|
...module as MiddlewareModule,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
path === "/_app.tsx" || path === "/_app.ts" ||
|
||||||
|
path === "/_app.jsx" || path === "/_app.js"
|
||||||
|
) {
|
||||||
|
app = module as AppModule;
|
||||||
|
} else if (
|
||||||
|
path === "/_404.tsx" || path === "/_404.ts" ||
|
||||||
|
path === "/_404.jsx" || path === "/_404.js"
|
||||||
|
) {
|
||||||
|
const { default: component, config } = module as UnknownPageModule;
|
||||||
|
let { handler } = module as UnknownPageModule;
|
||||||
|
if (component && handler === undefined) {
|
||||||
|
handler = (_req, { render }) => render();
|
||||||
|
}
|
||||||
|
|
||||||
|
notFound = {
|
||||||
|
pattern: pathToPattern(baseRoute),
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
component,
|
||||||
|
handler: handler ?? ((req) => rutt.defaultOtherHandler(req)),
|
||||||
|
csp: Boolean(config?.csp ?? false),
|
||||||
|
};
|
||||||
|
} else if (
|
||||||
|
path === "/_500.tsx" || path === "/_500.ts" ||
|
||||||
|
path === "/_500.jsx" || path === "/_500.js"
|
||||||
|
) {
|
||||||
|
const { default: component, config } = module as ErrorPageModule;
|
||||||
|
let { handler } = module as ErrorPageModule;
|
||||||
|
if (component && handler === undefined) {
|
||||||
|
handler = (_req, { render }) => render();
|
||||||
|
}
|
||||||
|
|
||||||
|
error = {
|
||||||
|
pattern: pathToPattern(baseRoute),
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
component,
|
||||||
|
handler: handler ??
|
||||||
|
((req, ctx) => rutt.defaultErrorHandler(req, ctx, ctx.error)),
|
||||||
|
csp: Boolean(config?.csp ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortRoutes(routes);
|
||||||
|
sortRoutes(middlewares);
|
||||||
|
|
||||||
|
for (const [self, module] of Object.entries(manifest.islands)) {
|
||||||
|
const url = new URL(self, baseUrl).href;
|
||||||
|
if (!url.startsWith(baseUrl)) {
|
||||||
|
throw new TypeError("Island is not a child of the basepath.");
|
||||||
|
}
|
||||||
|
const path = url.substring(baseUrl.length).substring("islands".length);
|
||||||
|
const baseRoute = path.substring(1, path.length - extname(path).length);
|
||||||
|
const name = sanitizeIslandName(baseRoute);
|
||||||
|
const id = name.toLowerCase();
|
||||||
|
if (typeof module.default !== "function") {
|
||||||
|
throw new TypeError(
|
||||||
|
`Islands must default export a component ('${self}').`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
islands.push({ id, name, url, component: module.default });
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticFiles: StaticFile[] = [];
|
||||||
|
try {
|
||||||
|
const staticFolder = new URL(
|
||||||
|
opts.staticDir ?? "./static",
|
||||||
|
manifest.baseUrl,
|
||||||
|
);
|
||||||
|
// TODO(lucacasonato): remove the extranious Deno.readDir when
|
||||||
|
// https://github.com/denoland/deno_std/issues/1310 is fixed.
|
||||||
|
for await (const _ of Deno.readDir(fromFileUrl(staticFolder))) {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
const entires = walk(fromFileUrl(staticFolder), {
|
||||||
|
includeFiles: true,
|
||||||
|
includeDirs: false,
|
||||||
|
followSymlinks: false,
|
||||||
|
});
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
for await (const entry of entires) {
|
||||||
|
const localUrl = toFileUrl(entry.path);
|
||||||
|
const path = localUrl.href.substring(staticFolder.href.length);
|
||||||
|
const stat = await Deno.stat(localUrl);
|
||||||
|
const contentType = typeByExtension(extname(path)) ??
|
||||||
|
"application/octet-stream";
|
||||||
|
const etag = await crypto.subtle.digest(
|
||||||
|
"SHA-1",
|
||||||
|
encoder.encode(BUILD_ID + path),
|
||||||
|
).then((hash) =>
|
||||||
|
Array.from(new Uint8Array(hash))
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
const staticFile: StaticFile = {
|
||||||
|
localUrl,
|
||||||
|
path,
|
||||||
|
size: stat.size,
|
||||||
|
contentType,
|
||||||
|
etag,
|
||||||
|
};
|
||||||
|
staticFiles.push(staticFile);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Deno.errors.NotFound) {
|
||||||
|
// Do nothing.
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServerContext(
|
||||||
|
routes,
|
||||||
|
islands,
|
||||||
|
staticFiles,
|
||||||
|
opts.render ?? DEFAULT_RENDER_FN,
|
||||||
|
middlewares,
|
||||||
|
app,
|
||||||
|
notFound,
|
||||||
|
error,
|
||||||
|
opts.plugins ?? [],
|
||||||
|
importMapURL,
|
||||||
|
jsxConfig,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This functions returns a request handler that handles all routes required
|
||||||
|
* by fresh, including static files.
|
||||||
|
*/
|
||||||
|
handler(): RequestHandler {
|
||||||
|
const inner = rutt.router<RouterState>(...this.#handlers());
|
||||||
|
const withMiddlewares = this.#composeMiddlewares(this.#middlewares);
|
||||||
|
return function handler(req: Request, connInfo: ConnInfo) {
|
||||||
|
// Redirect requests that end with a trailing slash
|
||||||
|
// to their non-trailing slash counterpart.
|
||||||
|
// Ex: /about/ -> /about
|
||||||
|
//const url = new URL(req.url);
|
||||||
|
//if (url.pathname.length > 1 && url.pathname.endsWith("/")) {
|
||||||
|
// url.pathname = url.pathname.slice(0, -1);
|
||||||
|
// return Response.redirect(url.href, Status.TemporaryRedirect);
|
||||||
|
//}
|
||||||
|
return withMiddlewares(req, connInfo, inner);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify which middlewares should be applied for a request,
|
||||||
|
* chain them and return a handler response
|
||||||
|
*/
|
||||||
|
#composeMiddlewares(middlewares: MiddlewareRoute[]) {
|
||||||
|
return (
|
||||||
|
req: Request,
|
||||||
|
connInfo: ConnInfo,
|
||||||
|
inner: rutt.Handler<RouterState>,
|
||||||
|
) => {
|
||||||
|
// identify middlewares to apply, if any.
|
||||||
|
// middlewares should be already sorted from deepest to shallow layer
|
||||||
|
const mws = selectMiddlewares(req.url, middlewares);
|
||||||
|
|
||||||
|
const handlers: (() => Response | Promise<Response>)[] = [];
|
||||||
|
|
||||||
|
const ctx = {
|
||||||
|
next() {
|
||||||
|
const handler = handlers.shift()!;
|
||||||
|
return Promise.resolve(handler());
|
||||||
|
},
|
||||||
|
...connInfo,
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const mw of mws) {
|
||||||
|
if (mw.handler instanceof Array) {
|
||||||
|
for (const handler of mw.handler) {
|
||||||
|
handlers.push(() => handler(req, ctx));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const handler = mw.handler;
|
||||||
|
handlers.push(() => handler(req, ctx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.push(() => inner(req, ctx));
|
||||||
|
|
||||||
|
const handler = handlers.shift()!;
|
||||||
|
return handler();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function returns all routes required by fresh as an extended
|
||||||
|
* path-to-regex, to handler mapping.
|
||||||
|
*/
|
||||||
|
#handlers(): [
|
||||||
|
rutt.Routes<RouterState>,
|
||||||
|
rutt.Handler<RouterState>,
|
||||||
|
rutt.ErrorHandler<RouterState>,
|
||||||
|
] {
|
||||||
|
const routes: rutt.Routes<RouterState> = {};
|
||||||
|
|
||||||
|
routes[`${INTERNAL_PREFIX}${JS_PREFIX}/${BUILD_ID}/:path*`] = this
|
||||||
|
.#bundleAssetRoute();
|
||||||
|
|
||||||
|
if (this.#dev) {
|
||||||
|
routes[REFRESH_JS_URL] = () => {
|
||||||
|
const js =
|
||||||
|
`new EventSource("${ALIVE_URL}").addEventListener("message", function listener(e) { if (e.data !== "${BUILD_ID}") { this.removeEventListener('message', listener); location.reload(); } });`;
|
||||||
|
return new Response(js, {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/javascript; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
routes[ALIVE_URL] = () => {
|
||||||
|
let timerId: number | undefined = undefined;
|
||||||
|
const body = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(`data: ${BUILD_ID}\nretry: 100\n\n`);
|
||||||
|
timerId = setInterval(() => {
|
||||||
|
controller.enqueue(`data: ${BUILD_ID}\n\n`);
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (timerId !== undefined) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new Response(body.pipeThrough(new TextEncoderStream()), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/event-stream",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the static file routes.
|
||||||
|
// each files has 2 static routes:
|
||||||
|
// - one serving the file at its location without a "cache bursting" mechanism
|
||||||
|
// - one containing the BUILD_ID in the path that can be cached
|
||||||
|
for (
|
||||||
|
const { localUrl, path, size, contentType, etag } of this.#staticFiles
|
||||||
|
) {
|
||||||
|
const route = sanitizePathToRegex(path);
|
||||||
|
routes[`GET@${route}`] = this.#staticFileHandler(
|
||||||
|
localUrl,
|
||||||
|
size,
|
||||||
|
contentType,
|
||||||
|
etag,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const genRender = <Data = undefined>(
|
||||||
|
route: Route<Data> | UnknownPage | ErrorPage,
|
||||||
|
status: number,
|
||||||
|
) => {
|
||||||
|
const imports: string[] = [];
|
||||||
|
if (this.#dev) {
|
||||||
|
imports.push(REFRESH_JS_URL);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
req: Request,
|
||||||
|
params: Record<string, string>,
|
||||||
|
error?: unknown,
|
||||||
|
) => {
|
||||||
|
return async (data?: Data) => {
|
||||||
|
if (route.component === undefined) {
|
||||||
|
throw new Error("This page does not have a component to render.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof route.component === "function" &&
|
||||||
|
route.component.constructor.name === "AsyncFunction"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Async components are not supported. Fetch data inside of a route handler, as described in the docs: https://fresh.deno.dev/docs/getting-started/fetching-data",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preloads: string[] = [];
|
||||||
|
const resp = await internalRender({
|
||||||
|
route,
|
||||||
|
islands: this.#islands,
|
||||||
|
plugins: this.#plugins,
|
||||||
|
app: this.#app,
|
||||||
|
imports,
|
||||||
|
preloads,
|
||||||
|
renderFn: this.#renderFn,
|
||||||
|
url: new URL(req.url),
|
||||||
|
params,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const [body, csp] = resp;
|
||||||
|
if (csp) {
|
||||||
|
if (this.#dev) {
|
||||||
|
csp.directives.connectSrc = [
|
||||||
|
...(csp.directives.connectSrc ?? []),
|
||||||
|
SELF,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const directive = serializeCSPDirectives(csp.directives);
|
||||||
|
if (csp.reportOnly) {
|
||||||
|
headers["content-security-policy-report-only"] = directive;
|
||||||
|
} else {
|
||||||
|
headers["content-security-policy"] = directive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response(body, { status, headers });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUnknownRender = genRender(this.#notFound, Status.NotFound);
|
||||||
|
|
||||||
|
for (const route of this.#routes) {
|
||||||
|
const createRender = genRender(route, Status.OK);
|
||||||
|
if (typeof route.handler === "function") {
|
||||||
|
routes[route.pattern] = (req, ctx, params) =>
|
||||||
|
(route.handler as Handler)(req, {
|
||||||
|
...ctx,
|
||||||
|
params,
|
||||||
|
render: createRender(req, params),
|
||||||
|
renderNotFound: createUnknownRender(req, {}),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const [method, handler] of Object.entries(route.handler)) {
|
||||||
|
routes[`${method}@${route.pattern}`] = (req, ctx, params) =>
|
||||||
|
handler(req, {
|
||||||
|
...ctx,
|
||||||
|
params,
|
||||||
|
render: createRender(req, params),
|
||||||
|
renderNotFound: createUnknownRender(req, {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unknownHandler: rutt.Handler<RouterState> = (
|
||||||
|
req,
|
||||||
|
ctx,
|
||||||
|
) =>
|
||||||
|
this.#notFound.handler(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
render: createUnknownRender(req, {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorHandlerRender = genRender(
|
||||||
|
this.#error,
|
||||||
|
Status.InternalServerError,
|
||||||
|
);
|
||||||
|
const errorHandler: rutt.ErrorHandler<RouterState> = (
|
||||||
|
req,
|
||||||
|
ctx,
|
||||||
|
error,
|
||||||
|
) => {
|
||||||
|
console.error(
|
||||||
|
"%cAn error occurred during route handling or page rendering.",
|
||||||
|
"color:red",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return this.#error.handler(
|
||||||
|
req,
|
||||||
|
{
|
||||||
|
...ctx,
|
||||||
|
error,
|
||||||
|
render: errorHandlerRender(req, {}, error),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return [routes, unknownHandler, errorHandler];
|
||||||
|
}
|
||||||
|
|
||||||
|
#staticFileHandler(
|
||||||
|
localUrl: URL,
|
||||||
|
size: number,
|
||||||
|
contentType: string,
|
||||||
|
etag: string,
|
||||||
|
): rutt.MatchHandler {
|
||||||
|
return async (req: Request) => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const key = url.searchParams.get(ASSET_CACHE_BUST_KEY);
|
||||||
|
if (key !== null && BUILD_ID !== key) {
|
||||||
|
url.searchParams.delete(ASSET_CACHE_BUST_KEY);
|
||||||
|
const location = url.pathname + url.search;
|
||||||
|
return new Response("", {
|
||||||
|
status: 307,
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
location,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const headers = new Headers({
|
||||||
|
"content-type": contentType,
|
||||||
|
etag,
|
||||||
|
vary: "If-None-Match",
|
||||||
|
});
|
||||||
|
if (key !== null) {
|
||||||
|
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
|
}
|
||||||
|
const ifNoneMatch = req.headers.get("if-none-match");
|
||||||
|
if (ifNoneMatch === etag || ifNoneMatch === "W/" + etag) {
|
||||||
|
return new Response(null, { status: 304, headers });
|
||||||
|
} else {
|
||||||
|
const file = await Deno.open(localUrl);
|
||||||
|
headers.set("content-length", String(size));
|
||||||
|
return new Response(file.readable, { headers });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a router that contains all fresh routes. Should be mounted at
|
||||||
|
* constants.INTERNAL_PREFIX
|
||||||
|
*/
|
||||||
|
#bundleAssetRoute = (): rutt.MatchHandler => {
|
||||||
|
return async (_req, _ctx, params) => {
|
||||||
|
const path = `/${params.path}`;
|
||||||
|
const file = await this.#bundler.get(path);
|
||||||
|
let res;
|
||||||
|
if (file) {
|
||||||
|
const headers = new Headers({
|
||||||
|
"Cache-Control": "public, max-age=604800, immutable",
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = typeByExtension(extname(path));
|
||||||
|
if (contentType) {
|
||||||
|
headers.set("Content-Type", contentType);
|
||||||
|
}
|
||||||
|
|
||||||
|
res = new Response(file, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return res ?? new Response(null, {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_RENDER_FN: RenderFunction = (_ctx, render) => {
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_APP: AppModule = {
|
||||||
|
default: ({ Component }) => h(Component, {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_NOT_FOUND: UnknownPage = {
|
||||||
|
pattern: "",
|
||||||
|
url: "",
|
||||||
|
name: "_404",
|
||||||
|
handler: (req) => rutt.defaultOtherHandler(req),
|
||||||
|
csp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ERROR: ErrorPage = {
|
||||||
|
pattern: "",
|
||||||
|
url: "",
|
||||||
|
name: "_500",
|
||||||
|
component: DefaultErrorHandler,
|
||||||
|
handler: (_req, ctx) => ctx.render(),
|
||||||
|
csp: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a list of middlewares that needs to be applied for request url
|
||||||
|
* @param url the request url
|
||||||
|
* @param middlewares Array of middlewares handlers and their routes as path-to-regexp style
|
||||||
|
*/
|
||||||
|
export function selectMiddlewares(url: string, middlewares: MiddlewareRoute[]) {
|
||||||
|
const selectedMws: Middleware[] = [];
|
||||||
|
const reqURL = new URL(url);
|
||||||
|
|
||||||
|
for (const { compiledPattern, handler } of middlewares) {
|
||||||
|
const res = compiledPattern.exec(reqURL);
|
||||||
|
if (res) {
|
||||||
|
selectedMws.push({ handler });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedMws;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sort pages by their relative routing priority, based on the parts in the
|
||||||
|
* route matcher
|
||||||
|
*/
|
||||||
|
function sortRoutes<T extends { pattern: string }>(routes: T[]) {
|
||||||
|
routes.sort((a, b) => {
|
||||||
|
const partsA = a.pattern.split("/");
|
||||||
|
const partsB = b.pattern.split("/");
|
||||||
|
for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
|
||||||
|
const partA = partsA[i];
|
||||||
|
const partB = partsB[i];
|
||||||
|
if (partA === undefined) return -1;
|
||||||
|
if (partB === undefined) return 1;
|
||||||
|
if (partA === partB) continue;
|
||||||
|
const priorityA = partA.startsWith(":") ? partA.endsWith("*") ? 0 : 1 : 2;
|
||||||
|
const priorityB = partB.startsWith(":") ? partB.endsWith("*") ? 0 : 1 : 2;
|
||||||
|
return Math.max(Math.min(priorityB - priorityA, 1), -1);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transform a filesystem URL path to a `path-to-regex` style matcher. */
|
||||||
|
function pathToPattern(path: string): string {
|
||||||
|
const parts = path.split("/");
|
||||||
|
if (parts[parts.length - 1] === "index") {
|
||||||
|
parts.pop();
|
||||||
|
}
|
||||||
|
const route = "/" + parts
|
||||||
|
.map((part) => {
|
||||||
|
if (part.startsWith("[...") && part.endsWith("]")) {
|
||||||
|
return `:${part.slice(4, part.length - 1)}**`;
|
||||||
|
}
|
||||||
|
if (part.startsWith("[") && part.endsWith("]")) {
|
||||||
|
return `:${part.slice(1, part.length - 1)}`;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
})
|
||||||
|
.join("/");
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a path for use in a URL. Returns null if the path is unparsable.
|
||||||
|
export function normalizeURLPath(path: string): string | null {
|
||||||
|
try {
|
||||||
|
const pathUrl = new URL("file:///");
|
||||||
|
pathUrl.pathname = path;
|
||||||
|
return pathUrl.pathname;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizePathToRegex(path: string): string {
|
||||||
|
return path
|
||||||
|
.replaceAll("\*", "\\*")
|
||||||
|
.replaceAll("\+", "\\+")
|
||||||
|
.replaceAll("\?", "\\?")
|
||||||
|
.replaceAll("\{", "\\{")
|
||||||
|
.replaceAll("\}", "\\}")
|
||||||
|
.replaceAll("\(", "\\(")
|
||||||
|
.replaceAll("\)", "\\)")
|
||||||
|
.replaceAll("\:", "\\:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPascalCase(text: string): string {
|
||||||
|
return text.replace(
|
||||||
|
/(^\w|-\w)/g,
|
||||||
|
(substring) => substring.replace(/-/, "").toUpperCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeIslandName(name: string): string {
|
||||||
|
const fileName = name.replace("/", "");
|
||||||
|
return toPascalCase(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeCSPDirectives(csp: ContentSecurityPolicyDirectives): string {
|
||||||
|
return Object.entries(csp)
|
||||||
|
.filter(([_key, value]) => value !== undefined)
|
||||||
|
.map(([k, v]: [string, string | string[]]) => {
|
||||||
|
// Turn camel case into snake case.
|
||||||
|
const key = k.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
|
||||||
|
const value = Array.isArray(v) ? v.join(" ") : v;
|
||||||
|
return `${key} ${value}`;
|
||||||
|
})
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function middlewarePathToPattern(baseRoute: string) {
|
||||||
|
baseRoute = baseRoute.slice(0, -"_middleware".length);
|
||||||
|
let pattern = pathToPattern(baseRoute);
|
||||||
|
if (pattern.endsWith("/")) {
|
||||||
|
pattern = pattern.slice(0, -1) + "{/*}?";
|
||||||
|
}
|
||||||
|
const compiledPattern = new URLPattern({ pathname: pattern });
|
||||||
|
return { pattern, compiledPattern };
|
||||||
|
}
|
24
fresh-main/src/server/context_test.ts
Normal file
24
fresh-main/src/server/context_test.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { assert } from "../../tests/deps.ts";
|
||||||
|
import { middlewarePathToPattern, selectMiddlewares } from "./context.ts";
|
||||||
|
import { MiddlewareRoute } from "./types.ts";
|
||||||
|
|
||||||
|
Deno.test("selectMiddlewares", () => {
|
||||||
|
const url = "https://fresh.deno.dev/api/abc/def";
|
||||||
|
const middlewaresPath = [
|
||||||
|
// should select
|
||||||
|
"_middleware",
|
||||||
|
"api/_middleware",
|
||||||
|
"api/[id]/_middleware",
|
||||||
|
"api/[id]/[path]/_middleware",
|
||||||
|
|
||||||
|
// should not select
|
||||||
|
"api/xyz/_middleware",
|
||||||
|
"api/[id]/xyz/_middleware",
|
||||||
|
"api/[id]/[path]/foo/_middleware",
|
||||||
|
];
|
||||||
|
const mwRoutes = middlewaresPath.map((path) =>
|
||||||
|
middlewarePathToPattern(path)
|
||||||
|
) as MiddlewareRoute[];
|
||||||
|
const mws = selectMiddlewares(url, mwRoutes);
|
||||||
|
assert(mws.length === 4);
|
||||||
|
});
|
58
fresh-main/src/server/default_error_page.ts
Normal file
58
fresh-main/src/server/default_error_page.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { h } from "preact";
|
||||||
|
import { DEBUG } from "./constants.ts";
|
||||||
|
import type { ErrorPageProps } from "./types.ts";
|
||||||
|
|
||||||
|
export default function DefaultErrorPage(props: ErrorPageProps) {
|
||||||
|
const { error } = props;
|
||||||
|
|
||||||
|
let message = undefined;
|
||||||
|
if (DEBUG) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.stack;
|
||||||
|
} else {
|
||||||
|
message = String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
style: {
|
||||||
|
border: "#f3f4f6 2px solid",
|
||||||
|
borderTop: "red 4px solid",
|
||||||
|
background: "#f9fafb",
|
||||||
|
margin: 16,
|
||||||
|
minWidth: "300px",
|
||||||
|
width: "50%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
h("p", {
|
||||||
|
style: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "12pt",
|
||||||
|
padding: 16,
|
||||||
|
fontFamily: "sans-serif",
|
||||||
|
},
|
||||||
|
}, "An error occurred during route handling or page rendering."),
|
||||||
|
message && h("pre", {
|
||||||
|
style: {
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "12pt",
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: 16,
|
||||||
|
paddingTop: 0,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
},
|
||||||
|
}, message),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
31
fresh-main/src/server/deps.ts
Normal file
31
fresh-main/src/server/deps.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
// -- std --
|
||||||
|
export {
|
||||||
|
extname,
|
||||||
|
fromFileUrl,
|
||||||
|
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 { serve } from "https://deno.land/std@0.150.0/http/server.ts";
|
||||||
|
export type {
|
||||||
|
ConnInfo,
|
||||||
|
Handler as RequestHandler,
|
||||||
|
ServeInit,
|
||||||
|
} from "https://deno.land/std@0.150.0/http/server.ts";
|
||||||
|
export { Status } from "https://deno.land/std@0.150.0/http/http_status.ts";
|
||||||
|
export {
|
||||||
|
typeByExtension,
|
||||||
|
} from "https://deno.land/std@0.150.0/media_types/mod.ts";
|
||||||
|
|
||||||
|
// -- rutt --
|
||||||
|
export * as rutt from "https://deno.land/x/rutt@0.0.14/mod.ts";
|
||||||
|
|
||||||
|
// -- esbuild --
|
||||||
|
// @deno-types="https://deno.land/x/esbuild@v0.14.51/mod.d.ts"
|
||||||
|
import * as esbuildWasm from "https://deno.land/x/esbuild@v0.14.51/wasm.js";
|
||||||
|
import * as esbuildNative from "https://deno.land/x/esbuild@v0.14.51/mod.js";
|
||||||
|
// @ts-ignore trust me
|
||||||
|
const esbuild: typeof esbuildWasm = Deno.run === undefined
|
||||||
|
? esbuildWasm
|
||||||
|
: esbuildNative;
|
||||||
|
export { esbuild, esbuildWasm as esbuildTypes };
|
||||||
|
export { denoPlugin } from "https://deno.land/x/esbuild_deno_loader@0.5.2/mod.ts";
|
BIN
fresh-main/src/server/esbuild_v0.14.51.wasm
Normal file
BIN
fresh-main/src/server/esbuild_v0.14.51.wasm
Normal file
Binary file not shown.
15
fresh-main/src/server/htmlescape.ts
Normal file
15
fresh-main/src/server/htmlescape.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// This utility is based on https://github.com/zertosh/htmlescape
|
||||||
|
// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE
|
||||||
|
|
||||||
|
const ESCAPE_LOOKUP: { [match: string]: string } = {
|
||||||
|
">": "\\u003e",
|
||||||
|
"<": "\\u003c",
|
||||||
|
"\u2028": "\\u2028",
|
||||||
|
"\u2029": "\\u2029",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCAPE_REGEX = /[><\u2028\u2029]/g;
|
||||||
|
|
||||||
|
export function htmlEscapeJsonString(str: string): string {
|
||||||
|
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||||
|
}
|
18
fresh-main/src/server/htmlescape_test.ts
Normal file
18
fresh-main/src/server/htmlescape_test.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { htmlEscapeJsonString } from "./htmlescape.ts";
|
||||||
|
import { assertEquals } from "../../tests/deps.ts";
|
||||||
|
|
||||||
|
Deno.test("with angle brackets should escape", () => {
|
||||||
|
const evilObj = { evil: "<script></script>" };
|
||||||
|
assertEquals(
|
||||||
|
htmlEscapeJsonString(JSON.stringify(evilObj)),
|
||||||
|
'{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("with angle brackets should parse back", () => {
|
||||||
|
const evilObj = { evil: "<script></script>" };
|
||||||
|
assertEquals(
|
||||||
|
JSON.parse(htmlEscapeJsonString(JSON.stringify(evilObj))),
|
||||||
|
evilObj,
|
||||||
|
);
|
||||||
|
});
|
72
fresh-main/src/server/mod.ts
Normal file
72
fresh-main/src/server/mod.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { ServerContext } from "./context.ts";
|
||||||
|
import { serve } from "./deps.ts";
|
||||||
|
export { Status } from "./deps.ts";
|
||||||
|
import {
|
||||||
|
AppModule,
|
||||||
|
ErrorPageModule,
|
||||||
|
IslandModule,
|
||||||
|
MiddlewareModule,
|
||||||
|
RouteModule,
|
||||||
|
StartOptions,
|
||||||
|
UnknownPageModule,
|
||||||
|
} from "./types.ts";
|
||||||
|
export type {
|
||||||
|
AppProps,
|
||||||
|
ErrorHandler,
|
||||||
|
ErrorHandlerContext,
|
||||||
|
ErrorPageProps,
|
||||||
|
FreshOptions,
|
||||||
|
Handler,
|
||||||
|
HandlerContext,
|
||||||
|
Handlers,
|
||||||
|
MiddlewareHandler,
|
||||||
|
MiddlewareHandlerContext,
|
||||||
|
PageProps,
|
||||||
|
Plugin,
|
||||||
|
PluginRenderResult,
|
||||||
|
PluginRenderScripts,
|
||||||
|
PluginRenderStyleTag,
|
||||||
|
RenderFunction,
|
||||||
|
RouteConfig,
|
||||||
|
StartOptions,
|
||||||
|
UnknownHandler,
|
||||||
|
UnknownHandlerContext,
|
||||||
|
UnknownPageProps,
|
||||||
|
} from "./types.ts";
|
||||||
|
export { RenderContext } from "./render.ts";
|
||||||
|
export type { InnerRenderFunction } from "./render.ts";
|
||||||
|
|
||||||
|
export interface Manifest {
|
||||||
|
routes: Record<
|
||||||
|
string,
|
||||||
|
| RouteModule
|
||||||
|
| MiddlewareModule
|
||||||
|
| AppModule
|
||||||
|
| ErrorPageModule
|
||||||
|
| UnknownPageModule
|
||||||
|
>;
|
||||||
|
islands: Record<string, IslandModule>;
|
||||||
|
baseUrl: string;
|
||||||
|
config?: DenoConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DenoConfig {
|
||||||
|
importMap: string;
|
||||||
|
compilerOptions?: {
|
||||||
|
jsx?: string;
|
||||||
|
jsxImportSource?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ServerContext };
|
||||||
|
|
||||||
|
export async function start(routes: Manifest, opts: StartOptions = {}) {
|
||||||
|
const ctx = await ServerContext.fromManifest(routes, opts);
|
||||||
|
opts.port ??= 8000;
|
||||||
|
if (opts.experimentalDenoServe === true) {
|
||||||
|
// @ts-ignore as `Deno.serve` is still unstable.
|
||||||
|
await Deno.serve(ctx.handler() as Deno.ServeHandler, opts);
|
||||||
|
} else {
|
||||||
|
await serve(ctx.handler(), opts);
|
||||||
|
}
|
||||||
|
}
|
387
fresh-main/src/server/render.ts
Normal file
387
fresh-main/src/server/render.ts
Normal file
@ -0,0 +1,387 @@
|
|||||||
|
import { renderToString } from "preact-render-to-string";
|
||||||
|
import { ComponentChildren, ComponentType, h, options } from "preact";
|
||||||
|
import {
|
||||||
|
AppModule,
|
||||||
|
ErrorPage,
|
||||||
|
Island,
|
||||||
|
Plugin,
|
||||||
|
PluginRenderFunctionResult,
|
||||||
|
PluginRenderResult,
|
||||||
|
PluginRenderStyleTag,
|
||||||
|
RenderFunction,
|
||||||
|
Route,
|
||||||
|
UnknownPage,
|
||||||
|
} from "./types.ts";
|
||||||
|
import { HEAD_CONTEXT } from "../runtime/head.ts";
|
||||||
|
import { CSP_CONTEXT, nonce, NONE, UNSAFE_INLINE } from "../runtime/csp.ts";
|
||||||
|
import { ContentSecurityPolicy } from "../runtime/csp.ts";
|
||||||
|
import { bundleAssetUrl } from "./constants.ts";
|
||||||
|
import { assetHashingHook } from "../runtime/utils.ts";
|
||||||
|
import { htmlEscapeJsonString } from "./htmlescape.ts";
|
||||||
|
|
||||||
|
export interface RenderOptions<Data> {
|
||||||
|
route: Route<Data> | UnknownPage | ErrorPage;
|
||||||
|
islands: Island[];
|
||||||
|
plugins: Plugin[];
|
||||||
|
app: AppModule;
|
||||||
|
imports: string[];
|
||||||
|
preloads: string[];
|
||||||
|
url: URL;
|
||||||
|
params: Record<string, string | string[]>;
|
||||||
|
renderFn: RenderFunction;
|
||||||
|
data?: Data;
|
||||||
|
error?: unknown;
|
||||||
|
lang?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InnerRenderFunction = () => string;
|
||||||
|
|
||||||
|
export class RenderContext {
|
||||||
|
#id: string;
|
||||||
|
#state: Map<string, unknown> = new Map();
|
||||||
|
#styles: string[] = [];
|
||||||
|
#url: URL;
|
||||||
|
#route: string;
|
||||||
|
#lang: string;
|
||||||
|
|
||||||
|
constructor(id: string, url: URL, route: string, lang: string) {
|
||||||
|
this.#id = id;
|
||||||
|
this.#url = url;
|
||||||
|
this.#route = route;
|
||||||
|
this.#lang = lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A unique ID for this logical JIT render. */
|
||||||
|
get id(): string {
|
||||||
|
return this.#id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State that is persisted between multiple renders with the same render
|
||||||
|
* context. This is useful because one logical JIT render could have multiple
|
||||||
|
* preact render passes due to suspense.
|
||||||
|
*/
|
||||||
|
get state(): Map<string, unknown> {
|
||||||
|
return this.#state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All of the CSS style rules that should be inlined into the document.
|
||||||
|
* Adding to this list across multiple renders is supported (even across
|
||||||
|
* suspense!). The CSS rules will always be inserted on the client in the
|
||||||
|
* order specified here.
|
||||||
|
*/
|
||||||
|
get styles(): string[] {
|
||||||
|
return this.#styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The URL of the page being rendered. */
|
||||||
|
get url(): URL {
|
||||||
|
return this.#url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The route matcher (e.g. /blog/:id) that the request matched for this page
|
||||||
|
* to be rendered. */
|
||||||
|
get route(): string {
|
||||||
|
return this.#route;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The language of the page being rendered. Defaults to "en". */
|
||||||
|
get lang(): string {
|
||||||
|
return this.#lang;
|
||||||
|
}
|
||||||
|
set lang(lang: string) {
|
||||||
|
this.#lang = lang;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultCsp() {
|
||||||
|
return {
|
||||||
|
directives: { defaultSrc: [NONE], styleSrc: [UNSAFE_INLINE] },
|
||||||
|
reportOnly: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function renders out a page. Rendering is synchronous and non streaming.
|
||||||
|
* Suspense boundaries are not supported.
|
||||||
|
*/
|
||||||
|
export async function render<Data>(
|
||||||
|
opts: RenderOptions<Data>,
|
||||||
|
): Promise<[string, ContentSecurityPolicy | undefined]> {
|
||||||
|
const props: Record<string, unknown> = {
|
||||||
|
params: opts.params,
|
||||||
|
url: opts.url,
|
||||||
|
route: opts.route.pattern,
|
||||||
|
data: opts.data,
|
||||||
|
};
|
||||||
|
if (opts.error) {
|
||||||
|
props.error = opts.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const csp: ContentSecurityPolicy | undefined = opts.route.csp
|
||||||
|
? defaultCsp()
|
||||||
|
: undefined;
|
||||||
|
const headComponents: ComponentChildren[] = [];
|
||||||
|
|
||||||
|
const vnode = h(CSP_CONTEXT.Provider, {
|
||||||
|
value: csp,
|
||||||
|
children: h(HEAD_CONTEXT.Provider, {
|
||||||
|
value: headComponents,
|
||||||
|
children: h(opts.app.default, {
|
||||||
|
Component() {
|
||||||
|
return h(opts.route.component! as ComponentType<unknown>, props);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = new RenderContext(
|
||||||
|
crypto.randomUUID(),
|
||||||
|
opts.url,
|
||||||
|
opts.route.pattern,
|
||||||
|
opts.lang ?? "en",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (csp) {
|
||||||
|
// Clear the csp
|
||||||
|
const newCsp = defaultCsp();
|
||||||
|
csp.directives = newCsp.directives;
|
||||||
|
csp.reportOnly = newCsp.reportOnly;
|
||||||
|
}
|
||||||
|
// Clear the head components
|
||||||
|
headComponents.splice(0, headComponents.length);
|
||||||
|
|
||||||
|
// Setup the interesting VNode types
|
||||||
|
ISLANDS.splice(0, ISLANDS.length, ...opts.islands);
|
||||||
|
|
||||||
|
// Clear the encountered vnodes
|
||||||
|
ENCOUNTERED_ISLANDS.clear();
|
||||||
|
|
||||||
|
// Clear the island props
|
||||||
|
ISLAND_PROPS = [];
|
||||||
|
|
||||||
|
let bodyHtml: string | null = null;
|
||||||
|
|
||||||
|
function realRender(): string {
|
||||||
|
bodyHtml = renderToString(vnode);
|
||||||
|
return bodyHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins = opts.plugins.filter((p) => p.render !== null);
|
||||||
|
const renderResults: [Plugin, PluginRenderResult][] = [];
|
||||||
|
|
||||||
|
function render(): PluginRenderFunctionResult {
|
||||||
|
const plugin = plugins.shift();
|
||||||
|
if (plugin) {
|
||||||
|
const res = plugin.render!({ render });
|
||||||
|
if (res === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
`${plugin?.name}'s render hook did not return a PluginRenderResult object.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
renderResults.push([plugin, res]);
|
||||||
|
} else {
|
||||||
|
realRender();
|
||||||
|
}
|
||||||
|
if (bodyHtml === null) {
|
||||||
|
throw new Error(
|
||||||
|
`The 'render' function was not called by ${plugin?.name}'s render hook.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
htmlText: bodyHtml,
|
||||||
|
requiresHydration: ENCOUNTERED_ISLANDS.size > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await opts.renderFn(ctx, () => render().htmlText);
|
||||||
|
|
||||||
|
if (bodyHtml === null) {
|
||||||
|
throw new Error("The `render` function was not called by the renderer.");
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyHtml = bodyHtml as string;
|
||||||
|
|
||||||
|
const imports = opts.imports.map((url) => {
|
||||||
|
const randomNonce = crypto.randomUUID().replace(/-/g, "");
|
||||||
|
if (csp) {
|
||||||
|
csp.directives.scriptSrc = [
|
||||||
|
...csp.directives.scriptSrc ?? [],
|
||||||
|
nonce(randomNonce),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [url, randomNonce] as const;
|
||||||
|
});
|
||||||
|
|
||||||
|
const state: [islands: unknown[], plugins: unknown[]] = [ISLAND_PROPS, []];
|
||||||
|
const styleTags: PluginRenderStyleTag[] = [];
|
||||||
|
|
||||||
|
let script =
|
||||||
|
`const STATE_COMPONENT = document.getElementById("__FRSH_STATE");const STATE = JSON.parse(STATE_COMPONENT?.textContent ?? "[[],[]]");`;
|
||||||
|
|
||||||
|
for (const [plugin, res] of renderResults) {
|
||||||
|
for (const hydrate of res.scripts ?? []) {
|
||||||
|
const i = state[1].push(hydrate.state) - 1;
|
||||||
|
const randomNonce = crypto.randomUUID().replace(/-/g, "");
|
||||||
|
if (csp) {
|
||||||
|
csp.directives.scriptSrc = [
|
||||||
|
...csp.directives.scriptSrc ?? [],
|
||||||
|
nonce(randomNonce),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const url = bundleAssetUrl(
|
||||||
|
`/plugin-${plugin.name}-${hydrate.entrypoint}.js`,
|
||||||
|
);
|
||||||
|
imports.push([url, randomNonce] as const);
|
||||||
|
|
||||||
|
script += `import p${i} from "${url}";p${i}(STATE[1][${i}]);`;
|
||||||
|
}
|
||||||
|
styleTags.splice(styleTags.length, 0, ...res.styles ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ENCOUNTERED_ISLANDS.size > 0) {
|
||||||
|
// Load the main.js script
|
||||||
|
{
|
||||||
|
const randomNonce = crypto.randomUUID().replace(/-/g, "");
|
||||||
|
if (csp) {
|
||||||
|
csp.directives.scriptSrc = [
|
||||||
|
...csp.directives.scriptSrc ?? [],
|
||||||
|
nonce(randomNonce),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const url = bundleAssetUrl("/main.js");
|
||||||
|
imports.push([url, randomNonce] as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
script += `import { revive } from "${bundleAssetUrl("/main.js")}";`;
|
||||||
|
|
||||||
|
// Prepare the inline script that loads and revives the islands
|
||||||
|
let islandRegistry = "";
|
||||||
|
for (const island of ENCOUNTERED_ISLANDS) {
|
||||||
|
const randomNonce = crypto.randomUUID().replace(/-/g, "");
|
||||||
|
if (csp) {
|
||||||
|
csp.directives.scriptSrc = [
|
||||||
|
...csp.directives.scriptSrc ?? [],
|
||||||
|
nonce(randomNonce),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const url = bundleAssetUrl(`/island-${island.id}.js`);
|
||||||
|
imports.push([url, randomNonce] as const);
|
||||||
|
script += `import ${island.name} from "${url}";`;
|
||||||
|
islandRegistry += `${island.id}:${island.name},`;
|
||||||
|
}
|
||||||
|
script += `revive({${islandRegistry}}, STATE[0]);`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state[0].length > 0 || state[1].length > 0) {
|
||||||
|
// Append state to the body
|
||||||
|
bodyHtml += `<script id="__FRSH_STATE" type="application/json">${
|
||||||
|
htmlEscapeJsonString(JSON.stringify(state))
|
||||||
|
}</script>`;
|
||||||
|
|
||||||
|
// Append the inline script to the body
|
||||||
|
const randomNonce = crypto.randomUUID().replace(/-/g, "");
|
||||||
|
if (csp) {
|
||||||
|
csp.directives.scriptSrc = [
|
||||||
|
...csp.directives.scriptSrc ?? [],
|
||||||
|
nonce(randomNonce),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
bodyHtml +=
|
||||||
|
`<script type="module" nonce="${randomNonce}">${script}</script>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.styles.length > 0) {
|
||||||
|
const node = h("style", {
|
||||||
|
id: "__FRSH_STYLE",
|
||||||
|
dangerouslySetInnerHTML: { __html: ctx.styles.join("\n") },
|
||||||
|
});
|
||||||
|
headComponents.splice(0, 0, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const style of styleTags) {
|
||||||
|
const node = h("style", {
|
||||||
|
id: style.id,
|
||||||
|
dangerouslySetInnerHTML: { __html: style.cssText },
|
||||||
|
media: style.media,
|
||||||
|
});
|
||||||
|
headComponents.splice(0, 0, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = template({
|
||||||
|
bodyHtml,
|
||||||
|
headComponents,
|
||||||
|
imports,
|
||||||
|
preloads: opts.preloads,
|
||||||
|
lang: ctx.lang,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [html, csp];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemplateOptions {
|
||||||
|
bodyHtml: string;
|
||||||
|
headComponents: ComponentChildren[];
|
||||||
|
imports: (readonly [string, string])[];
|
||||||
|
preloads: string[];
|
||||||
|
lang: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function template(opts: TemplateOptions): string {
|
||||||
|
const page = h(
|
||||||
|
"html",
|
||||||
|
{ lang: opts.lang },
|
||||||
|
h(
|
||||||
|
"head",
|
||||||
|
null,
|
||||||
|
h("meta", { charSet: "UTF-8" }),
|
||||||
|
h("meta", {
|
||||||
|
name: "viewport",
|
||||||
|
content: "width=device-width, initial-scale=1.0",
|
||||||
|
}),
|
||||||
|
opts.preloads.map((src) =>
|
||||||
|
h("link", { rel: "modulepreload", href: src })
|
||||||
|
),
|
||||||
|
opts.imports.map(([src, nonce]) =>
|
||||||
|
h("script", { src: src, nonce: nonce, type: "module" })
|
||||||
|
),
|
||||||
|
opts.headComponents,
|
||||||
|
),
|
||||||
|
h("body", { dangerouslySetInnerHTML: { __html: opts.bodyHtml } }),
|
||||||
|
);
|
||||||
|
return "<!DOCTYPE html>" + renderToString(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up a preact option hook to track when vnode with custom functions are
|
||||||
|
// created.
|
||||||
|
const ISLANDS: Island[] = [];
|
||||||
|
const ENCOUNTERED_ISLANDS: Set<Island> = new Set([]);
|
||||||
|
let ISLAND_PROPS: unknown[] = [];
|
||||||
|
const originalHook = options.vnode;
|
||||||
|
let ignoreNext = false;
|
||||||
|
options.vnode = (vnode) => {
|
||||||
|
assetHashingHook(vnode);
|
||||||
|
const originalType = vnode.type as ComponentType<unknown>;
|
||||||
|
if (typeof vnode.type === "function") {
|
||||||
|
const island = ISLANDS.find((island) => island.component === originalType);
|
||||||
|
if (island) {
|
||||||
|
if (ignoreNext) {
|
||||||
|
ignoreNext = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ENCOUNTERED_ISLANDS.add(island);
|
||||||
|
vnode.type = (props) => {
|
||||||
|
ignoreNext = true;
|
||||||
|
const child = h(originalType, props);
|
||||||
|
ISLAND_PROPS.push(props);
|
||||||
|
return h(
|
||||||
|
`!--frsh-${island.id}:${ISLAND_PROPS.length - 1}--`,
|
||||||
|
null,
|
||||||
|
child,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (originalHook) originalHook(vnode);
|
||||||
|
};
|
14
fresh-main/src/server/render_test.ts
Normal file
14
fresh-main/src/server/render_test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { template } from "./render.ts";
|
||||||
|
import { assertStringIncludes } from "../../tests/deps.ts";
|
||||||
|
|
||||||
|
Deno.test("check lang", () => {
|
||||||
|
const lang = "fr";
|
||||||
|
const body = template({
|
||||||
|
bodyHtml: "",
|
||||||
|
headComponents: [],
|
||||||
|
imports: [],
|
||||||
|
preloads: [],
|
||||||
|
lang,
|
||||||
|
});
|
||||||
|
assertStringIncludes(body, `<html lang="${lang}">`);
|
||||||
|
});
|
306
fresh-main/src/server/types.ts
Normal file
306
fresh-main/src/server/types.ts
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import { ComponentType } from "preact";
|
||||||
|
import { ConnInfo, rutt, ServeInit } from "./deps.ts";
|
||||||
|
import { InnerRenderFunction, RenderContext } from "./render.ts";
|
||||||
|
|
||||||
|
// --- APPLICATION CONFIGURATION ---
|
||||||
|
|
||||||
|
export type StartOptions = ServeInit & FreshOptions & {
|
||||||
|
/**
|
||||||
|
* UNSTABLE: use the `Deno.serve` API as the underlying HTTP server instead of
|
||||||
|
* the `std/http` API. Do not use this in production.
|
||||||
|
*
|
||||||
|
* This option is experimental and may be removed in a future Fresh release.
|
||||||
|
*/
|
||||||
|
experimentalDenoServe?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FreshOptions {
|
||||||
|
render?: RenderFunction;
|
||||||
|
plugins?: Plugin[];
|
||||||
|
staticDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderFunction = (
|
||||||
|
ctx: RenderContext,
|
||||||
|
render: InnerRenderFunction,
|
||||||
|
) => void | Promise<void>;
|
||||||
|
|
||||||
|
/// --- ROUTES ---
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export interface PageProps<T = any> {
|
||||||
|
/** The URL of the request that resulted in this page being rendered. */
|
||||||
|
url: URL;
|
||||||
|
|
||||||
|
/** The route matcher (e.g. /blog/:id) that the request matched for this page
|
||||||
|
* to be rendered. */
|
||||||
|
route: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameters that were matched from the route.
|
||||||
|
*
|
||||||
|
* For the `/foo/:bar` route with url `/foo/123`, `params` would be
|
||||||
|
* `{ bar: '123' }`. For a route with no matchers, `params` would be `{}`. For
|
||||||
|
* a wildcard route, like `/foo/:path*` with url `/foo/bar/baz`, `params` would
|
||||||
|
* be `{ path: 'bar/baz' }`.
|
||||||
|
*/
|
||||||
|
params: Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional data passed into `HandlerContext.render`. Defaults to
|
||||||
|
* `undefined`.
|
||||||
|
*/
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteConfig {
|
||||||
|
/**
|
||||||
|
* A route override for the page. This is useful for pages where the route
|
||||||
|
* can not be expressed through the filesystem routing capabilities.
|
||||||
|
*
|
||||||
|
* The route override must be a path-to-regexp compatible route matcher.
|
||||||
|
*/
|
||||||
|
routeOverride?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If Content-Security-Policy should be enabled for this page. If 'true', a
|
||||||
|
* locked down policy will be used that allows only the scripts and styles
|
||||||
|
* that are generated by fresh. Additional scripts and styles can be added
|
||||||
|
* using the `useCSP` hook.
|
||||||
|
*/
|
||||||
|
csp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HandlerContext<Data = unknown, State = Record<string, unknown>>
|
||||||
|
extends ConnInfo {
|
||||||
|
params: Record<string, string>;
|
||||||
|
render: (data?: Data) => Response | Promise<Response>;
|
||||||
|
renderNotFound: () => Response | Promise<Response>;
|
||||||
|
state: State;
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export type Handler<T = any, State = Record<string, unknown>> = (
|
||||||
|
req: Request,
|
||||||
|
ctx: HandlerContext<T, State>,
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export type Handlers<T = any, State = Record<string, unknown>> = {
|
||||||
|
[K in typeof rutt.METHODS[number]]?: Handler<T, State>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface RouteModule {
|
||||||
|
default?: ComponentType<PageProps>;
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
handler?: Handler<any, any> | Handlers<any, any>;
|
||||||
|
config?: RouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export interface Route<Data = any> {
|
||||||
|
pattern: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
component?: ComponentType<PageProps<Data>>;
|
||||||
|
handler: Handler<Data> | Handlers<Data>;
|
||||||
|
csp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- APP ---
|
||||||
|
|
||||||
|
export interface AppProps {
|
||||||
|
Component: ComponentType<Record<never, never>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppModule {
|
||||||
|
default: ComponentType<AppProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UNKNOWN PAGE ---
|
||||||
|
|
||||||
|
export interface UnknownPageProps {
|
||||||
|
/** The URL of the request that resulted in this page being rendered. */
|
||||||
|
url: URL;
|
||||||
|
|
||||||
|
/** The route matcher (e.g. /blog/:id) that the request matched for this page
|
||||||
|
* to be rendered. */
|
||||||
|
route: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnknownHandlerContext<State = Record<string, unknown>>
|
||||||
|
extends ConnInfo {
|
||||||
|
render: () => Response | Promise<Response>;
|
||||||
|
state: State;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UnknownHandler = (
|
||||||
|
req: Request,
|
||||||
|
ctx: UnknownHandlerContext,
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
export interface UnknownPageModule {
|
||||||
|
default?: ComponentType<UnknownPageProps>;
|
||||||
|
handler?: UnknownHandler;
|
||||||
|
config?: RouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnknownPage {
|
||||||
|
pattern: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
component?: ComponentType<UnknownPageProps>;
|
||||||
|
handler: UnknownHandler;
|
||||||
|
csp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ERROR PAGE ---
|
||||||
|
|
||||||
|
export interface ErrorPageProps {
|
||||||
|
/** The URL of the request that resulted in this page being rendered. */
|
||||||
|
url: URL;
|
||||||
|
|
||||||
|
/** The route matcher (e.g. /blog/:id) that the request matched for this page
|
||||||
|
* to be rendered. */
|
||||||
|
pattern: string;
|
||||||
|
|
||||||
|
/** The error that caused the error page to be loaded. */
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorHandlerContext<State = Record<string, unknown>>
|
||||||
|
extends ConnInfo {
|
||||||
|
error: unknown;
|
||||||
|
render: () => Response | Promise<Response>;
|
||||||
|
state: State;
|
||||||
|
}
|
||||||
|
export type ErrorHandler = (
|
||||||
|
req: Request,
|
||||||
|
ctx: ErrorHandlerContext,
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
export interface ErrorPageModule {
|
||||||
|
default?: ComponentType<ErrorPageProps>;
|
||||||
|
handler?: ErrorHandler;
|
||||||
|
config?: RouteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErrorPage {
|
||||||
|
pattern: string;
|
||||||
|
url: string;
|
||||||
|
name: string;
|
||||||
|
component?: ComponentType<ErrorPageProps>;
|
||||||
|
handler: ErrorHandler;
|
||||||
|
csp: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MIDDLEWARES ---
|
||||||
|
|
||||||
|
export interface MiddlewareHandlerContext<State = Record<string, unknown>>
|
||||||
|
extends ConnInfo {
|
||||||
|
next: () => Promise<Response>;
|
||||||
|
state: State;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiddlewareRoute extends Middleware {
|
||||||
|
/**
|
||||||
|
* path-to-regexp style url path
|
||||||
|
*/
|
||||||
|
pattern: string;
|
||||||
|
/**
|
||||||
|
* URLPattern of the route
|
||||||
|
*/
|
||||||
|
compiledPattern: URLPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MiddlewareHandler<State = Record<string, unknown>> = (
|
||||||
|
req: Request,
|
||||||
|
ctx: MiddlewareHandlerContext<State>,
|
||||||
|
) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
export interface MiddlewareModule<State = any> {
|
||||||
|
handler: MiddlewareHandler<State> | MiddlewareHandler<State>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Middleware<State = Record<string, unknown>> {
|
||||||
|
handler: MiddlewareHandler<State> | MiddlewareHandler<State>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ISLANDS ---
|
||||||
|
|
||||||
|
export interface IslandModule {
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
default: ComponentType<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Island {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
component: ComponentType<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PLUGINS ---
|
||||||
|
|
||||||
|
export interface Plugin {
|
||||||
|
/** The name of the plugin. Must be snake-case. */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** A map of a snake-case names to a import specifiers. The entrypoints
|
||||||
|
* declared here can later be used in the "scripts" option of
|
||||||
|
* `PluginRenderResult` to load the entrypoint's code on the client.
|
||||||
|
*/
|
||||||
|
entrypoints?: Record<string, string>;
|
||||||
|
|
||||||
|
/** The render hook is called on the server every time some JSX needs to
|
||||||
|
* be turned into HTML. The render hook needs to call the `ctx.render`
|
||||||
|
* function exactly once.
|
||||||
|
*
|
||||||
|
* The hook can return a `PluginRenderResult` object that can do things like
|
||||||
|
* inject CSS into the page, or load additional JS files on the client.
|
||||||
|
*/
|
||||||
|
render?(ctx: PluginRenderContext): PluginRenderResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRenderContext {
|
||||||
|
render: PluginRenderFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRenderResult {
|
||||||
|
/** CSS styles to be injected into the page. */
|
||||||
|
styles?: PluginRenderStyleTag[];
|
||||||
|
/** JS scripts to ship to the client. */
|
||||||
|
scripts?: PluginRenderScripts[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRenderStyleTag {
|
||||||
|
cssText: string;
|
||||||
|
media?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRenderScripts {
|
||||||
|
/** The "key" of the entrypoint (as specified in `Plugin.entrypoints`) for the
|
||||||
|
* script that should be loaded. The script must be an ES module that exports
|
||||||
|
* a default function.
|
||||||
|
*
|
||||||
|
* The default function is invoked with the `state` argument specified below.
|
||||||
|
*/
|
||||||
|
entrypoint: string;
|
||||||
|
/** The state argument that is passed to the default export invocation of the
|
||||||
|
* entrypoint's default export. The state must be JSON-serializable.
|
||||||
|
*/
|
||||||
|
state: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PluginRenderFunction = () => PluginRenderFunctionResult;
|
||||||
|
|
||||||
|
export interface PluginRenderFunctionResult {
|
||||||
|
/** The HTML text that was rendered. */
|
||||||
|
htmlText: string;
|
||||||
|
/** If the renderer encountered any islands that require hydration on the
|
||||||
|
* client.
|
||||||
|
*/
|
||||||
|
requiresHydration: boolean;
|
||||||
|
}
|
180
fresh-main/update.ts
Normal file
180
fresh-main/update.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { join, Node, parse, Project, resolve } from "./src/dev/deps.ts";
|
||||||
|
import { error } from "./src/dev/error.ts";
|
||||||
|
import { freshImports, twindImports } from "./src/dev/imports.ts";
|
||||||
|
import { collect, ensureMinDenoVersion, generate } from "./src/dev/mod.ts";
|
||||||
|
|
||||||
|
ensureMinDenoVersion();
|
||||||
|
|
||||||
|
const help = `fresh-update
|
||||||
|
|
||||||
|
Update a Fresh project. This updates dependencies and optionally performs code
|
||||||
|
mods to update a project's source code to the latest recommended patterns.
|
||||||
|
|
||||||
|
To upgrade a projecct in the current directory, run:
|
||||||
|
fresh-update .
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
fresh-update <DIRECTORY>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const flags = parse(Deno.args, {});
|
||||||
|
|
||||||
|
if (flags._.length !== 1) {
|
||||||
|
error(help);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedDirectory = Deno.args[0];
|
||||||
|
const resolvedDirectory = resolve(unresolvedDirectory);
|
||||||
|
|
||||||
|
// Update dependencies in the import map.
|
||||||
|
const IMPORT_MAP_PATH = join(resolvedDirectory, "import_map.json");
|
||||||
|
let importMapText = await Deno.readTextFile(IMPORT_MAP_PATH);
|
||||||
|
const importMap = JSON.parse(importMapText);
|
||||||
|
freshImports(importMap.imports);
|
||||||
|
if (importMap.imports["twind"]) {
|
||||||
|
twindImports(importMap.imports);
|
||||||
|
}
|
||||||
|
importMapText = JSON.stringify(importMap, null, 2);
|
||||||
|
await Deno.writeTextFile(IMPORT_MAP_PATH, importMapText);
|
||||||
|
|
||||||
|
// Code mod for classic JSX -> automatic JSX.
|
||||||
|
const JSX_CODEMOD =
|
||||||
|
`This project is using the classic JSX transform. Would you like to update to the
|
||||||
|
automatic JSX transform? This will remove the /** @jsx h */ pragma from your
|
||||||
|
source code and add the jsx: "react-jsx" compiler option to your deno.json file.`;
|
||||||
|
const DENO_JSON_PATH = join(resolvedDirectory, "deno.json");
|
||||||
|
let denoJsonText = await Deno.readTextFile(DENO_JSON_PATH);
|
||||||
|
const denoJson = JSON.parse(denoJsonText);
|
||||||
|
if (denoJson.compilerOptions?.jsx !== "react-jsx" && confirm(JSX_CODEMOD)) {
|
||||||
|
console.log("Updating config file...");
|
||||||
|
denoJson.compilerOptions = denoJson.compilerOptions || {};
|
||||||
|
denoJson.compilerOptions.jsx = "react-jsx";
|
||||||
|
denoJson.compilerOptions.jsxImportSource = "preact";
|
||||||
|
denoJsonText = JSON.stringify(denoJson, null, 2);
|
||||||
|
await Deno.writeTextFile(DENO_JSON_PATH, denoJsonText);
|
||||||
|
|
||||||
|
const project = new Project();
|
||||||
|
const sfs = project.addSourceFilesAtPaths(
|
||||||
|
join(resolvedDirectory, "**", "*.{js,jsx,ts,tsx}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const sf of sfs) {
|
||||||
|
for (const d of sf.getImportDeclarations()) {
|
||||||
|
if (d.getModuleSpecifierValue() !== "preact") continue;
|
||||||
|
for (const n of d.getNamedImports()) {
|
||||||
|
const name = n.getName();
|
||||||
|
if (name === "h" || name === "Fragment") n.remove();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
d.getNamedImports().length === 0 &&
|
||||||
|
d.getNamespaceImport() === undefined &&
|
||||||
|
d.getDefaultImport() === undefined
|
||||||
|
) {
|
||||||
|
d.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let text = sf.getFullText();
|
||||||
|
text = text.replaceAll("/** @jsx h */\n", "");
|
||||||
|
text = text.replaceAll("/** @jsxFrag Fragment */\n", "");
|
||||||
|
sf.replaceWithText(text);
|
||||||
|
|
||||||
|
await sf.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code mod for class={tw`border`} to class="border".
|
||||||
|
const TWIND_CODEMOD =
|
||||||
|
`This project is using an old version of the twind integration. Would you like to
|
||||||
|
update to the new twind plugin? This will remove the 'class={tw\`border\`}'
|
||||||
|
boilerplate from your source code replace it with the simpler 'class="border"'.`;
|
||||||
|
if (importMap.imports["@twind"] && confirm(TWIND_CODEMOD)) {
|
||||||
|
await Deno.remove(join(resolvedDirectory, importMap.imports["@twind"]));
|
||||||
|
|
||||||
|
delete importMap.imports["@twind"];
|
||||||
|
importMapText = JSON.stringify(importMap, null, 2);
|
||||||
|
await Deno.writeTextFile(IMPORT_MAP_PATH, importMapText);
|
||||||
|
|
||||||
|
const MAIN_TS = `/// <reference no-default-lib="true" />
|
||||||
|
/// <reference lib="dom" />
|
||||||
|
/// <reference lib="dom.iterable" />
|
||||||
|
/// <reference lib="dom.asynciterable" />
|
||||||
|
/// <reference lib="deno.ns" />
|
||||||
|
|
||||||
|
import { start } from "$fresh/server.ts";
|
||||||
|
import manifest from "./fresh.gen.ts";
|
||||||
|
|
||||||
|
import twindPlugin from "$fresh/plugins/twind.ts";
|
||||||
|
import twindConfig from "./twind.config.ts";
|
||||||
|
|
||||||
|
await start(manifest, { plugins: [twindPlugin(twindConfig)] });\n`;
|
||||||
|
const MAIN_TS_PATH = join(resolvedDirectory, "main.ts");
|
||||||
|
await Deno.writeTextFile(MAIN_TS_PATH, MAIN_TS);
|
||||||
|
|
||||||
|
const TWIND_CONFIG_TS = `import { Options } from "$fresh/plugins/twind.ts";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
selfURL: import.meta.url,
|
||||||
|
} as Options;
|
||||||
|
`;
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
join(resolvedDirectory, "twind.config.ts"),
|
||||||
|
TWIND_CONFIG_TS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const project = new Project();
|
||||||
|
const sfs = project.addSourceFilesAtPaths(
|
||||||
|
join(resolvedDirectory, "**", "*.{js,jsx,ts,tsx}"),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const sf of sfs) {
|
||||||
|
const nodes = sf.forEachDescendantAsArray();
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!n.wasForgotten() && Node.isJsxAttribute(n)) {
|
||||||
|
const init = n.getInitializer();
|
||||||
|
const name = n.getName();
|
||||||
|
if (
|
||||||
|
Node.isJsxExpression(init) &&
|
||||||
|
(name === "class" || name === "className")
|
||||||
|
) {
|
||||||
|
const expr = init.getExpression();
|
||||||
|
if (Node.isTaggedTemplateExpression(expr)) {
|
||||||
|
const tag = expr.getTag();
|
||||||
|
if (Node.isIdentifier(tag) && tag.getText() === "tw") {
|
||||||
|
const template = expr.getTemplate();
|
||||||
|
if (Node.isNoSubstitutionTemplateLiteral(template)) {
|
||||||
|
n.setInitializer(`"${template.getLiteralValue()}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (expr?.getFullText() === `tw(props.class ?? "")`) {
|
||||||
|
n.setInitializer(`{props.class}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = sf.getFullText();
|
||||||
|
const removeTw = [...text.matchAll(/tw[,\s`(]/g)].length === 1;
|
||||||
|
|
||||||
|
for (const d of sf.getImportDeclarations()) {
|
||||||
|
if (d.getModuleSpecifierValue() !== "@twind") continue;
|
||||||
|
for (const n of d.getNamedImports()) {
|
||||||
|
const name = n.getName();
|
||||||
|
if (name === "tw" && removeTw) n.remove();
|
||||||
|
}
|
||||||
|
d.setModuleSpecifier("twind");
|
||||||
|
if (
|
||||||
|
d.getNamedImports().length === 0 &&
|
||||||
|
d.getNamespaceImport() === undefined &&
|
||||||
|
d.getDefaultImport() === undefined
|
||||||
|
) {
|
||||||
|
d.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await sf.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = await collect(resolvedDirectory);
|
||||||
|
await generate(resolvedDirectory, manifest);
|
14
fresh-main/versions.json
Normal file
14
fresh-main/versions.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
"1.1.2",
|
||||||
|
"1.1.1",
|
||||||
|
"1.1.0",
|
||||||
|
"1.0.2",
|
||||||
|
"1.0.1",
|
||||||
|
"1.0.0",
|
||||||
|
"1.0.0-rc.6",
|
||||||
|
"1.0.0-rc.5",
|
||||||
|
"1.0.0-rc.4",
|
||||||
|
"1.0.0-rc.3",
|
||||||
|
"1.0.0-rc.2",
|
||||||
|
"1.0.0-rc.1"
|
||||||
|
]
|
32
fresh.gen.ts
32
fresh.gen.ts
@ -5,29 +5,32 @@
|
|||||||
import config from "./deno.json" assert { type: "json" };
|
import config from "./deno.json" assert { type: "json" };
|
||||||
import * as $0 from "./routes/_404.tsx";
|
import * as $0 from "./routes/_404.tsx";
|
||||||
import * as $1 from "./routes/_middleware.ts";
|
import * as $1 from "./routes/_middleware.ts";
|
||||||
import * as $2 from "./routes/api/login.ts";
|
import * as $2 from "./routes/api/logout.ts";
|
||||||
import * as $3 from "./routes/api/logout.ts";
|
import * as $3 from "./routes/dir/[...path].tsx";
|
||||||
import * as $4 from "./routes/dir/[...path].tsx";
|
import * as $4 from "./routes/dir/_middleware.ts";
|
||||||
import * as $5 from "./routes/doc/index.tsx";
|
import * as $5 from "./routes/doc/_middleware.ts";
|
||||||
import * as $6 from "./routes/index.tsx";
|
import * as $6 from "./routes/doc/index.tsx";
|
||||||
import * as $7 from "./routes/login.tsx";
|
import * as $7 from "./routes/index.tsx";
|
||||||
|
import * as $8 from "./routes/login.tsx";
|
||||||
import * as $$0 from "./islands/ContentRenderer.tsx";
|
import * as $$0 from "./islands/ContentRenderer.tsx";
|
||||||
import * as $$1 from "./islands/Counter.tsx";
|
import * as $$1 from "./islands/Counter.tsx";
|
||||||
import * as $$2 from "./islands/DirList.tsx";
|
import * as $$2 from "./islands/DirList.tsx";
|
||||||
import * as $$3 from "./islands/DocSearch.tsx";
|
import * as $$3 from "./islands/DocSearch.tsx";
|
||||||
import * as $$4 from "./islands/FileViewer.tsx";
|
import * as $$4 from "./islands/FileViewer.tsx";
|
||||||
import * as $$5 from "./islands/UpList.tsx";
|
import * as $$5 from "./islands/Login.tsx";
|
||||||
|
import * as $$6 from "./islands/UpList.tsx";
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
routes: {
|
routes: {
|
||||||
"./routes/_404.tsx": $0,
|
"./routes/_404.tsx": $0,
|
||||||
"./routes/_middleware.ts": $1,
|
"./routes/_middleware.ts": $1,
|
||||||
"./routes/api/login.ts": $2,
|
"./routes/api/logout.ts": $2,
|
||||||
"./routes/api/logout.ts": $3,
|
"./routes/dir/[...path].tsx": $3,
|
||||||
"./routes/dir/[...path].tsx": $4,
|
"./routes/dir/_middleware.ts": $4,
|
||||||
"./routes/doc/index.tsx": $5,
|
"./routes/doc/_middleware.ts": $5,
|
||||||
"./routes/index.tsx": $6,
|
"./routes/doc/index.tsx": $6,
|
||||||
"./routes/login.tsx": $7,
|
"./routes/index.tsx": $7,
|
||||||
|
"./routes/login.tsx": $8,
|
||||||
},
|
},
|
||||||
islands: {
|
islands: {
|
||||||
"./islands/ContentRenderer.tsx": $$0,
|
"./islands/ContentRenderer.tsx": $$0,
|
||||||
@ -35,7 +38,8 @@ const manifest = {
|
|||||||
"./islands/DirList.tsx": $$2,
|
"./islands/DirList.tsx": $$2,
|
||||||
"./islands/DocSearch.tsx": $$3,
|
"./islands/DocSearch.tsx": $$3,
|
||||||
"./islands/FileViewer.tsx": $$4,
|
"./islands/FileViewer.tsx": $$4,
|
||||||
"./islands/UpList.tsx": $$5,
|
"./islands/Login.tsx": $$5,
|
||||||
|
"./islands/UpList.tsx": $$6,
|
||||||
},
|
},
|
||||||
baseUrl: import.meta.url,
|
baseUrl: import.meta.url,
|
||||||
config,
|
config,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"$fresh/": "https://deno.land/x/fresh@1.1.2/",
|
"$fresh/": "./fresh-main/",
|
||||||
"preact": "https://esm.sh/preact@10.11.0",
|
"preact": "https://esm.sh/preact@10.11.0",
|
||||||
"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-render-to-string": "https://esm.sh/*preact-render-to-string@5.2.4",
|
||||||
|
@ -53,7 +53,7 @@ export function DirList(props: DirListProps) {
|
|||||||
</li>
|
</li>
|
||||||
<ListItem
|
<ListItem
|
||||||
key=".."
|
key=".."
|
||||||
href={`/dir/${encodePath(join(data.path, ".."))}?pretty`}
|
href={`/dir/${encodePath(join(data.path, ".."))}/?pretty`}
|
||||||
icon="/icon/back.svg"
|
icon="/icon/back.svg"
|
||||||
>
|
>
|
||||||
...
|
...
|
||||||
@ -61,7 +61,7 @@ export function DirList(props: DirListProps) {
|
|||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={file.name}
|
key={file.name}
|
||||||
href={`/dir/${encodePath(join(data.path, file.name))}?pretty`}
|
href={`/dir/${encodePath(join(data.path, file.name))}${(file.isDirectory ? "/" : "")}?pretty`}
|
||||||
icon={file.isDirectory
|
icon={file.isDirectory
|
||||||
? "/icon/folder.svg"
|
? "/icon/folder.svg"
|
||||||
: extToIcon(extname(file.name))}
|
: extToIcon(extname(file.name))}
|
||||||
|
52
islands/Login.tsx
Normal file
52
islands/Login.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
export default function LoginForm({
|
||||||
|
redirect = "/",
|
||||||
|
failed = false,
|
||||||
|
}: { redirect?: string
|
||||||
|
failed?: boolean }
|
||||||
|
) {
|
||||||
|
|
||||||
|
return <div class="p-4 absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]
|
||||||
|
flex flex-col items-center border-gray-500 border-2 rounded-md
|
||||||
|
sm:max-w-screen-sm max-w-screen-md">
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
class="w-32 h-32"
|
||||||
|
alt="the fresh logo: a sliced lemon dripping with juice"
|
||||||
|
/>
|
||||||
|
<h1 class="text-2xl font-bold">Login</h1>
|
||||||
|
{failed ? <p class="text-red-500">Login failed</p> : null}
|
||||||
|
<form
|
||||||
|
action={"/login?redirect=" + redirect}
|
||||||
|
method="POST"
|
||||||
|
class="flex flex-col gap-2 items-stretch"
|
||||||
|
>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<div class="basis-40 flex items-center flex-1">
|
||||||
|
<label for="username" class="w-20">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
id="username"
|
||||||
|
class="border-b-2 focus:border-green-500 transition-colors flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center flex-1">
|
||||||
|
<label for="password" class="w-20">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
id="password"
|
||||||
|
class="border-b-2 focus:border-green-500 transition-colors flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="bg-gray-400 p-2 rounded
|
||||||
|
m-auto"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
@ -5,7 +5,7 @@ import { encodePath } from "../util/util.ts";
|
|||||||
|
|
||||||
function stairs(path: string) {
|
function stairs(path: string) {
|
||||||
if (path === ".") return [];
|
if (path === ".") return [];
|
||||||
const uplist = path.split("/");
|
const uplist = path.split("/").filter(x=> x.length > 0);
|
||||||
let current = ".";
|
let current = ".";
|
||||||
const stairs = [];
|
const stairs = [];
|
||||||
for (const up of uplist) {
|
for (const up of uplist) {
|
||||||
@ -34,7 +34,7 @@ export default function UpList(props: { path: string }) {
|
|||||||
<span class="p-2">/</span>
|
<span class="p-2">/</span>
|
||||||
<a
|
<a
|
||||||
class="flex flex-wrap p-2 hover:bg-gray-400 rounded-sm"
|
class="flex flex-wrap p-2 hover:bg-gray-400 rounded-sm"
|
||||||
href={`/dir/${encodePath(cur)}?pretty`}
|
href={`/dir/${encodePath(cur)}/?pretty`}
|
||||||
>
|
>
|
||||||
<img src={asset("/icon/folder.svg")} />
|
<img src={asset("/icon/folder.svg")} />
|
||||||
<span class="ml-1">{up}</span>
|
<span class="ml-1">{up}</span>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { MiddlewareHandlerContext } from "$fresh/server.ts";
|
import { HandlerContext, MiddlewareHandlerContext, Status } from "$fresh/server.ts";
|
||||||
import { getCookies } from "http/cookie.ts";
|
import { getCookies } from "http/cookie.ts";
|
||||||
import { verify } from "djwt";
|
import { verify } from "djwt";
|
||||||
import { prepareSecretKey } from "../util/secret.ts";
|
import { prepareSecretKey } from "../util/secret.ts";
|
||||||
@ -7,15 +7,14 @@ export const handler = async (
|
|||||||
req: Request,
|
req: Request,
|
||||||
ctx: MiddlewareHandlerContext<Record<string, unknown>>,
|
ctx: MiddlewareHandlerContext<Record<string, unknown>>,
|
||||||
) => {
|
) => {
|
||||||
|
|
||||||
const secret_key = await prepareSecretKey();
|
const secret_key = await prepareSecretKey();
|
||||||
const cookies = getCookies(req.headers);
|
const cookies = getCookies(req.headers);
|
||||||
const jwt = cookies["auth"];
|
const jwt = cookies["auth"];
|
||||||
try {
|
try {
|
||||||
const payload = await verify(jwt, secret_key);
|
const payload = await verify(jwt, secret_key);
|
||||||
ctx.state["login"] = payload;
|
ctx.state["login"] = payload;
|
||||||
} catch (e) {
|
} catch (_) {
|
||||||
ctx.state["login"] = null;
|
ctx.state["login"] = null;
|
||||||
}
|
}
|
||||||
return await ctx.next();
|
return await ctx.next();
|
||||||
};
|
}
|
||||||
|
@ -1,66 +0,0 @@
|
|||||||
import { HandlerContext } from "$fresh/server.ts";
|
|
||||||
import { setCookie } from "http/cookie.ts";
|
|
||||||
import { Status } from "http/http_status.ts";
|
|
||||||
import { connectDB } from "../../src/user/db.ts";
|
|
||||||
import { getUser, verifyUser } from "../../src/user/user.ts";
|
|
||||||
import { create as createJWT } from "djwt";
|
|
||||||
import { prepareSecretKey } from "../../util/secret.ts";
|
|
||||||
|
|
||||||
async function POST(req: Request, _ctx: HandlerContext): Promise<Response> {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const form = await req.formData();
|
|
||||||
const username = form.get("username");
|
|
||||||
const password = form.get("password");
|
|
||||||
if (username && password) {
|
|
||||||
const DB = await connectDB();
|
|
||||||
const user = await getUser(DB, username.toString());
|
|
||||||
if (user) {
|
|
||||||
const SECRET_KEY = await prepareSecretKey();
|
|
||||||
if (await verifyUser(user, password.toString())) {
|
|
||||||
const headers = new Headers();
|
|
||||||
const jwt = await createJWT({ alg: "HS512", typ: "JWT" }, {
|
|
||||||
username: user.name,
|
|
||||||
}, SECRET_KEY);
|
|
||||||
setCookie(headers, {
|
|
||||||
name: "auth",
|
|
||||||
value: jwt,
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "Strict",
|
|
||||||
maxAge: 60 * 60 * 24 * 7,
|
|
||||||
domain: url.hostname,
|
|
||||||
path: "/",
|
|
||||||
secure: url.protocol === "https:",
|
|
||||||
});
|
|
||||||
|
|
||||||
headers.set("Location", "/");
|
|
||||||
return new Response(null, {
|
|
||||||
status: Status.SeeOther, // See Other
|
|
||||||
headers: headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
`<!DOCTYPE html><html>
|
|
||||||
<head> <title> Login Failed </title> </head>
|
|
||||||
<body>
|
|
||||||
<h1> Login Failed </h1>
|
|
||||||
<p> <a href="/"> Back to Home </a> </p>
|
|
||||||
<script>
|
|
||||||
document.location.href = "/login";
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "text/html",
|
|
||||||
},
|
|
||||||
status: Status.Forbidden,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const handler = {
|
|
||||||
POST,
|
|
||||||
};
|
|
@ -10,6 +10,7 @@ import DirList, { EntryInfo } from "../../islands/DirList.tsx";
|
|||||||
import FileViewer from "../../islands/FileViewer.tsx";
|
import FileViewer from "../../islands/FileViewer.tsx";
|
||||||
import RenderView from "../../islands/ContentRenderer.tsx";
|
import RenderView from "../../islands/ContentRenderer.tsx";
|
||||||
import { serveFile } from "http/file_server.ts";
|
import { serveFile } from "http/file_server.ts";
|
||||||
|
import { Status } from "http/http_status.ts";
|
||||||
|
|
||||||
type DirProps = {
|
type DirProps = {
|
||||||
type: "dir";
|
type: "dir";
|
||||||
@ -23,11 +24,17 @@ type FileProps = {
|
|||||||
stat: Deno.FileInfo;
|
stat: Deno.FileInfo;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DirOrFileProps = DirProps | FileProps;
|
export type DirOrFileProps = DirProps | FileProps;
|
||||||
|
|
||||||
async function renderFile(req: Request, path: string) {
|
type RenderOption = {
|
||||||
|
fileInfo?: Deno.FileInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderFile(req: Request, path: string, { fileInfo }: RenderOption = {}) {
|
||||||
try {
|
try {
|
||||||
const fileInfo = await Deno.stat(path);
|
if (!fileInfo) {
|
||||||
|
fileInfo = await Deno.stat(path);
|
||||||
|
}
|
||||||
if (fileInfo.isDirectory) {
|
if (fileInfo.isDirectory) {
|
||||||
// if index.html exists, serve it.
|
// if index.html exists, serve it.
|
||||||
// otherwise, serve a directory listing.
|
// otherwise, serve a directory listing.
|
||||||
@ -61,7 +68,6 @@ async function renderFile(req: Request, path: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await serveFile(req, path, {
|
const res = await serveFile(req, path, {
|
||||||
fileInfo,
|
fileInfo,
|
||||||
});
|
});
|
||||||
@ -76,11 +82,13 @@ async function renderFile(req: Request, path: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPage(_req: Request, path: string, ctx: HandlerContext) {
|
async function renderPage(_req: Request, path: string, ctx: HandlerContext, { fileInfo }: RenderOption = {}) {
|
||||||
try {
|
try {
|
||||||
const stat = await Deno.stat(path);
|
if (!fileInfo) {
|
||||||
|
fileInfo = await Deno.stat(path);
|
||||||
|
}
|
||||||
|
|
||||||
if (stat.isDirectory) {
|
if (fileInfo.isDirectory) {
|
||||||
const filesIter = await Deno.readDir(path);
|
const filesIter = await Deno.readDir(path);
|
||||||
const files: EntryInfo[] = [];
|
const files: EntryInfo[] = [];
|
||||||
for await (const file of filesIter) {
|
for await (const file of filesIter) {
|
||||||
@ -93,14 +101,14 @@ async function renderPage(_req: Request, path: string, ctx: HandlerContext) {
|
|||||||
}
|
}
|
||||||
return await ctx.render({
|
return await ctx.render({
|
||||||
type: "dir",
|
type: "dir",
|
||||||
stat,
|
fileInfo,
|
||||||
files,
|
files,
|
||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
return await ctx.render({
|
return await ctx.render({
|
||||||
type: "file",
|
type: "file",
|
||||||
stat,
|
fileInfo,
|
||||||
path,
|
path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -113,31 +121,22 @@ async function renderPage(_req: Request, path: string, ctx: HandlerContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function GET(req: Request, ctx: HandlerContext): Promise<Response> {
|
async function GET(req: Request, ctx: HandlerContext): Promise<Response> {
|
||||||
|
|
||||||
const authRequired = Deno.env.get("AUTH_REQUIRED") === "true";
|
|
||||||
if (authRequired) {
|
|
||||||
const login = ctx.state["login"];
|
|
||||||
//console.log("login", login);
|
|
||||||
if (!login) {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
"Location": "/login",
|
|
||||||
"content-type": "text/plain",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
|
||||||
"Access-Control-Allow-Headers":
|
|
||||||
"Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const path = removePrefixFromPathname(decodePath(url.pathname), "/dir");
|
const path = removePrefixFromPathname(decodePath(url.pathname), "/dir");
|
||||||
|
const fileInfo = await Deno.stat(path);
|
||||||
|
if (fileInfo.isFile && url.pathname.endsWith("/")) {
|
||||||
|
url.pathname = url.pathname.slice(0, -1);
|
||||||
|
return Response.redirect(url, Status.TemporaryRedirect);
|
||||||
|
}
|
||||||
|
if ((!fileInfo.isFile) && (!url.pathname.endsWith("/"))) {
|
||||||
|
url.pathname += "/";
|
||||||
|
return Response.redirect(url, Status.TemporaryRedirect);
|
||||||
|
}
|
||||||
|
|
||||||
if (url.searchParams.has("pretty")) {
|
if (url.searchParams.has("pretty")) {
|
||||||
return await renderPage(req, path, ctx);
|
return await renderPage(req, path, ctx, { fileInfo });
|
||||||
} else {
|
} else {
|
||||||
return await renderFile(req, path);
|
return await renderFile(req, path, { fileInfo });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
1
routes/dir/_middleware.ts
Normal file
1
routes/dir/_middleware.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { handler } from "../../src/login_middleware.ts";
|
1
routes/doc/_middleware.ts
Normal file
1
routes/doc/_middleware.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { handler } from "../../src/login_middleware.ts";
|
@ -1,26 +1,14 @@
|
|||||||
import { Head } from "$fresh/runtime.ts";
|
import { Head } from "$fresh/runtime.ts";
|
||||||
import { HandlerContext, Handlers, PageProps } from "$fresh/server.ts";
|
import { HandlerContext, Handlers, PageProps, Status } from "$fresh/server.ts";
|
||||||
import DocSearch from "../../islands/DocSearch.tsx";
|
import DocSearch from "../../islands/DocSearch.tsx";
|
||||||
import { Doc } from "../../src/collect.ts";
|
import { Doc } from "../../src/collect.ts";
|
||||||
import { docCollector } from "../../src/store/doc.ts";
|
import { docCollector } from "../../src/store/doc.ts";
|
||||||
|
|
||||||
async function GET(req: Request, ctx: HandlerContext): Promise<Response> {
|
async function GET(req: Request, ctx: HandlerContext): Promise<Response> {
|
||||||
const authRequired = Deno.env.get("AUTH_REQUIRED") === "true";
|
const url = new URL(req.url);
|
||||||
if (authRequired) {
|
if (url.pathname.endsWith("/")) {
|
||||||
const login = ctx.state["login"];
|
url.pathname = url.pathname.slice(0, -1);
|
||||||
if (!login) {
|
return Response.redirect(url, Status.TemporaryRedirect);
|
||||||
return new Response(null, {
|
|
||||||
status: 302,
|
|
||||||
headers: {
|
|
||||||
"Location": "/login",
|
|
||||||
"content-type": "text/plain",
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Allow-Methods": "GET,HEAD,PUT,PATCH,POST,DELETE",
|
|
||||||
"Access-Control-Allow-Headers":
|
|
||||||
"Content-Type, Access-Control-Allow-Headers, Authorization, X-Requested-With",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const docs = docCollector.getDocs();
|
const docs = docCollector.getDocs();
|
||||||
return await ctx.render({ docs });
|
return await ctx.render({ docs });
|
||||||
|
123
routes/login.tsx
123
routes/login.tsx
@ -1,54 +1,91 @@
|
|||||||
import { Head } from "$fresh/runtime.ts";
|
import { Head } from "$fresh/runtime.ts";
|
||||||
|
import { HandlerContext, PageProps } from "$fresh/server.ts";
|
||||||
|
import { setCookie } from "http/cookie.ts";
|
||||||
|
import { Status } from "http/http_status.ts";
|
||||||
|
import { connectDB } from "../src/user/db.ts";
|
||||||
|
import { getUser, verifyUser } from "../src/user/user.ts";
|
||||||
|
import { create as createJWT } from "djwt";
|
||||||
|
import { prepareSecretKey } from "../util/secret.ts";
|
||||||
|
import LoginForm from "../islands/Login.tsx";
|
||||||
|
|
||||||
export default function Login() {
|
async function GET(_req: Request, ctx: HandlerContext){
|
||||||
|
return await ctx.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function POST(req: Request, _ctx: HandlerContext): Promise<Response> {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const form = await req.formData();
|
||||||
|
const username = form.get("username");
|
||||||
|
const password = form.get("password");
|
||||||
|
if (username && password) {
|
||||||
|
const DB = await connectDB();
|
||||||
|
const user = await getUser(DB, username.toString());
|
||||||
|
if (user) {
|
||||||
|
const SECRET_KEY = await prepareSecretKey();
|
||||||
|
if (await verifyUser(user, password.toString())) {
|
||||||
|
const headers = new Headers();
|
||||||
|
const jwt = await createJWT({ alg: "HS512", typ: "JWT" }, {
|
||||||
|
username: user.name,
|
||||||
|
}, SECRET_KEY);
|
||||||
|
setCookie(headers, {
|
||||||
|
name: "auth",
|
||||||
|
value: jwt,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "Strict",
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
domain: url.hostname,
|
||||||
|
path: "/",
|
||||||
|
secure: url.protocol === "https:",
|
||||||
|
});
|
||||||
|
|
||||||
|
let redirect = "/";
|
||||||
|
if (url.searchParams.has("redirect")) {
|
||||||
|
redirect = url.searchParams.get("redirect")!;
|
||||||
|
}
|
||||||
|
headers.set("Location", redirect);
|
||||||
|
return new Response(null, {
|
||||||
|
status: Status.SeeOther, // See Other
|
||||||
|
headers: headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html><html>
|
||||||
|
<head> <title> Login Failed </title> </head>
|
||||||
|
<body>
|
||||||
|
<h1> Login Failed </h1>
|
||||||
|
<p> <a href="/"> Back to Home </a> </p>
|
||||||
|
<script>
|
||||||
|
document.location.href = "/login?failed=true&redirect=${url.searchParams.get("redirect")}";
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
},
|
||||||
|
status: Status.Unauthorized,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = {
|
||||||
|
GET,
|
||||||
|
POST,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Login(props: PageProps) {
|
||||||
|
const redirect = props.url.searchParams.get("redirect");
|
||||||
|
const failed = props.url.searchParams.get("failed") === "true";
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Simple file server - Login</title>
|
<title>Simple file server - Login</title>
|
||||||
</Head>
|
</Head>
|
||||||
<div class="">
|
<div class="">
|
||||||
<div class="p-4 absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]
|
<LoginForm redirect={redirect ?? "/"} failed={failed}/>
|
||||||
flex flex-col items-center border-gray-500 border-2 rounded-md
|
|
||||||
sm:max-w-screen-sm max-w-screen-md">
|
|
||||||
<img
|
|
||||||
src="/logo.svg"
|
|
||||||
class="w-32 h-32"
|
|
||||||
alt="the fresh logo: a sliced lemon dripping with juice"
|
|
||||||
/>
|
|
||||||
<form
|
|
||||||
action="/api/login"
|
|
||||||
method="POST"
|
|
||||||
class="flex flex-col gap-2 items-stretch"
|
|
||||||
>
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
<div class="basis-40 flex items-center flex-1">
|
|
||||||
<label for="username" class="w-20">Username</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
id="username"
|
|
||||||
class="border-b-2 focus:border-green-500 transition-colors flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center flex-1">
|
|
||||||
<label for="password" class="w-20">Password</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
id="password"
|
|
||||||
class="border-b-2 focus:border-green-500 transition-colors flex-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="bg-gray-400 p-2 rounded
|
|
||||||
m-auto"
|
|
||||||
>
|
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
20
src/login_middleware.ts
Normal file
20
src/login_middleware.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { MiddlewareHandlerContext, Status } from "$fresh/server.ts";
|
||||||
|
|
||||||
|
export const handler = async (
|
||||||
|
req: Request,
|
||||||
|
ctx: MiddlewareHandlerContext<Record<string, unknown>>,
|
||||||
|
) => {
|
||||||
|
const authRequired = Deno.env.get("AUTH_REQUIRED") === "true";
|
||||||
|
if (authRequired) {
|
||||||
|
const login = ctx.state["login"];
|
||||||
|
if (!login) {
|
||||||
|
return new Response(null, {
|
||||||
|
status: Status.Found,
|
||||||
|
headers: {
|
||||||
|
Location: `/login?redirect=${encodeURIComponent(req.url)}`,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await ctx.next();
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user