Compare commits

...

No commits in common. "v0.1.5" and "main" have entirely different histories.
v0.1.5 ... main

49 changed files with 2787 additions and 4928 deletions

View file

@ -6,3 +6,4 @@ indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

View file

@ -1 +0,0 @@
node_modules

View file

@ -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
View file

@ -0,0 +1,38 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
check:
runs-on: node-24
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Cache npm dependencies
uses: https://code.forgejo.org/actions/cache@v4
with:
path: ~/.npm
key: node-24-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
- run: npm run check
build:
needs: check
strategy:
matrix:
node: [22, 24]
runs-on: node-${{ matrix.node }}
steps:
- uses: https://code.forgejo.org/actions/checkout@v4
- name: Cache npm dependencies
uses: https://code.forgejo.org/actions/cache@v4
with:
path: ~/.npm
key: node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
- run: npm ci
- run: npm run build
- run: npm test

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Ensure that git uses lf line-endings for text files.
* text=auto eol=lf

2
.nvmrc
View file

@ -1 +1 @@
lts/erbium
18

View file

@ -1,10 +0,0 @@
language: node_js
os:
- linux
- osx
- windows
node_js:
- node
- lts/*
- 12
- 10

View file

@ -1,5 +1,15 @@
# Changelog
## [0.2.0](https://github.com/websnacksjs/websnacks/releases/tag/v0.2.0) (2021-02-28)
### Features
- add dangerouslySetInnerHTML attr ([#15](https://github.com/websnacksjs/websnacks/issues/15), [3f356dd](https://github.com/websnacksjs/websnacks/commit/3f356ddfeeb38e8a60c32d26c3e9e8715d0246c3))
### Misc
- **BREAKING CHANGE** update node-watch optional dep to major v0.7.1
## [0.1.5](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.5) (2020-06-14)
### Bugfixes

View file

@ -35,7 +35,7 @@ Mozilla Public License Version 2.0
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"

View file

@ -1,20 +1,9 @@
# websnacks: Minimal Dependency Server-Side JSX for Static Sites
<div>
[![NPM release](https://img.shields.io/npm/v/@websnacksjs/websnacks?style=flat-square)](https://www.npmjs.com/package/@websnacksjs/websnacks "NPM release")
[![NPM](https://img.shields.io/npm/l/@websnacksjs/websnacks?style=flat-square)](https://www.mozilla.org/en-US/MPL/2.0/FAQ/ "License info")
[![Build status](https://img.shields.io/travis/com/websnacksjs/websnacks/master?style=flat-square)](https://travis-ci.com/websnacksjs/websnacks "Build status for master branch")
</div>
<div>
[![Dependency status](https://img.shields.io/david/websnacksjs/websnacks?style=flat-square)](https://david-dm.org/websnacksjs/websnacks "Dependency status")
[![Optional dependency status](https://img.shields.io/david/optional/websnacksjs/websnacks?style=flat-square)](https://david-dm.org/websnacksjs/websnacks?type=optional "Optional dependency status")
[![Dev dependency status](https://img.shields.io/david/dev/websnacksjs/websnacks?style=flat-square)](https://david-dm.org/websnacksjs/websnacks?type=dev "Dev dependency status")
</div>
[![NPM release](https://badges.git.theinnerlimit.ch/npm/v/@websnacksjs/websnacks?style=flat-square)](https://www.npmjs.com/package/@websnacksjs/websnacks "NPM release")
[![NPM](https://badges.git.theinnerlimit.ch/npm/l/@websnacksjs/websnacks?style=flat-square)](https://www.mozilla.org/en-US/MPL/2.0/FAQ/ "License info")
[![Build status](https://git.theinnerlimit.ch/websnacksjs/websnacks/badges/workflows/ci.yml/badge.svg?event=push&label=ci&style=flat-square)](https://git.theinnerlimit.ch/websnacksjs/websnacks/actions?workflow=ci.yml "CI status for main branch")
![Dependency status](https://badges.git.theinnerlimit.ch/librariesio/release/npm/%40websnacksjs%2Fwebsnacks?style=flat-square)
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
View 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"
}
}
}
}
]
}

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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>
);

File diff suppressed because it is too large Load diff

View file

@ -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": "../../"
}
}

View file

@ -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>
);

View file

@ -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>
);

View file

@ -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/**/*"]
}

View file

@ -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;

2141
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,53 +1,49 @@
{
"name": "@websnacksjs/websnacks",
"description": "Minimal dependency server-side JSX for static sites",
"version": "0.1.5",
"author": {
"name": "M. George Hansen",
"email": "mgeorge@technopolitica.com"
},
"license": "MPL-2.0",
"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.2.4",
"@typescript-eslint/eslint-plugin": "^3.0.2",
"@typescript-eslint/parser": "^3.0.2",
"eslint": "^7.1.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"prettier": "^2.0.5",
"ts-node": "^8.10.2",
"typescript": "~3.9.3"
},
"optionalDependencies": {
"node-watch": "^0.6.4",
"ws": "^7.3.0"
}
"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"
}
}

View file

@ -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);

View file

@ -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);
};

View file

@ -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;

View file

@ -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() {
@ -26,213 +26,227 @@ 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<string> =>
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,35 +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) => {
console.log(`${filePath}:${event} 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;

View file

@ -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);
});
};

View file

@ -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;
}
}

View file

@ -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 || [],
});

View file

@ -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;

View file

@ -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,38 +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 (
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 };
}

View file

@ -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";

View file

@ -4,278 +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 HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
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;
export interface SetInnerHtmlAttributes {
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;
}
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;
};
}
}

View file

@ -3,80 +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 } = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
};
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, "&quot;");
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);
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;
}
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") {
normalizedAttrName = "class";
}
if (attrValue === true) {
output += ` ${normalizedAttrName}=""`;
} else {
output += ` ${normalizedAttrName}="${escapeAttr(
attrValue.toString()
)}"`;
}
}
// Ignore the special attr for setting raw inner HTML.
if (attrName === "dangerouslySetInnerHTML") {
continue;
}
output += ">";
return output;
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") {
normalizedAttrName = "class";
}
if (attrValue === true) {
output += ` ${normalizedAttrName}=""`;
} else {
output += ` ${normalizedAttrName}="${escapeAttr(attrValue.toString())}"`;
}
}
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)}>`;
};
/**
@ -88,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;
};

View file

@ -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
View file

@ -0,0 +1,3 @@
export const isErrnoException = (
error: Error,
): error is NodeJS.ErrnoException => "code" in error;

View file

@ -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;
};

View file

@ -3,145 +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;
});
});
});

View file

@ -3,153 +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");
});
});
});

View file

@ -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";

View file

@ -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);
}

View file

@ -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;
}
})();
};

View file

@ -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);
};

View file

@ -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;
}
});
}

View file

@ -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;
}
});
}

View file

@ -3,118 +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&gt;&lt;/div></div&gt;&lt;/div></html>"
);
});
test("escapes HTML in tag names", () => {
const html = renderPage(<html>{createElement("div></div", null)}</html>);
expect(html).toEqual(
"<!DOCTYPE html><html><div&gt;&lt;/div></div&gt;&lt;/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("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',
);
});
});

View file

@ -1,7 +1,7 @@
{
"extends": "../tsconfig-base.json",
"compilerOptions": {
"jsx": "react",
"jsxFactory": "createElement"
}
"extends": "../tsconfig-base.json",
"compilerOptions": {
"jsx": "react",
"jsxFactory": "createElement"
}
}

View file

@ -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
}
}

View file

@ -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"]
}