Compare commits
No commits in common. "v0.1.0" and "main" have entirely different histories.
52 changed files with 3283 additions and 3639 deletions
|
|
@ -6,3 +6,4 @@ indent_size = 4
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
end_of_line = lf
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
node_modules
|
|
||||||
11
.eslintrc
11
.eslintrc
|
|
@ -1,11 +0,0 @@
|
||||||
{
|
|
||||||
"root": true,
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"plugins": ["@typescript-eslint", "prettier"],
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/eslint-recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended",
|
|
||||||
"plugin:prettier/recommended"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
38
.forgejo/workflows/ci.yml
Normal file
38
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: node-24
|
||||||
|
steps:
|
||||||
|
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||||
|
- name: Cache npm dependencies
|
||||||
|
uses: https://code.forgejo.org/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: node-24-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run check
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: check
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [22, 24]
|
||||||
|
runs-on: node-${{ matrix.node }}
|
||||||
|
steps:
|
||||||
|
- uses: https://code.forgejo.org/actions/checkout@v4
|
||||||
|
- name: Cache npm dependencies
|
||||||
|
uses: https://code.forgejo.org/actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.npm
|
||||||
|
key: node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run build
|
||||||
|
- run: npm test
|
||||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Ensure that git uses lf line-endings for text files.
|
||||||
|
* text=auto eol=lf
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,3 +1,4 @@
|
||||||
/dist
|
/dist
|
||||||
|
/.temp
|
||||||
public/
|
public/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
|
||||||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
lts/erbium
|
18
|
||||||
|
|
|
||||||
52
CHANGELOG.md
Normal file
52
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.2.0](https://github.com/websnacksjs/websnacks/releases/tag/v0.2.0) (2021-02-28)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- add dangerouslySetInnerHTML attr ([#15](https://github.com/websnacksjs/websnacks/issues/15), [3f356dd](https://github.com/websnacksjs/websnacks/commit/3f356ddfeeb38e8a60c32d26c3e9e8715d0246c3))
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
|
||||||
|
- **BREAKING CHANGE** update node-watch optional dep to major v0.7.1
|
||||||
|
|
||||||
|
## [0.1.5](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.5) (2020-06-14)
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
|
||||||
|
- stack size exceed error on purging module cache ([32eee9b](https://github.com/websnacksjs/websnacks/commit/32eee9b2e04475452905e3478f0fa2a21ad3ccf4))
|
||||||
|
|
||||||
|
## [0.1.4](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.4) (2020-06-10)
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
|
||||||
|
- dev cmd didn't watch files due to import mangling ([4e44d83](https://github.com/websnacksjs/websnacks/commit/4e44d8369451e19af616a8c03c2ff7f4065b3f50))
|
||||||
|
- dont require config file ([5520bb3](https://github.com/websnacksjs/websnacks/commit/5520bb3571189726df73a2945d9a6e7f5671e7ff))
|
||||||
|
|
||||||
|
## [0.1.3](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.3) (2020-06-04)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- provide Fragment component ([#9](https://github.com/websnacksjs/websnacks/issues/9), [f1aca35](https://github.com/websnacksjs/websnacks/commit/f1aca350ed7e63e277fae7f9cc01039a29442bcb))
|
||||||
|
|
||||||
|
## [0.1.2](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.2) (2020-06-03)
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
|
||||||
|
- type signature of Component too narrow ([7fd8fe9](https://github.com/websnacksjs/websnacks/commit/7fd8fe9be855c5eb02f0d3b291fc6403a4c636a2))
|
||||||
|
|
||||||
|
## [0.1.1](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.1) (2020-06-03)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- support rendering array elements ([#7](https://github.com/websnacksjs/websnacks/issues/7)) ([f5a83e7](https://github.com/websnacksjs/websnacks/commit/f5a83e7b7f618a67b37f74863ef3600a771383f4))
|
||||||
|
|
||||||
|
### Bugfixes
|
||||||
|
|
||||||
|
- output declarations, sourcemaps again ([5568777](https://github.com/websnacksjs/websnacks/commit/5568777f6af0b1a591f7c177c965f91540ff8167))
|
||||||
|
- target es2018 to support node10+ ([e774fc1](https://github.com/websnacksjs/websnacks/commit/e774fc1b6c7e87d58ca4544cad8c293d04dae470))
|
||||||
|
- support number elements in jsx templates ([#5](https://github.com/websnacksjs/websnacks/issues/5)) ([83c0c91](https://github.com/websnacksjs/websnacks/commit/83c0c91bcfc3c219793fa05da84c9cde0fbf6c85))
|
||||||
|
|
||||||
|
## [0.1.0](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.0) (2020-05-26)
|
||||||
|
|
||||||
|
**Note:** Initial release.
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
# websnacks: Minimal Dependency Server-Side JSX for Static Sites
|
# websnacks: Minimal Dependency Server-Side JSX for Static Sites
|
||||||
|
|
||||||
|
[](https://www.npmjs.com/package/@websnacksjs/websnacks "NPM release")
|
||||||
|
[](https://www.mozilla.org/en-US/MPL/2.0/FAQ/ "License info")
|
||||||
|
[](https://git.theinnerlimit.ch/websnacksjs/websnacks/actions?workflow=ci.yml "CI status for main branch")
|
||||||
|

|
||||||
|
|
||||||
Develop fully static websites using typesafe JSX templates on the server without the complex build system and dependency management of server-side rendered React frameworks.
|
Develop fully static websites using typesafe JSX templates on the server without the complex build system and dependency management of server-side rendered React frameworks.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
|
||||||
41
biome.json
Normal file
41
biome.json
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.4.14/schema.json",
|
||||||
|
"files": {
|
||||||
|
"includes": ["**", "!dist", "!node_modules", "!.temp"]
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"style": {
|
||||||
|
"useShorthandFunctionType": "off"
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"useJsxKeyInIterable": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["test/**"],
|
||||||
|
"linter": {
|
||||||
|
"rules": {
|
||||||
|
"a11y": {
|
||||||
|
"useHtmlLang": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { stylesheet } from "typestyle";
|
import { stylesheet } from "typestyle";
|
||||||
import { Component, createElement } from "websnacks";
|
import {
|
||||||
|
type Component,
|
||||||
|
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
|
||||||
|
createElement,
|
||||||
|
} from "websnacks";
|
||||||
|
|
||||||
const styles = stylesheet({
|
const styles = stylesheet({
|
||||||
header: {
|
header: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { normalize } from "csstips";
|
import { normalize } from "csstips";
|
||||||
import { stylesheet } from "typestyle";
|
import { stylesheet } from "typestyle";
|
||||||
import { Component, createElement } from "websnacks";
|
import {
|
||||||
|
type Component,
|
||||||
|
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
|
||||||
|
createElement,
|
||||||
|
} from "websnacks";
|
||||||
|
|
||||||
import { stylesheetPath } from "../config";
|
import { stylesheetPath } from "../config";
|
||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
||||||
|
|
@ -46,10 +50,7 @@ export const Layout: Component<LayoutProps> = ({ children, headline }) => (
|
||||||
{headline && ` | ${headline}`}
|
{headline && ` | ${headline}`}
|
||||||
</title>
|
</title>
|
||||||
<meta name="description" content="" />
|
<meta name="description" content="" />
|
||||||
<meta
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
name="viewport"
|
|
||||||
content="width=device-width, initial-scale=1"
|
|
||||||
/>
|
|
||||||
<link rel="stylesheet" href={stylesheetPath} />
|
<link rel="stylesheet" href={stylesheetPath} />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { stylesheet } from "typestyle";
|
import { stylesheet } from "typestyle";
|
||||||
import { Component, createElement } from "websnacks";
|
import {
|
||||||
|
type Component,
|
||||||
|
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
|
||||||
|
createElement,
|
||||||
|
} from "websnacks";
|
||||||
|
|
||||||
const styles = stylesheet({
|
const styles = stylesheet({
|
||||||
navbar: {
|
navbar: {
|
||||||
|
|
|
||||||
1367
examples/personal-site/package-lock.json
generated
1367
examples/personal-site/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "websnacks-example-personal-site",
|
"name": "websnacks-example-personal-site",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "websnacks -r ts-node/register build",
|
"build": "websnacks -r ts-node/register build",
|
||||||
"dev": "websnacks -r ts-node/register dev"
|
"dev": "websnacks -r ts-node/register dev"
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,25 @@
|
||||||
import { Component, createElement } from "websnacks";
|
import {
|
||||||
|
type Component,
|
||||||
|
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
|
||||||
|
createElement,
|
||||||
|
} from "websnacks";
|
||||||
|
|
||||||
import { Layout } from "../components/layout";
|
import { Layout } from "../components/layout";
|
||||||
|
|
||||||
export const page: Component = () => (
|
export const page: Component = () => (
|
||||||
<Layout>
|
<Layout>
|
||||||
<p>
|
<p>
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur
|
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur dapibus
|
||||||
dapibus condimentum mauris et egestas. Quisque orci nulla, consequat
|
condimentum mauris et egestas. Quisque orci nulla, consequat at erat
|
||||||
at erat laoreet, malesuada sodales nisi. Sed in lorem semper lorem
|
laoreet, malesuada sodales nisi. Sed in lorem semper lorem placerat
|
||||||
placerat fermentum a id arcu. Curabitur non aliquam tellus, sed
|
fermentum a id arcu. Curabitur non aliquam tellus, sed auctor lacus. Nunc
|
||||||
auctor lacus. Nunc sit amet lectus ultrices, sodales nisl sit amet,
|
sit amet lectus ultrices, sodales nisl sit amet, luctus nisl. Nunc mollis
|
||||||
luctus nisl. Nunc mollis imperdiet quam, eget sollicitudin leo
|
imperdiet quam, eget sollicitudin leo tincidunt vel. Duis felis dui,
|
||||||
tincidunt vel. Duis felis dui, imperdiet aliquam bibendum sed,
|
imperdiet aliquam bibendum sed, auctor et dolor. Vivamus odio ipsum,
|
||||||
auctor et dolor. Vivamus odio ipsum, venenatis in felis sed, aliquam
|
venenatis in felis sed, aliquam dictum turpis. Pellentesque pellentesque
|
||||||
dictum turpis. Pellentesque pellentesque consequat neque, id
|
consequat neque, id imperdiet diam molestie nec. Nullam ut vestibulum est.
|
||||||
imperdiet diam molestie nec. Nullam ut vestibulum est. Pellentesque
|
Pellentesque orci urna, porta vel porta quis, semper ut enim. Donec sit
|
||||||
orci urna, porta vel porta quis, semper ut enim. Donec sit amet urna
|
amet urna arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
|
||||||
arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
|
|
||||||
</p>
|
</p>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { stylesheet } from "typestyle";
|
import { stylesheet } from "typestyle";
|
||||||
import { Component, createElement } from "websnacks";
|
import {
|
||||||
|
type Component,
|
||||||
|
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
|
||||||
|
createElement,
|
||||||
|
} from "websnacks";
|
||||||
|
|
||||||
import { Layout } from "../components/layout";
|
import { Layout } from "../components/layout";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"jsxFactory": "createElement",
|
"jsxFactory": "createElement",
|
||||||
"target": "ES2019",
|
"target": "ES2018",
|
||||||
"lib": ["ES2019"],
|
"lib": ["ES2018"],
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "node:fs";
|
||||||
import * as path from "path";
|
import * as path from "node:path";
|
||||||
import { Config } from "websnacks";
|
import type { Config } from "websnacks";
|
||||||
|
|
||||||
import { stylesheetPath } from "./config";
|
import { stylesheetPath } from "./config";
|
||||||
|
|
||||||
const config: Config = {
|
const _config: Config = {
|
||||||
// Watch additional files and folders for changes when the dev server is
|
// Watch additional files and folders for changes when the dev server is
|
||||||
// running.
|
// running.
|
||||||
watch: ["components/", "config.ts"],
|
watch: ["components/", "config.ts"],
|
||||||
|
|
@ -23,4 +23,3 @@ const config: Config = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export = config;
|
|
||||||
|
|
|
||||||
10
index.d.ts
vendored
10
index.d.ts
vendored
|
|
@ -4,13 +4,3 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./dist";
|
export * from "./dist";
|
||||||
|
|
||||||
import { HTMLElement } from "./dist";
|
|
||||||
import { IntrinsicElements as JsxIntrinsics } from "./dist/jsx";
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
namespace JSX {
|
|
||||||
type Element = HTMLElement;
|
|
||||||
type IntrinsicElements = JsxIntrinsics;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
1618
package-lock.json
generated
1618
package-lock.json
generated
File diff suppressed because it is too large
Load diff
32
package.json
32
package.json
|
|
@ -1,14 +1,15 @@
|
||||||
{
|
{
|
||||||
"name": "@websnacksjs/websnacks",
|
"name": "@websnacksjs/websnacks",
|
||||||
"description": "Minimal dependency server-side JSX for static sites",
|
"description": "Minimal dependency server-side JSX for static sites",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "M. George Hansen",
|
"name": "M. George Hansen",
|
||||||
"email": "mgeorge@technopolitica.com"
|
"email": "mgeorge@technopolitica.com"
|
||||||
},
|
},
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
|
"repository": "github:websnacksjs/websnacks",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "types.d.ts",
|
"types": "types.d.ts",
|
||||||
|
|
@ -23,21 +24,26 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
"check": "biome check .",
|
||||||
"clean": "ts-node scripts/clean.ts",
|
"clean": "ts-node scripts/clean.ts",
|
||||||
"prepublishOnly": "npm run clean && npm run build"
|
"prepublishOnly": "npm run reset && npm test",
|
||||||
|
"pretest": "npm run build",
|
||||||
|
"preversion": "npm run reset && npm test",
|
||||||
|
"release": "npm version",
|
||||||
|
"reset": "npm run clean && npm ci",
|
||||||
|
"test": "npm run test:unit && npm run test:e2e",
|
||||||
|
"test:unit": "cd test && ts-node --script-mode ./run-tests.ts",
|
||||||
|
"test:e2e": "cd test && ts-node --script-mode ./run-e2e.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "~12",
|
"@biomejs/biome": "2.4.14",
|
||||||
"@types/ws": "^7.2.4",
|
"@types/node": "~18",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.24.0",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/parser": "^2.24.0",
|
"ts-node": "^10.9.2",
|
||||||
"eslint": "^6.8.0",
|
"typescript": "~4.9.5"
|
||||||
"eslint-config-prettier": "^6.11.0",
|
|
||||||
"ts-node": "^8.10.1",
|
|
||||||
"typescript": "~3.8.3"
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"node-watch": "^0.6.3",
|
"node-watch": "^0.7.4",
|
||||||
"ws": "^7.2.5"
|
"ws": "^8.20.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,29 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from "fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||||
const DIST_DIR = path.join(ROOT_DIR, "dist");
|
const DIST_DIR = path.join(ROOT_DIR, "dist");
|
||||||
|
const TEST_DIR = path.join(ROOT_DIR, ".temp");
|
||||||
|
|
||||||
fs.rmdirSync(DIST_DIR, { recursive: true });
|
const rmdirRecursive = (dirPath: string): void => {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entryNames = fs.readdirSync(dirPath);
|
||||||
|
for (const entryName of entryNames) {
|
||||||
|
const entryPath = path.join(dirPath, entryName);
|
||||||
|
const dirent = fs.lstatSync(entryPath);
|
||||||
|
if (dirent.isDirectory()) {
|
||||||
|
rmdirRecursive(entryPath);
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(entryPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.rmdirSync(dirPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
rmdirRecursive(DIST_DIR);
|
||||||
|
rmdirRecursive(TEST_DIR);
|
||||||
|
|
|
||||||
25
src/build.ts
25
src/build.ts
|
|
@ -3,12 +3,12 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "node:fs";
|
||||||
import * as path from "path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
import { Config, ConfigPaths } from "./config";
|
import type { Config, ConfigPaths } from "./config";
|
||||||
import { renderPage } from "./render";
|
import { renderPage } from "./render";
|
||||||
import { purgeModuleAndDepsFromCache, walkDir } from "./utils";
|
import { decacheModule, walkDir } from "./utils";
|
||||||
|
|
||||||
const renderPagesToHtml = async ({
|
const renderPagesToHtml = async ({
|
||||||
pagesDir,
|
pagesDir,
|
||||||
|
|
@ -22,20 +22,19 @@ const renderPagesToHtml = async ({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure that we don't cache page modules when running in dev server.
|
// Ensure that we don't cache page modules when running in dev server.
|
||||||
purgeModuleAndDepsFromCache(srcPath);
|
decacheModule(srcPath);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
const pageSrc = require(srcPath);
|
const pageSrc = require(srcPath);
|
||||||
if (!("page" in pageSrc)) {
|
if (!("page" in pageSrc)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`page source at ${srcPath} does not export a "page" variable`
|
`page source at ${srcPath} does not export a "page" variable`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let compiledHtml;
|
let compiledHtml: string;
|
||||||
try {
|
try {
|
||||||
compiledHtml = renderPage(pageSrc.page());
|
compiledHtml = renderPage(pageSrc.page());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(
|
throw new Error(`failed to compile ${srcPath}: ${error}`);
|
||||||
`failed to compile ${srcPath}: ${error.stack ?? error}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const relPath = path.relative(pagesDir, path.dirname(srcPath));
|
const relPath = path.relative(pagesDir, path.dirname(srcPath));
|
||||||
let baseName = path.basename(srcPath, ".tsx");
|
let baseName = path.basename(srcPath, ".tsx");
|
||||||
|
|
@ -47,7 +46,7 @@ const renderPagesToHtml = async ({
|
||||||
(async () => {
|
(async () => {
|
||||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||||
await fs.writeFile(destPath, compiledHtml);
|
await fs.writeFile(destPath, compiledHtml);
|
||||||
})()
|
})(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await Promise.all(deferred);
|
await Promise.all(deferred);
|
||||||
|
|
@ -59,7 +58,7 @@ const copyStaticAssets = async ({
|
||||||
}: ConfigPaths): Promise<void> => {
|
}: ConfigPaths): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await fs.access(staticAssetsDir);
|
await fs.access(staticAssetsDir);
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
// Static assets folder doesn't exist, so no-op.
|
// Static assets folder doesn't exist, so no-op.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -72,7 +71,7 @@ const copyStaticAssets = async ({
|
||||||
(async () => {
|
(async () => {
|
||||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||||
await fs.copyFile(assetPath, destPath);
|
await fs.copyFile(assetPath, destPath);
|
||||||
})()
|
})(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await Promise.all(deferred);
|
await Promise.all(deferred);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import { renderSite } from "../../build";
|
import { renderSite } from "../../build";
|
||||||
import { loadConfig } from "../../config";
|
import { loadConfig } from "../../config";
|
||||||
import { Command, UsageError } from "../types";
|
import { type Command, UsageError } from "../types";
|
||||||
|
|
||||||
const helpText = `\
|
const helpText = `\
|
||||||
Usage: websnacks build [ROOT_DIR]
|
Usage: websnacks build [ROOT_DIR]
|
||||||
|
|
|
||||||
|
|
@ -3,33 +3,34 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { existsSync, promises as fs, watch } from "fs";
|
import { existsSync, promises as fs, watch } from "node:fs";
|
||||||
import * as http from "http";
|
import * as http from "node:http";
|
||||||
import * as path from "path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
import { renderSite } from "../../build";
|
import { renderSite } from "../../build";
|
||||||
import { Config, loadConfig } from "../../config";
|
import { type Config, loadConfig } from "../../config";
|
||||||
import { Command, UsageError } from "../types";
|
import { isErrnoException } from "../../utils/error";
|
||||||
|
import { type Command, UsageError } from "../types";
|
||||||
|
|
||||||
const SERVER_PORT = 8080;
|
const DEFAULT_SERVER_PORT = 8080;
|
||||||
|
|
||||||
const injectLiveReloadScript = (htmlContents: string): string =>
|
const injectLiveReloadScript = (htmlContents: string, port: number): string =>
|
||||||
htmlContents.replace(
|
htmlContents.replace(
|
||||||
"</html>",
|
"</html>",
|
||||||
`
|
`
|
||||||
<script>
|
<script>
|
||||||
const ws = new WebSocket("ws://127.0.0.1:${SERVER_PORT}");
|
const ws = new WebSocket("ws://127.0.0.1:${port}");
|
||||||
ws.onmessage = function() {
|
ws.onmessage = function() {
|
||||||
console.log('dev server requested reload, reloading...');
|
console.log('dev server requested reload, reloading...');
|
||||||
location.reload();
|
location.reload();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</html>
|
</html>
|
||||||
`
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const guessMimeType = (ext: string): string => {
|
const guessMimeType = (ext: string): string => {
|
||||||
let mimeType;
|
let mimeType: string;
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case ".apng":
|
case ".apng":
|
||||||
mimeType = "image/apng";
|
mimeType = "image/apng";
|
||||||
|
|
@ -118,8 +119,23 @@ const guessMimeType = (ext: string): string => {
|
||||||
return mimeType;
|
return mimeType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const serve = (publicDir: string): http.Server => {
|
const portFromServer = (
|
||||||
const server = http.createServer(async (req, res) => {
|
addrInfo: { port: number } | object | string | undefined | null,
|
||||||
|
): number => {
|
||||||
|
if (
|
||||||
|
typeof addrInfo !== "object" ||
|
||||||
|
addrInfo == null ||
|
||||||
|
!("port" in addrInfo)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"server address does not have a valid port (this should never happen!)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return addrInfo.port;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startHttpServer = async (publicDir: string): Promise<http.Server> => {
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
if (req.url == null) {
|
if (req.url == null) {
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end();
|
res.end();
|
||||||
|
|
@ -133,10 +149,10 @@ const serve = (publicDir: string): http.Server => {
|
||||||
reqExt = ".html";
|
reqExt = ".html";
|
||||||
}
|
}
|
||||||
|
|
||||||
let contents;
|
let contents: Buffer | string;
|
||||||
try {
|
try {
|
||||||
contents = await fs.readFile(path.join(publicDir, reqPath));
|
contents = await fs.readFile(path.join(publicDir, reqPath));
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
console.error(`unable to load file ${reqPath}`);
|
console.error(`unable to load file ${reqPath}`);
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
res.end();
|
res.end();
|
||||||
|
|
@ -144,31 +160,57 @@ const serve = (publicDir: string): http.Server => {
|
||||||
}
|
}
|
||||||
const mimeType = guessMimeType(reqExt);
|
const mimeType = guessMimeType(reqExt);
|
||||||
if (mimeType === "text/html") {
|
if (mimeType === "text/html") {
|
||||||
contents = injectLiveReloadScript(contents.toString("utf8"));
|
const port = portFromServer(req.socket.address());
|
||||||
|
contents = injectLiveReloadScript(contents.toString("utf8"), port);
|
||||||
}
|
}
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
"Content-Type": mimeType,
|
"Content-Type": mimeType,
|
||||||
});
|
});
|
||||||
res.end(contents);
|
res.end(contents);
|
||||||
});
|
});
|
||||||
return server;
|
const listen = async (port?: number): Promise<void> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
httpServer
|
||||||
|
.once("error", (error) => reject(error))
|
||||||
|
.once("listening", () => resolve())
|
||||||
|
.listen(port);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await listen(DEFAULT_SERVER_PORT);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
isErrnoException(error) &&
|
||||||
|
error.code !== "EADDRINUSE"
|
||||||
|
) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await listen();
|
||||||
|
}
|
||||||
|
const port = portFromServer(httpServer.address());
|
||||||
|
console.log(`Listening at http://127.0.0.1:${port}`);
|
||||||
|
return httpServer;
|
||||||
};
|
};
|
||||||
|
|
||||||
const startWebSocketServer = async (
|
const startWebSocketServer = async (
|
||||||
server: http.Server
|
httpServer: http.Server,
|
||||||
): Promise<import("ws").Server | undefined> => {
|
): Promise<import("ws").Server | undefined> => {
|
||||||
// Attempt to load the ws module, aborting if it isn't available.
|
// Attempt to load the ws module, aborting if it isn't available.
|
||||||
let ws;
|
let ws: typeof import("ws");
|
||||||
try {
|
try {
|
||||||
ws = await import("ws");
|
ws = await import("ws");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== "MODULE_NOT_FOUND") {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
isErrnoException(error) &&
|
||||||
|
error.code !== "MODULE_NOT_FOUND"
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.warn(`'ws' module not found, live-reloading will be disabled`);
|
console.warn(`'ws' module not found, live-reloading will be disabled`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wsServer = new ws.Server({ server });
|
const wsServer = new ws.Server({ server: httpServer });
|
||||||
wsServer.on("connection", () => {
|
wsServer.on("connection", () => {
|
||||||
console.log("connected to dev site");
|
console.log("connected to dev site");
|
||||||
});
|
});
|
||||||
|
|
@ -177,28 +219,32 @@ const startWebSocketServer = async (
|
||||||
|
|
||||||
const watchFolders = async (
|
const watchFolders = async (
|
||||||
folders: string[],
|
folders: string[],
|
||||||
listener: (eventType: "update" | "remove", fileName: string) => void
|
listener: (eventType?: "update" | "remove", fileName?: string) => void,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
// Try to load node-watch, falling back to fs watch if node-watch isn't
|
// Try to load node-watch, falling back to fs watch if node-watch isn't
|
||||||
// available.
|
// available.
|
||||||
try {
|
try {
|
||||||
const { default: watch } = await import("node-watch");
|
const nodeWatch = await import("node-watch");
|
||||||
watch(folders, { recursive: true }, listener);
|
nodeWatch.default(folders, { recursive: true }, listener);
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code !== "MODULE_NOT_FOUND") {
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
isErrnoException(error) &&
|
||||||
|
error.code !== "MODULE_NOT_FOUND"
|
||||||
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.warn(
|
console.warn(
|
||||||
`'node-watch' module not found, falling back to fs.watch (may ` +
|
`'node-watch' module not found, falling back to fs.watch (may ` +
|
||||||
`result in file watch issues on some OSes)`
|
"result in file watch issues on some OSes)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// NOTE: fs.watch has significant cross-platform issues, including
|
// NOTE: fs.watch has significant cross-platform issues, including
|
||||||
// triggering duplicate file events on some systems.
|
// triggering duplicate file events on some systems.
|
||||||
for (const folder of folders) {
|
for (const folder of folders) {
|
||||||
watch(folder, { recursive: true }, (_, fileName) => {
|
watch(folder, { recursive: true }, (_, fileName) => {
|
||||||
listener("update", fileName);
|
listener("update", fileName || undefined);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -244,19 +290,18 @@ const devCommand: Command = {
|
||||||
};
|
};
|
||||||
const config = await rebuild();
|
const config = await rebuild();
|
||||||
const { outDir } = config.paths;
|
const { outDir } = config.paths;
|
||||||
const httpServer = serve(outDir);
|
const httpServer = await startHttpServer(outDir);
|
||||||
const wsServer = await startWebSocketServer(httpServer);
|
const wsServer = await startWebSocketServer(httpServer);
|
||||||
httpServer.listen(SERVER_PORT, () => {
|
|
||||||
console.log(`Listening at http://127.0.0.1:${SERVER_PORT}`);
|
|
||||||
});
|
|
||||||
const watchedFolders = config.watch.filter((filePath) =>
|
const watchedFolders = config.watch.filter((filePath) =>
|
||||||
existsSync(filePath)
|
existsSync(filePath),
|
||||||
);
|
);
|
||||||
watchFolders(watchedFolders, async (event, filePath) => {
|
await watchFolders(watchedFolders, async (event, filePath) => {
|
||||||
console.log(`${filePath}:${event} triggering rebuild...`);
|
const filePathForLog = filePath || "<UNKNOWN_FILE>";
|
||||||
|
const eventForLog = event || "<UNKNOWN_EVENT>";
|
||||||
|
console.log(`${filePathForLog}:${eventForLog} triggering rebuild...`);
|
||||||
await rebuild();
|
await rebuild();
|
||||||
if (wsServer != null) {
|
if (wsServer != null) {
|
||||||
console.log(`rebuild finished, reloading browsers...`);
|
console.log("rebuild finished, reloading browsers...");
|
||||||
for (const ws of wsServer.clients) {
|
for (const ws of wsServer.clients) {
|
||||||
ws.send("reload");
|
ws.send("reload");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Command, UsageError } from "./types";
|
import { type Command, UsageError } from "./types";
|
||||||
|
|
||||||
const globalHelpText = `\
|
const globalHelpText = `\
|
||||||
Usage: websnacks [...globalOptions] <command>
|
Usage: websnacks [...globalOptions] <command>
|
||||||
|
|
@ -24,7 +24,7 @@ interface Options {
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseArgs = (
|
const parseArgs = (
|
||||||
args: string[]
|
args: string[],
|
||||||
): { options: Options; commandName?: string; commandArgs: string[] } => {
|
): { options: Options; commandName?: string; commandArgs: string[] } => {
|
||||||
const options: Options = {
|
const options: Options = {
|
||||||
showHelp: false,
|
showHelp: false,
|
||||||
|
|
@ -36,20 +36,22 @@ const parseArgs = (
|
||||||
const opt = args.shift();
|
const opt = args.shift();
|
||||||
switch (opt) {
|
switch (opt) {
|
||||||
case "-h":
|
case "-h":
|
||||||
case "--help":
|
case "--help": {
|
||||||
options.showHelp = true;
|
options.showHelp = true;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "-r":
|
case "-r":
|
||||||
case "--require":
|
case "--require": {
|
||||||
const moduleName = args.shift();
|
const moduleName = args.shift();
|
||||||
if (moduleName == null) {
|
if (moduleName == null) {
|
||||||
throw new UsageError(
|
throw new UsageError(
|
||||||
`-r requires a valid module name`,
|
"-r requires a valid module name",
|
||||||
globalHelpText
|
globalHelpText,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
options.require.push(moduleName);
|
options.require.push(moduleName);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new UsageError(`unknown option ${opt}`, globalHelpText);
|
throw new UsageError(`unknown option ${opt}`, globalHelpText);
|
||||||
}
|
}
|
||||||
|
|
@ -65,7 +67,7 @@ const _main = async (args: string[]): Promise<void> => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (commandName == null) {
|
if (commandName == null) {
|
||||||
throw new UsageError(`must specify a valid command`, globalHelpText);
|
throw new UsageError("must specify a valid command", globalHelpText);
|
||||||
}
|
}
|
||||||
for (const moduleName of options.require) {
|
for (const moduleName of options.require) {
|
||||||
await import(moduleName);
|
await import(moduleName);
|
||||||
|
|
@ -80,10 +82,7 @@ const _main = async (args: string[]): Promise<void> => {
|
||||||
command = await import("./commands/dev");
|
command = await import("./commands/dev");
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new UsageError(
|
throw new UsageError(`unknown command ${commandName}`, globalHelpText);
|
||||||
`unknown command ${commandName}`,
|
|
||||||
globalHelpText
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// NOTE: Should this just delegate to the command?
|
// NOTE: Should this just delegate to the command?
|
||||||
for (const arg of commandArgs) {
|
for (const arg of commandArgs) {
|
||||||
|
|
|
||||||
|
|
@ -25,15 +25,28 @@ export interface HTMLElement {
|
||||||
/**
|
/**
|
||||||
* All valid types of elements that can be rendered to HTML.
|
* All valid types of elements that can be rendered to HTML.
|
||||||
*/
|
*/
|
||||||
export type Element = HTMLElement | string | boolean | undefined | null;
|
export type Element =
|
||||||
|
| Element[]
|
||||||
|
| HTMLElement
|
||||||
|
| string
|
||||||
|
| number
|
||||||
|
| boolean
|
||||||
|
| undefined
|
||||||
|
| null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom HTMLElement factory that can be parameterized by props.
|
* Custom HTMLElement factory that can be parameterized by props.
|
||||||
*/
|
*/
|
||||||
export interface Component<P extends object = {}> {
|
export interface Component<P extends object = Record<string, unknown>> {
|
||||||
(
|
(
|
||||||
props: P & {
|
props: P & {
|
||||||
children?: Element[];
|
children?: Element[];
|
||||||
}
|
},
|
||||||
): HTMLElement;
|
): HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Fragment: Component = ({ children }) => ({
|
||||||
|
tag: "#fragment",
|
||||||
|
attributes: {},
|
||||||
|
children: children || [],
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from "path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
import { purgeModuleAndDepsFromCache } from "./utils";
|
import { decacheModule } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Paths used during configuration.
|
* Paths used during configuration.
|
||||||
|
|
@ -47,6 +47,7 @@ export interface Config {
|
||||||
watch: string[];
|
watch: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,13 +58,31 @@ const noop = () => {};
|
||||||
* @return Fully-realized configuration.
|
* @return Fully-realized configuration.
|
||||||
*/
|
*/
|
||||||
export const loadConfig = async (rootDir: string): Promise<Config> => {
|
export const loadConfig = async (rootDir: string): Promise<Config> => {
|
||||||
const configPath = require.resolve(path.resolve(rootDir, "websnacks"));
|
let configPath = "";
|
||||||
purgeModuleAndDepsFromCache(configPath);
|
let userConfig: UserConfig = {};
|
||||||
|
// Attempt to load a websnacks.ts/js file in rootDir.
|
||||||
|
try {
|
||||||
|
configPath = require.resolve(path.resolve(rootDir, "websnacks"));
|
||||||
|
decacheModule(configPath);
|
||||||
// TODO: validate user config.
|
// TODO: validate user config.
|
||||||
const userConfig = await import(configPath);
|
userConfig = await import(configPath);
|
||||||
|
} catch (_error) {
|
||||||
|
// Use default config;
|
||||||
|
}
|
||||||
const outDir = path.join(rootDir, "public");
|
const outDir = path.join(rootDir, "public");
|
||||||
const pagesDir = path.join(rootDir, "pages");
|
const pagesDir = path.join(rootDir, "pages");
|
||||||
const staticAssetsDir = path.join(rootDir, "static");
|
const staticAssetsDir = path.join(rootDir, "static");
|
||||||
|
|
||||||
|
const watch = [pagesDir, staticAssetsDir];
|
||||||
|
if (configPath) {
|
||||||
|
watch.push(path.relative(rootDir, configPath));
|
||||||
|
}
|
||||||
|
if (userConfig.watch != null) {
|
||||||
|
for (const userWatch of userConfig.watch) {
|
||||||
|
watch.push(path.relative(rootDir, userWatch));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paths: {
|
paths: {
|
||||||
rootDir,
|
rootDir,
|
||||||
|
|
@ -75,11 +94,8 @@ export const loadConfig = async (rootDir: string): Promise<Config> => {
|
||||||
afterSiteRender: noop,
|
afterSiteRender: noop,
|
||||||
...userConfig.hooks,
|
...userConfig.hooks,
|
||||||
},
|
},
|
||||||
watch: [
|
watch,
|
||||||
...userConfig.watch.map((p: string) => path.relative(rootDir, p)),
|
|
||||||
path.relative(rootDir, configPath),
|
|
||||||
pagesDir,
|
|
||||||
staticAssetsDir,
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const defineConfig = (userConfig: UserConfig): UserConfig => userConfig;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Component, Element, HTMLElement } from "./component";
|
import type { Component, Element, HTMLElement } from "./component";
|
||||||
import { HTMLAttributes } from "./jsx";
|
import type { HTMLAttributes } from "./jsx";
|
||||||
|
import { flatDeep } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an HTMLElement from a custom Component.
|
* Create an HTMLElement from a custom Component.
|
||||||
|
|
@ -37,12 +38,12 @@ export function createElement(
|
||||||
...children: Element[]
|
...children: Element[]
|
||||||
): HTMLElement;
|
): HTMLElement;
|
||||||
export function createElement(
|
export function createElement(
|
||||||
type: string | Component<any>,
|
type: string | Component,
|
||||||
props: (HTMLAttributes & Record<string, any>) | null,
|
props: object | null,
|
||||||
...children: Element[]
|
...children: Element[]
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
// Flatten the children array so we can accept arrays as children.
|
// Flatten the children array so we can accept arrays as children.
|
||||||
const normalizedChildren = children.flat();
|
const normalizedChildren = flatDeep(children);
|
||||||
if (type instanceof Function) {
|
if (type instanceof Function) {
|
||||||
return type({ ...props, children: normalizedChildren });
|
return type({ ...props, children: normalizedChildren });
|
||||||
}
|
}
|
||||||
|
|
@ -50,5 +51,26 @@ export function createElement(
|
||||||
if (type !== type.toLowerCase()) {
|
if (type !== type.toLowerCase()) {
|
||||||
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
|
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
|
||||||
}
|
}
|
||||||
return { tag: type, attributes: props || {}, children: normalizedChildren };
|
const attrs: Record<string, string | number | boolean> = {};
|
||||||
|
for (const [key, value] of Object.entries(props || {})) {
|
||||||
|
if (key === "dangerouslySetInnerHTML") {
|
||||||
|
if (children.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
attrs[key] = value.__html;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value !== "string" &&
|
||||||
|
typeof value !== "number" &&
|
||||||
|
typeof value !== "boolean"
|
||||||
|
) {
|
||||||
|
console.warn(`non-primitive attribute ${key} = ${JSON.stringify(value)}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
attrs[key] = value;
|
||||||
|
}
|
||||||
|
return { tag: type, attributes: attrs, children: normalizedChildren };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { HTMLElement, Component } from "./component";
|
export { Component, Fragment, HTMLElement } from "./component";
|
||||||
export { UserConfig as Config } from "./config";
|
export { defineConfig, UserConfig as Config } from "./config";
|
||||||
export { createElement } from "./create-element";
|
export { createElement } from "./create-element";
|
||||||
|
export * from "./jsx";
|
||||||
|
|
|
||||||
23
src/jsx.ts
23
src/jsx.ts
|
|
@ -3,10 +3,13 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
import type { HTMLElement } from "./component";
|
||||||
|
|
||||||
export interface RdfaAttributes {
|
export interface RdfaAttributes {
|
||||||
about?: string;
|
about?: string;
|
||||||
datatype?: string;
|
datatype?: string;
|
||||||
inlist?: any;
|
inlist?: boolean;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
property?: string;
|
property?: string;
|
||||||
resource?: string;
|
resource?: string;
|
||||||
|
|
@ -22,7 +25,14 @@ export interface MicrodataAttributes {
|
||||||
itemRef?: string;
|
itemRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
|
export interface SetInnerHtmlAttributes {
|
||||||
|
dangerouslySetInnerHTML?: { __html: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HTMLAttributes
|
||||||
|
extends RdfaAttributes,
|
||||||
|
MicrodataAttributes,
|
||||||
|
SetInnerHtmlAttributes {
|
||||||
accept?: string;
|
accept?: string;
|
||||||
acceptCharset?: string;
|
acceptCharset?: string;
|
||||||
accessKey?: string;
|
accessKey?: string;
|
||||||
|
|
@ -60,7 +70,7 @@ export interface HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
|
||||||
dir?: "auto" | "rtl" | "ltr";
|
dir?: "auto" | "rtl" | "ltr";
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disableRemotePlayback?: boolean;
|
disableRemotePlayback?: boolean;
|
||||||
download?: any;
|
download?: boolean | string;
|
||||||
draggable?: boolean;
|
draggable?: boolean;
|
||||||
encType?: string;
|
encType?: string;
|
||||||
form?: string;
|
form?: string;
|
||||||
|
|
@ -154,7 +164,10 @@ export interface HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
|
||||||
wrap?: string;
|
wrap?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntrinsicElements {
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
type Element = HTMLElement;
|
||||||
|
type IntrinsicElements = {
|
||||||
a: HTMLAttributes;
|
a: HTMLAttributes;
|
||||||
abbr: HTMLAttributes;
|
abbr: HTMLAttributes;
|
||||||
address: HTMLAttributes;
|
address: HTMLAttributes;
|
||||||
|
|
@ -270,4 +283,6 @@ export interface IntrinsicElements {
|
||||||
var: HTMLAttributes;
|
var: HTMLAttributes;
|
||||||
video: HTMLAttributes;
|
video: HTMLAttributes;
|
||||||
wbr: HTMLAttributes;
|
wbr: HTMLAttributes;
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Element, HTMLElement } from "./component";
|
import type { Element, HTMLElement } from "./component";
|
||||||
|
|
||||||
const HTML_ESCAPES: { [char: string]: string } = {
|
const HTML_ESCAPES: { [char: string]: string } = {
|
||||||
"&": "&",
|
"&": "&",
|
||||||
|
|
@ -22,20 +22,34 @@ const renderElement = (elem: Element): string => {
|
||||||
if (elem == null || typeof elem === "boolean") {
|
if (elem == null || typeof elem === "boolean") {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
if (typeof elem === "number") {
|
||||||
|
return elem.toString();
|
||||||
|
}
|
||||||
if (typeof elem === "string") {
|
if (typeof elem === "string") {
|
||||||
return escapeHtml(elem);
|
return escapeHtml(elem);
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(elem)) {
|
||||||
|
return elem.map((e) => renderElement(e)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
let output = "";
|
let output = "";
|
||||||
output += startTag(elem);
|
output += startTag(elem);
|
||||||
|
if (elem.attributes.dangerouslySetInnerHTML != null) {
|
||||||
|
output += elem.attributes.dangerouslySetInnerHTML;
|
||||||
|
} else {
|
||||||
for (const child of elem.children) {
|
for (const child of elem.children) {
|
||||||
output += renderElement(child);
|
output += renderElement(child);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
output += endTag(elem);
|
output += endTag(elem);
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
const startTag = (elem: HTMLElement): string => {
|
const startTag = (elem: HTMLElement): string => {
|
||||||
|
if (elem.tag === "#fragment") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
let output = `<${escapeHtml(elem.tag)}`;
|
let output = `<${escapeHtml(elem.tag)}`;
|
||||||
|
|
||||||
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
|
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
|
||||||
|
|
@ -45,6 +59,11 @@ const startTag = (elem: HTMLElement): string => {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ignore the special attr for setting raw inner HTML.
|
||||||
|
if (attrName === "dangerouslySetInnerHTML") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
|
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
|
||||||
if (normalizedAttrName === "classname") {
|
if (normalizedAttrName === "classname") {
|
||||||
normalizedAttrName = "class";
|
normalizedAttrName = "class";
|
||||||
|
|
@ -52,9 +71,7 @@ const startTag = (elem: HTMLElement): string => {
|
||||||
if (attrValue === true) {
|
if (attrValue === true) {
|
||||||
output += ` ${normalizedAttrName}=""`;
|
output += ` ${normalizedAttrName}=""`;
|
||||||
} else {
|
} else {
|
||||||
output += ` ${normalizedAttrName}="${escapeAttr(
|
output += ` ${normalizedAttrName}="${escapeAttr(attrValue.toString())}"`;
|
||||||
attrValue.toString()
|
|
||||||
)}"`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +79,12 @@ const startTag = (elem: HTMLElement): string => {
|
||||||
return output;
|
return output;
|
||||||
};
|
};
|
||||||
|
|
||||||
const endTag = (elem: HTMLElement): string => `</${escapeHtml(elem.tag)}>`;
|
const endTag = (elem: HTMLElement): string => {
|
||||||
|
if (elem.tag === "#fragment") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return `</${escapeHtml(elem.tag)}>`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a complete HTML page from an HTMLElement. Note that the root element
|
* Render a complete HTML page from an HTMLElement. Note that the root element
|
||||||
|
|
@ -72,10 +94,20 @@ const endTag = (elem: HTMLElement): string => `</${escapeHtml(elem.tag)}>`;
|
||||||
*
|
*
|
||||||
* @return Fully rendered HTML document as a string.
|
* @return Fully rendered HTML document as a string.
|
||||||
*/
|
*/
|
||||||
export const renderPage = (rootElem: HTMLElement): string => {
|
export const renderPage = (rootElem: Element): string => {
|
||||||
|
if (rootElem == null) {
|
||||||
|
throw new Error("root page element cannot be null");
|
||||||
|
}
|
||||||
|
if (typeof rootElem !== "object" || !("tag" in rootElem)) {
|
||||||
|
throw new Error(
|
||||||
|
`root page element must be a valid HTMLElement, got ${JSON.stringify(
|
||||||
|
rootElem,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (rootElem.tag.toLowerCase() !== "html") {
|
if (rootElem.tag.toLowerCase() !== "html") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`attempted to render page with non-HTML root element ${rootElem.tag}`
|
`attempted to render page with non-HTML root element ${rootElem.tag}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
50
src/utils.ts
50
src/utils.ts
|
|
@ -1,50 +0,0 @@
|
||||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
||||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
||||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { promises as fs } from "fs";
|
|
||||||
import * as path from "path";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively walk a directory, returning the files it finds.
|
|
||||||
*
|
|
||||||
* @param dirPath Path to the directory to walk.
|
|
||||||
*
|
|
||||||
* @return Generator that yields the files found while walking the directory.
|
|
||||||
*/
|
|
||||||
export const walkDir = async function* (
|
|
||||||
dirPath: string
|
|
||||||
): AsyncGenerator<string> {
|
|
||||||
const dirEnts = await fs.readdir(dirPath, { withFileTypes: true });
|
|
||||||
for (const dirEnt of dirEnts) {
|
|
||||||
if (dirEnt.isDirectory()) {
|
|
||||||
yield* walkDir(path.join(dirPath, dirEnt.name));
|
|
||||||
}
|
|
||||||
if (dirEnt.isFile()) {
|
|
||||||
yield path.join(dirPath, dirEnt.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Purge cached versions of a node module and all of its dependencies from the
|
|
||||||
* global require cache, ensuring that future imports reload the module from
|
|
||||||
* disk.
|
|
||||||
*
|
|
||||||
* @param modName Name of the module to purge from the require cache.
|
|
||||||
*/
|
|
||||||
export const purgeModuleAndDepsFromCache = (modName: string): void => {
|
|
||||||
var modPath = require.resolve(modName);
|
|
||||||
if (modPath == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mod = require.cache[modPath];
|
|
||||||
if (mod == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const child of mod.children) {
|
|
||||||
purgeModuleAndDepsFromCache(child.id);
|
|
||||||
}
|
|
||||||
delete require.cache[modPath];
|
|
||||||
};
|
|
||||||
75
src/utils/decache-module.ts
Normal file
75
src/utils/decache-module.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { isErrnoException } from "./error";
|
||||||
|
|
||||||
|
const resolveModulePath = (importPath: string): string | undefined => {
|
||||||
|
try {
|
||||||
|
return require.resolve(importPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
isErrnoException(error) &&
|
||||||
|
error.code === "MODULE_NOT_FOUND"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeParentModuleRef = (mod: NodeModule): void => {
|
||||||
|
const parent = mod.parent;
|
||||||
|
if (parent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siblings = parent.children;
|
||||||
|
const nSiblings = siblings.length;
|
||||||
|
for (let i = nSiblings - 1; i >= 0; i--) {
|
||||||
|
const sibling = siblings[i];
|
||||||
|
if (sibling.id === mod.id) {
|
||||||
|
siblings.splice(i, 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear a module and its dependencies from node's module cache, ensuring that
|
||||||
|
* requiring the module again will reload the code from disk.
|
||||||
|
*
|
||||||
|
* @param importPath Path or name of the module to resolve (same as
|
||||||
|
* {@see require}).
|
||||||
|
*
|
||||||
|
* @throws Error if the module could not be resolved due to filesystem error.
|
||||||
|
*/
|
||||||
|
export const decacheModule = (importPath: string): void => {
|
||||||
|
const modulePath = resolveModulePath(importPath);
|
||||||
|
if (modulePath == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DFS the module dependency tree, using iteration to avoid stack size
|
||||||
|
// exceeded exceptions.
|
||||||
|
const modsToCheck: NodeModule[] = [];
|
||||||
|
const visited: Set<string> = new Set();
|
||||||
|
let currentMod: NodeModule | undefined = require.cache[modulePath];
|
||||||
|
while (currentMod != null) {
|
||||||
|
if (visited.has(currentMod.id)) {
|
||||||
|
currentMod = modsToCheck.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeParentModuleRef(currentMod);
|
||||||
|
delete require.cache[currentMod.id];
|
||||||
|
for (const childMod of currentMod.children) {
|
||||||
|
modsToCheck.push(childMod);
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(currentMod.id);
|
||||||
|
currentMod = modsToCheck.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
3
src/utils/error.ts
Normal file
3
src/utils/error.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const isErrnoException = (
|
||||||
|
error: Error,
|
||||||
|
): error is NodeJS.ErrnoException => "code" in error;
|
||||||
51
src/utils/index.ts
Normal file
51
src/utils/index.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
export { decacheModule } from "./decache-module";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk a directory, returning the files it finds.
|
||||||
|
*
|
||||||
|
* @param dirPath Path to the directory to walk.
|
||||||
|
*
|
||||||
|
* @return Generator that yields the files found while walking the directory.
|
||||||
|
*/
|
||||||
|
export const walkDir = async function* (
|
||||||
|
dirPath: string,
|
||||||
|
): AsyncGenerator<string> {
|
||||||
|
const dirEnts = await fs.readdir(dirPath, { withFileTypes: true });
|
||||||
|
for (const dirEnt of dirEnts) {
|
||||||
|
if (dirEnt.isDirectory()) {
|
||||||
|
yield* walkDir(path.join(dirPath, dirEnt.name));
|
||||||
|
}
|
||||||
|
if (dirEnt.isFile()) {
|
||||||
|
yield path.join(dirPath, dirEnt.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Flattenable<T> = Array<T | Flattenable<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten an arbitrarily-deeply nested array into a flat array.
|
||||||
|
*
|
||||||
|
* @param arr Array to flatten.
|
||||||
|
*
|
||||||
|
* @return Flattened array.
|
||||||
|
*/
|
||||||
|
export const flatDeep = <T>(arr: Flattenable<T>): T[] => {
|
||||||
|
const flattenedArr: T[] = [];
|
||||||
|
for (const val of arr) {
|
||||||
|
if (Array.isArray(val)) {
|
||||||
|
flattenedArr.push(...flatDeep(val));
|
||||||
|
} else {
|
||||||
|
flattenedArr.push(val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattenedArr;
|
||||||
|
};
|
||||||
151
test/e2e/build.tsx
Normal file
151
test/e2e/build.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
npmCmd,
|
||||||
|
runCommand,
|
||||||
|
WEBSNACKS_BIN_PATH,
|
||||||
|
WEBSNACKS_REPO_ROOT,
|
||||||
|
withTempDir,
|
||||||
|
} from "../helpers/e2e";
|
||||||
|
import { testSuite } from "../lib";
|
||||||
|
|
||||||
|
testSuite("build command", ({ test }) => {
|
||||||
|
test("runs without throwing error", async () => {
|
||||||
|
await withTempDir(async (tempDirPath) => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "tsconfig.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
module: "CommonJS",
|
||||||
|
moduleResolution: "node",
|
||||||
|
jsx: "react",
|
||||||
|
jsxFactory: "createElement",
|
||||||
|
target: "ES2018",
|
||||||
|
lib: ["ES2018"],
|
||||||
|
strict: true,
|
||||||
|
noUnusedLocals: true,
|
||||||
|
noUnusedParameters: true,
|
||||||
|
noImplicitReturns: true,
|
||||||
|
noFallthroughCasesInSwitch: true,
|
||||||
|
},
|
||||||
|
include: ["components/**/*", "pages/**/*"],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "websnacks.ts"),
|
||||||
|
`
|
||||||
|
import { Config } from "websnacks";
|
||||||
|
const config: Config = {
|
||||||
|
watch: [],
|
||||||
|
};
|
||||||
|
export = config;
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pagesPath = path.join(tempDirPath, "pages");
|
||||||
|
await fs.mkdir(pagesPath);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pagesPath, "index.tsx"),
|
||||||
|
`
|
||||||
|
import { createElement } from "websnacks";
|
||||||
|
export const page = () => <html />;
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
devDependencies: {
|
||||||
|
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
await runCommand(npmCmd, ["install", "--silent"], {
|
||||||
|
cwd: tempDirPath,
|
||||||
|
}).complete;
|
||||||
|
const cmd = runCommand(
|
||||||
|
"node",
|
||||||
|
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"],
|
||||||
|
{
|
||||||
|
cwd: tempDirPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await cmd.complete;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works without config file", async () => {
|
||||||
|
await withTempDir(async (tempDirPath) => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "tsconfig.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
module: "CommonJS",
|
||||||
|
moduleResolution: "node",
|
||||||
|
jsx: "react",
|
||||||
|
jsxFactory: "createElement",
|
||||||
|
target: "ES2018",
|
||||||
|
lib: ["ES2018"],
|
||||||
|
strict: true,
|
||||||
|
noUnusedLocals: true,
|
||||||
|
noUnusedParameters: true,
|
||||||
|
noImplicitReturns: true,
|
||||||
|
noFallthroughCasesInSwitch: true,
|
||||||
|
},
|
||||||
|
include: ["components/**/*", "pages/**/*"],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pagesPath = path.join(tempDirPath, "pages");
|
||||||
|
await fs.mkdir(pagesPath);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pagesPath, "index.tsx"),
|
||||||
|
`
|
||||||
|
import { createElement } from "websnacks";
|
||||||
|
export const page = () => <html />;
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
devDependencies: {
|
||||||
|
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
await runCommand(npmCmd, ["install", "--silent"], {
|
||||||
|
cwd: tempDirPath,
|
||||||
|
}).complete;
|
||||||
|
const cmd = runCommand(
|
||||||
|
"node",
|
||||||
|
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"],
|
||||||
|
{
|
||||||
|
cwd: tempDirPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await cmd.complete;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
160
test/e2e/dev.tsx
Normal file
160
test/e2e/dev.tsx
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
npmCmd,
|
||||||
|
runCommand,
|
||||||
|
WEBSNACKS_BIN_PATH,
|
||||||
|
WEBSNACKS_REPO_ROOT,
|
||||||
|
wait,
|
||||||
|
withTempDir,
|
||||||
|
} from "../helpers/e2e";
|
||||||
|
import { testSuite } from "../lib";
|
||||||
|
|
||||||
|
testSuite("dev command", ({ test, expect }) => {
|
||||||
|
test("starts without throwing error", async () => {
|
||||||
|
await withTempDir(async (tempDirPath) => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "tsconfig.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
module: "CommonJS",
|
||||||
|
moduleResolution: "node",
|
||||||
|
jsx: "react",
|
||||||
|
jsxFactory: "createElement",
|
||||||
|
target: "ES2018",
|
||||||
|
lib: ["ES2018"],
|
||||||
|
strict: true,
|
||||||
|
noUnusedLocals: true,
|
||||||
|
noUnusedParameters: true,
|
||||||
|
noImplicitReturns: true,
|
||||||
|
noFallthroughCasesInSwitch: true,
|
||||||
|
},
|
||||||
|
include: ["components/**/*", "pages/**/*"],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "websnacks.ts"),
|
||||||
|
`
|
||||||
|
import { Config } from "websnacks";
|
||||||
|
const config: Config = {
|
||||||
|
watch: [],
|
||||||
|
};
|
||||||
|
export = config;
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pagesPath = path.join(tempDirPath, "pages");
|
||||||
|
await fs.mkdir(pagesPath);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pagesPath, "index.tsx"),
|
||||||
|
`
|
||||||
|
import { createElement } from "websnacks";
|
||||||
|
export const page = () => <html />;
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
devDependencies: {
|
||||||
|
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
await runCommand(npmCmd, ["install", "--silent"], {
|
||||||
|
cwd: tempDirPath,
|
||||||
|
}).complete;
|
||||||
|
const cmd = runCommand(
|
||||||
|
"node",
|
||||||
|
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"],
|
||||||
|
{
|
||||||
|
cwd: tempDirPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// FIXME: This test is a bit brittle due to relying on timeouts.
|
||||||
|
await wait(10_000);
|
||||||
|
cmd.process.kill();
|
||||||
|
const stdout = await cmd.complete;
|
||||||
|
expect(stdout).toStartWith("Listening at");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("works without config file", async () => {
|
||||||
|
await withTempDir(async (tempDirPath) => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "tsconfig.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
compilerOptions: {
|
||||||
|
esModuleInterop: true,
|
||||||
|
module: "CommonJS",
|
||||||
|
moduleResolution: "node",
|
||||||
|
jsx: "react",
|
||||||
|
jsxFactory: "createElement",
|
||||||
|
target: "ES2018",
|
||||||
|
lib: ["ES2018"],
|
||||||
|
strict: true,
|
||||||
|
noUnusedLocals: true,
|
||||||
|
noUnusedParameters: true,
|
||||||
|
noImplicitReturns: true,
|
||||||
|
noFallthroughCasesInSwitch: true,
|
||||||
|
},
|
||||||
|
include: ["components/**/*", "pages/**/*"],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const pagesPath = path.join(tempDirPath, "pages");
|
||||||
|
await fs.mkdir(pagesPath);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(pagesPath, "index.tsx"),
|
||||||
|
`
|
||||||
|
import { createElement } from "websnacks";
|
||||||
|
export const page = () => <html />;
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tempDirPath, "package.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
devDependencies: {
|
||||||
|
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
);
|
||||||
|
await runCommand(npmCmd, ["install", "--silent"], {
|
||||||
|
cwd: tempDirPath,
|
||||||
|
}).complete;
|
||||||
|
const cmd = runCommand(
|
||||||
|
"node",
|
||||||
|
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"],
|
||||||
|
{
|
||||||
|
cwd: tempDirPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// FIXME: This test is a bit brittle due to relying on timeouts.
|
||||||
|
await wait(10_000);
|
||||||
|
cmd.process.kill();
|
||||||
|
const stdout = await cmd.complete;
|
||||||
|
expect(stdout).toStartWith("Listening at");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
168
test/helpers/e2e.ts
Normal file
168
test/helpers/e2e.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a timeout and wait for at least the specified number of milliseconds,
|
||||||
|
* resolving the promise once the event loop meets or exceeds timeMs.
|
||||||
|
*
|
||||||
|
* @param timeMs Time in milliseconds to wait.
|
||||||
|
*/
|
||||||
|
export const wait = async (timeMs: number): Promise<void> => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(), timeMs);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform an operation within a unique temporary directory created within a
|
||||||
|
* special .test-dist folder within this websnacks repository.
|
||||||
|
*
|
||||||
|
* @note Currently the temporary folder is **not** cleaned up once the operation
|
||||||
|
* has finished. I've had issues with losing work due to buggy removal
|
||||||
|
* code and haven't been willing to risk it again. To cleanup these
|
||||||
|
* temporary folders it should be as easy as removing the whole
|
||||||
|
* ".test-dist" folder from your checkout.
|
||||||
|
*
|
||||||
|
* @param op Operation to perform which receives the fully resolved temp
|
||||||
|
* directory path as its only argument.
|
||||||
|
*/
|
||||||
|
export const withTempDir = async (
|
||||||
|
op: (tempDirPath: string) => Promise<void> | void,
|
||||||
|
): Promise<void> => {
|
||||||
|
await fs.mkdir(TEMP_PATH, { recursive: true });
|
||||||
|
const tempDirPath = await fs.mkdtemp(`${TEMP_PATH}/`);
|
||||||
|
try {
|
||||||
|
await op(tempDirPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`(${tempDirPath}): ${error}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully resolved path to the root of this websnacks repository.
|
||||||
|
*/
|
||||||
|
export const WEBSNACKS_REPO_ROOT = path.resolve(__dirname, "..", "..");
|
||||||
|
/**
|
||||||
|
* Fully resolved path to the websnacks CLI script in this repository.
|
||||||
|
*/
|
||||||
|
export const WEBSNACKS_BIN_PATH = path.join(
|
||||||
|
WEBSNACKS_REPO_ROOT,
|
||||||
|
"bin",
|
||||||
|
"websnacks.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A handle to an asynchronous shell command run in a subprocess.
|
||||||
|
*/
|
||||||
|
export interface AsyncCommand {
|
||||||
|
/**
|
||||||
|
* Promise that resolves with the stdout of the subprocess once the
|
||||||
|
* subprocess exits with a zero-code.
|
||||||
|
*
|
||||||
|
* The promise rejects if the subprocess exits with a non-zero code, the
|
||||||
|
* subprocess writes to its stderr, or the command failed to spawn.
|
||||||
|
*/
|
||||||
|
complete: Promise<string>;
|
||||||
|
/**
|
||||||
|
* Handle to to child process for event-based process manipulation.
|
||||||
|
*/
|
||||||
|
process: ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options used to configure {@link runCommand}.
|
||||||
|
*/
|
||||||
|
export interface CliOptions {
|
||||||
|
/**
|
||||||
|
* Working directory where the command should be run. Defaults to the
|
||||||
|
* current working directory.
|
||||||
|
*/
|
||||||
|
cwd?: string;
|
||||||
|
/**
|
||||||
|
* Timeout in milliseconds after which a command that hasn't exited will
|
||||||
|
* reject the promise and kill the subprocess.
|
||||||
|
*/
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CLI_OPTIONS = {
|
||||||
|
timeoutMs: 15_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a shell command in a subprocess.
|
||||||
|
*
|
||||||
|
* This provides a more user-friendly promise-based interface to
|
||||||
|
* {@link child_process.spawn}. The obj
|
||||||
|
*
|
||||||
|
* @param command Name of the shell command to run.
|
||||||
|
* @param args Array of arguments to pass to the command.
|
||||||
|
* @param options Parameters to change how the command is run and resolved.
|
||||||
|
*
|
||||||
|
* @returns Command object for handling in client code.
|
||||||
|
*/
|
||||||
|
export const runCommand = (
|
||||||
|
command: string,
|
||||||
|
args: string[] = [],
|
||||||
|
options?: CliOptions,
|
||||||
|
): AsyncCommand => {
|
||||||
|
const optionsWithDefaults = { ...DEFAULT_CLI_OPTIONS, ...options };
|
||||||
|
const process = spawn(command, args, {
|
||||||
|
...optionsWithDefaults,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
const complete = new Promise<string>((resolve, reject) => {
|
||||||
|
let threwError = false;
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
process.stdout.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
process.stderr.on("data", (data) => {
|
||||||
|
threwError = true;
|
||||||
|
process.kill();
|
||||||
|
reject(new Error(`command output to stderr: ${data.toString()}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
threwError = true;
|
||||||
|
process.kill();
|
||||||
|
reject(
|
||||||
|
new Error(`max timeout of ${optionsWithDefaults.timeoutMs}ms reached`),
|
||||||
|
);
|
||||||
|
}, optionsWithDefaults.timeoutMs);
|
||||||
|
process.on("exit", (code) => {
|
||||||
|
if (threwError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (code !== null && code !== 0) {
|
||||||
|
reject(new Error(`command exited with non-zero code: ${code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
process.on("error", (error) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (!threwError) {
|
||||||
|
reject(new Error(`command errored: ${error}`));
|
||||||
|
threwError = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
complete,
|
||||||
|
process,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const npmCmd = os.platform() === "win32" ? "npm.cmd" : "npm";
|
||||||
208
test/lib/expect.ts
Normal file
208
test/lib/expect.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { areEqual, displayValue, matches } from "./utils";
|
||||||
|
|
||||||
|
class ExpectError extends Error {
|
||||||
|
public constructor(reason: string, expected: unknown, actual: unknown) {
|
||||||
|
super(
|
||||||
|
`${reason}\n` +
|
||||||
|
`\texpected: ${displayValue(expected)}\n` +
|
||||||
|
`\tactual : ${displayValue(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generic expectation builder which knows nothing about the type of value it
|
||||||
|
* is operating upon.
|
||||||
|
*
|
||||||
|
* This is the base class expectation that allows only type-agnostic assertions
|
||||||
|
* upon its contained value, and all other expectation classes inherit from
|
||||||
|
* Expect.
|
||||||
|
*/
|
||||||
|
export class Expect<T> {
|
||||||
|
protected readonly value: T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new expectation around a value.
|
||||||
|
*
|
||||||
|
* @param value Value to place assertions upon.
|
||||||
|
*/
|
||||||
|
public constructor(value: T) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect the value to equal an expected value.
|
||||||
|
*
|
||||||
|
* Note that strict equality checking is used for primitives and structural
|
||||||
|
* equality is used for objects.
|
||||||
|
*
|
||||||
|
* @param expected Expected value.
|
||||||
|
*
|
||||||
|
* @throws ExpectError If the actual value does not equal the expected value.
|
||||||
|
*/
|
||||||
|
public toEqual(expected: T): void {
|
||||||
|
if (!areEqual(this.value, expected)) {
|
||||||
|
throw new ExpectError(
|
||||||
|
"value does not equal expected",
|
||||||
|
expected,
|
||||||
|
this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String-specific Expect assertions.
|
||||||
|
*/
|
||||||
|
export class StringExpect extends Expect<string> {
|
||||||
|
/**
|
||||||
|
* Expect the string value to match a RegExp pattern.
|
||||||
|
*
|
||||||
|
* @param pattern Regular expression to match against.
|
||||||
|
*
|
||||||
|
* @throws ExpectError If the actual value does not match the expected
|
||||||
|
* RegExp pattern.
|
||||||
|
*/
|
||||||
|
public toMatch(pattern: RegExp): void {
|
||||||
|
if (!this.value.match(pattern)) {
|
||||||
|
throw new ExpectError(
|
||||||
|
"value does not match expected pattern",
|
||||||
|
pattern,
|
||||||
|
this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect the string value to start with a particular prefix.
|
||||||
|
*
|
||||||
|
* @param prefix Prefix that the string is expected to start with.
|
||||||
|
*
|
||||||
|
* @throws ExpectError If the actual value does not start with the expected
|
||||||
|
* prefix.
|
||||||
|
*/
|
||||||
|
public toStartWith(prefix: string): void {
|
||||||
|
if (!this.value.startsWith(prefix)) {
|
||||||
|
throw new ExpectError(
|
||||||
|
"value does not start with expected prefix",
|
||||||
|
prefix,
|
||||||
|
this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expect the string value to end with a particular suffix.
|
||||||
|
*
|
||||||
|
* @param suffix Suffix that the string is expected to end with.
|
||||||
|
*
|
||||||
|
* @throws ExpectError If the actual value does not end with the expected
|
||||||
|
* suffix.
|
||||||
|
*/
|
||||||
|
public toEndWith(suffix: string): void {
|
||||||
|
if (!this.value.endsWith(suffix)) {
|
||||||
|
throw new ExpectError(
|
||||||
|
"value does not end with expected suffix",
|
||||||
|
suffix,
|
||||||
|
this.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function-specific Expect assertions.
|
||||||
|
*/
|
||||||
|
export class FunctionExpect<T> extends Expect<() => T> {
|
||||||
|
/**
|
||||||
|
* Expect the function to throw an Error with error message matching a
|
||||||
|
* string or pattern.
|
||||||
|
*
|
||||||
|
* @param pattern String that exactly matches the error message or RegExp
|
||||||
|
* that should match the error message.
|
||||||
|
*
|
||||||
|
* @throws ExpectError If the function does not throw an error, throws a
|
||||||
|
* non-Error value, or throws an Error whose message does not match
|
||||||
|
* the expected pattern.
|
||||||
|
*/
|
||||||
|
public toThrowErrorMatching(pattern: string | RegExp): void {
|
||||||
|
try {
|
||||||
|
this.value();
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
throw new ExpectError("function threw non-Error value", pattern, error);
|
||||||
|
}
|
||||||
|
if (!matches(error.message, pattern)) {
|
||||||
|
throw new ExpectError(
|
||||||
|
`thrown Error's message does not match pattern`,
|
||||||
|
pattern,
|
||||||
|
error.message,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new ExpectError(
|
||||||
|
"function did not throw expected error",
|
||||||
|
pattern,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an Expect assertion builder on a string value.
|
||||||
|
*
|
||||||
|
* @param str String value to place expectations upon.
|
||||||
|
*/
|
||||||
|
export function expect(str: string): StringExpect;
|
||||||
|
/**
|
||||||
|
* Create an Expect assertion builder on a function value.
|
||||||
|
*
|
||||||
|
* Useful primarily for asserting that a function throws an expected Error,
|
||||||
|
* e.g.:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // Passes assertion.
|
||||||
|
* expect(() => throw new Error('oh noes!')).toThrowErrorMatching('oh noes!');
|
||||||
|
*
|
||||||
|
* // Fails assertion since non-error value was thrown in func.
|
||||||
|
* expect(() => throw "oh noes!").toThrow('oh noes!');
|
||||||
|
*
|
||||||
|
* // Fails assertion since func doesn't throw.
|
||||||
|
* expect(() => 1 / 2).toThrowErrorMatching('oh noes!');
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param fn Function to place expectations upon.
|
||||||
|
*/
|
||||||
|
export function expect<T>(fn: () => T): FunctionExpect<T>;
|
||||||
|
/**
|
||||||
|
* Create an Expect assertion upon some value.
|
||||||
|
*
|
||||||
|
* Expectations are declarative assertions on values that immediately throw an
|
||||||
|
* Error when the assertion is violated. This abstraction allows for readable
|
||||||
|
* test assertions like the following:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* // Doesn't throw since strings are equal.
|
||||||
|
* expect("hai").toEqual("hai");
|
||||||
|
*
|
||||||
|
* // Throws an Error since 3 !== 2.
|
||||||
|
* expect(3).toEqual(2);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param value Value to place expectations upon.
|
||||||
|
*/
|
||||||
|
export function expect(value: unknown): Expect<unknown> {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return new StringExpect(value);
|
||||||
|
}
|
||||||
|
if (typeof value === "function") {
|
||||||
|
return new FunctionExpect(value as () => unknown);
|
||||||
|
}
|
||||||
|
return new Expect(value);
|
||||||
|
}
|
||||||
118
test/lib/harness.ts
Normal file
118
test/lib/harness.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect } from "./expect";
|
||||||
|
import { displayValue, shuffle } from "./utils";
|
||||||
|
|
||||||
|
interface Test {
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
runTest(): void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestResult = {
|
||||||
|
testName: string;
|
||||||
|
} & (
|
||||||
|
| {
|
||||||
|
result: "pass";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
result: "fail";
|
||||||
|
error: Error;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const runTest = async (test: Test): Promise<TestResult> => {
|
||||||
|
let result: TestResult;
|
||||||
|
try {
|
||||||
|
await test.runTest();
|
||||||
|
result = {
|
||||||
|
testName: test.name,
|
||||||
|
result: "pass",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
result = {
|
||||||
|
testName: test.name,
|
||||||
|
result: "fail",
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error
|
||||||
|
: new Error(`threw non-error object: ${displayValue(error)}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context object that is passed into a test suite definition.
|
||||||
|
*/
|
||||||
|
export interface TestSuiteContext {
|
||||||
|
/**
|
||||||
|
* Define a test in this test suite.
|
||||||
|
*
|
||||||
|
* Tests are functions that pass if they are executed and don't throw (or
|
||||||
|
* that resolve for async tests), and that fail if they throw an error (or
|
||||||
|
* reject for async tests).
|
||||||
|
*
|
||||||
|
* Note that tests are executed in a random order within a test suite in
|
||||||
|
* order to prevent accidentally creating order dependencies between tests,
|
||||||
|
* which can result in brittle tests and is a code smell that might indicate
|
||||||
|
* that the code under test is also brittle.
|
||||||
|
*/
|
||||||
|
test: (name: string, def: () => void | Promise<void>) => void;
|
||||||
|
/**
|
||||||
|
* Expectation builder function used to build human-readable assertions and
|
||||||
|
* errors.
|
||||||
|
*/
|
||||||
|
expect: typeof expect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define a suite of tests to run as a single unit.
|
||||||
|
*
|
||||||
|
* A test suite executes immediately, running tests in a randomly determined
|
||||||
|
* order.
|
||||||
|
*
|
||||||
|
* Note that currently there is no support for having multiple test suites per
|
||||||
|
* test file; you CAN have multiple test suites in a file but if the first test
|
||||||
|
* suite fails any subsequent test suites won't be executed.
|
||||||
|
*
|
||||||
|
* @param suiteName Name of the test suite for reporting.
|
||||||
|
* @param def Function used to declare the tests
|
||||||
|
*/
|
||||||
|
export const testSuite = (
|
||||||
|
suiteName: string,
|
||||||
|
def: (ctx: TestSuiteContext) => void,
|
||||||
|
): void => {
|
||||||
|
const tests: Test[] = [];
|
||||||
|
const test = (name: string, runTest: () => void | Promise<void>): void => {
|
||||||
|
tests.push({ name, runTest });
|
||||||
|
};
|
||||||
|
def({ test, expect });
|
||||||
|
|
||||||
|
// Randomly shuffle the tests so that we can catch accidental order
|
||||||
|
// dependencies.
|
||||||
|
shuffle(tests);
|
||||||
|
(async () => {
|
||||||
|
const results = await Promise.all(tests.map((test) => runTest(test)));
|
||||||
|
let passed = 0;
|
||||||
|
for (const testResult of results) {
|
||||||
|
if (testResult.result === "fail") {
|
||||||
|
console.error(
|
||||||
|
`[TEST FAILURE] "${suiteName}": "${testResult.testName}": ` +
|
||||||
|
`${testResult.error.stack}\n`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
passed += 1;
|
||||||
|
}
|
||||||
|
console.info(
|
||||||
|
`[TEST] suite "${suiteName}": ${passed} of ${tests.length} succeeded\n\n`,
|
||||||
|
);
|
||||||
|
if (passed < tests.length) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
6
test/lib/index.ts
Normal file
6
test/lib/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { testSuite } from "./harness";
|
||||||
112
test/lib/utils.ts
Normal file
112
test/lib/utils.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Randomly rearrange the items of an array in-place.
|
||||||
|
*
|
||||||
|
* @param arr Array to shuffle.
|
||||||
|
*/
|
||||||
|
export const shuffle = <T>(arr: T[]): void => {
|
||||||
|
let j: number;
|
||||||
|
let x: T;
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
j = Math.floor(Math.random() * (i + 1));
|
||||||
|
x = arr[i];
|
||||||
|
arr[i] = arr[j];
|
||||||
|
arr[j] = x;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const areArraysEqual = <T>(a: T[], b: T[]): boolean => {
|
||||||
|
if (a.length !== b.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (!areEqual(a[i], b[i])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const areObjectsEqual = <T extends Record<string, unknown>>(
|
||||||
|
a: T,
|
||||||
|
b: T,
|
||||||
|
): boolean => {
|
||||||
|
const aKeys = Object.keys(a) as Array<keyof T>;
|
||||||
|
const bKeys = Object.keys(b) as Array<keyof T>;
|
||||||
|
if (aKeys.length !== bKeys.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const key of aKeys) {
|
||||||
|
if (!areEqual(a[key], b[key])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether two values are structurally equal, with support for
|
||||||
|
* primitive values, arrays, deeply nested objects, and RegExp.
|
||||||
|
*
|
||||||
|
* @param a First value to test equality with.
|
||||||
|
* @param b Second value to test equality with.
|
||||||
|
*
|
||||||
|
* @return Whether the two values are structurally equal.
|
||||||
|
*/
|
||||||
|
export const areEqual = <T>(a: T, b: T): boolean => {
|
||||||
|
if (Array.isArray(a) && Array.isArray(b)) {
|
||||||
|
return areArraysEqual(a, b);
|
||||||
|
}
|
||||||
|
if (a instanceof RegExp && b instanceof RegExp) {
|
||||||
|
return a.source === b.source;
|
||||||
|
}
|
||||||
|
if (typeof a === "object" && typeof b === "object") {
|
||||||
|
return areObjectsEqual(
|
||||||
|
a as Record<string, unknown>,
|
||||||
|
b as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return a === b;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return whether a string exactly matches an expected string OR matches a
|
||||||
|
* RegExp pattern.
|
||||||
|
*
|
||||||
|
* If the passed pattern is a string this uses strict equality checking, and
|
||||||
|
* if the passed pattern is a RegExp object it tests the value against it.
|
||||||
|
*
|
||||||
|
* @param value String value to test.
|
||||||
|
* @param pattern String or RegExp pattern to match value against.
|
||||||
|
*/
|
||||||
|
export const matches = (value: string, pattern: string | RegExp): boolean => {
|
||||||
|
if (typeof pattern === "string") {
|
||||||
|
return value === pattern;
|
||||||
|
}
|
||||||
|
return pattern.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a JavaScript value for debugging and error messages.
|
||||||
|
*
|
||||||
|
* This is essentially JSON.stringify, but with special cases for undefined (
|
||||||
|
* which normally isn't rendered with JSON.stringify) and RegExp (to display
|
||||||
|
* source for the regexp).
|
||||||
|
*
|
||||||
|
* @param value Value to render.
|
||||||
|
*
|
||||||
|
* @return Rendered value to display.
|
||||||
|
*/
|
||||||
|
export const displayValue = (value: unknown): string => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return "undefined";
|
||||||
|
}
|
||||||
|
if (value instanceof RegExp) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
return JSON.stringify(value);
|
||||||
|
};
|
||||||
23
test/run-e2e.ts
Normal file
23
test/run-e2e.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fork } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
import { shuffle } from "./lib/utils";
|
||||||
|
|
||||||
|
const TEST_SUITES_DIR = path.join(__dirname, "e2e");
|
||||||
|
const files = fs.readdirSync(TEST_SUITES_DIR);
|
||||||
|
// Shuffle test suites to detect ordering dependencies between them.
|
||||||
|
shuffle(files);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(TEST_SUITES_DIR, file);
|
||||||
|
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
23
test/run-tests.ts
Normal file
23
test/run-tests.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fork } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
|
||||||
|
import { shuffle } from "./lib/utils";
|
||||||
|
|
||||||
|
const TEST_SUITES_DIR = path.join(__dirname, "test-suites");
|
||||||
|
const files = fs.readdirSync(TEST_SUITES_DIR);
|
||||||
|
// Shuffle test suites to detect ordering dependencies between them.
|
||||||
|
shuffle(files);
|
||||||
|
for (const file of files) {
|
||||||
|
const fullPath = path.join(TEST_SUITES_DIR, file);
|
||||||
|
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
149
test/test-suites/rendering.tsx
Normal file
149
test/test-suites/rendering.tsx
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type Component, createElement, Fragment } from "../../dist";
|
||||||
|
import { renderPage } from "../../dist/render";
|
||||||
|
import { testSuite } from "../lib";
|
||||||
|
|
||||||
|
testSuite("renderPage", ({ test, expect }) => {
|
||||||
|
test("throws an Error when root elem is not html tag", () => {
|
||||||
|
expect(() => renderPage(<div />)).toThrowErrorMatching(
|
||||||
|
"attempted to render page with non-HTML root element div",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("outputs a HTML5 DOCTYPE declaration", () => {
|
||||||
|
const html = renderPage(<html />);
|
||||||
|
expect(html).toStartWith("<!DOCTYPE html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapes HTML in tag names", () => {
|
||||||
|
const html = renderPage(<html>{createElement("div></div", null)}</html>);
|
||||||
|
expect(html).toEqual(
|
||||||
|
"<!DOCTYPE html><html><div></div></div></div></html>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders html attributes", () => {
|
||||||
|
const html = renderPage(
|
||||||
|
<html>
|
||||||
|
<div className="test" id="1" />
|
||||||
|
</html>,
|
||||||
|
);
|
||||||
|
expect(html).toEqual(
|
||||||
|
'<!DOCTYPE html><html><div class="test" id="1"></div></html>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders common html tags", () => {
|
||||||
|
const html = renderPage(
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div />
|
||||||
|
</body>
|
||||||
|
</html>,
|
||||||
|
);
|
||||||
|
expect(html).toEqual(
|
||||||
|
"<!DOCTYPE html><html><head><title></title></head><body><div></div></body></html>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders text nodes", () => {
|
||||||
|
const html = renderPage(<html>There are three lights!</html>);
|
||||||
|
expect(html).toEqual("<!DOCTYPE html><html>There are three lights!</html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders spliced number nodes", () => {
|
||||||
|
const nLights = 3;
|
||||||
|
const html = renderPage(<html>There are {nLights} lights!</html>);
|
||||||
|
expect(html).toEqual("<!DOCTYPE html><html>There are 3 lights!</html>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders spliced arrays", () => {
|
||||||
|
const Light: Component<{ lightN: number }> = ({ lightN }) => (
|
||||||
|
<div>{lightN}</div>
|
||||||
|
);
|
||||||
|
const lights = [1, 2, 3];
|
||||||
|
const html = renderPage(
|
||||||
|
<html>
|
||||||
|
There are{" "}
|
||||||
|
{lights.map((lightN) => (
|
||||||
|
<Light lightN={lightN} />
|
||||||
|
))}{" "}
|
||||||
|
lights!
|
||||||
|
</html>,
|
||||||
|
);
|
||||||
|
expect(html).toEqual(
|
||||||
|
"<!DOCTYPE html><html>There are <div>1</div><div>2</div><div>3</div> lights!</html>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders components w/ custom properties", () => {
|
||||||
|
interface LightProps {
|
||||||
|
nLights: number;
|
||||||
|
}
|
||||||
|
const Light: Component<LightProps> = ({ nLights }) => (
|
||||||
|
<div>{nLights} lights</div>
|
||||||
|
);
|
||||||
|
const html = renderPage(
|
||||||
|
<html>
|
||||||
|
There are <Light nLights={3} />!
|
||||||
|
</html>,
|
||||||
|
);
|
||||||
|
expect(html).toEqual(
|
||||||
|
"<!DOCTYPE html><html>There are <div>3 lights</div>!</html>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders fragment children only", () => {
|
||||||
|
const html = renderPage(
|
||||||
|
<html>
|
||||||
|
<Fragment>
|
||||||
|
<div>test of</div>
|
||||||
|
<div>fragments</div>
|
||||||
|
</Fragment>
|
||||||
|
</html>,
|
||||||
|
);
|
||||||
|
expect(html).toEqual(
|
||||||
|
"<!DOCTYPE html><html><div>test of</div><div>fragments</div></html>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders unescaped HTML via dangerouslySetInnerHTML", () => {
|
||||||
|
const html = renderPage(
|
||||||
|
<html>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: "<div>red alert!</div>",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</html>,
|
||||||
|
);
|
||||||
|
expect(html).toEqual(
|
||||||
|
"<!DOCTYPE html><html><div><div>red alert!</div></div></html>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws error when both dangerouslySetInnerHTML and children prop present", () => {
|
||||||
|
expect(() =>
|
||||||
|
renderPage(
|
||||||
|
<html>
|
||||||
|
<div
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: "<div>set phasers to kill</div>",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>set phasers to stun</div>
|
||||||
|
</div>
|
||||||
|
</html>,
|
||||||
|
),
|
||||||
|
).toThrowErrorMatching(
|
||||||
|
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
7
test/tsconfig.json
Normal file
7
test/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"extends": "../tsconfig-base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "createElement"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tsconfig-base.json
Normal file
13
tsconfig-base.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"target": "ES2018",
|
||||||
|
"lib": ["ES2018"],
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,9 @@
|
||||||
{
|
{
|
||||||
|
"extends": "./tsconfig-base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"sourceMap": true,
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"esModuleInterop": true,
|
"sourceMap": true,
|
||||||
"module": "CommonJS",
|
"outDir": "./dist"
|
||||||
"moduleResolution": "node",
|
|
||||||
"target": "ES2019",
|
|
||||||
"lib": ["ES2019"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noImplicitReturns": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue