Compare commits

...

10 commits

Author SHA1 Message Date
5118a8174b
chore: replace eslint & prettier w/ biomejs (#21)
* chore: replace eslint & prettier w/ biomejs

* fix syntax error in ci.yml workflow

* ensure that build CI jobs only run if check job succeeds to save resources
2024-07-15 08:36:52 -07:00
73135dd4b5
chore: replace travis w/ github actions for CI (#20)
* chore: replace travis w/ github actions for CI

* chore: replace travis w/ github actions for CI

* remove node 24

* Update readme with checks badge
2024-07-15 08:10:57 -07:00
999b9b54a2
chore: update min supported node version to >=18 (#19)
Update required minimum node version to >=18 in preparation for updating other dependencies that require newer node versions.
2024-07-15 00:01:14 -07:00
a13295b223
0.2.0 2021-02-28 09:02:26 -08:00
c7907cd3fd
chore(release): update changelog for v0.2.0 2021-02-28 08:58:15 -08:00
17d304d994
chore: update ts-node & typescript dev deps
Major updates to ts-node (^9.1.1) and typescript (~4.2.2).
2021-02-28 08:45:07 -08:00
73ccc1916d
chore: update eslint dev dependencies
Major update to typescript-eslint plugins (v4.15.2) and eslint-config-prettier (v8.1.0); minor updates to other eslint components.

Doesn't seem to generate any warnings after updating so I think we're good?
2021-02-28 08:35:53 -08:00
efafec2c56
chore: update optional deps (ws & node-watch)
Updates node-watch dep to ^0.7.1 which is a breaking change in the interface of watch function for compatibility on certain windows systems that don't give event and filename info.

Also updates ws dep to v^7.4.3, minor version and bug fixes.
2021-02-28 08:26:02 -08:00
868eca4011
fix: eslint warning in dev runner 2021-02-28 08:22:44 -08:00
2bf1125b83
feat: add dangerouslySetInnerHTML attr
This adds are new attribute to Elements, "dangerouslySetInnerHTML", which like the same attribute from React allows one to take a stirng of unescaped HTML and render it unconditionally. This is of course a potentially dangerous operation that can open your app up to XSS attacks, but for interoperating with existing content management systems and libraries that output HTML (e.g. markdown renderers).

Using "dangerouslySetInnerHTML" on an element with children will generate an error within createElement, since it doesn't make sense to have both children and inner HTML.
2021-01-02 21:24:17 -08:00
48 changed files with 2675 additions and 4870 deletions

View file

@ -1,2 +0,0 @@
node_modules/
dist/

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

31
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [mainline]
pull_request:
branches: [mainline]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run check
build:
needs: check
strategy:
matrix:
os: [windows-latest, ubuntu-latest, macos-latest]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm run build
- run: npm test

2
.nvmrc
View file

@ -1 +1 @@
lts/erbium 18

View file

@ -1,2 +0,0 @@
node_modules/
dist/

View file

@ -1,7 +0,0 @@
{
"endOfLine": "lf",
"singleQuote": false,
"trailingComma": "all",
"tabWidth": 4,
"semi": true
}

View file

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

View file

@ -1,5 +1,15 @@
# Changelog # 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) ## [0.1.5](https://github.com/websnacksjs/websnacks/releases/tag/v0.1.5) (2020-06-14)
### Bugfixes ### Bugfixes

View file

@ -4,7 +4,7 @@
[![NPM release](https://img.shields.io/npm/v/@websnacksjs/websnacks?style=flat-square)](https://www.npmjs.com/package/@websnacksjs/websnacks "NPM release") [![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") [![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/mainline?style=flat-square)](https://travis-ci.com/websnacksjs/websnacks "Build status for mainline branch") [![Build status](https://img.shields.io/github/check-runs/websnacksjs/websnacks/mainline?style=flat-square)](https://github.com/websnacksjs/websnacks/actions?query=branch%3Amainline "Build status for mainline branch")
</div> </div>

36
biome.json Normal file
View file

@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
"organizeImports": {
"enabled": true,
"ignore": ["dist", "node_modules", ".temp"]
},
"formatter": {
"enabled": true,
"ignore": ["dist", "node_modules", ".temp"]
},
"linter": {
"enabled": true,
"ignore": ["dist", "node_modules", ".temp"],
"rules": {
"recommended": true,
"style": {
"useShorthandFunctionType": "off"
},
"correctness": {
"useJsxKeyInIterable": "off"
}
}
},
"overrides": [
{
"include": ["test"],
"linter": {
"rules": {
"a11y": {
"useHtmlLang": "off"
}
}
}
}
]
}

View file

@ -1,5 +1,5 @@
import { stylesheet } from "typestyle"; import { stylesheet } from "typestyle";
import { Component, createElement } from "websnacks"; import { type Component, createElement } from "websnacks";
const styles = stylesheet({ const styles = stylesheet({
header: { header: {

View file

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

View file

@ -1,5 +1,5 @@
import { stylesheet } from "typestyle"; import { stylesheet } from "typestyle";
import { Component, createElement } from "websnacks"; import { type Component, createElement } from "websnacks";
const styles = stylesheet({ const styles = stylesheet({
navbar: { navbar: {

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,22 +1,21 @@
import { Component, createElement } from "websnacks"; import { type Component, createElement } from "websnacks";
import { Layout } from "../components/layout"; import { Layout } from "../components/layout";
export const page: Component = () => ( export const page: Component = () => (
<Layout> <Layout>
<p> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur dapibus
dapibus condimentum mauris et egestas. Quisque orci nulla, consequat condimentum mauris et egestas. Quisque orci nulla, consequat at erat
at erat laoreet, malesuada sodales nisi. Sed in lorem semper lorem laoreet, malesuada sodales nisi. Sed in lorem semper lorem placerat
placerat fermentum a id arcu. Curabitur non aliquam tellus, sed fermentum a id arcu. Curabitur non aliquam tellus, sed auctor lacus. Nunc
auctor lacus. Nunc sit amet lectus ultrices, sodales nisl sit amet, sit amet lectus ultrices, sodales nisl sit amet, luctus nisl. Nunc mollis
luctus nisl. Nunc mollis imperdiet quam, eget sollicitudin leo imperdiet quam, eget sollicitudin leo tincidunt vel. Duis felis dui,
tincidunt vel. Duis felis dui, imperdiet aliquam bibendum sed, imperdiet aliquam bibendum sed, auctor et dolor. Vivamus odio ipsum,
auctor et dolor. Vivamus odio ipsum, venenatis in felis sed, aliquam venenatis in felis sed, aliquam dictum turpis. Pellentesque pellentesque
dictum turpis. Pellentesque pellentesque consequat neque, id consequat neque, id imperdiet diam molestie nec. Nullam ut vestibulum est.
imperdiet diam molestie nec. Nullam ut vestibulum est. Pellentesque Pellentesque orci urna, porta vel porta quis, semper ut enim. Donec sit
orci urna, porta vel porta quis, semper ut enim. Donec sit amet urna amet urna arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
arcu. Nam tincidunt fermentum ligula a pharetra.{" "}
</p> </p>
</Layout> </Layout>
); );

View file

@ -1,5 +1,5 @@
import { stylesheet } from "typestyle"; import { stylesheet } from "typestyle";
import { Component, createElement } from "websnacks"; import { type Component, createElement } from "websnacks";
import { Layout } from "../components/layout"; import { Layout } from "../components/layout";

View file

@ -1,6 +1,6 @@
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { Config } from "websnacks"; import type { Config } from "websnacks";
import { stylesheetPath } from "./config"; import { stylesheetPath } from "./config";
@ -23,4 +23,3 @@ const config: Config = {
}, },
}, },
}; };
export = config;

2006
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
{ {
"name": "@websnacksjs/websnacks", "name": "@websnacksjs/websnacks",
"description": "Minimal dependency server-side JSX for static sites", "description": "Minimal dependency server-side JSX for static sites",
"version": "0.1.5", "version": "0.2.0",
"author": { "author": {
"name": "M. George Hansen", "name": "M. George Hansen",
"email": "mgeorge@technopolitica.com" "email": "mgeorge@technopolitica.com"
@ -9,7 +9,7 @@
"license": "MPL-2.0", "license": "MPL-2.0",
"repository": "github:websnacksjs/websnacks", "repository": "github:websnacksjs/websnacks",
"engines": { "engines": {
"node": ">=10" "node": ">=18"
}, },
"main": "dist/index.js", "main": "dist/index.js",
"types": "types.d.ts", "types": "types.d.ts",
@ -24,6 +24,7 @@
], ],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"check": "biome check .",
"clean": "ts-node scripts/clean.ts", "clean": "ts-node scripts/clean.ts",
"prepublishOnly": "npm run reset && npm test", "prepublishOnly": "npm run reset && npm test",
"pretest": "npm run build", "pretest": "npm run build",
@ -35,20 +36,14 @@
"test:e2e": "cd test && ts-node --script-mode ./run-e2e.ts" "test:e2e": "cd test && ts-node --script-mode ./run-e2e.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "~10", "@biomejs/biome": "1.8.3",
"@types/ws": "^7.2.4", "@types/node": "~18",
"@typescript-eslint/eslint-plugin": "^3.0.2", "@types/ws": "^7.4.0",
"@typescript-eslint/parser": "^3.0.2", "ts-node": "^10.9.2",
"eslint": "^7.1.0", "typescript": "~4.9.5"
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"prettier": "=2.2.1",
"ts-node": "^8.10.2",
"typescript": "~3.9.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"node-watch": "^0.6.4", "node-watch": "^0.7.1",
"ws": "^7.3.0" "ws": "^7.4.3"
} }
} }

View file

@ -3,8 +3,8 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import * as fs from "fs"; import * as fs from "node:fs";
import * as path from "path"; import * as path from "node:path";
const ROOT_DIR = path.resolve(__dirname, ".."); const ROOT_DIR = path.resolve(__dirname, "..");
const DIST_DIR = path.join(ROOT_DIR, "dist"); const DIST_DIR = path.join(ROOT_DIR, "dist");

View file

@ -3,10 +3,10 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { Config, ConfigPaths } from "./config"; import type { Config, ConfigPaths } from "./config";
import { renderPage } from "./render"; import { renderPage } from "./render";
import { decacheModule, walkDir } from "./utils"; import { decacheModule, walkDir } from "./utils";
@ -30,13 +30,11 @@ const renderPagesToHtml = async ({
`page source at ${srcPath} does not export a "page" variable`, `page source at ${srcPath} does not export a "page" variable`,
); );
} }
let compiledHtml; let compiledHtml: string;
try { try {
compiledHtml = renderPage(pageSrc.page()); compiledHtml = renderPage(pageSrc.page());
} catch (error) { } catch (error) {
throw new Error( throw new Error(`failed to compile ${srcPath}: ${error}`);
`failed to compile ${srcPath}: ${error.stack ?? error}`,
);
} }
const relPath = path.relative(pagesDir, path.dirname(srcPath)); const relPath = path.relative(pagesDir, path.dirname(srcPath));
let baseName = path.basename(srcPath, ".tsx"); let baseName = path.basename(srcPath, ".tsx");

View file

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

View file

@ -3,14 +3,14 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { existsSync, promises as fs, watch } from "fs"; import { promises as fs, existsSync, watch } from "node:fs";
import * as http from "http"; import * as http from "node:http";
import * as net from "net"; import * as path from "node:path";
import * as path from "path";
import { renderSite } from "../../build"; import { renderSite } from "../../build";
import { Config, loadConfig } from "../../config"; import { type Config, loadConfig } from "../../config";
import { Command, UsageError } from "../types"; import { isErrnoException } from "../../utils/error";
import { type Command, UsageError } from "../types";
const DEFAULT_SERVER_PORT = 8080; const DEFAULT_SERVER_PORT = 8080;
@ -30,7 +30,7 @@ const injectLiveReloadScript = (htmlContents: string, port: number): string =>
); );
const guessMimeType = (ext: string): string => { const guessMimeType = (ext: string): string => {
let mimeType; let mimeType: string;
switch (ext) { switch (ext) {
case ".apng": case ".apng":
mimeType = "image/apng"; mimeType = "image/apng";
@ -119,14 +119,16 @@ const guessMimeType = (ext: string): string => {
return mimeType; return mimeType;
}; };
const portFromServer = (server: Pick<net.Server, "address">): number => { const portFromServer = (
const addrInfo = server.address(); addrInfo: { port: number } | object | string | undefined | null,
if (addrInfo == null) { ): number => {
throw new Error(`server address is null (this should never happen!)`); if (
} typeof addrInfo !== "object" ||
if (typeof addrInfo === "string") { addrInfo == null ||
!("port" in addrInfo)
) {
throw new Error( throw new Error(
`server address is a string (this should never happen!)`, "server address does not have a valid port (this should never happen!)",
); );
} }
return addrInfo.port; return addrInfo.port;
@ -147,7 +149,7 @@ const startHttpServer = async (publicDir: string): Promise<http.Server> => {
reqExt = ".html"; reqExt = ".html";
} }
let contents; let contents: Buffer | string;
try { try {
contents = await fs.readFile(path.join(publicDir, reqPath)); contents = await fs.readFile(path.join(publicDir, reqPath));
} catch (error) { } catch (error) {
@ -158,7 +160,7 @@ const startHttpServer = async (publicDir: string): Promise<http.Server> => {
} }
const mimeType = guessMimeType(reqExt); const mimeType = guessMimeType(reqExt);
if (mimeType === "text/html") { if (mimeType === "text/html") {
const port = portFromServer(req.socket); const port = portFromServer(req.socket.address());
contents = injectLiveReloadScript(contents.toString("utf8"), port); contents = injectLiveReloadScript(contents.toString("utf8"), port);
} }
res.writeHead(200, { res.writeHead(200, {
@ -166,7 +168,7 @@ const startHttpServer = async (publicDir: string): Promise<http.Server> => {
}); });
res.end(contents); res.end(contents);
}); });
const listen = async (port?: number): Promise<string> => const listen = async (port?: number): Promise<void> =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
httpServer httpServer
.once("error", (error) => reject(error)) .once("error", (error) => reject(error))
@ -176,12 +178,16 @@ const startHttpServer = async (publicDir: string): Promise<http.Server> => {
try { try {
await listen(DEFAULT_SERVER_PORT); await listen(DEFAULT_SERVER_PORT);
} catch (error) { } catch (error) {
if (error.code !== "EADDRINUSE") { if (
error instanceof Error &&
isErrnoException(error) &&
error.code !== "EADDRINUSE"
) {
throw error; throw error;
} }
await listen(); await listen();
} }
const port = portFromServer(httpServer); const port = portFromServer(httpServer.address());
console.log(`Listening at http://127.0.0.1:${port}`); console.log(`Listening at http://127.0.0.1:${port}`);
return httpServer; return httpServer;
}; };
@ -190,11 +196,15 @@ const startWebSocketServer = async (
httpServer: http.Server, httpServer: http.Server,
): Promise<import("ws").Server | undefined> => { ): Promise<import("ws").Server | undefined> => {
// Attempt to load the ws module, aborting if it isn't available. // Attempt to load the ws module, aborting if it isn't available.
let ws; let ws: typeof import("ws");
try { try {
ws = await import("ws"); ws = await import("ws");
} catch (error) { } catch (error) {
if (error.code !== "MODULE_NOT_FOUND") { if (
error instanceof Error &&
isErrnoException(error) &&
error.code !== "MODULE_NOT_FOUND"
) {
throw error; throw error;
} }
console.warn(`'ws' module not found, live-reloading will be disabled`); console.warn(`'ws' module not found, live-reloading will be disabled`);
@ -209,7 +219,7 @@ const startWebSocketServer = async (
const watchFolders = async ( const watchFolders = async (
folders: string[], folders: string[],
listener: (eventType: "update" | "remove", fileName: string) => void, listener: (eventType?: "update" | "remove", fileName?: string) => void,
): Promise<void> => { ): Promise<void> => {
// Try to load node-watch, falling back to fs watch if node-watch isn't // Try to load node-watch, falling back to fs watch if node-watch isn't
// available. // available.
@ -218,19 +228,23 @@ const watchFolders = async (
nodeWatch.default(folders, { recursive: true }, listener); nodeWatch.default(folders, { recursive: true }, listener);
return; return;
} catch (error) { } catch (error) {
if (error.code !== "MODULE_NOT_FOUND") { if (
error instanceof Error &&
isErrnoException(error) &&
error.code !== "MODULE_NOT_FOUND"
) {
throw error; throw error;
} }
console.warn( console.warn(
`'node-watch' module not found, falling back to fs.watch (may ` + `'node-watch' module not found, falling back to fs.watch (may ` +
`result in file watch issues on some OSes)`, "result in file watch issues on some OSes)",
); );
} }
// NOTE: fs.watch has significant cross-platform issues, including // NOTE: fs.watch has significant cross-platform issues, including
// triggering duplicate file events on some systems. // triggering duplicate file events on some systems.
for (const folder of folders) { for (const folder of folders) {
watch(folder, { recursive: true }, (_, fileName) => { watch(folder, { recursive: true }, (_, fileName) => {
listener("update", fileName); listener("update", fileName || undefined);
}); });
} }
}; };
@ -282,10 +296,12 @@ const devCommand: Command = {
existsSync(filePath), existsSync(filePath),
); );
await watchFolders(watchedFolders, async (event, filePath) => { await watchFolders(watchedFolders, async (event, filePath) => {
console.log(`${filePath}:${event} triggering rebuild...`); const filePathForLog = filePath || "<UNKNOWN_FILE>";
const eventForLog = event || "<UNKNOWN_EVENT>";
console.log(`${filePathForLog}:${eventForLog} triggering rebuild...`);
await rebuild(); await rebuild();
if (wsServer != null) { if (wsServer != null) {
console.log(`rebuild finished, reloading browsers...`); console.log("rebuild finished, reloading browsers...");
for (const ws of wsServer.clients) { for (const ws of wsServer.clients) {
ws.send("reload"); ws.send("reload");
} }

View file

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

View file

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

View file

@ -3,7 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import * as path from "path"; import * as path from "node:path";
import { decacheModule } from "./utils"; import { decacheModule } from "./utils";
@ -58,7 +58,7 @@ const noop = () => {};
* @return Fully-realized configuration. * @return Fully-realized configuration.
*/ */
export const loadConfig = async (rootDir: string): Promise<Config> => { export const loadConfig = async (rootDir: string): Promise<Config> => {
let configPath; let configPath = "";
let userConfig: UserConfig = {}; let userConfig: UserConfig = {};
// Attempt to load a websnacks.ts/js file in rootDir. // Attempt to load a websnacks.ts/js file in rootDir.
try { try {
@ -74,7 +74,7 @@ export const loadConfig = async (rootDir: string): Promise<Config> => {
const staticAssetsDir = path.join(rootDir, "static"); const staticAssetsDir = path.join(rootDir, "static");
const watch = [pagesDir, staticAssetsDir]; const watch = [pagesDir, staticAssetsDir];
if (configPath != null) { if (configPath) {
watch.push(path.relative(rootDir, configPath)); watch.push(path.relative(rootDir, configPath));
} }
if (userConfig.watch != null) { if (userConfig.watch != null) {
@ -97,3 +97,5 @@ export const loadConfig = async (rootDir: string): Promise<Config> => {
watch, 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/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { Component, Element, HTMLElement } from "./component"; import type { Component, Element, HTMLElement } from "./component";
import { HTMLAttributes } from "./jsx"; import type { HTMLAttributes } from "./jsx";
import { flatDeep } from "./utils"; import { flatDeep } from "./utils";
/** /**
@ -38,8 +38,7 @@ export function createElement(
...children: Element[] ...children: Element[]
): HTMLElement; ): HTMLElement;
export function createElement( export function createElement(
// eslint-disable-next-line @typescript-eslint/no-explicit-any type: string | Component,
type: string | Component<any>,
props: object | null, props: object | null,
...children: Element[] ...children: Element[]
): HTMLElement { ): HTMLElement {
@ -54,14 +53,21 @@ export function createElement(
} }
const attrs: Record<string, string | number | boolean> = {}; const attrs: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(props || {})) { 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 ( if (
typeof value !== "string" && typeof value !== "string" &&
typeof value !== "number" && typeof value !== "number" &&
typeof value !== "boolean" typeof value !== "boolean"
) { ) {
console.warn( console.warn(`non-primitive attribute ${key} = ${JSON.stringify(value)}`);
`non-primitive attribute ${key} = ${JSON.stringify(value)}`,
);
continue; continue;
} }
attrs[key] = value; attrs[key] = value;

View file

@ -4,6 +4,6 @@
*/ */
export { HTMLElement, Component, Fragment } from "./component"; export { HTMLElement, Component, Fragment } from "./component";
export { UserConfig as Config } from "./config"; export { UserConfig as Config, defineConfig } from "./config";
export { createElement } from "./create-element"; export { createElement } from "./create-element";
export * from "./jsx"; export * from "./jsx";

View file

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

View file

@ -3,7 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { Element, HTMLElement } from "./component"; import type { Element, HTMLElement } from "./component";
const HTML_ESCAPES: { [char: string]: string } = { const HTML_ESCAPES: { [char: string]: string } = {
"&": "&amp;", "&": "&amp;",
@ -34,9 +34,13 @@ const renderElement = (elem: Element): string => {
let output = ""; let output = "";
output += startTag(elem); output += startTag(elem);
if (elem.attributes.dangerouslySetInnerHTML != null) {
output += elem.attributes.dangerouslySetInnerHTML;
} else {
for (const child of elem.children) { for (const child of elem.children) {
output += renderElement(child); output += renderElement(child);
} }
}
output += endTag(elem); output += endTag(elem);
return output; return output;
}; };
@ -55,6 +59,11 @@ const startTag = (elem: HTMLElement): string => {
continue; continue;
} }
// Ignore the special attr for setting raw inner HTML.
if (attrName === "dangerouslySetInnerHTML") {
continue;
}
let normalizedAttrName = escapeHtml(attrName.toLowerCase()); let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") { if (normalizedAttrName === "classname") {
normalizedAttrName = "class"; normalizedAttrName = "class";
@ -62,9 +71,7 @@ const startTag = (elem: HTMLElement): string => {
if (attrValue === true) { if (attrValue === true) {
output += ` ${normalizedAttrName}=""`; output += ` ${normalizedAttrName}=""`;
} else { } else {
output += ` ${normalizedAttrName}="${escapeAttr( output += ` ${normalizedAttrName}="${escapeAttr(attrValue.toString())}"`;
attrValue.toString(),
)}"`;
} }
} }
@ -88,8 +95,8 @@ const endTag = (elem: HTMLElement): string => {
* @return Fully rendered HTML document as a string. * @return Fully rendered HTML document as a string.
*/ */
export const renderPage = (rootElem: Element): string => { export const renderPage = (rootElem: Element): string => {
if (rootElem == undefined) { if (rootElem == null) {
throw new Error(`root page element cannot be null`); throw new Error("root page element cannot be null");
} }
if (typeof rootElem !== "object" || !("tag" in rootElem)) { if (typeof rootElem !== "object" || !("tag" in rootElem)) {
throw new Error( throw new Error(

View file

@ -3,11 +3,17 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { isErrnoException } from "./error";
const resolveModulePath = (importPath: string): string | undefined => { const resolveModulePath = (importPath: string): string | undefined => {
try { try {
return require.resolve(importPath); return require.resolve(importPath);
} catch (error) { } catch (error) {
if (error.code === "MODULE_NOT_FOUND") { if (
error instanceof Error &&
isErrnoException(error) &&
error.code === "MODULE_NOT_FOUND"
) {
return; return;
} }
throw error; throw error;

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/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as path from "path"; import * as path from "node:path";
export { decacheModule } from "./decache-module"; export { decacheModule } from "./decache-module";

View file

@ -3,14 +3,14 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { import {
npmCmd,
runCommand,
WEBSNACKS_BIN_PATH, WEBSNACKS_BIN_PATH,
WEBSNACKS_REPO_ROOT, WEBSNACKS_REPO_ROOT,
npmCmd,
runCommand,
withTempDir, withTempDir,
} from "../helpers/e2e"; } from "../helpers/e2e";
import { testSuite } from "../lib"; import { testSuite } from "../lib";

View file

@ -3,15 +3,15 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { import {
WEBSNACKS_BIN_PATH,
WEBSNACKS_REPO_ROOT,
npmCmd, npmCmd,
runCommand, runCommand,
wait, wait,
WEBSNACKS_BIN_PATH,
WEBSNACKS_REPO_ROOT,
withTempDir, withTempDir,
} from "../helpers/e2e"; } from "../helpers/e2e";
import { testSuite } from "../lib"; import { testSuite } from "../lib";

View file

@ -3,10 +3,10 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { ChildProcess, spawn } from "child_process"; import { type ChildProcess, spawn } from "node:child_process";
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as os from "os"; import * as os from "node:os";
import * as path from "path"; import * as path from "node:path";
/** /**
* Set a timeout and wait for at least the specified number of milliseconds, * Set a timeout and wait for at least the specified number of milliseconds,
@ -137,9 +137,7 @@ export const runCommand = (
threwError = true; threwError = true;
process.kill(); process.kill();
reject( reject(
new Error( new Error(`max timeout of ${optionsWithDefaults.timeoutMs}ms reached`),
`max timeout of ${optionsWithDefaults.timeoutMs}ms reached`,
),
); );
}, optionsWithDefaults.timeoutMs); }, optionsWithDefaults.timeoutMs);
process.on("exit", (code) => { process.on("exit", (code) => {

View file

@ -48,7 +48,7 @@ export class Expect<T> {
public toEqual(expected: T): void { public toEqual(expected: T): void {
if (!areEqual(this.value, expected)) { if (!areEqual(this.value, expected)) {
throw new ExpectError( throw new ExpectError(
`value does not equal expected`, "value does not equal expected",
expected, expected,
this.value, this.value,
); );
@ -71,7 +71,7 @@ export class StringExpect extends Expect<string> {
public toMatch(pattern: RegExp): void { public toMatch(pattern: RegExp): void {
if (!this.value.match(pattern)) { if (!this.value.match(pattern)) {
throw new ExpectError( throw new ExpectError(
`value does not match expected pattern`, "value does not match expected pattern",
pattern, pattern,
this.value, this.value,
); );
@ -89,7 +89,7 @@ export class StringExpect extends Expect<string> {
public toStartWith(prefix: string): void { public toStartWith(prefix: string): void {
if (!this.value.startsWith(prefix)) { if (!this.value.startsWith(prefix)) {
throw new ExpectError( throw new ExpectError(
`value does not start with expected prefix`, "value does not start with expected prefix",
prefix, prefix,
this.value, this.value,
); );
@ -107,7 +107,7 @@ export class StringExpect extends Expect<string> {
public toEndWith(suffix: string): void { public toEndWith(suffix: string): void {
if (!this.value.endsWith(suffix)) { if (!this.value.endsWith(suffix)) {
throw new ExpectError( throw new ExpectError(
`value does not end with expected suffix`, "value does not end with expected suffix",
suffix, suffix,
this.value, this.value,
); );
@ -135,11 +135,7 @@ export class FunctionExpect<T> extends Expect<() => T> {
this.value(); this.value();
} catch (error) { } catch (error) {
if (!(error instanceof Error)) { if (!(error instanceof Error)) {
throw new ExpectError( throw new ExpectError("function threw non-Error value", pattern, error);
`function threw non-Error value`,
pattern,
error,
);
} }
if (!matches(error.message, pattern)) { if (!matches(error.message, pattern)) {
throw new ExpectError( throw new ExpectError(
@ -151,7 +147,7 @@ export class FunctionExpect<T> extends Expect<() => T> {
return; return;
} }
throw new ExpectError( throw new ExpectError(
`function did not throw expected error`, "function did not throw expected error",
pattern, pattern,
null, null,
); );

View file

@ -39,9 +39,7 @@ const runTest = async (test: Test): Promise<TestResult> => {
error: error:
error instanceof Error error instanceof Error
? error ? error
: new Error( : new Error(`threw non-error object: ${displayValue(error)}`),
`threw non-error object: ${displayValue(error)}`,
),
}; };
} }
return result; return result;

View file

@ -20,7 +20,7 @@ export const shuffle = <T>(arr: T[]): void => {
}; };
const areArraysEqual = <T>(a: T[], b: T[]): boolean => { const areArraysEqual = <T>(a: T[], b: T[]): boolean => {
if (a.length != b.length) { if (a.length !== b.length) {
return false; return false;
} }
for (let i = 0; i < a.length; i++) { for (let i = 0; i < a.length; i++) {

View file

@ -3,9 +3,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { fork } from "child_process"; import { fork } from "node:child_process";
import * as fs from "fs"; import * as fs from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { shuffle } from "./lib/utils"; import { shuffle } from "./lib/utils";

View file

@ -3,9 +3,9 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { fork } from "child_process"; import { fork } from "node:child_process";
import * as fs from "fs"; import * as fs from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { shuffle } from "./lib/utils"; import { shuffle } from "./lib/utils";

View file

@ -3,7 +3,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { Component, createElement, Fragment } from "../../dist"; import { type Component, Fragment, createElement } from "../../dist";
import { renderPage } from "../../dist/render"; import { renderPage } from "../../dist/render";
import { testSuite } from "../lib"; import { testSuite } from "../lib";
@ -20,9 +20,7 @@ testSuite("renderPage", ({ test, expect }) => {
}); });
test("escapes HTML in tag names", () => { test("escapes HTML in tag names", () => {
const html = renderPage( const html = renderPage(<html>{createElement("div></div", null)}</html>);
<html>{createElement("div></div", null)}</html>,
);
expect(html).toEqual( 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>",
); );
@ -57,9 +55,7 @@ testSuite("renderPage", ({ test, expect }) => {
test("renders text nodes", () => { test("renders text nodes", () => {
const html = renderPage(<html>There are three lights!</html>); const html = renderPage(<html>There are three lights!</html>);
expect(html).toEqual( expect(html).toEqual("<!DOCTYPE html><html>There are three lights!</html>");
"<!DOCTYPE html><html>There are three lights!</html>",
);
}); });
test("renders spliced number nodes", () => { test("renders spliced number nodes", () => {
@ -117,4 +113,40 @@ testSuite("renderPage", ({ test, expect }) => {
"<!DOCTYPE html><html><div>test of</div><div>fragments</div></html>", "<!DOCTYPE html><html><div>test of</div><div>fragments</div></html>",
); );
}); });
test("renders unescaped HTML via dangerouslySetInnerHTML", () => {
const html = renderPage(
<html>
<div
// biome-ignore lint/security/noDangerouslySetInnerHtml: explicit test
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
// biome-ignore lint/security/noDangerouslySetInnerHtml: explicit test
// biome-ignore lint/security/noDangerouslySetInnerHtmlWithChildren: explicit test
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',
);
});
}); });