Compare commits

..

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

51 changed files with 4517 additions and 3024 deletions

View file

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

1
.eslintignore Normal file
View file

@ -0,0 +1 @@
node_modules

23
.eslintrc Normal file
View file

@ -0,0 +1,23 @@
{
"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"
}
}

View file

@ -1,38 +0,0 @@
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
View file

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

1
.gitignore vendored
View file

@ -1,4 +1,3 @@
/dist
/.temp
public/
node_modules/

2
.nvmrc
View file

@ -1 +1 @@
18
lts/erbium

9
.travis.yml Normal file
View file

@ -0,0 +1,9 @@
language: node_js
node_js:
- node
- lts/*
- 14
- 13
- 12
- 11
- 10

View file

@ -1,34 +1,5 @@
# Changelog
## [0.2.0](https://github.com/websnacksjs/websnacks/releases/tag/v0.2.0) (2021-02-28)
### Features
- add dangerouslySetInnerHTML attr ([#15](https://github.com/websnacksjs/websnacks/issues/15), [3f356dd](https://github.com/websnacksjs/websnacks/commit/3f356ddfeeb38e8a60c32d26c3e9e8715d0246c3))
### Misc
- **BREAKING CHANGE** update node-watch optional dep to major v0.7.1
## [0.1.5](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.5) (2020-06-14)
### Bugfixes
- stack size exceed error on purging module cache ([32eee9b](https://github.com/websnacksjs/websnacks/commit/32eee9b2e04475452905e3478f0fa2a21ad3ccf4))
## [0.1.4](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.4) (2020-06-10)
### Bugfixes
- dev cmd didn't watch files due to import mangling ([4e44d83](https://github.com/websnacksjs/websnacks/commit/4e44d8369451e19af616a8c03c2ff7f4065b3f50))
- dont require config file ([5520bb3](https://github.com/websnacksjs/websnacks/commit/5520bb3571189726df73a2945d9a6e7f5671e7ff))
## [0.1.3](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.3) (2020-06-04)
### Features
- provide Fragment component ([#9](https://github.com/websnacksjs/websnacks/issues/9), [f1aca35](https://github.com/websnacksjs/websnacks/commit/f1aca350ed7e63e277fae7f9cc01039a29442bcb))
## [0.1.2](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.2) (2020-06-03)
### Bugfixes

View file

@ -1,9 +1,20 @@
# websnacks: Minimal Dependency Server-Side JSX for Static Sites
[![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)
<div>
[![NPM release](https://img.shields.io/npm/v/@websnacksjs/websnacks)](https://www.npmjs.com/package/@websnacksjs/websnacks "NPM release")
[![NPM](https://img.shields.io/npm/l/@websnacksjs/websnacks)](https://www.mozilla.org/en-US/MPL/2.0/FAQ/ "License info")
[![Build status](https://img.shields.io/travis/websnacksjs/websnacks?style=flat-square)](https://travis-ci.org/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>
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.

View file

@ -1,41 +0,0 @@
{
"$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,9 +1,5 @@
import { stylesheet } from "typestyle";
import {
type Component,
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
createElement,
} from "websnacks";
import { Component, createElement } from "websnacks";
const styles = stylesheet({
header: {

View file

@ -1,10 +1,6 @@
import { normalize } from "csstips";
import { stylesheet } from "typestyle";
import {
type Component,
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
createElement,
} from "websnacks";
import { Component, createElement } from "websnacks";
import { stylesheetPath } from "../config";
import { Header } from "./header";
@ -50,7 +46,10 @@ export const Layout: Component<LayoutProps> = ({ children, headline }) => (
{headline && ` | ${headline}`}
</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<link rel="stylesheet" href={stylesheetPath} />
</head>

View file

@ -1,9 +1,5 @@
import { stylesheet } from "typestyle";
import {
type Component,
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
createElement,
} from "websnacks";
import { Component, createElement } from "websnacks";
const styles = stylesheet({
navbar: {

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
{
"name": "websnacks-example-personal-site",
"type": "module",
"scripts": {
"build": "websnacks -r ts-node/register build",
"dev": "websnacks -r ts-node/register dev"

View file

@ -1,25 +1,22 @@
import {
type Component,
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
createElement,
} from "websnacks";
import { Component, 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.{" "}
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,9 +1,5 @@
import { stylesheet } from "typestyle";
import {
type Component,
// biome-ignore lint/correctness/noUnusedImports: required to support JSX
createElement,
} from "websnacks";
import { Component, createElement } from "websnacks";
import { Layout } from "../components/layout";

View file

@ -1,10 +1,10 @@
import { promises as fs } from "node:fs";
import * as path from "node:path";
import type { Config } from "websnacks";
import { promises as fs } from "fs";
import * as path from "path";
import { Config } from "websnacks";
import { stylesheetPath } from "./config";
const _config: Config = {
const config: Config = {
// Watch additional files and folders for changes when the dev server is
// running.
watch: ["components/", "config.ts"],
@ -23,3 +23,4 @@ const _config: Config = {
},
},
};
export = config;

2009
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,14 @@
{
"name": "@websnacksjs/websnacks",
"description": "Minimal dependency server-side JSX for static sites",
"version": "0.2.0",
"version": "0.1.2",
"author": {
"name": "M. George Hansen",
"email": "mgeorge@technopolitica.com"
},
"license": "MPL-2.0",
"repository": "github:websnacksjs/websnacks",
"engines": {
"node": ">=18"
"node": ">=10"
},
"main": "dist/index.js",
"types": "types.d.ts",
@ -24,26 +23,29 @@
],
"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"
"test": "ts-node --project=test/tsconfig.json test/run-tests.ts"
},
"devDependencies": {
"@biomejs/biome": "2.4.14",
"@types/node": "~18",
"@types/ws": "^8.18.1",
"ts-node": "^10.9.2",
"typescript": "~4.9.5"
"@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.7.4",
"ws": "^8.20.0"
"node-watch": "^0.6.4",
"ws": "^7.3.0"
}
}

View file

@ -3,12 +3,11 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import * as fs from "fs";
import * as path from "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)) {
@ -28,4 +27,3 @@ const rmdirRecursive = (dirPath: string): void => {
};
rmdirRecursive(DIST_DIR);
rmdirRecursive(TEST_DIR);

View file

@ -3,12 +3,12 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import { promises as fs } from "fs";
import * as path from "path";
import type { Config, ConfigPaths } from "./config";
import { Config, ConfigPaths } from "./config";
import { renderPage } from "./render";
import { decacheModule, walkDir } from "./utils";
import { purgeModuleAndDepsFromCache, walkDir } from "./utils";
const renderPagesToHtml = async ({
pagesDir,
@ -22,19 +22,21 @@ const renderPagesToHtml = async ({
}
// Ensure that we don't cache page modules when running in dev server.
decacheModule(srcPath);
purgeModuleAndDepsFromCache(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`,
`page source at ${srcPath} does not export a "page" variable`
);
}
let compiledHtml: string;
let compiledHtml;
try {
compiledHtml = renderPage(pageSrc.page());
} catch (error) {
throw new Error(`failed to compile ${srcPath}: ${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");
@ -46,7 +48,7 @@ const renderPagesToHtml = async ({
(async () => {
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.writeFile(destPath, compiledHtml);
})(),
})()
);
}
await Promise.all(deferred);
@ -58,7 +60,7 @@ const copyStaticAssets = async ({
}: ConfigPaths): Promise<void> => {
try {
await fs.access(staticAssetsDir);
} catch (_error) {
} catch (error) {
// Static assets folder doesn't exist, so no-op.
return;
}
@ -71,7 +73,7 @@ const copyStaticAssets = async ({
(async () => {
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(assetPath, destPath);
})(),
})()
);
}
await Promise.all(deferred);

View file

@ -5,7 +5,7 @@
import { renderSite } from "../../build";
import { loadConfig } from "../../config";
import { type Command, UsageError } from "../types";
import { Command, UsageError } from "../types";
const helpText = `\
Usage: websnacks build [ROOT_DIR]

View file

@ -3,34 +3,33 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { existsSync, promises as fs, watch } from "node:fs";
import * as http from "node:http";
import * as path from "node:path";
import { existsSync, promises as fs, watch } from "fs";
import * as http from "http";
import * as path from "path";
import { renderSite } from "../../build";
import { type Config, loadConfig } from "../../config";
import { isErrnoException } from "../../utils/error";
import { type Command, UsageError } from "../types";
import { Config, loadConfig } from "../../config";
import { Command, UsageError } from "../types";
const DEFAULT_SERVER_PORT = 8080;
const SERVER_PORT = 8080;
const injectLiveReloadScript = (htmlContents: string, port: number): string =>
const injectLiveReloadScript = (htmlContents: string): string =>
htmlContents.replace(
"</html>",
`
<script>
const ws = new WebSocket("ws://127.0.0.1:${port}");
const ws = new WebSocket("ws://127.0.0.1:${SERVER_PORT}");
ws.onmessage = function() {
console.log('dev server requested reload, reloading...');
location.reload();
};
</script>
</html>
`,
`
);
const guessMimeType = (ext: string): string => {
let mimeType: string;
let mimeType;
switch (ext) {
case ".apng":
mimeType = "image/apng";
@ -119,23 +118,8 @@ const guessMimeType = (ext: string): string => {
return mimeType;
};
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) => {
const serve = (publicDir: string): http.Server => {
const server = http.createServer(async (req, res) => {
if (req.url == null) {
res.writeHead(404);
res.end();
@ -149,10 +133,10 @@ const startHttpServer = async (publicDir: string): Promise<http.Server> => {
reqExt = ".html";
}
let contents: Buffer | string;
let contents;
try {
contents = await fs.readFile(path.join(publicDir, reqPath));
} catch (_error) {
} catch (error) {
console.error(`unable to load file ${reqPath}`);
res.writeHead(404);
res.end();
@ -160,57 +144,31 @@ const startHttpServer = async (publicDir: string): Promise<http.Server> => {
}
const mimeType = guessMimeType(reqExt);
if (mimeType === "text/html") {
const port = portFromServer(req.socket.address());
contents = injectLiveReloadScript(contents.toString("utf8"), port);
contents = injectLiveReloadScript(contents.toString("utf8"));
}
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;
return server;
};
const startWebSocketServer = async (
httpServer: http.Server,
server: http.Server
): Promise<import("ws").Server | undefined> => {
// Attempt to load the ws module, aborting if it isn't available.
let ws: typeof import("ws");
let ws;
try {
ws = await import("ws");
} catch (error) {
if (
error instanceof Error &&
isErrnoException(error) &&
error.code !== "MODULE_NOT_FOUND"
) {
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 });
const wsServer = new ws.Server({ server });
wsServer.on("connection", () => {
console.log("connected to dev site");
});
@ -219,32 +177,28 @@ const startWebSocketServer = async (
const watchFolders = async (
folders: string[],
listener: (eventType?: "update" | "remove", fileName?: string) => void,
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);
const { default: watch } = await import("node-watch");
watch(folders, { recursive: true }, listener);
return;
} catch (error) {
if (
error instanceof Error &&
isErrnoException(error) &&
error.code !== "MODULE_NOT_FOUND"
) {
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)",
`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);
listener("update", fileName);
});
}
};
@ -290,18 +244,19 @@ const devCommand: Command = {
};
const config = await rebuild();
const { outDir } = config.paths;
const httpServer = await startHttpServer(outDir);
const httpServer = serve(outDir);
const wsServer = await startWebSocketServer(httpServer);
httpServer.listen(SERVER_PORT, () => {
console.log(`Listening at http://127.0.0.1:${SERVER_PORT}`);
});
const watchedFolders = config.watch.filter((filePath) =>
existsSync(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...`);
watchFolders(watchedFolders, async (event, filePath) => {
console.log(`${filePath}:${event} triggering rebuild...`);
await rebuild();
if (wsServer != null) {
console.log("rebuild finished, reloading browsers...");
console.log(`rebuild finished, reloading browsers...`);
for (const ws of wsServer.clients) {
ws.send("reload");
}

View file

@ -3,7 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { type Command, UsageError } from "./types";
import { Command, UsageError } from "./types";
const globalHelpText = `\
Usage: websnacks [...globalOptions] <command>
@ -24,7 +24,7 @@ interface Options {
}
const parseArgs = (
args: string[],
args: string[]
): { options: Options; commandName?: string; commandArgs: string[] } => {
const options: Options = {
showHelp: false,
@ -45,8 +45,8 @@ const parseArgs = (
const moduleName = args.shift();
if (moduleName == null) {
throw new UsageError(
"-r requires a valid module name",
globalHelpText,
`-r requires a valid module name`,
globalHelpText
);
}
options.require.push(moduleName);
@ -67,7 +67,7 @@ const _main = async (args: string[]): Promise<void> => {
return;
}
if (commandName == null) {
throw new UsageError("must specify a valid command", globalHelpText);
throw new UsageError(`must specify a valid command`, globalHelpText);
}
for (const moduleName of options.require) {
await import(moduleName);
@ -82,7 +82,10 @@ const _main = async (args: string[]): Promise<void> => {
command = await import("./commands/dev");
break;
default:
throw new UsageError(`unknown command ${commandName}`, globalHelpText);
throw new UsageError(
`unknown command ${commandName}`,
globalHelpText
);
}
// NOTE: Should this just delegate to the command?
for (const arg of commandArgs) {

View file

@ -37,16 +37,10 @@ export type Element =
/**
* Custom HTMLElement factory that can be parameterized by props.
*/
export interface Component<P extends object = Record<string, unknown>> {
export interface Component<P extends object = {}> {
(
props: P & {
children?: Element[];
},
}
): HTMLElement;
}
export const Fragment: Component = ({ children }) => ({
tag: "#fragment",
attributes: {},
children: children || [],
});

View file

@ -3,9 +3,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import * as path from "node:path";
import * as path from "path";
import { decacheModule } from "./utils";
import { purgeModuleAndDepsFromCache } from "./utils";
/**
* Paths used during configuration.
@ -58,31 +58,13 @@ 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);
const configPath = require.resolve(path.resolve(rootDir, "websnacks"));
purgeModuleAndDepsFromCache(configPath);
// TODO: validate user config.
userConfig = await import(configPath);
} catch (_error) {
// Use default config;
}
const userConfig = await import(configPath);
const outDir = path.join(rootDir, "public");
const pagesDir = path.join(rootDir, "pages");
const staticAssetsDir = path.join(rootDir, "static");
const watch = [pagesDir, staticAssetsDir];
if (configPath) {
watch.push(path.relative(rootDir, configPath));
}
if (userConfig.watch != null) {
for (const userWatch of userConfig.watch) {
watch.push(path.relative(rootDir, userWatch));
}
}
return {
paths: {
rootDir,
@ -94,8 +76,11 @@ export const loadConfig = async (rootDir: string): Promise<Config> => {
afterSiteRender: noop,
...userConfig.hooks,
},
watch,
watch: [
...userConfig.watch.map((p: string) => path.relative(rootDir, p)),
path.relative(rootDir, configPath),
pagesDir,
staticAssetsDir,
],
};
};
export const defineConfig = (userConfig: UserConfig): UserConfig => userConfig;

View file

@ -3,8 +3,8 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { Component, Element, HTMLElement } from "./component";
import type { HTMLAttributes } from "./jsx";
import { Component, Element, HTMLElement } from "./component";
import { HTMLAttributes } from "./jsx";
import { flatDeep } from "./utils";
/**
@ -38,7 +38,8 @@ export function createElement(
...children: Element[]
): HTMLElement;
export function createElement(
type: string | Component,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type: string | Component<any>,
props: object | null,
...children: Element[]
): HTMLElement {
@ -53,21 +54,14 @@ export function createElement(
}
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)}`);
console.warn(
`non-primitive attribute ${key} = ${JSON.stringify(value)}`
);
continue;
}
attrs[key] = value;

View file

@ -3,7 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
export { Component, Fragment, HTMLElement } from "./component";
export { defineConfig, UserConfig as Config } from "./config";
export { HTMLElement, Component } from "./component";
export { UserConfig as Config } from "./config";
export { createElement } from "./create-element";
export * from "./jsx";

View file

@ -4,7 +4,7 @@
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { HTMLElement } from "./component";
import { HTMLElement } from "./component";
export interface RdfaAttributes {
about?: string;
@ -25,14 +25,7 @@ export interface MicrodataAttributes {
itemRef?: string;
}
export interface SetInnerHtmlAttributes {
dangerouslySetInnerHTML?: { __html: string };
}
export interface HTMLAttributes
extends RdfaAttributes,
MicrodataAttributes,
SetInnerHtmlAttributes {
export interface HTMLAttributes extends RdfaAttributes, MicrodataAttributes {
accept?: string;
acceptCharset?: string;
accessKey?: string;

View file

@ -3,7 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import type { Element, HTMLElement } from "./component";
import { Element, HTMLElement } from "./component";
const HTML_ESCAPES: { [char: string]: string } = {
"&": "&amp;",
@ -34,22 +34,14 @@ const renderElement = (elem: Element): string => {
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 "";
}
let output = `<${escapeHtml(elem.tag)}`;
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
@ -59,11 +51,6 @@ const startTag = (elem: HTMLElement): string => {
continue;
}
// Ignore the special attr for setting raw inner HTML.
if (attrName === "dangerouslySetInnerHTML") {
continue;
}
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") {
normalizedAttrName = "class";
@ -71,7 +58,9 @@ const startTag = (elem: HTMLElement): string => {
if (attrValue === true) {
output += ` ${normalizedAttrName}=""`;
} else {
output += ` ${normalizedAttrName}="${escapeAttr(attrValue.toString())}"`;
output += ` ${normalizedAttrName}="${escapeAttr(
attrValue.toString()
)}"`;
}
}
@ -79,12 +68,7 @@ const startTag = (elem: HTMLElement): string => {
return output;
};
const endTag = (elem: HTMLElement): string => {
if (elem.tag === "#fragment") {
return "";
}
return `</${escapeHtml(elem.tag)}>`;
};
const endTag = (elem: HTMLElement): string => `</${escapeHtml(elem.tag)}>`;
/**
* Render a complete HTML page from an HTMLElement. Note that the root element
@ -94,20 +78,10 @@ const endTag = (elem: HTMLElement): string => {
*
* @return Fully rendered HTML document as a string.
*/
export const renderPage = (rootElem: Element): string => {
if (rootElem == null) {
throw new Error("root page element cannot be null");
}
if (typeof rootElem !== "object" || !("tag" in rootElem)) {
throw new Error(
`root page element must be a valid HTMLElement, got ${JSON.stringify(
rootElem,
)}`,
);
}
export const renderPage = (rootElem: HTMLElement): string => {
if (rootElem.tag.toLowerCase() !== "html") {
throw new Error(
`attempted to render page with non-HTML root element ${rootElem.tag}`,
`attempted to render page with non-HTML root element ${rootElem.tag}`
);
}

71
src/utils.ts Normal file
View file

@ -0,0 +1,71 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { promises as fs } from "fs";
import * as path from "path";
/**
* Recursively walk a directory, returning the files it finds.
*
* @param dirPath Path to the directory to walk.
*
* @return Generator that yields the files found while walking the directory.
*/
export const walkDir = async function* (
dirPath: string
): AsyncGenerator<string> {
const dirEnts = await fs.readdir(dirPath, { withFileTypes: true });
for (const dirEnt of dirEnts) {
if (dirEnt.isDirectory()) {
yield* walkDir(path.join(dirPath, dirEnt.name));
}
if (dirEnt.isFile()) {
yield path.join(dirPath, dirEnt.name);
}
}
};
/**
* Purge cached versions of a node module and all of its dependencies from the
* global require cache, ensuring that future imports reload the module from
* disk.
*
* @param modName Name of the module to purge from the require cache.
*/
export const purgeModuleAndDepsFromCache = (modName: string): void => {
const modPath = require.resolve(modName);
if (modPath == null) {
return;
}
const mod = require.cache[modPath];
if (mod == null) {
return;
}
for (const child of mod.children) {
purgeModuleAndDepsFromCache(child.id);
}
delete require.cache[modPath];
};
export type Flattenable<T> = Array<T | Flattenable<T>>;
/**
* Flatten an arbitrarily-deeply nested array into a flat array.
*
* @param arr Array to flatten.
*
* @return Flattened array.
*/
export const flatDeep = <T>(arr: Flattenable<T>): T[] => {
const flattenedArr: T[] = [];
for (const val of arr) {
if (Array.isArray(val)) {
flattenedArr.push(...flatDeep(val));
} else {
flattenedArr.push(val);
}
}
return flattenedArr;
};

View file

@ -1,75 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { isErrnoException } from "./error";
const resolveModulePath = (importPath: string): string | undefined => {
try {
return require.resolve(importPath);
} catch (error) {
if (
error instanceof Error &&
isErrnoException(error) &&
error.code === "MODULE_NOT_FOUND"
) {
return;
}
throw error;
}
};
const removeParentModuleRef = (mod: NodeModule): void => {
const parent = mod.parent;
if (parent == null) {
return;
}
const siblings = parent.children;
const nSiblings = siblings.length;
for (let i = nSiblings - 1; i >= 0; i--) {
const sibling = siblings[i];
if (sibling.id === mod.id) {
siblings.splice(i, 1);
return;
}
}
};
/**
* Clear a module and its dependencies from node's module cache, ensuring that
* requiring the module again will reload the code from disk.
*
* @param importPath Path or name of the module to resolve (same as
* {@see require}).
*
* @throws Error if the module could not be resolved due to filesystem error.
*/
export const decacheModule = (importPath: string): void => {
const modulePath = resolveModulePath(importPath);
if (modulePath == null) {
return;
}
// DFS the module dependency tree, using iteration to avoid stack size
// exceeded exceptions.
const modsToCheck: NodeModule[] = [];
const visited: Set<string> = new Set();
let currentMod: NodeModule | undefined = require.cache[modulePath];
while (currentMod != null) {
if (visited.has(currentMod.id)) {
currentMod = modsToCheck.pop();
continue;
}
removeParentModuleRef(currentMod);
delete require.cache[currentMod.id];
for (const childMod of currentMod.children) {
modsToCheck.push(childMod);
}
visited.add(currentMod.id);
currentMod = modsToCheck.pop();
}
};

View file

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

View file

@ -1,51 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
export { decacheModule } from "./decache-module";
/**
* Recursively walk a directory, returning the files it finds.
*
* @param dirPath Path to the directory to walk.
*
* @return Generator that yields the files found while walking the directory.
*/
export const walkDir = async function* (
dirPath: string,
): AsyncGenerator<string> {
const dirEnts = await fs.readdir(dirPath, { withFileTypes: true });
for (const dirEnt of dirEnts) {
if (dirEnt.isDirectory()) {
yield* walkDir(path.join(dirPath, dirEnt.name));
}
if (dirEnt.isFile()) {
yield path.join(dirPath, dirEnt.name);
}
}
};
export type Flattenable<T> = Array<T | Flattenable<T>>;
/**
* Flatten an arbitrarily-deeply nested array into a flat array.
*
* @param arr Array to flatten.
*
* @return Flattened array.
*/
export const flatDeep = <T>(arr: Flattenable<T>): T[] => {
const flattenedArr: T[] = [];
for (const val of arr) {
if (Array.isArray(val)) {
flattenedArr.push(...flatDeep(val));
} else {
flattenedArr.push(val);
}
}
return flattenedArr;
};

View file

@ -1,151 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import {
npmCmd,
runCommand,
WEBSNACKS_BIN_PATH,
WEBSNACKS_REPO_ROOT,
withTempDir,
} from "../helpers/e2e";
import { testSuite } from "../lib";
testSuite("build command", ({ test }) => {
test("runs without throwing error", async () => {
await withTempDir(async (tempDirPath) => {
await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
esModuleInterop: true,
module: "CommonJS",
moduleResolution: "node",
jsx: "react",
jsxFactory: "createElement",
target: "ES2018",
lib: ["ES2018"],
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noImplicitReturns: true,
noFallthroughCasesInSwitch: true,
},
include: ["components/**/*", "pages/**/*"],
}),
{
encoding: "utf8",
},
);
await fs.writeFile(
path.join(tempDirPath, "websnacks.ts"),
`
import { Config } from "websnacks";
const config: Config = {
watch: [],
};
export = config;
`,
{
encoding: "utf8",
},
);
const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath);
await fs.writeFile(
path.join(pagesPath, "index.tsx"),
`
import { createElement } from "websnacks";
export const page = () => <html />;
`,
{
encoding: "utf8",
},
);
await fs.writeFile(
path.join(tempDirPath, "package.json"),
JSON.stringify({
devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
},
}),
{ encoding: "utf8" },
);
await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath,
}).complete;
const cmd = runCommand(
"node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"],
{
cwd: tempDirPath,
},
);
await cmd.complete;
});
});
test("works without config file", async () => {
await withTempDir(async (tempDirPath) => {
await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
esModuleInterop: true,
module: "CommonJS",
moduleResolution: "node",
jsx: "react",
jsxFactory: "createElement",
target: "ES2018",
lib: ["ES2018"],
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noImplicitReturns: true,
noFallthroughCasesInSwitch: true,
},
include: ["components/**/*", "pages/**/*"],
}),
{
encoding: "utf8",
},
);
const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath);
await fs.writeFile(
path.join(pagesPath, "index.tsx"),
`
import { createElement } from "websnacks";
export const page = () => <html />;
`,
{
encoding: "utf8",
},
);
await fs.writeFile(
path.join(tempDirPath, "package.json"),
JSON.stringify({
devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
},
}),
{ encoding: "utf8" },
);
await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath,
}).complete;
const cmd = runCommand(
"node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"],
{
cwd: tempDirPath,
},
);
await cmd.complete;
});
});
});

View file

@ -1,160 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { promises as fs } from "node:fs";
import * as path from "node:path";
import {
npmCmd,
runCommand,
WEBSNACKS_BIN_PATH,
WEBSNACKS_REPO_ROOT,
wait,
withTempDir,
} from "../helpers/e2e";
import { testSuite } from "../lib";
testSuite("dev command", ({ test, expect }) => {
test("starts without throwing error", async () => {
await withTempDir(async (tempDirPath) => {
await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
esModuleInterop: true,
module: "CommonJS",
moduleResolution: "node",
jsx: "react",
jsxFactory: "createElement",
target: "ES2018",
lib: ["ES2018"],
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noImplicitReturns: true,
noFallthroughCasesInSwitch: true,
},
include: ["components/**/*", "pages/**/*"],
}),
{
encoding: "utf8",
},
);
await fs.writeFile(
path.join(tempDirPath, "websnacks.ts"),
`
import { Config } from "websnacks";
const config: Config = {
watch: [],
};
export = config;
`,
{
encoding: "utf8",
},
);
const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath);
await fs.writeFile(
path.join(pagesPath, "index.tsx"),
`
import { createElement } from "websnacks";
export const page = () => <html />;
`,
{
encoding: "utf8",
},
);
await fs.writeFile(
path.join(tempDirPath, "package.json"),
JSON.stringify({
devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
},
}),
{ encoding: "utf8" },
);
await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath,
}).complete;
const cmd = runCommand(
"node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"],
{
cwd: tempDirPath,
},
);
// FIXME: This test is a bit brittle due to relying on timeouts.
await wait(10_000);
cmd.process.kill();
const stdout = await cmd.complete;
expect(stdout).toStartWith("Listening at");
});
});
test("works without config file", async () => {
await withTempDir(async (tempDirPath) => {
await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({
compilerOptions: {
esModuleInterop: true,
module: "CommonJS",
moduleResolution: "node",
jsx: "react",
jsxFactory: "createElement",
target: "ES2018",
lib: ["ES2018"],
strict: true,
noUnusedLocals: true,
noUnusedParameters: true,
noImplicitReturns: true,
noFallthroughCasesInSwitch: true,
},
include: ["components/**/*", "pages/**/*"],
}),
{
encoding: "utf8",
},
);
const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath);
await fs.writeFile(
path.join(pagesPath, "index.tsx"),
`
import { createElement } from "websnacks";
export const page = () => <html />;
`,
{
encoding: "utf8",
},
);
await fs.writeFile(
path.join(tempDirPath, "package.json"),
JSON.stringify({
devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
},
}),
{ encoding: "utf8" },
);
await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath,
}).complete;
const cmd = runCommand(
"node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"],
{
cwd: tempDirPath,
},
);
// FIXME: This test is a bit brittle due to relying on timeouts.
await wait(10_000);
cmd.process.kill();
const stdout = await cmd.complete;
expect(stdout).toStartWith("Listening at");
});
});
});

View file

@ -1,168 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { type ChildProcess, spawn } from "node:child_process";
import { promises as fs } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
/**
* Set a timeout and wait for at least the specified number of milliseconds,
* resolving the promise once the event loop meets or exceeds timeMs.
*
* @param timeMs Time in milliseconds to wait.
*/
export const wait = async (timeMs: number): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => resolve(), timeMs);
});
};
const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp");
/**
* Perform an operation within a unique temporary directory created within a
* special .test-dist folder within this websnacks repository.
*
* @note Currently the temporary folder is **not** cleaned up once the operation
* has finished. I've had issues with losing work due to buggy removal
* code and haven't been willing to risk it again. To cleanup these
* temporary folders it should be as easy as removing the whole
* ".test-dist" folder from your checkout.
*
* @param op Operation to perform which receives the fully resolved temp
* directory path as its only argument.
*/
export const withTempDir = async (
op: (tempDirPath: string) => Promise<void> | void,
): Promise<void> => {
await fs.mkdir(TEMP_PATH, { recursive: true });
const tempDirPath = await fs.mkdtemp(`${TEMP_PATH}/`);
try {
await op(tempDirPath);
} catch (error) {
throw new Error(`(${tempDirPath}): ${error}`);
}
};
/**
* Fully resolved path to the root of this websnacks repository.
*/
export const WEBSNACKS_REPO_ROOT = path.resolve(__dirname, "..", "..");
/**
* Fully resolved path to the websnacks CLI script in this repository.
*/
export const WEBSNACKS_BIN_PATH = path.join(
WEBSNACKS_REPO_ROOT,
"bin",
"websnacks.js",
);
/**
* A handle to an asynchronous shell command run in a subprocess.
*/
export interface AsyncCommand {
/**
* Promise that resolves with the stdout of the subprocess once the
* subprocess exits with a zero-code.
*
* The promise rejects if the subprocess exits with a non-zero code, the
* subprocess writes to its stderr, or the command failed to spawn.
*/
complete: Promise<string>;
/**
* Handle to to child process for event-based process manipulation.
*/
process: ChildProcess;
}
/**
* Options used to configure {@link runCommand}.
*/
export interface CliOptions {
/**
* Working directory where the command should be run. Defaults to the
* current working directory.
*/
cwd?: string;
/**
* Timeout in milliseconds after which a command that hasn't exited will
* reject the promise and kill the subprocess.
*/
timeoutMs?: number;
}
const DEFAULT_CLI_OPTIONS = {
timeoutMs: 15_000,
};
/**
* Execute a shell command in a subprocess.
*
* This provides a more user-friendly promise-based interface to
* {@link child_process.spawn}. The obj
*
* @param command Name of the shell command to run.
* @param args Array of arguments to pass to the command.
* @param options Parameters to change how the command is run and resolved.
*
* @returns Command object for handling in client code.
*/
export const runCommand = (
command: string,
args: string[] = [],
options?: CliOptions,
): AsyncCommand => {
const optionsWithDefaults = { ...DEFAULT_CLI_OPTIONS, ...options };
const process = spawn(command, args, {
...optionsWithDefaults,
stdio: "pipe",
});
const complete = new Promise<string>((resolve, reject) => {
let threwError = false;
let stdout = "";
process.stdout.on("data", (data) => {
stdout += data.toString();
});
process.stderr.on("data", (data) => {
threwError = true;
process.kill();
reject(new Error(`command output to stderr: ${data.toString()}`));
});
const timer = setTimeout(() => {
threwError = true;
process.kill();
reject(
new Error(`max timeout of ${optionsWithDefaults.timeoutMs}ms reached`),
);
}, optionsWithDefaults.timeoutMs);
process.on("exit", (code) => {
if (threwError) {
return;
}
clearTimeout(timer);
if (code !== null && code !== 0) {
reject(new Error(`command exited with non-zero code: ${code}`));
return;
}
resolve(stdout);
});
process.on("error", (error) => {
clearTimeout(timer);
if (!threwError) {
reject(new Error(`command errored: ${error}`));
threwError = true;
}
});
});
return {
complete,
process,
};
};
export const npmCmd = os.platform() === "win32" ? "npm.cmd" : "npm";

View file

@ -10,7 +10,7 @@ class ExpectError extends Error {
super(
`${reason}\n` +
`\texpected: ${displayValue(expected)}\n` +
`\tactual : ${displayValue(actual)}`,
`\tactual : ${displayValue(actual)}`
);
}
}
@ -48,9 +48,9 @@ export class Expect<T> {
public toEqual(expected: T): void {
if (!areEqual(this.value, expected)) {
throw new ExpectError(
"value does not equal expected",
`value does not equal expected`,
expected,
this.value,
this.value
);
}
}
@ -71,9 +71,9 @@ export class StringExpect extends Expect<string> {
public toMatch(pattern: RegExp): void {
if (!this.value.match(pattern)) {
throw new ExpectError(
"value does not match expected pattern",
`value does not match expected pattern`,
pattern,
this.value,
this.value
);
}
}
@ -89,9 +89,9 @@ export class StringExpect extends Expect<string> {
public toStartWith(prefix: string): void {
if (!this.value.startsWith(prefix)) {
throw new ExpectError(
"value does not start with expected prefix",
`value does not start with expected prefix`,
prefix,
this.value,
this.value
);
}
}
@ -107,9 +107,9 @@ export class StringExpect extends Expect<string> {
public toEndWith(suffix: string): void {
if (!this.value.endsWith(suffix)) {
throw new ExpectError(
"value does not end with expected suffix",
`value does not end with expected suffix`,
suffix,
this.value,
this.value
);
}
}
@ -135,21 +135,25 @@ export class FunctionExpect<T> extends Expect<() => T> {
this.value();
} catch (error) {
if (!(error instanceof Error)) {
throw new ExpectError("function threw non-Error value", pattern, 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,
error.message
);
}
return;
}
throw new ExpectError(
"function did not throw expected error",
`function did not throw expected error`,
pattern,
null,
null
);
}
}

View file

@ -39,7 +39,9 @@ const runTest = async (test: Test): Promise<TestResult> => {
error:
error instanceof Error
? error
: new Error(`threw non-error object: ${displayValue(error)}`),
: new Error(
`threw non-error object: ${displayValue(error)}`
),
};
}
return result;
@ -84,7 +86,7 @@ export interface TestSuiteContext {
*/
export const testSuite = (
suiteName: string,
def: (ctx: TestSuiteContext) => void,
def: (ctx: TestSuiteContext) => void
): void => {
const tests: Test[] = [];
const test = (name: string, runTest: () => void | Promise<void>): void => {
@ -102,14 +104,14 @@ export const testSuite = (
if (testResult.result === "fail") {
console.error(
`[TEST FAILURE] "${suiteName}": "${testResult.testName}": ` +
`${testResult.error.stack}\n`,
`${testResult.error.stack}\n`
);
continue;
}
passed += 1;
}
console.info(
`[TEST] suite "${suiteName}": ${passed} of ${tests.length} succeeded\n\n`,
`[TEST] suite "${suiteName}": ${passed} of ${tests.length} succeeded\n\n`
);
if (passed < tests.length) {
process.exitCode = 1;

View file

@ -20,7 +20,7 @@ export const shuffle = <T>(arr: T[]): void => {
};
const areArraysEqual = <T>(a: T[], b: T[]): boolean => {
if (a.length !== b.length) {
if (a.length != b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
@ -33,7 +33,7 @@ const areArraysEqual = <T>(a: T[], b: T[]): boolean => {
const areObjectsEqual = <T extends Record<string, unknown>>(
a: T,
b: T,
b: T
): boolean => {
const aKeys = Object.keys(a) as Array<keyof T>;
const bKeys = Object.keys(b) as Array<keyof T>;
@ -67,7 +67,7 @@ export const areEqual = <T>(a: T, b: T): boolean => {
if (typeof a === "object" && typeof b === "object") {
return areObjectsEqual(
a as Record<string, unknown>,
b as Record<string, unknown>,
b as Record<string, unknown>
);
}
return a === b;

View file

@ -1,23 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { fork } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { shuffle } from "./lib/utils";
const TEST_SUITES_DIR = path.join(__dirname, "e2e");
const files = fs.readdirSync(TEST_SUITES_DIR);
// Shuffle test suites to detect ordering dependencies between them.
shuffle(files);
for (const file of files) {
const fullPath = path.join(TEST_SUITES_DIR, file);
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
if (code !== 0) {
process.exitCode = 1;
}
});
}

View file

@ -3,13 +3,14 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { fork } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { fork } from "child_process";
import * as fs from "fs";
import * as path from "path";
import { shuffle } from "./lib/utils";
const TEST_SUITES_DIR = path.join(__dirname, "test-suites");
const files = fs.readdirSync(TEST_SUITES_DIR);
// Shuffle test suites to detect ordering dependencies between them.
shuffle(files);

View file

@ -3,14 +3,14 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { type Component, createElement, Fragment } from "../../dist";
import { Component, createElement } 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",
"attempted to render page with non-HTML root element div"
);
});
@ -20,9 +20,11 @@ testSuite("renderPage", ({ test, expect }) => {
});
test("escapes HTML in tag names", () => {
const html = renderPage(<html>{createElement("div></div", null)}</html>);
const html = renderPage(
<html>{createElement("div></div", null)}</html>
);
expect(html).toEqual(
"<!DOCTYPE html><html><div&gt;&lt;/div></div&gt;&lt;/div></html>",
"<!DOCTYPE html><html><div&gt;&lt;/div></div&gt;&lt;/div></html>"
);
});
@ -30,10 +32,10 @@ testSuite("renderPage", ({ test, expect }) => {
const html = renderPage(
<html>
<div className="test" id="1" />
</html>,
</html>
);
expect(html).toEqual(
'<!DOCTYPE html><html><div class="test" id="1"></div></html>',
'<!DOCTYPE html><html><div class="test" id="1"></div></html>'
);
});
@ -46,16 +48,18 @@ testSuite("renderPage", ({ test, expect }) => {
<body>
<div />
</body>
</html>,
</html>
);
expect(html).toEqual(
"<!DOCTYPE html><html><head><title></title></head><body><div></div></body></html>",
"<!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>");
expect(html).toEqual(
"<!DOCTYPE html><html>There are three lights!</html>"
);
});
test("renders spliced number nodes", () => {
@ -76,10 +80,10 @@ testSuite("renderPage", ({ test, expect }) => {
<Light lightN={lightN} />
))}{" "}
lights!
</html>,
</html>
);
expect(html).toEqual(
"<!DOCTYPE html><html>There are <div>1</div><div>2</div><div>3</div> lights!</html>",
"<!DOCTYPE html><html>There are <div>1</div><div>2</div><div>3</div> lights!</html>"
);
});
@ -93,57 +97,10 @@ testSuite("renderPage", ({ test, expect }) => {
const html = renderPage(
<html>
There are <Light nLights={3} />!
</html>,
</html>
);
expect(html).toEqual(
"<!DOCTYPE html><html>There are <div>3 lights</div>!</html>",
);
});
test("renders fragment children only", () => {
const html = renderPage(
<html>
<Fragment>
<div>test of</div>
<div>fragments</div>
</Fragment>
</html>,
);
expect(html).toEqual(
"<!DOCTYPE html><html><div>test of</div><div>fragments</div></html>",
);
});
test("renders unescaped HTML via dangerouslySetInnerHTML", () => {
const html = renderPage(
<html>
<div
dangerouslySetInnerHTML={{
__html: "<div>red alert!</div>",
}}
/>
</html>,
);
expect(html).toEqual(
"<!DOCTYPE html><html><div><div>red alert!</div></div></html>",
);
});
test("throws error when both dangerouslySetInnerHTML and children prop present", () => {
expect(() =>
renderPage(
<html>
<div
dangerouslySetInnerHTML={{
__html: "<div>set phasers to kill</div>",
}}
>
<div>set phasers to stun</div>
</div>
</html>,
),
).toThrowErrorMatching(
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
"<!DOCTYPE html><html>There are <div>3 lights</div>!</html>"
);
});
});

View file

@ -1,5 +1,6 @@
{
"compilerOptions": {
"esModuleInterop": true,
"module": "CommonJS",
"moduleResolution": "node",
"target": "ES2018",