Compare commits
No commits in common. "v0.2.0" and "main" have entirely different histories.
47 changed files with 2765 additions and 5041 deletions
|
|
@ -1,2 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
|
@ -1,23 +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:react/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"settings": {
|
||||
"react": {
|
||||
"pragma": "createElement"
|
||||
}
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"react/prop-types": "off",
|
||||
"react/jsx-key": "off"
|
||||
}
|
||||
}
|
||||
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
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
lts/erbium
|
||||
18
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
{
|
||||
"endOfLine": "lf",
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 4,
|
||||
"semi": true
|
||||
}
|
||||
12
.travis.yml
12
.travis.yml
|
|
@ -1,12 +0,0 @@
|
|||
git:
|
||||
autocrlf: input
|
||||
language: node_js
|
||||
os:
|
||||
- linux
|
||||
- osx
|
||||
- windows
|
||||
node_js:
|
||||
- node
|
||||
- lts/*
|
||||
- 12
|
||||
- 10
|
||||
19
README.md
19
README.md
|
|
@ -1,20 +1,9 @@
|
|||
# websnacks: Minimal Dependency Server-Side JSX for Static Sites
|
||||
|
||||
<div>
|
||||
|
||||
[](https://www.npmjs.com/package/@websnacksjs/websnacks "NPM release")
|
||||
[](https://www.mozilla.org/en-US/MPL/2.0/FAQ/ "License info")
|
||||
[](https://travis-ci.com/websnacksjs/websnacks "Build status for mainline branch")
|
||||
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
[](https://david-dm.org/websnacksjs/websnacks "Dependency status")
|
||||
[](https://david-dm.org/websnacksjs/websnacks?type=optional "Optional dependency status")
|
||||
[](https://david-dm.org/websnacksjs/websnacks?type=dev "Dev dependency status")
|
||||
|
||||
</div>
|
||||
[](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.
|
||||
|
||||
|
|
|
|||
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,25 +1,29 @@
|
|||
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({
|
||||
header: {
|
||||
background: "#6c42bd",
|
||||
color: "#fff",
|
||||
padding: "32px",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 1px 8px -3px #000",
|
||||
},
|
||||
headline: {
|
||||
fontSize: "28px",
|
||||
},
|
||||
header: {
|
||||
background: "#6c42bd",
|
||||
color: "#fff",
|
||||
padding: "32px",
|
||||
textAlign: "center",
|
||||
boxShadow: "0 1px 8px -3px #000",
|
||||
},
|
||||
headline: {
|
||||
fontSize: "28px",
|
||||
},
|
||||
});
|
||||
|
||||
export interface HeaderProps {
|
||||
headline: string;
|
||||
headline: string;
|
||||
}
|
||||
|
||||
export const Header: Component<HeaderProps> = ({ headline }) => (
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.headline}>{headline}</h1>
|
||||
</header>
|
||||
<header className={styles.header}>
|
||||
<h1 className={styles.headline}>{headline}</h1>
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { normalize } from "csstips";
|
||||
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 { Header } from "./header";
|
||||
|
|
@ -9,60 +13,57 @@ import { Navbar } from "./navbar";
|
|||
normalize();
|
||||
|
||||
const styles = stylesheet({
|
||||
html: {
|
||||
height: "100%",
|
||||
},
|
||||
wrapper: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
margin: 0,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
},
|
||||
mainBody: {
|
||||
padding: "16px",
|
||||
},
|
||||
navbar: {
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
zIndex: 9,
|
||||
},
|
||||
html: {
|
||||
height: "100%",
|
||||
},
|
||||
wrapper: {
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
margin: 0,
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
},
|
||||
mainBody: {
|
||||
padding: "16px",
|
||||
},
|
||||
navbar: {
|
||||
display: "flex",
|
||||
flex: "0 0 auto",
|
||||
zIndex: 9,
|
||||
},
|
||||
});
|
||||
|
||||
const SITE_TITLE = "Example Site";
|
||||
|
||||
export interface LayoutProps {
|
||||
headline?: string;
|
||||
headline?: string;
|
||||
}
|
||||
|
||||
export const Layout: Component<LayoutProps> = ({ children, headline }) => (
|
||||
<html className={styles.html} lang="en-US">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>
|
||||
{SITE_TITLE}
|
||||
{headline && ` | ${headline}`}
|
||||
</title>
|
||||
<meta name="description" content="" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<link rel="stylesheet" href={stylesheetPath} />
|
||||
</head>
|
||||
<html className={styles.html} lang="en-US">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>
|
||||
{SITE_TITLE}
|
||||
{headline && ` | ${headline}`}
|
||||
</title>
|
||||
<meta name="description" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href={stylesheetPath} />
|
||||
</head>
|
||||
|
||||
<body className={styles.wrapper}>
|
||||
<div className={styles.navbar}>
|
||||
<Navbar />
|
||||
</div>
|
||||
<body className={styles.wrapper}>
|
||||
<div className={styles.navbar}>
|
||||
<Navbar />
|
||||
</div>
|
||||
|
||||
<main className={styles.main}>
|
||||
<Header headline={headline || SITE_TITLE} />
|
||||
<main className={styles.main}>
|
||||
<Header headline={headline || SITE_TITLE} />
|
||||
|
||||
<div className={styles.mainBody}>{children}</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
<div className={styles.mainBody}>{children}</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,43 +1,47 @@
|
|||
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({
|
||||
navbar: {
|
||||
minWidth: "140px",
|
||||
borderRight: "1px solid #ddd",
|
||||
background: "#fff",
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
borderBottom: "1px solid #333",
|
||||
padding: "6px",
|
||||
margin: "0 4px",
|
||||
fontSize: "18px",
|
||||
},
|
||||
linksList: {
|
||||
padding: "3px 16px 0",
|
||||
},
|
||||
linksListItem: {
|
||||
padding: "6px",
|
||||
},
|
||||
navbar: {
|
||||
minWidth: "140px",
|
||||
borderRight: "1px solid #ddd",
|
||||
background: "#fff",
|
||||
},
|
||||
sectionTitle: {
|
||||
color: "#333",
|
||||
textAlign: "center",
|
||||
borderBottom: "1px solid #333",
|
||||
padding: "6px",
|
||||
margin: "0 4px",
|
||||
fontSize: "18px",
|
||||
},
|
||||
linksList: {
|
||||
padding: "3px 16px 0",
|
||||
},
|
||||
linksListItem: {
|
||||
padding: "6px",
|
||||
},
|
||||
});
|
||||
|
||||
const links = [
|
||||
{ title: "Home", href: "/" },
|
||||
{ title: "Projects", href: "/projects" },
|
||||
{ title: "Home", href: "/" },
|
||||
{ title: "Projects", href: "/projects" },
|
||||
];
|
||||
|
||||
export const Navbar: Component = () => (
|
||||
<nav className={styles.navbar}>
|
||||
<h2 className={styles.sectionTitle}>Navigation</h2>
|
||||
<nav className={styles.navbar}>
|
||||
<h2 className={styles.sectionTitle}>Navigation</h2>
|
||||
|
||||
<ol className={styles.linksList}>
|
||||
{links.map(({ title, href }) => (
|
||||
<li className={styles.linksListItem}>
|
||||
<a href={href}>{title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<ol className={styles.linksList}>
|
||||
{links.map(({ title, href }) => (
|
||||
<li className={styles.linksListItem}>
|
||||
<a href={href}>{title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
|
|
|
|||
1505
examples/personal-site/package-lock.json
generated
1505
examples/personal-site/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"name": "websnacks-example-personal-site",
|
||||
"scripts": {
|
||||
"build": "websnacks -r ts-node/register build",
|
||||
"dev": "websnacks -r ts-node/register dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"csstips": "^1.2.0",
|
||||
"ts-node": "^8.10.1",
|
||||
"typestyle": "^2.1.0",
|
||||
"websnacks": "../../"
|
||||
}
|
||||
"name": "websnacks-example-personal-site",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "websnacks -r ts-node/register build",
|
||||
"dev": "websnacks -r ts-node/register dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"csstips": "^1.2.0",
|
||||
"ts-node": "^8.10.1",
|
||||
"typestyle": "^2.1.0",
|
||||
"websnacks": "../../"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
export const page: Component = () => (
|
||||
<Layout>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur
|
||||
dapibus condimentum mauris et egestas. Quisque orci nulla, consequat
|
||||
at erat laoreet, malesuada sodales nisi. Sed in lorem semper lorem
|
||||
placerat fermentum a id arcu. Curabitur non aliquam tellus, sed
|
||||
auctor lacus. Nunc sit amet lectus ultrices, sodales nisl sit amet,
|
||||
luctus nisl. Nunc mollis imperdiet quam, eget sollicitudin leo
|
||||
tincidunt vel. Duis felis dui, imperdiet aliquam bibendum sed,
|
||||
auctor et dolor. Vivamus odio ipsum, venenatis in felis sed, aliquam
|
||||
dictum turpis. Pellentesque pellentesque consequat neque, id
|
||||
imperdiet diam molestie nec. Nullam ut vestibulum est. Pellentesque
|
||||
orci urna, porta vel porta quis, semper ut enim. Donec sit amet urna
|
||||
arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
|
||||
</p>
|
||||
</Layout>
|
||||
<Layout>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur dapibus
|
||||
condimentum mauris et egestas. Quisque orci nulla, consequat at erat
|
||||
laoreet, malesuada sodales nisi. Sed in lorem semper lorem placerat
|
||||
fermentum a id arcu. Curabitur non aliquam tellus, sed auctor lacus. Nunc
|
||||
sit amet lectus ultrices, sodales nisl sit amet, luctus nisl. Nunc mollis
|
||||
imperdiet quam, eget sollicitudin leo tincidunt vel. Duis felis dui,
|
||||
imperdiet aliquam bibendum sed, auctor et dolor. Vivamus odio ipsum,
|
||||
venenatis in felis sed, aliquam dictum turpis. Pellentesque pellentesque
|
||||
consequat neque, id imperdiet diam molestie nec. Nullam ut vestibulum est.
|
||||
Pellentesque orci urna, porta vel porta quis, semper ut enim. Donec sit
|
||||
amet urna arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
|
||||
</p>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,30 @@
|
|||
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";
|
||||
|
||||
const styles = stylesheet({
|
||||
projectsGrid: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
width: "25%",
|
||||
},
|
||||
projectsGrid: {
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
width: "25%",
|
||||
},
|
||||
});
|
||||
|
||||
export const page: Component = () => (
|
||||
<Layout>
|
||||
<h1>Projects</h1>
|
||||
<Layout>
|
||||
<h1>Projects</h1>
|
||||
|
||||
<div className={styles.projectsGrid}>
|
||||
<div>Project 1</div>
|
||||
<div>Project 2</div>
|
||||
<div>Project 3</div>
|
||||
<div>Project 4</div>
|
||||
<div>Project 5</div>
|
||||
</div>
|
||||
</Layout>
|
||||
<div className={styles.projectsGrid}>
|
||||
<div>Project 1</div>
|
||||
<div>Project 2</div>
|
||||
<div>Project 3</div>
|
||||
<div>Project 4</div>
|
||||
<div>Project 5</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"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/**/*"]
|
||||
"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/**/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,25 @@
|
|||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import { Config } from "websnacks";
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import type { Config } from "websnacks";
|
||||
|
||||
import { stylesheetPath } from "./config";
|
||||
|
||||
const config: Config = {
|
||||
// Watch additional files and folders for changes when the dev server is
|
||||
// running.
|
||||
watch: ["components/", "config.ts"],
|
||||
// Hooks to execute after certain rendering events. Currently only
|
||||
// afterSiteRender is supported.
|
||||
hooks: {
|
||||
async afterSiteRender({ outDir }): Promise<void> {
|
||||
// NOTE: we dynamically import typestyle so that the global style
|
||||
// registry is properly updated once all pages are reloaded in
|
||||
// dev. We could also create a typestyle object in config.ts,
|
||||
// or even multiple objects to split up our styles into e.g. a
|
||||
// critical-path.css and noncrticial.css.
|
||||
const { getStyles } = await import("typestyle");
|
||||
const styles = getStyles();
|
||||
await fs.writeFile(path.join(outDir, stylesheetPath), styles);
|
||||
},
|
||||
},
|
||||
const _config: Config = {
|
||||
// Watch additional files and folders for changes when the dev server is
|
||||
// running.
|
||||
watch: ["components/", "config.ts"],
|
||||
// Hooks to execute after certain rendering events. Currently only
|
||||
// afterSiteRender is supported.
|
||||
hooks: {
|
||||
async afterSiteRender({ outDir }): Promise<void> {
|
||||
// NOTE: we dynamically import typestyle so that the global style
|
||||
// registry is properly updated once all pages are reloaded in
|
||||
// dev. We could also create a typestyle object in config.ts,
|
||||
// or even multiple objects to split up our styles into e.g. a
|
||||
// critical-path.css and noncrticial.css.
|
||||
const { getStyles } = await import("typestyle");
|
||||
const styles = getStyles();
|
||||
await fs.writeFile(path.join(outDir, stylesheetPath), styles);
|
||||
},
|
||||
},
|
||||
};
|
||||
export = config;
|
||||
|
|
|
|||
2179
package-lock.json
generated
2179
package-lock.json
generated
File diff suppressed because it is too large
Load diff
99
package.json
99
package.json
|
|
@ -1,54 +1,49 @@
|
|||
{
|
||||
"name": "@websnacksjs/websnacks",
|
||||
"description": "Minimal dependency server-side JSX for static sites",
|
||||
"version": "0.2.0",
|
||||
"author": {
|
||||
"name": "M. George Hansen",
|
||||
"email": "mgeorge@technopolitica.com"
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
"repository": "github:websnacksjs/websnacks",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "types.d.ts",
|
||||
"bin": "bin/websnacks.js",
|
||||
"files": [
|
||||
"/bin/websnacks.js",
|
||||
"/dist/**/*.js",
|
||||
"/dist/**/*.d.ts",
|
||||
"/dist/**/*.map",
|
||||
"/src/**/*.ts",
|
||||
"/index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "ts-node scripts/clean.ts",
|
||||
"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": {
|
||||
"@types/node": "~10",
|
||||
"@types/ws": "^7.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.15.2",
|
||||
"@typescript-eslint/parser": "^4.15.2",
|
||||
"eslint": "^7.21.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-prettier": "^3.3.1",
|
||||
"eslint-plugin-react": "^7.22.0",
|
||||
"prettier": "=2.2.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "~4.2.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-watch": "^0.7.1",
|
||||
"ws": "^7.4.3"
|
||||
}
|
||||
"name": "@websnacksjs/websnacks",
|
||||
"description": "Minimal dependency server-side JSX for static sites",
|
||||
"version": "0.2.0",
|
||||
"author": {
|
||||
"name": "M. George Hansen",
|
||||
"email": "mgeorge@technopolitica.com"
|
||||
},
|
||||
"license": "MPL-2.0",
|
||||
"repository": "github:websnacksjs/websnacks",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "types.d.ts",
|
||||
"bin": "bin/websnacks.js",
|
||||
"files": [
|
||||
"/bin/websnacks.js",
|
||||
"/dist/**/*.js",
|
||||
"/dist/**/*.d.ts",
|
||||
"/dist/**/*.map",
|
||||
"/src/**/*.ts",
|
||||
"/index.d.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"check": "biome check .",
|
||||
"clean": "ts-node scripts/clean.ts",
|
||||
"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": {
|
||||
"@biomejs/biome": "2.4.14",
|
||||
"@types/node": "~18",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~4.9.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"node-watch": "^0.7.4",
|
||||
"ws": "^8.20.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,28 +3,28 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, "..");
|
||||
const DIST_DIR = path.join(ROOT_DIR, "dist");
|
||||
const TEST_DIR = path.join(ROOT_DIR, ".temp");
|
||||
|
||||
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);
|
||||
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);
|
||||
|
|
|
|||
126
src/build.ts
126
src/build.ts
|
|
@ -3,80 +3,78 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { Config, ConfigPaths } from "./config";
|
||||
import type { Config, ConfigPaths } from "./config";
|
||||
import { renderPage } from "./render";
|
||||
import { decacheModule, walkDir } from "./utils";
|
||||
|
||||
const renderPagesToHtml = async ({
|
||||
pagesDir,
|
||||
outDir,
|
||||
pagesDir,
|
||||
outDir,
|
||||
}: ConfigPaths): Promise<void> => {
|
||||
const deferred = [];
|
||||
for await (const srcPath of walkDir(pagesDir)) {
|
||||
const ext = path.extname(srcPath);
|
||||
if (ext !== ".tsx") {
|
||||
continue;
|
||||
}
|
||||
const deferred = [];
|
||||
for await (const srcPath of walkDir(pagesDir)) {
|
||||
const ext = path.extname(srcPath);
|
||||
if (ext !== ".tsx") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ensure that we don't cache page modules when running in dev server.
|
||||
decacheModule(srcPath);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pageSrc = require(srcPath);
|
||||
if (!("page" in pageSrc)) {
|
||||
throw new Error(
|
||||
`page source at ${srcPath} does not export a "page" variable`,
|
||||
);
|
||||
}
|
||||
let compiledHtml;
|
||||
try {
|
||||
compiledHtml = renderPage(pageSrc.page());
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`failed to compile ${srcPath}: ${error.stack ?? error}`,
|
||||
);
|
||||
}
|
||||
const relPath = path.relative(pagesDir, path.dirname(srcPath));
|
||||
let baseName = path.basename(srcPath, ".tsx");
|
||||
if (baseName === "index") {
|
||||
baseName = "";
|
||||
}
|
||||
const destPath = path.join(outDir, relPath, baseName, "index.html");
|
||||
deferred.push(
|
||||
(async () => {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.writeFile(destPath, compiledHtml);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(deferred);
|
||||
// Ensure that we don't cache page modules when running in dev server.
|
||||
decacheModule(srcPath);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const pageSrc = require(srcPath);
|
||||
if (!("page" in pageSrc)) {
|
||||
throw new Error(
|
||||
`page source at ${srcPath} does not export a "page" variable`,
|
||||
);
|
||||
}
|
||||
let compiledHtml: string;
|
||||
try {
|
||||
compiledHtml = renderPage(pageSrc.page());
|
||||
} catch (error) {
|
||||
throw new Error(`failed to compile ${srcPath}: ${error}`);
|
||||
}
|
||||
const relPath = path.relative(pagesDir, path.dirname(srcPath));
|
||||
let baseName = path.basename(srcPath, ".tsx");
|
||||
if (baseName === "index") {
|
||||
baseName = "";
|
||||
}
|
||||
const destPath = path.join(outDir, relPath, baseName, "index.html");
|
||||
deferred.push(
|
||||
(async () => {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.writeFile(destPath, compiledHtml);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(deferred);
|
||||
};
|
||||
|
||||
const copyStaticAssets = async ({
|
||||
staticAssetsDir,
|
||||
outDir,
|
||||
staticAssetsDir,
|
||||
outDir,
|
||||
}: ConfigPaths): Promise<void> => {
|
||||
try {
|
||||
await fs.access(staticAssetsDir);
|
||||
} catch (error) {
|
||||
// Static assets folder doesn't exist, so no-op.
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.access(staticAssetsDir);
|
||||
} catch (_error) {
|
||||
// Static assets folder doesn't exist, so no-op.
|
||||
return;
|
||||
}
|
||||
|
||||
const deferred = [];
|
||||
for await (const assetPath of walkDir(staticAssetsDir)) {
|
||||
const relPath = path.relative(staticAssetsDir, assetPath);
|
||||
const destPath = path.join(outDir, relPath);
|
||||
deferred.push(
|
||||
(async () => {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.copyFile(assetPath, destPath);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(deferred);
|
||||
const deferred = [];
|
||||
for await (const assetPath of walkDir(staticAssetsDir)) {
|
||||
const relPath = path.relative(staticAssetsDir, assetPath);
|
||||
const destPath = path.join(outDir, relPath);
|
||||
deferred.push(
|
||||
(async () => {
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.copyFile(assetPath, destPath);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
await Promise.all(deferred);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -86,6 +84,6 @@ const copyStaticAssets = async ({
|
|||
* @param config Configuration for the site.
|
||||
*/
|
||||
export const renderSite = async ({ paths, hooks }: Config): Promise<void> => {
|
||||
await Promise.all([renderPagesToHtml(paths), copyStaticAssets(paths)]);
|
||||
await hooks.afterSiteRender(paths);
|
||||
await Promise.all([renderPagesToHtml(paths), copyStaticAssets(paths)]);
|
||||
await hooks.afterSiteRender(paths);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { renderSite } from "../../build";
|
||||
import { loadConfig } from "../../config";
|
||||
import { Command, UsageError } from "../types";
|
||||
import { type Command, UsageError } from "../types";
|
||||
|
||||
const helpText = `\
|
||||
Usage: websnacks build [ROOT_DIR]
|
||||
|
|
@ -18,16 +18,16 @@ Args:
|
|||
`;
|
||||
|
||||
interface BuildArgs {
|
||||
rootDir: string;
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
const parseArgs = (args: string[]): BuildArgs => {
|
||||
if (args.length > 1) {
|
||||
throw new UsageError("too many arguments provided", helpText);
|
||||
}
|
||||
return {
|
||||
rootDir: args[0] || process.cwd(),
|
||||
};
|
||||
if (args.length > 1) {
|
||||
throw new UsageError("too many arguments provided", helpText);
|
||||
}
|
||||
return {
|
||||
rootDir: args[0] || process.cwd(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -35,11 +35,11 @@ const parseArgs = (args: string[]): BuildArgs => {
|
|||
* static files.
|
||||
*/
|
||||
const buildCommand: Command = {
|
||||
execute: async (args: string[]): Promise<void> => {
|
||||
const { rootDir } = parseArgs(args);
|
||||
const config = await loadConfig(rootDir);
|
||||
await renderSite(config);
|
||||
},
|
||||
helpText,
|
||||
execute: async (args: string[]): Promise<void> => {
|
||||
const { rootDir } = parseArgs(args);
|
||||
const config = await loadConfig(rootDir);
|
||||
await renderSite(config);
|
||||
},
|
||||
helpText,
|
||||
};
|
||||
export = buildCommand;
|
||||
|
|
|
|||
|
|
@ -3,21 +3,21 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { existsSync, promises as fs, watch } from "fs";
|
||||
import * as http from "http";
|
||||
import * as net from "net";
|
||||
import * as path from "path";
|
||||
import { existsSync, promises as fs, watch } from "node:fs";
|
||||
import * as http from "node:http";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { renderSite } from "../../build";
|
||||
import { Config, loadConfig } from "../../config";
|
||||
import { Command, UsageError } from "../types";
|
||||
import { type Config, loadConfig } from "../../config";
|
||||
import { isErrnoException } from "../../utils/error";
|
||||
import { type Command, UsageError } from "../types";
|
||||
|
||||
const DEFAULT_SERVER_PORT = 8080;
|
||||
|
||||
const injectLiveReloadScript = (htmlContents: string, port: number): string =>
|
||||
htmlContents.replace(
|
||||
"</html>",
|
||||
`
|
||||
htmlContents.replace(
|
||||
"</html>",
|
||||
`
|
||||
<script>
|
||||
const ws = new WebSocket("ws://127.0.0.1:${port}");
|
||||
ws.onmessage = function() {
|
||||
|
|
@ -27,212 +27,226 @@ const injectLiveReloadScript = (htmlContents: string, port: number): string =>
|
|||
</script>
|
||||
</html>
|
||||
`,
|
||||
);
|
||||
);
|
||||
|
||||
const guessMimeType = (ext: string): string => {
|
||||
let mimeType;
|
||||
switch (ext) {
|
||||
case ".apng":
|
||||
mimeType = "image/apng";
|
||||
break;
|
||||
case ".bmp":
|
||||
mimeType = "image/bmp";
|
||||
break;
|
||||
case ".css":
|
||||
mimeType = "text/css";
|
||||
break;
|
||||
case ".eot":
|
||||
mimeType = "application/vnd.ms-fontobject";
|
||||
break;
|
||||
case ".gif":
|
||||
mimeType = "image/gif";
|
||||
break;
|
||||
case ".htm":
|
||||
case ".html":
|
||||
mimeType = "text/html";
|
||||
break;
|
||||
case ".ico":
|
||||
mimeType = "image/vnd.microsoft.icon";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
mimeType = "text/javascript";
|
||||
break;
|
||||
case ".mp3":
|
||||
mimeType = "audio/mpeg";
|
||||
break;
|
||||
case ".mpeg":
|
||||
mimeType = "video/mpeg";
|
||||
break;
|
||||
case ".oga":
|
||||
mimeType = "audio/ogg";
|
||||
break;
|
||||
case ".ogv":
|
||||
mimeType = "video/ogg";
|
||||
break;
|
||||
case ".otf":
|
||||
mimeType = "font/otf";
|
||||
break;
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case ".svg":
|
||||
mimeType = "image/svg+xml";
|
||||
break;
|
||||
case ".txt":
|
||||
mimeType = "text/plain";
|
||||
break;
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
mimeType = "image/tiff";
|
||||
break;
|
||||
case ".ttf":
|
||||
mimeType = "font/ttf";
|
||||
break;
|
||||
case ".wav":
|
||||
mimeType = "audio/wav";
|
||||
break;
|
||||
case ".weba":
|
||||
mimeType = "audio/webm";
|
||||
break;
|
||||
case ".webm":
|
||||
mimeType = "video/webm";
|
||||
break;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
case ".woff":
|
||||
mimeType = "font/woff";
|
||||
break;
|
||||
case ".woff2":
|
||||
mimeType = "font/woff2";
|
||||
break;
|
||||
default:
|
||||
// Default to binary mimetype which most browsers will be able to
|
||||
// correctly interpret in the right context.
|
||||
mimeType = "application/octet-stream";
|
||||
}
|
||||
return mimeType;
|
||||
let mimeType: string;
|
||||
switch (ext) {
|
||||
case ".apng":
|
||||
mimeType = "image/apng";
|
||||
break;
|
||||
case ".bmp":
|
||||
mimeType = "image/bmp";
|
||||
break;
|
||||
case ".css":
|
||||
mimeType = "text/css";
|
||||
break;
|
||||
case ".eot":
|
||||
mimeType = "application/vnd.ms-fontobject";
|
||||
break;
|
||||
case ".gif":
|
||||
mimeType = "image/gif";
|
||||
break;
|
||||
case ".htm":
|
||||
case ".html":
|
||||
mimeType = "text/html";
|
||||
break;
|
||||
case ".ico":
|
||||
mimeType = "image/vnd.microsoft.icon";
|
||||
break;
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
mimeType = "image/jpeg";
|
||||
break;
|
||||
case ".js":
|
||||
case ".mjs":
|
||||
mimeType = "text/javascript";
|
||||
break;
|
||||
case ".mp3":
|
||||
mimeType = "audio/mpeg";
|
||||
break;
|
||||
case ".mpeg":
|
||||
mimeType = "video/mpeg";
|
||||
break;
|
||||
case ".oga":
|
||||
mimeType = "audio/ogg";
|
||||
break;
|
||||
case ".ogv":
|
||||
mimeType = "video/ogg";
|
||||
break;
|
||||
case ".otf":
|
||||
mimeType = "font/otf";
|
||||
break;
|
||||
case ".png":
|
||||
mimeType = "image/png";
|
||||
break;
|
||||
case ".svg":
|
||||
mimeType = "image/svg+xml";
|
||||
break;
|
||||
case ".txt":
|
||||
mimeType = "text/plain";
|
||||
break;
|
||||
case ".tif":
|
||||
case ".tiff":
|
||||
mimeType = "image/tiff";
|
||||
break;
|
||||
case ".ttf":
|
||||
mimeType = "font/ttf";
|
||||
break;
|
||||
case ".wav":
|
||||
mimeType = "audio/wav";
|
||||
break;
|
||||
case ".weba":
|
||||
mimeType = "audio/webm";
|
||||
break;
|
||||
case ".webm":
|
||||
mimeType = "video/webm";
|
||||
break;
|
||||
case ".webp":
|
||||
mimeType = "image/webp";
|
||||
break;
|
||||
case ".woff":
|
||||
mimeType = "font/woff";
|
||||
break;
|
||||
case ".woff2":
|
||||
mimeType = "font/woff2";
|
||||
break;
|
||||
default:
|
||||
// Default to binary mimetype which most browsers will be able to
|
||||
// correctly interpret in the right context.
|
||||
mimeType = "application/octet-stream";
|
||||
}
|
||||
return mimeType;
|
||||
};
|
||||
|
||||
const portFromServer = (server: Pick<net.Server, "address">): number => {
|
||||
const addrInfo = server.address();
|
||||
if (addrInfo == null) {
|
||||
throw new Error(`server address is null (this should never happen!)`);
|
||||
}
|
||||
if (typeof addrInfo === "string") {
|
||||
throw new Error(
|
||||
`server address is a string (this should never happen!)`,
|
||||
);
|
||||
}
|
||||
return addrInfo.port;
|
||||
const portFromServer = (
|
||||
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) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const httpServer = http.createServer(async (req, res) => {
|
||||
if (req.url == null) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let reqExt = path.extname(req.url);
|
||||
let reqPath = req.url;
|
||||
if (!reqExt) {
|
||||
reqPath = path.join(reqPath, "index.html");
|
||||
reqExt = ".html";
|
||||
}
|
||||
let reqExt = path.extname(req.url);
|
||||
let reqPath = req.url;
|
||||
if (!reqExt) {
|
||||
reqPath = path.join(reqPath, "index.html");
|
||||
reqExt = ".html";
|
||||
}
|
||||
|
||||
let contents;
|
||||
try {
|
||||
contents = await fs.readFile(path.join(publicDir, reqPath));
|
||||
} catch (error) {
|
||||
console.error(`unable to load file ${reqPath}`);
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const mimeType = guessMimeType(reqExt);
|
||||
if (mimeType === "text/html") {
|
||||
const port = portFromServer(req.socket);
|
||||
contents = injectLiveReloadScript(contents.toString("utf8"), port);
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": mimeType,
|
||||
});
|
||||
res.end(contents);
|
||||
});
|
||||
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.code !== "EADDRINUSE") {
|
||||
throw error;
|
||||
}
|
||||
await listen();
|
||||
}
|
||||
const port = portFromServer(httpServer);
|
||||
console.log(`Listening at http://127.0.0.1:${port}`);
|
||||
return httpServer;
|
||||
let contents: Buffer | string;
|
||||
try {
|
||||
contents = await fs.readFile(path.join(publicDir, reqPath));
|
||||
} catch (_error) {
|
||||
console.error(`unable to load file ${reqPath}`);
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const mimeType = guessMimeType(reqExt);
|
||||
if (mimeType === "text/html") {
|
||||
const port = portFromServer(req.socket.address());
|
||||
contents = injectLiveReloadScript(contents.toString("utf8"), port);
|
||||
}
|
||||
res.writeHead(200, {
|
||||
"Content-Type": mimeType,
|
||||
});
|
||||
res.end(contents);
|
||||
});
|
||||
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 (
|
||||
httpServer: http.Server,
|
||||
httpServer: http.Server,
|
||||
): Promise<import("ws").Server | undefined> => {
|
||||
// Attempt to load the ws module, aborting if it isn't available.
|
||||
let ws;
|
||||
try {
|
||||
ws = await import("ws");
|
||||
} catch (error) {
|
||||
if (error.code !== "MODULE_NOT_FOUND") {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`'ws' module not found, live-reloading will be disabled`);
|
||||
return;
|
||||
}
|
||||
const wsServer = new ws.Server({ server: httpServer });
|
||||
wsServer.on("connection", () => {
|
||||
console.log("connected to dev site");
|
||||
});
|
||||
return wsServer;
|
||||
// Attempt to load the ws module, aborting if it isn't available.
|
||||
let ws: typeof import("ws");
|
||||
try {
|
||||
ws = await import("ws");
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
isErrnoException(error) &&
|
||||
error.code !== "MODULE_NOT_FOUND"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(`'ws' module not found, live-reloading will be disabled`);
|
||||
return;
|
||||
}
|
||||
const wsServer = new ws.Server({ server: httpServer });
|
||||
wsServer.on("connection", () => {
|
||||
console.log("connected to dev site");
|
||||
});
|
||||
return wsServer;
|
||||
};
|
||||
|
||||
const watchFolders = async (
|
||||
folders: string[],
|
||||
listener: (eventType?: "update" | "remove", fileName?: string) => void,
|
||||
folders: string[],
|
||||
listener: (eventType?: "update" | "remove", fileName?: string) => void,
|
||||
): Promise<void> => {
|
||||
// Try to load node-watch, falling back to fs watch if node-watch isn't
|
||||
// available.
|
||||
try {
|
||||
const nodeWatch = await import("node-watch");
|
||||
nodeWatch.default(folders, { recursive: true }, listener);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error.code !== "MODULE_NOT_FOUND") {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`'node-watch' module not found, falling back to fs.watch (may ` +
|
||||
`result in file watch issues on some OSes)`,
|
||||
);
|
||||
}
|
||||
// NOTE: fs.watch has significant cross-platform issues, including
|
||||
// triggering duplicate file events on some systems.
|
||||
for (const folder of folders) {
|
||||
watch(folder, { recursive: true }, (_, fileName) => {
|
||||
listener("update", fileName);
|
||||
});
|
||||
}
|
||||
// Try to load node-watch, falling back to fs watch if node-watch isn't
|
||||
// available.
|
||||
try {
|
||||
const nodeWatch = await import("node-watch");
|
||||
nodeWatch.default(folders, { recursive: true }, listener);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
isErrnoException(error) &&
|
||||
error.code !== "MODULE_NOT_FOUND"
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
console.warn(
|
||||
`'node-watch' module not found, falling back to fs.watch (may ` +
|
||||
"result in file watch issues on some OSes)",
|
||||
);
|
||||
}
|
||||
// NOTE: fs.watch has significant cross-platform issues, including
|
||||
// triggering duplicate file events on some systems.
|
||||
for (const folder of folders) {
|
||||
watch(folder, { recursive: true }, (_, fileName) => {
|
||||
listener("update", fileName || undefined);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const helpText = `\
|
||||
|
|
@ -245,16 +259,16 @@ Args:
|
|||
`;
|
||||
|
||||
interface DevArgs {
|
||||
rootDir: string;
|
||||
rootDir: string;
|
||||
}
|
||||
|
||||
const parseArgs = (args: string[]): DevArgs | null => {
|
||||
if (args.length > 1) {
|
||||
throw new UsageError("too many arguments provided", helpText);
|
||||
}
|
||||
return {
|
||||
rootDir: args[0] || process.cwd(),
|
||||
};
|
||||
if (args.length > 1) {
|
||||
throw new UsageError("too many arguments provided", helpText);
|
||||
}
|
||||
return {
|
||||
rootDir: args[0] || process.cwd(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -263,39 +277,37 @@ const parseArgs = (args: string[]): DevArgs | null => {
|
|||
* production static hosting environment as closely as possible.
|
||||
*/
|
||||
const devCommand: Command = {
|
||||
async execute(args: string[]): Promise<void> {
|
||||
const parsedArgs = parseArgs(args);
|
||||
if (!parsedArgs) {
|
||||
return;
|
||||
}
|
||||
const { rootDir } = parsedArgs;
|
||||
const rebuild = async (): Promise<Config> => {
|
||||
const config = await loadConfig(rootDir);
|
||||
await renderSite(config);
|
||||
return config;
|
||||
};
|
||||
const config = await rebuild();
|
||||
const { outDir } = config.paths;
|
||||
const httpServer = await startHttpServer(outDir);
|
||||
const wsServer = await startWebSocketServer(httpServer);
|
||||
const watchedFolders = config.watch.filter((filePath) =>
|
||||
existsSync(filePath),
|
||||
);
|
||||
await watchFolders(watchedFolders, async (event, filePath) => {
|
||||
const filePathForLog = filePath || "<UNKNOWN_FILE>";
|
||||
const eventForLog = event || "<UNKNOWN_EVENT>";
|
||||
console.log(
|
||||
`${filePathForLog}:${eventForLog} triggering rebuild...`,
|
||||
);
|
||||
await rebuild();
|
||||
if (wsServer != null) {
|
||||
console.log(`rebuild finished, reloading browsers...`);
|
||||
for (const ws of wsServer.clients) {
|
||||
ws.send("reload");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
helpText,
|
||||
async execute(args: string[]): Promise<void> {
|
||||
const parsedArgs = parseArgs(args);
|
||||
if (!parsedArgs) {
|
||||
return;
|
||||
}
|
||||
const { rootDir } = parsedArgs;
|
||||
const rebuild = async (): Promise<Config> => {
|
||||
const config = await loadConfig(rootDir);
|
||||
await renderSite(config);
|
||||
return config;
|
||||
};
|
||||
const config = await rebuild();
|
||||
const { outDir } = config.paths;
|
||||
const httpServer = await startHttpServer(outDir);
|
||||
const wsServer = await startWebSocketServer(httpServer);
|
||||
const watchedFolders = config.watch.filter((filePath) =>
|
||||
existsSync(filePath),
|
||||
);
|
||||
await watchFolders(watchedFolders, async (event, filePath) => {
|
||||
const filePathForLog = filePath || "<UNKNOWN_FILE>";
|
||||
const eventForLog = event || "<UNKNOWN_EVENT>";
|
||||
console.log(`${filePathForLog}:${eventForLog} triggering rebuild...`);
|
||||
await rebuild();
|
||||
if (wsServer != null) {
|
||||
console.log("rebuild finished, reloading browsers...");
|
||||
for (const ws of wsServer.clients) {
|
||||
ws.send("reload");
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
helpText,
|
||||
};
|
||||
export = devCommand;
|
||||
|
|
|
|||
157
src/cli/index.ts
157
src/cli/index.ts
|
|
@ -3,7 +3,7 @@
|
|||
* 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 = `\
|
||||
Usage: websnacks [...globalOptions] <command>
|
||||
|
|
@ -19,97 +19,94 @@ Commands:
|
|||
`;
|
||||
|
||||
interface Options {
|
||||
showHelp: boolean;
|
||||
require: string[];
|
||||
showHelp: boolean;
|
||||
require: string[];
|
||||
}
|
||||
|
||||
const parseArgs = (
|
||||
args: string[],
|
||||
args: string[],
|
||||
): { options: Options; commandName?: string; commandArgs: string[] } => {
|
||||
const options: Options = {
|
||||
showHelp: false,
|
||||
require: [],
|
||||
};
|
||||
// Look ahead for the first argument that doesn't start with a "-" to
|
||||
// indicate the end of option parsing.
|
||||
while (args.length > 0 && args[0].indexOf("-") >= 0) {
|
||||
const opt = args.shift();
|
||||
switch (opt) {
|
||||
case "-h":
|
||||
case "--help": {
|
||||
options.showHelp = true;
|
||||
break;
|
||||
}
|
||||
case "-r":
|
||||
case "--require": {
|
||||
const moduleName = args.shift();
|
||||
if (moduleName == null) {
|
||||
throw new UsageError(
|
||||
`-r requires a valid module name`,
|
||||
globalHelpText,
|
||||
);
|
||||
}
|
||||
options.require.push(moduleName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new UsageError(`unknown option ${opt}`, globalHelpText);
|
||||
}
|
||||
}
|
||||
const commandName = args.shift();
|
||||
return { options, commandName, commandArgs: args };
|
||||
const options: Options = {
|
||||
showHelp: false,
|
||||
require: [],
|
||||
};
|
||||
// Look ahead for the first argument that doesn't start with a "-" to
|
||||
// indicate the end of option parsing.
|
||||
while (args.length > 0 && args[0].indexOf("-") >= 0) {
|
||||
const opt = args.shift();
|
||||
switch (opt) {
|
||||
case "-h":
|
||||
case "--help": {
|
||||
options.showHelp = true;
|
||||
break;
|
||||
}
|
||||
case "-r":
|
||||
case "--require": {
|
||||
const moduleName = args.shift();
|
||||
if (moduleName == null) {
|
||||
throw new UsageError(
|
||||
"-r requires a valid module name",
|
||||
globalHelpText,
|
||||
);
|
||||
}
|
||||
options.require.push(moduleName);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new UsageError(`unknown option ${opt}`, globalHelpText);
|
||||
}
|
||||
}
|
||||
const commandName = args.shift();
|
||||
return { options, commandName, commandArgs: args };
|
||||
};
|
||||
|
||||
const _main = async (args: string[]): Promise<void> => {
|
||||
const { options, commandName, commandArgs } = parseArgs(args);
|
||||
if (options.showHelp) {
|
||||
console.log(`${globalHelpText}\n`);
|
||||
return;
|
||||
}
|
||||
if (commandName == null) {
|
||||
throw new UsageError(`must specify a valid command`, globalHelpText);
|
||||
}
|
||||
for (const moduleName of options.require) {
|
||||
await import(moduleName);
|
||||
}
|
||||
const { options, commandName, commandArgs } = parseArgs(args);
|
||||
if (options.showHelp) {
|
||||
console.log(`${globalHelpText}\n`);
|
||||
return;
|
||||
}
|
||||
if (commandName == null) {
|
||||
throw new UsageError("must specify a valid command", globalHelpText);
|
||||
}
|
||||
for (const moduleName of options.require) {
|
||||
await import(moduleName);
|
||||
}
|
||||
|
||||
let command: Command;
|
||||
switch (commandName) {
|
||||
case "build":
|
||||
command = await import("./commands/build");
|
||||
break;
|
||||
case "dev":
|
||||
command = await import("./commands/dev");
|
||||
break;
|
||||
default:
|
||||
throw new UsageError(
|
||||
`unknown command ${commandName}`,
|
||||
globalHelpText,
|
||||
);
|
||||
}
|
||||
// NOTE: Should this just delegate to the command?
|
||||
for (const arg of commandArgs) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(`${command.helpText}\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await command.execute(commandArgs);
|
||||
let command: Command;
|
||||
switch (commandName) {
|
||||
case "build":
|
||||
command = await import("./commands/build");
|
||||
break;
|
||||
case "dev":
|
||||
command = await import("./commands/dev");
|
||||
break;
|
||||
default:
|
||||
throw new UsageError(`unknown command ${commandName}`, globalHelpText);
|
||||
}
|
||||
// NOTE: Should this just delegate to the command?
|
||||
for (const arg of commandArgs) {
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
console.log(`${command.helpText}\n`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
await command.execute(commandArgs);
|
||||
};
|
||||
|
||||
/**
|
||||
* Entrypoint of the CLI app.
|
||||
*/
|
||||
export const main = (): void => {
|
||||
_main(process.argv.slice(2)).catch((error) => {
|
||||
if (error instanceof UsageError) {
|
||||
console.error(`Error: ${error.message}\n`);
|
||||
console.log(`${error.helpText}\n`);
|
||||
} else {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.stack : JSON.stringify(error);
|
||||
console.error(`Unexpected error: ${errorMsg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
_main(process.argv.slice(2)).catch((error) => {
|
||||
if (error instanceof UsageError) {
|
||||
console.error(`Error: ${error.message}\n`);
|
||||
console.log(`${error.helpText}\n`);
|
||||
} else {
|
||||
const errorMsg =
|
||||
error instanceof Error ? error.stack : JSON.stringify(error);
|
||||
console.error(`Unexpected error: ${errorMsg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,16 +7,16 @@
|
|||
* CLI command representing an action that the CLI program supports.
|
||||
*/
|
||||
export interface Command {
|
||||
/**
|
||||
* Execute the command with the specified arguments.
|
||||
*
|
||||
* @param args List of CLI arguments to pass to the command.
|
||||
*/
|
||||
execute(args: string[]): Promise<void>;
|
||||
/**
|
||||
* Help text for this command.
|
||||
*/
|
||||
helpText: string;
|
||||
/**
|
||||
* Execute the command with the specified arguments.
|
||||
*
|
||||
* @param args List of CLI arguments to pass to the command.
|
||||
*/
|
||||
execute(args: string[]): Promise<void>;
|
||||
/**
|
||||
* Help text for this command.
|
||||
*/
|
||||
helpText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,11 +24,11 @@ export interface Command {
|
|||
* text to guide the user to correct their mistake.
|
||||
*/
|
||||
export class UsageError extends Error {
|
||||
public readonly helpText: string;
|
||||
public readonly helpText: string;
|
||||
|
||||
public constructor(message: string, helpText: string) {
|
||||
super(message);
|
||||
public constructor(message: string, helpText: string) {
|
||||
super(message);
|
||||
|
||||
this.helpText = helpText;
|
||||
}
|
||||
this.helpText = helpText;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,46 +7,46 @@
|
|||
* An in-memory representation of a renderable HTML element.
|
||||
*/
|
||||
export interface HTMLElement {
|
||||
/**
|
||||
* Name of the tag that gets output upon rendering.
|
||||
*/
|
||||
tag: string;
|
||||
/**
|
||||
* Record of attribute names and values that should be output in the opening
|
||||
* tag.
|
||||
*/
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
/**
|
||||
* Child elements to render nested within this HTML element.
|
||||
*/
|
||||
children: Element[];
|
||||
/**
|
||||
* Name of the tag that gets output upon rendering.
|
||||
*/
|
||||
tag: string;
|
||||
/**
|
||||
* Record of attribute names and values that should be output in the opening
|
||||
* tag.
|
||||
*/
|
||||
attributes: Record<string, string | number | boolean>;
|
||||
/**
|
||||
* Child elements to render nested within this HTML element.
|
||||
*/
|
||||
children: Element[];
|
||||
}
|
||||
|
||||
/**
|
||||
* All valid types of elements that can be rendered to HTML.
|
||||
*/
|
||||
export type Element =
|
||||
| Element[]
|
||||
| HTMLElement
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| undefined
|
||||
| null;
|
||||
| Element[]
|
||||
| HTMLElement
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| undefined
|
||||
| null;
|
||||
|
||||
/**
|
||||
* Custom HTMLElement factory that can be parameterized by props.
|
||||
*/
|
||||
export interface Component<P extends object = {}> {
|
||||
(
|
||||
props: P & {
|
||||
children?: Element[];
|
||||
},
|
||||
): HTMLElement;
|
||||
export interface Component<P extends object = Record<string, unknown>> {
|
||||
(
|
||||
props: P & {
|
||||
children?: Element[];
|
||||
},
|
||||
): HTMLElement;
|
||||
}
|
||||
|
||||
export const Fragment: Component<{}> = ({ children }) => ({
|
||||
tag: "#fragment",
|
||||
attributes: {},
|
||||
children: children || [],
|
||||
export const Fragment: Component = ({ children }) => ({
|
||||
tag: "#fragment",
|
||||
attributes: {},
|
||||
children: children || [],
|
||||
});
|
||||
|
|
|
|||
108
src/config.ts
108
src/config.ts
|
|
@ -3,7 +3,7 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import * as path from "path";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { decacheModule } from "./utils";
|
||||
|
||||
|
|
@ -11,40 +11,40 @@ import { decacheModule } from "./utils";
|
|||
* Paths used during configuration.
|
||||
*/
|
||||
export interface ConfigPaths {
|
||||
rootDir: string;
|
||||
outDir: string;
|
||||
pagesDir: string;
|
||||
staticAssetsDir: string;
|
||||
rootDir: string;
|
||||
outDir: string;
|
||||
pagesDir: string;
|
||||
staticAssetsDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooks that allow user code to customize site rendering.
|
||||
*/
|
||||
export interface Hooks {
|
||||
/**
|
||||
* Hook that fires at the end of site rendering one all pages and assets are
|
||||
* fully rendered.
|
||||
*/
|
||||
afterSiteRender(context: ConfigPaths): Promise<void> | void;
|
||||
/**
|
||||
* Hook that fires at the end of site rendering one all pages and assets are
|
||||
* fully rendered.
|
||||
*/
|
||||
afterSiteRender(context: ConfigPaths): Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-provided configuration options.
|
||||
*/
|
||||
export type UserConfig = {
|
||||
/** Hook implementations that allow customizing the rendering process. */
|
||||
hooks?: Partial<Hooks>;
|
||||
/** Additional folders and files to watch by the development server. */
|
||||
watch?: string[];
|
||||
/** Hook implementations that allow customizing the rendering process. */
|
||||
hooks?: Partial<Hooks>;
|
||||
/** Additional folders and files to watch by the development server. */
|
||||
watch?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Fully-realized configuration for a websnacks site.
|
||||
*/
|
||||
export interface Config {
|
||||
paths: ConfigPaths;
|
||||
hooks: Hooks;
|
||||
watch: string[];
|
||||
paths: ConfigPaths;
|
||||
hooks: Hooks;
|
||||
watch: string[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
|
|
@ -58,42 +58,44 @@ const noop = () => {};
|
|||
* @return Fully-realized configuration.
|
||||
*/
|
||||
export const loadConfig = async (rootDir: string): Promise<Config> => {
|
||||
let 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.
|
||||
userConfig = await import(configPath);
|
||||
} catch (error) {
|
||||
// Use default config;
|
||||
}
|
||||
const outDir = path.join(rootDir, "public");
|
||||
const pagesDir = path.join(rootDir, "pages");
|
||||
const staticAssetsDir = path.join(rootDir, "static");
|
||||
let 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.
|
||||
userConfig = await import(configPath);
|
||||
} catch (_error) {
|
||||
// Use default config;
|
||||
}
|
||||
const outDir = path.join(rootDir, "public");
|
||||
const pagesDir = path.join(rootDir, "pages");
|
||||
const staticAssetsDir = path.join(rootDir, "static");
|
||||
|
||||
const watch = [pagesDir, staticAssetsDir];
|
||||
if (configPath != null) {
|
||||
watch.push(path.relative(rootDir, configPath));
|
||||
}
|
||||
if (userConfig.watch != null) {
|
||||
for (const userWatch of userConfig.watch) {
|
||||
watch.push(path.relative(rootDir, userWatch));
|
||||
}
|
||||
}
|
||||
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 {
|
||||
paths: {
|
||||
rootDir,
|
||||
outDir,
|
||||
pagesDir,
|
||||
staticAssetsDir,
|
||||
},
|
||||
hooks: {
|
||||
afterSiteRender: noop,
|
||||
...userConfig.hooks,
|
||||
},
|
||||
watch,
|
||||
};
|
||||
return {
|
||||
paths: {
|
||||
rootDir,
|
||||
outDir,
|
||||
pagesDir,
|
||||
staticAssetsDir,
|
||||
},
|
||||
hooks: {
|
||||
afterSiteRender: noop,
|
||||
...userConfig.hooks,
|
||||
},
|
||||
watch,
|
||||
};
|
||||
};
|
||||
|
||||
export const defineConfig = (userConfig: UserConfig): UserConfig => userConfig;
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, Element, HTMLElement } from "./component";
|
||||
import { HTMLAttributes } from "./jsx";
|
||||
import type { Component, Element, HTMLElement } from "./component";
|
||||
import type { HTMLAttributes } from "./jsx";
|
||||
import { flatDeep } from "./utils";
|
||||
|
||||
/**
|
||||
|
|
@ -18,9 +18,9 @@ import { flatDeep } from "./utils";
|
|||
* @return Fully-realized HTMLElement, ready for rendering.
|
||||
*/
|
||||
export function createElement<P extends object>(
|
||||
comp: Component<P>,
|
||||
props: P,
|
||||
...children: Element[]
|
||||
comp: Component<P>,
|
||||
props: P,
|
||||
...children: Element[]
|
||||
): HTMLElement;
|
||||
/**
|
||||
* Create an HTMLElement from a standard HTML5 tag.
|
||||
|
|
@ -33,47 +33,44 @@ export function createElement<P extends object>(
|
|||
* @return Fully-realized HTMLElement, ready for rendering.
|
||||
*/
|
||||
export function createElement(
|
||||
tag: string,
|
||||
attrs: HTMLAttributes | null,
|
||||
...children: Element[]
|
||||
tag: string,
|
||||
attrs: HTMLAttributes | null,
|
||||
...children: Element[]
|
||||
): HTMLElement;
|
||||
export function createElement(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type: string | Component<any>,
|
||||
props: object | null,
|
||||
...children: Element[]
|
||||
type: string | Component,
|
||||
props: object | null,
|
||||
...children: Element[]
|
||||
): HTMLElement {
|
||||
// Flatten the children array so we can accept arrays as children.
|
||||
const normalizedChildren = flatDeep(children);
|
||||
if (type instanceof Function) {
|
||||
return type({ ...props, children: normalizedChildren });
|
||||
}
|
||||
// Flatten the children array so we can accept arrays as children.
|
||||
const normalizedChildren = flatDeep(children);
|
||||
if (type instanceof Function) {
|
||||
return type({ ...props, children: normalizedChildren });
|
||||
}
|
||||
|
||||
if (type !== type.toLowerCase()) {
|
||||
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
|
||||
}
|
||||
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 };
|
||||
if (type !== type.toLowerCase()) {
|
||||
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
|
||||
}
|
||||
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,7 +3,7 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
export { HTMLElement, Component, Fragment } from "./component";
|
||||
export { UserConfig as Config } from "./config";
|
||||
export { Component, Fragment, HTMLElement } from "./component";
|
||||
export { defineConfig, UserConfig as Config } from "./config";
|
||||
export { createElement } from "./create-element";
|
||||
export * from "./jsx";
|
||||
|
|
|
|||
534
src/jsx.ts
534
src/jsx.ts
|
|
@ -4,285 +4,285 @@
|
|||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
import { HTMLElement } from "./component";
|
||||
import type { HTMLElement } from "./component";
|
||||
|
||||
export interface RdfaAttributes {
|
||||
about?: string;
|
||||
datatype?: string;
|
||||
inlist?: boolean;
|
||||
prefix?: string;
|
||||
property?: string;
|
||||
resource?: string;
|
||||
typeof?: string;
|
||||
vocab?: string;
|
||||
about?: string;
|
||||
datatype?: string;
|
||||
inlist?: boolean;
|
||||
prefix?: string;
|
||||
property?: string;
|
||||
resource?: string;
|
||||
typeof?: string;
|
||||
vocab?: string;
|
||||
}
|
||||
|
||||
export interface MicrodataAttributes {
|
||||
itemProp?: string;
|
||||
itemScope?: boolean;
|
||||
itemType?: string;
|
||||
itemID?: string;
|
||||
itemRef?: string;
|
||||
itemProp?: string;
|
||||
itemScope?: boolean;
|
||||
itemType?: string;
|
||||
itemID?: string;
|
||||
itemRef?: string;
|
||||
}
|
||||
|
||||
export interface SetInnerHtmlAttributes {
|
||||
dangerouslySetInnerHTML?: { __html: string };
|
||||
dangerouslySetInnerHTML?: { __html: string };
|
||||
}
|
||||
|
||||
export interface HTMLAttributes
|
||||
extends RdfaAttributes,
|
||||
MicrodataAttributes,
|
||||
SetInnerHtmlAttributes {
|
||||
accept?: string;
|
||||
acceptCharset?: string;
|
||||
accessKey?: string;
|
||||
action?: string;
|
||||
allowFullScreen?: boolean;
|
||||
allowTransparency?: boolean;
|
||||
alt?: string;
|
||||
as?: string;
|
||||
async?: boolean;
|
||||
autoComplete?: string;
|
||||
autoCorrect?: string;
|
||||
autoFocus?: boolean;
|
||||
autoPlay?: boolean;
|
||||
capture?: boolean;
|
||||
cellPadding?: number | string;
|
||||
cellSpacing?: number | string;
|
||||
charSet?: string;
|
||||
challenge?: string;
|
||||
checked?: boolean;
|
||||
class?: string;
|
||||
className?: string;
|
||||
cols?: number;
|
||||
colSpan?: number;
|
||||
content?: string;
|
||||
contentEditable?: boolean;
|
||||
contextMenu?: string;
|
||||
controls?: boolean;
|
||||
controlsList?: string;
|
||||
coords?: string;
|
||||
crossOrigin?: string;
|
||||
data?: string;
|
||||
dateTime?: string;
|
||||
default?: boolean;
|
||||
defer?: boolean;
|
||||
dir?: "auto" | "rtl" | "ltr";
|
||||
disabled?: boolean;
|
||||
disableRemotePlayback?: boolean;
|
||||
download?: boolean | string;
|
||||
draggable?: boolean;
|
||||
encType?: string;
|
||||
form?: string;
|
||||
formAction?: string;
|
||||
formEncType?: string;
|
||||
formMethod?: string;
|
||||
formNoValidate?: boolean;
|
||||
formTarget?: string;
|
||||
frameBorder?: number | string;
|
||||
headers?: string;
|
||||
height?: number | string;
|
||||
hidden?: boolean;
|
||||
high?: number;
|
||||
href?: string;
|
||||
hrefLang?: string;
|
||||
for?: string;
|
||||
htmlFor?: string;
|
||||
httpEquiv?: string;
|
||||
icon?: string;
|
||||
id?: string;
|
||||
inputMode?: string;
|
||||
integrity?: string;
|
||||
is?: string;
|
||||
keyParams?: string;
|
||||
keyType?: string;
|
||||
kind?: string;
|
||||
label?: string;
|
||||
lang?: string;
|
||||
list?: string;
|
||||
loop?: boolean;
|
||||
low?: number;
|
||||
manifest?: string;
|
||||
marginHeight?: number;
|
||||
marginWidth?: number;
|
||||
max?: number | string;
|
||||
maxLength?: number;
|
||||
media?: string;
|
||||
mediaGroup?: string;
|
||||
method?: string;
|
||||
min?: number | string;
|
||||
minLength?: number;
|
||||
multiple?: boolean;
|
||||
muted?: boolean;
|
||||
name?: string;
|
||||
nonce?: string;
|
||||
noValidate?: boolean;
|
||||
open?: boolean;
|
||||
optimum?: number;
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
playsInline?: boolean;
|
||||
poster?: string;
|
||||
preload?: string;
|
||||
radioGroup?: string;
|
||||
readOnly?: boolean;
|
||||
rel?: string;
|
||||
required?: boolean;
|
||||
role?: string;
|
||||
rows?: number;
|
||||
rowSpan?: number;
|
||||
sandbox?: string;
|
||||
scope?: string;
|
||||
scoped?: boolean;
|
||||
scrolling?: string;
|
||||
seamless?: boolean;
|
||||
selected?: boolean;
|
||||
shape?: string;
|
||||
size?: number;
|
||||
sizes?: string;
|
||||
slot?: string;
|
||||
span?: number;
|
||||
spellcheck?: boolean;
|
||||
src?: string;
|
||||
srcset?: string;
|
||||
srcDoc?: string;
|
||||
srcLang?: string;
|
||||
srcSet?: string;
|
||||
start?: number;
|
||||
step?: number | string;
|
||||
style?: string | { [key: string]: string | number };
|
||||
summary?: string;
|
||||
tabIndex?: number;
|
||||
target?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
useMap?: string;
|
||||
value?: string | string[] | number;
|
||||
volume?: string | number;
|
||||
width?: number | string;
|
||||
wmode?: string;
|
||||
wrap?: string;
|
||||
extends RdfaAttributes,
|
||||
MicrodataAttributes,
|
||||
SetInnerHtmlAttributes {
|
||||
accept?: string;
|
||||
acceptCharset?: string;
|
||||
accessKey?: string;
|
||||
action?: string;
|
||||
allowFullScreen?: boolean;
|
||||
allowTransparency?: boolean;
|
||||
alt?: string;
|
||||
as?: string;
|
||||
async?: boolean;
|
||||
autoComplete?: string;
|
||||
autoCorrect?: string;
|
||||
autoFocus?: boolean;
|
||||
autoPlay?: boolean;
|
||||
capture?: boolean;
|
||||
cellPadding?: number | string;
|
||||
cellSpacing?: number | string;
|
||||
charSet?: string;
|
||||
challenge?: string;
|
||||
checked?: boolean;
|
||||
class?: string;
|
||||
className?: string;
|
||||
cols?: number;
|
||||
colSpan?: number;
|
||||
content?: string;
|
||||
contentEditable?: boolean;
|
||||
contextMenu?: string;
|
||||
controls?: boolean;
|
||||
controlsList?: string;
|
||||
coords?: string;
|
||||
crossOrigin?: string;
|
||||
data?: string;
|
||||
dateTime?: string;
|
||||
default?: boolean;
|
||||
defer?: boolean;
|
||||
dir?: "auto" | "rtl" | "ltr";
|
||||
disabled?: boolean;
|
||||
disableRemotePlayback?: boolean;
|
||||
download?: boolean | string;
|
||||
draggable?: boolean;
|
||||
encType?: string;
|
||||
form?: string;
|
||||
formAction?: string;
|
||||
formEncType?: string;
|
||||
formMethod?: string;
|
||||
formNoValidate?: boolean;
|
||||
formTarget?: string;
|
||||
frameBorder?: number | string;
|
||||
headers?: string;
|
||||
height?: number | string;
|
||||
hidden?: boolean;
|
||||
high?: number;
|
||||
href?: string;
|
||||
hrefLang?: string;
|
||||
for?: string;
|
||||
htmlFor?: string;
|
||||
httpEquiv?: string;
|
||||
icon?: string;
|
||||
id?: string;
|
||||
inputMode?: string;
|
||||
integrity?: string;
|
||||
is?: string;
|
||||
keyParams?: string;
|
||||
keyType?: string;
|
||||
kind?: string;
|
||||
label?: string;
|
||||
lang?: string;
|
||||
list?: string;
|
||||
loop?: boolean;
|
||||
low?: number;
|
||||
manifest?: string;
|
||||
marginHeight?: number;
|
||||
marginWidth?: number;
|
||||
max?: number | string;
|
||||
maxLength?: number;
|
||||
media?: string;
|
||||
mediaGroup?: string;
|
||||
method?: string;
|
||||
min?: number | string;
|
||||
minLength?: number;
|
||||
multiple?: boolean;
|
||||
muted?: boolean;
|
||||
name?: string;
|
||||
nonce?: string;
|
||||
noValidate?: boolean;
|
||||
open?: boolean;
|
||||
optimum?: number;
|
||||
pattern?: string;
|
||||
placeholder?: string;
|
||||
playsInline?: boolean;
|
||||
poster?: string;
|
||||
preload?: string;
|
||||
radioGroup?: string;
|
||||
readOnly?: boolean;
|
||||
rel?: string;
|
||||
required?: boolean;
|
||||
role?: string;
|
||||
rows?: number;
|
||||
rowSpan?: number;
|
||||
sandbox?: string;
|
||||
scope?: string;
|
||||
scoped?: boolean;
|
||||
scrolling?: string;
|
||||
seamless?: boolean;
|
||||
selected?: boolean;
|
||||
shape?: string;
|
||||
size?: number;
|
||||
sizes?: string;
|
||||
slot?: string;
|
||||
span?: number;
|
||||
spellcheck?: boolean;
|
||||
src?: string;
|
||||
srcset?: string;
|
||||
srcDoc?: string;
|
||||
srcLang?: string;
|
||||
srcSet?: string;
|
||||
start?: number;
|
||||
step?: number | string;
|
||||
style?: string | { [key: string]: string | number };
|
||||
summary?: string;
|
||||
tabIndex?: number;
|
||||
target?: string;
|
||||
title?: string;
|
||||
type?: string;
|
||||
useMap?: string;
|
||||
value?: string | string[] | number;
|
||||
volume?: string | number;
|
||||
width?: number | string;
|
||||
wmode?: string;
|
||||
wrap?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
type Element = HTMLElement;
|
||||
type IntrinsicElements = {
|
||||
a: HTMLAttributes;
|
||||
abbr: HTMLAttributes;
|
||||
address: HTMLAttributes;
|
||||
area: HTMLAttributes;
|
||||
article: HTMLAttributes;
|
||||
aside: HTMLAttributes;
|
||||
audio: HTMLAttributes;
|
||||
b: HTMLAttributes;
|
||||
base: HTMLAttributes;
|
||||
bdi: HTMLAttributes;
|
||||
bdo: HTMLAttributes;
|
||||
big: HTMLAttributes;
|
||||
blockquote: HTMLAttributes;
|
||||
body: HTMLAttributes;
|
||||
br: HTMLAttributes;
|
||||
button: HTMLAttributes;
|
||||
canvas: HTMLAttributes;
|
||||
caption: HTMLAttributes;
|
||||
cite: HTMLAttributes;
|
||||
code: HTMLAttributes;
|
||||
col: HTMLAttributes;
|
||||
colgroup: HTMLAttributes;
|
||||
data: HTMLAttributes;
|
||||
datalist: HTMLAttributes;
|
||||
dd: HTMLAttributes;
|
||||
del: HTMLAttributes;
|
||||
details: HTMLAttributes;
|
||||
dfn: HTMLAttributes;
|
||||
dialog: HTMLAttributes;
|
||||
div: HTMLAttributes;
|
||||
dl: HTMLAttributes;
|
||||
dt: HTMLAttributes;
|
||||
em: HTMLAttributes;
|
||||
embed: HTMLAttributes;
|
||||
fieldset: HTMLAttributes;
|
||||
figcaption: HTMLAttributes;
|
||||
figure: HTMLAttributes;
|
||||
footer: HTMLAttributes;
|
||||
form: HTMLAttributes;
|
||||
h1: HTMLAttributes;
|
||||
h2: HTMLAttributes;
|
||||
h3: HTMLAttributes;
|
||||
h4: HTMLAttributes;
|
||||
h5: HTMLAttributes;
|
||||
h6: HTMLAttributes;
|
||||
head: HTMLAttributes;
|
||||
header: HTMLAttributes;
|
||||
hgroup: HTMLAttributes;
|
||||
hr: HTMLAttributes;
|
||||
html: HTMLAttributes;
|
||||
i: HTMLAttributes;
|
||||
iframe: HTMLAttributes;
|
||||
img: HTMLAttributes;
|
||||
input: HTMLAttributes;
|
||||
ins: HTMLAttributes;
|
||||
kbd: HTMLAttributes;
|
||||
keygen: HTMLAttributes;
|
||||
label: HTMLAttributes;
|
||||
legend: HTMLAttributes;
|
||||
li: HTMLAttributes;
|
||||
link: HTMLAttributes;
|
||||
main: HTMLAttributes;
|
||||
map: HTMLAttributes;
|
||||
mark: HTMLAttributes;
|
||||
marquee: HTMLAttributes;
|
||||
menu: HTMLAttributes;
|
||||
menuitem: HTMLAttributes;
|
||||
meta: HTMLAttributes;
|
||||
meter: HTMLAttributes;
|
||||
nav: HTMLAttributes;
|
||||
noscript: HTMLAttributes;
|
||||
object: HTMLAttributes;
|
||||
ol: HTMLAttributes;
|
||||
optgroup: HTMLAttributes;
|
||||
option: HTMLAttributes;
|
||||
output: HTMLAttributes;
|
||||
p: HTMLAttributes;
|
||||
param: HTMLAttributes;
|
||||
picture: HTMLAttributes;
|
||||
pre: HTMLAttributes;
|
||||
progress: HTMLAttributes;
|
||||
q: HTMLAttributes;
|
||||
rp: HTMLAttributes;
|
||||
rt: HTMLAttributes;
|
||||
ruby: HTMLAttributes;
|
||||
s: HTMLAttributes;
|
||||
samp: HTMLAttributes;
|
||||
script: HTMLAttributes;
|
||||
section: HTMLAttributes;
|
||||
select: HTMLAttributes;
|
||||
slot: HTMLAttributes;
|
||||
small: HTMLAttributes;
|
||||
source: HTMLAttributes;
|
||||
span: HTMLAttributes;
|
||||
strong: HTMLAttributes;
|
||||
style: HTMLAttributes;
|
||||
sub: HTMLAttributes;
|
||||
summary: HTMLAttributes;
|
||||
sup: HTMLAttributes;
|
||||
table: HTMLAttributes;
|
||||
tbody: HTMLAttributes;
|
||||
td: HTMLAttributes;
|
||||
textarea: HTMLAttributes;
|
||||
tfoot: HTMLAttributes;
|
||||
th: HTMLAttributes;
|
||||
thead: HTMLAttributes;
|
||||
time: HTMLAttributes;
|
||||
title: HTMLAttributes;
|
||||
tr: HTMLAttributes;
|
||||
track: HTMLAttributes;
|
||||
u: HTMLAttributes;
|
||||
ul: HTMLAttributes;
|
||||
var: HTMLAttributes;
|
||||
video: HTMLAttributes;
|
||||
wbr: HTMLAttributes;
|
||||
};
|
||||
}
|
||||
namespace JSX {
|
||||
type Element = HTMLElement;
|
||||
type IntrinsicElements = {
|
||||
a: HTMLAttributes;
|
||||
abbr: HTMLAttributes;
|
||||
address: HTMLAttributes;
|
||||
area: HTMLAttributes;
|
||||
article: HTMLAttributes;
|
||||
aside: HTMLAttributes;
|
||||
audio: HTMLAttributes;
|
||||
b: HTMLAttributes;
|
||||
base: HTMLAttributes;
|
||||
bdi: HTMLAttributes;
|
||||
bdo: HTMLAttributes;
|
||||
big: HTMLAttributes;
|
||||
blockquote: HTMLAttributes;
|
||||
body: HTMLAttributes;
|
||||
br: HTMLAttributes;
|
||||
button: HTMLAttributes;
|
||||
canvas: HTMLAttributes;
|
||||
caption: HTMLAttributes;
|
||||
cite: HTMLAttributes;
|
||||
code: HTMLAttributes;
|
||||
col: HTMLAttributes;
|
||||
colgroup: HTMLAttributes;
|
||||
data: HTMLAttributes;
|
||||
datalist: HTMLAttributes;
|
||||
dd: HTMLAttributes;
|
||||
del: HTMLAttributes;
|
||||
details: HTMLAttributes;
|
||||
dfn: HTMLAttributes;
|
||||
dialog: HTMLAttributes;
|
||||
div: HTMLAttributes;
|
||||
dl: HTMLAttributes;
|
||||
dt: HTMLAttributes;
|
||||
em: HTMLAttributes;
|
||||
embed: HTMLAttributes;
|
||||
fieldset: HTMLAttributes;
|
||||
figcaption: HTMLAttributes;
|
||||
figure: HTMLAttributes;
|
||||
footer: HTMLAttributes;
|
||||
form: HTMLAttributes;
|
||||
h1: HTMLAttributes;
|
||||
h2: HTMLAttributes;
|
||||
h3: HTMLAttributes;
|
||||
h4: HTMLAttributes;
|
||||
h5: HTMLAttributes;
|
||||
h6: HTMLAttributes;
|
||||
head: HTMLAttributes;
|
||||
header: HTMLAttributes;
|
||||
hgroup: HTMLAttributes;
|
||||
hr: HTMLAttributes;
|
||||
html: HTMLAttributes;
|
||||
i: HTMLAttributes;
|
||||
iframe: HTMLAttributes;
|
||||
img: HTMLAttributes;
|
||||
input: HTMLAttributes;
|
||||
ins: HTMLAttributes;
|
||||
kbd: HTMLAttributes;
|
||||
keygen: HTMLAttributes;
|
||||
label: HTMLAttributes;
|
||||
legend: HTMLAttributes;
|
||||
li: HTMLAttributes;
|
||||
link: HTMLAttributes;
|
||||
main: HTMLAttributes;
|
||||
map: HTMLAttributes;
|
||||
mark: HTMLAttributes;
|
||||
marquee: HTMLAttributes;
|
||||
menu: HTMLAttributes;
|
||||
menuitem: HTMLAttributes;
|
||||
meta: HTMLAttributes;
|
||||
meter: HTMLAttributes;
|
||||
nav: HTMLAttributes;
|
||||
noscript: HTMLAttributes;
|
||||
object: HTMLAttributes;
|
||||
ol: HTMLAttributes;
|
||||
optgroup: HTMLAttributes;
|
||||
option: HTMLAttributes;
|
||||
output: HTMLAttributes;
|
||||
p: HTMLAttributes;
|
||||
param: HTMLAttributes;
|
||||
picture: HTMLAttributes;
|
||||
pre: HTMLAttributes;
|
||||
progress: HTMLAttributes;
|
||||
q: HTMLAttributes;
|
||||
rp: HTMLAttributes;
|
||||
rt: HTMLAttributes;
|
||||
ruby: HTMLAttributes;
|
||||
s: HTMLAttributes;
|
||||
samp: HTMLAttributes;
|
||||
script: HTMLAttributes;
|
||||
section: HTMLAttributes;
|
||||
select: HTMLAttributes;
|
||||
slot: HTMLAttributes;
|
||||
small: HTMLAttributes;
|
||||
source: HTMLAttributes;
|
||||
span: HTMLAttributes;
|
||||
strong: HTMLAttributes;
|
||||
style: HTMLAttributes;
|
||||
sub: HTMLAttributes;
|
||||
summary: HTMLAttributes;
|
||||
sup: HTMLAttributes;
|
||||
table: HTMLAttributes;
|
||||
tbody: HTMLAttributes;
|
||||
td: HTMLAttributes;
|
||||
textarea: HTMLAttributes;
|
||||
tfoot: HTMLAttributes;
|
||||
th: HTMLAttributes;
|
||||
thead: HTMLAttributes;
|
||||
time: HTMLAttributes;
|
||||
title: HTMLAttributes;
|
||||
tr: HTMLAttributes;
|
||||
track: HTMLAttributes;
|
||||
u: HTMLAttributes;
|
||||
ul: HTMLAttributes;
|
||||
var: HTMLAttributes;
|
||||
video: HTMLAttributes;
|
||||
wbr: HTMLAttributes;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
158
src/render.ts
158
src/render.ts
|
|
@ -3,89 +3,87 @@
|
|||
* 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 escapeHtml = (text: string): string =>
|
||||
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
|
||||
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
|
||||
|
||||
const escapeAttr = (text: string): string => text.replace(/"/g, """);
|
||||
|
||||
const renderElement = (elem: Element): string => {
|
||||
// Ignore null and true/false to support nicer JSX conditional syntax with
|
||||
// &&, ||, !! operators.
|
||||
if (elem == null || typeof elem === "boolean") {
|
||||
return "";
|
||||
}
|
||||
if (typeof elem === "number") {
|
||||
return elem.toString();
|
||||
}
|
||||
if (typeof elem === "string") {
|
||||
return escapeHtml(elem);
|
||||
}
|
||||
if (Array.isArray(elem)) {
|
||||
return elem.map((e) => renderElement(e)).join("");
|
||||
}
|
||||
// Ignore null and true/false to support nicer JSX conditional syntax with
|
||||
// &&, ||, !! operators.
|
||||
if (elem == null || typeof elem === "boolean") {
|
||||
return "";
|
||||
}
|
||||
if (typeof elem === "number") {
|
||||
return elem.toString();
|
||||
}
|
||||
if (typeof elem === "string") {
|
||||
return escapeHtml(elem);
|
||||
}
|
||||
if (Array.isArray(elem)) {
|
||||
return elem.map((e) => renderElement(e)).join("");
|
||||
}
|
||||
|
||||
let output = "";
|
||||
output += startTag(elem);
|
||||
if (elem.attributes.dangerouslySetInnerHTML != null) {
|
||||
output += elem.attributes.dangerouslySetInnerHTML;
|
||||
} else {
|
||||
for (const child of elem.children) {
|
||||
output += renderElement(child);
|
||||
}
|
||||
}
|
||||
output += endTag(elem);
|
||||
return output;
|
||||
let output = "";
|
||||
output += startTag(elem);
|
||||
if (elem.attributes.dangerouslySetInnerHTML != null) {
|
||||
output += elem.attributes.dangerouslySetInnerHTML;
|
||||
} else {
|
||||
for (const child of elem.children) {
|
||||
output += renderElement(child);
|
||||
}
|
||||
}
|
||||
output += endTag(elem);
|
||||
return output;
|
||||
};
|
||||
|
||||
const startTag = (elem: HTMLElement): string => {
|
||||
if (elem.tag === "#fragment") {
|
||||
return "";
|
||||
}
|
||||
if (elem.tag === "#fragment") {
|
||||
return "";
|
||||
}
|
||||
|
||||
let output = `<${escapeHtml(elem.tag)}`;
|
||||
let output = `<${escapeHtml(elem.tag)}`;
|
||||
|
||||
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
|
||||
// Handle boolean attributes with a false value by not outputting the
|
||||
// attribute at all.
|
||||
if (attrValue === false) {
|
||||
continue;
|
||||
}
|
||||
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
|
||||
// Handle boolean attributes with a false value by not outputting the
|
||||
// attribute at all.
|
||||
if (attrValue === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ignore the special attr for setting raw inner HTML.
|
||||
if (attrName === "dangerouslySetInnerHTML") {
|
||||
continue;
|
||||
}
|
||||
// Ignore the special attr for setting raw inner HTML.
|
||||
if (attrName === "dangerouslySetInnerHTML") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
|
||||
if (normalizedAttrName === "classname") {
|
||||
normalizedAttrName = "class";
|
||||
}
|
||||
if (attrValue === true) {
|
||||
output += ` ${normalizedAttrName}=""`;
|
||||
} else {
|
||||
output += ` ${normalizedAttrName}="${escapeAttr(
|
||||
attrValue.toString(),
|
||||
)}"`;
|
||||
}
|
||||
}
|
||||
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
|
||||
if (normalizedAttrName === "classname") {
|
||||
normalizedAttrName = "class";
|
||||
}
|
||||
if (attrValue === true) {
|
||||
output += ` ${normalizedAttrName}=""`;
|
||||
} else {
|
||||
output += ` ${normalizedAttrName}="${escapeAttr(attrValue.toString())}"`;
|
||||
}
|
||||
}
|
||||
|
||||
output += ">";
|
||||
return output;
|
||||
output += ">";
|
||||
return output;
|
||||
};
|
||||
|
||||
const endTag = (elem: HTMLElement): string => {
|
||||
if (elem.tag === "#fragment") {
|
||||
return "";
|
||||
}
|
||||
return `</${escapeHtml(elem.tag)}>`;
|
||||
if (elem.tag === "#fragment") {
|
||||
return "";
|
||||
}
|
||||
return `</${escapeHtml(elem.tag)}>`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -97,23 +95,23 @@ const endTag = (elem: HTMLElement): string => {
|
|||
* @return Fully rendered HTML document as a string.
|
||||
*/
|
||||
export const renderPage = (rootElem: Element): string => {
|
||||
if (rootElem == undefined) {
|
||||
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") {
|
||||
throw new Error(
|
||||
`attempted to render page with non-HTML root element ${rootElem.tag}`,
|
||||
);
|
||||
}
|
||||
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") {
|
||||
throw new Error(
|
||||
`attempted to render page with non-HTML root element ${rootElem.tag}`,
|
||||
);
|
||||
}
|
||||
|
||||
let output = "<!DOCTYPE html>";
|
||||
output += renderElement(rootElem);
|
||||
return output;
|
||||
let output = "<!DOCTYPE html>";
|
||||
output += renderElement(rootElem);
|
||||
return output;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,32 +3,38 @@
|
|||
* 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.code === "MODULE_NOT_FOUND") {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -41,29 +47,29 @@ const removeParentModuleRef = (mod: NodeModule): void => {
|
|||
* @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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
removeParentModuleRef(currentMod);
|
||||
delete require.cache[currentMod.id];
|
||||
for (const childMod of currentMod.children) {
|
||||
modsToCheck.push(childMod);
|
||||
}
|
||||
|
||||
visited.add(currentMod.id);
|
||||
currentMod = modsToCheck.pop();
|
||||
}
|
||||
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;
|
||||
|
|
@ -3,8 +3,8 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export { decacheModule } from "./decache-module";
|
||||
|
||||
|
|
@ -16,17 +16,17 @@ export { decacheModule } from "./decache-module";
|
|||
* @return Generator that yields the files found while walking the directory.
|
||||
*/
|
||||
export const walkDir = async function* (
|
||||
dirPath: string,
|
||||
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);
|
||||
}
|
||||
}
|
||||
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>>;
|
||||
|
|
@ -39,13 +39,13 @@ export type Flattenable<T> = Array<T | Flattenable<T>>;
|
|||
* @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;
|
||||
const flattenedArr: T[] = [];
|
||||
for (const val of arr) {
|
||||
if (Array.isArray(val)) {
|
||||
flattenedArr.push(...flatDeep(val));
|
||||
} else {
|
||||
flattenedArr.push(val);
|
||||
}
|
||||
}
|
||||
return flattenedArr;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,149 +3,149 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
npmCmd,
|
||||
runCommand,
|
||||
WEBSNACKS_BIN_PATH,
|
||||
WEBSNACKS_REPO_ROOT,
|
||||
withTempDir,
|
||||
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"),
|
||||
`
|
||||
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"),
|
||||
`
|
||||
{
|
||||
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;
|
||||
});
|
||||
});
|
||||
{
|
||||
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"),
|
||||
`
|
||||
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;
|
||||
});
|
||||
});
|
||||
{
|
||||
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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
270
test/e2e/dev.tsx
270
test/e2e/dev.tsx
|
|
@ -3,158 +3,158 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import * as path from "path";
|
||||
import { promises as fs } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import {
|
||||
npmCmd,
|
||||
runCommand,
|
||||
wait,
|
||||
WEBSNACKS_BIN_PATH,
|
||||
WEBSNACKS_REPO_ROOT,
|
||||
withTempDir,
|
||||
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"),
|
||||
`
|
||||
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"),
|
||||
`
|
||||
{
|
||||
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");
|
||||
});
|
||||
});
|
||||
{
|
||||
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"),
|
||||
`
|
||||
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");
|
||||
});
|
||||
});
|
||||
{
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { promises as fs } from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
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,
|
||||
|
|
@ -15,9 +15,9 @@ import * as path from "path";
|
|||
* @param timeMs Time in milliseconds to wait.
|
||||
*/
|
||||
export const wait = async (timeMs: number): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(), timeMs);
|
||||
});
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(), timeMs);
|
||||
});
|
||||
};
|
||||
|
||||
const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp");
|
||||
|
|
@ -36,15 +36,15 @@ const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp");
|
|||
* directory path as its only argument.
|
||||
*/
|
||||
export const withTempDir = async (
|
||||
op: (tempDirPath: string) => Promise<void> | void,
|
||||
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}`);
|
||||
}
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -55,47 +55,47 @@ 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",
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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,
|
||||
timeoutMs: 15_000,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -111,60 +111,58 @@ const DEFAULT_CLI_OPTIONS = {
|
|||
* @returns Command object for handling in client code.
|
||||
*/
|
||||
export const runCommand = (
|
||||
command: string,
|
||||
args: string[] = [],
|
||||
options?: CliOptions,
|
||||
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;
|
||||
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()}`));
|
||||
});
|
||||
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,
|
||||
};
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@
|
|||
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)}`,
|
||||
);
|
||||
}
|
||||
public constructor(reason: string, expected: unknown, actual: unknown) {
|
||||
super(
|
||||
`${reason}\n` +
|
||||
`\texpected: ${displayValue(expected)}\n` +
|
||||
`\tactual : ${displayValue(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -24,138 +24,134 @@ class ExpectError extends Error {
|
|||
* Expect.
|
||||
*/
|
||||
export class Expect<T> {
|
||||
protected readonly value: 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;
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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 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 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -202,11 +198,11 @@ export function expect<T>(fn: () => T): FunctionExpect<T>;
|
|||
* @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);
|
||||
if (typeof value === "string") {
|
||||
return new StringExpect(value);
|
||||
}
|
||||
if (typeof value === "function") {
|
||||
return new FunctionExpect(value as () => unknown);
|
||||
}
|
||||
return new Expect(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,68 +7,66 @@ import { expect } from "./expect";
|
|||
import { displayValue, shuffle } from "./utils";
|
||||
|
||||
interface Test {
|
||||
readonly name: string;
|
||||
readonly name: string;
|
||||
|
||||
runTest(): void | Promise<void>;
|
||||
runTest(): void | Promise<void>;
|
||||
}
|
||||
|
||||
type TestResult = {
|
||||
testName: string;
|
||||
testName: string;
|
||||
} & (
|
||||
| {
|
||||
result: "pass";
|
||||
}
|
||||
| {
|
||||
result: "fail";
|
||||
error: Error;
|
||||
}
|
||||
| {
|
||||
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;
|
||||
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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -85,36 +83,36 @@ export interface TestSuiteContext {
|
|||
* @param def Function used to declare the tests
|
||||
*/
|
||||
export const testSuite = (
|
||||
suiteName: string,
|
||||
def: (ctx: TestSuiteContext) => void,
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
})();
|
||||
// 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;
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,43 +9,43 @@
|
|||
* @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;
|
||||
}
|
||||
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;
|
||||
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,
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -58,19 +58,19 @@ const areObjectsEqual = <T extends Record<string, unknown>>(
|
|||
* @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;
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -84,10 +84,10 @@ export const areEqual = <T>(a: T, b: T): boolean => {
|
|||
* @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);
|
||||
if (typeof pattern === "string") {
|
||||
return value === pattern;
|
||||
}
|
||||
return pattern.test(value);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -102,11 +102,11 @@ export const matches = (value: string, pattern: string | RegExp): boolean => {
|
|||
* @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);
|
||||
if (value === undefined) {
|
||||
return "undefined";
|
||||
}
|
||||
if (value instanceof RegExp) {
|
||||
return value.toString();
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { fork } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fork } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { shuffle } from "./lib/utils";
|
||||
|
||||
|
|
@ -14,10 +14,10 @@ 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;
|
||||
}
|
||||
});
|
||||
const fullPath = path.join(TEST_SUITES_DIR, file);
|
||||
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { fork } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { fork } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { shuffle } from "./lib/utils";
|
||||
|
||||
|
|
@ -14,10 +14,10 @@ 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;
|
||||
}
|
||||
});
|
||||
const fullPath = path.join(TEST_SUITES_DIR, file);
|
||||
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,151 +3,147 @@
|
|||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, createElement, Fragment } from "../../dist";
|
||||
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("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("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("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 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 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 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 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 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 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 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("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',
|
||||
);
|
||||
});
|
||||
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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"jsxFactory": "createElement"
|
||||
}
|
||||
"extends": "../tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"jsxFactory": "createElement"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2018",
|
||||
"lib": ["ES2018"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2018",
|
||||
"lib": ["ES2018"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"extends": "./tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"extends": "./tsconfig-base.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue