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
This commit is contained in:
M. George Hansen 2024-07-15 08:36:52 -07:00
parent 73135dd4b5
commit 5118a8174b
Signed by: mgeorgehansen
SSH key fingerprint: SHA256:JlIGiQLPyQ2RHTH3a2oVlb20Xkh9Glr8DUF4YTXHJxM
44 changed files with 2408 additions and 5691 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"
}
}

View file

@ -6,7 +6,16 @@ on:
branches: [mainline] branches: [mainline]
jobs: jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run check
build: build:
needs: check
strategy: strategy:
matrix: matrix:
os: [windows-latest, ubuntu-latest, macos-latest] os: [windows-latest, ubuntu-latest, macos-latest]

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
}

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,25 +1,25 @@
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: {
background: "#6c42bd", background: "#6c42bd",
color: "#fff", color: "#fff",
padding: "32px", padding: "32px",
textAlign: "center", textAlign: "center",
boxShadow: "0 1px 8px -3px #000", boxShadow: "0 1px 8px -3px #000",
}, },
headline: { headline: {
fontSize: "28px", fontSize: "28px",
}, },
}); });
export interface HeaderProps { export interface HeaderProps {
headline: string; headline: string;
} }
export const Header: Component<HeaderProps> = ({ headline }) => ( export const Header: Component<HeaderProps> = ({ headline }) => (
<header className={styles.header}> <header className={styles.header}>
<h1 className={styles.headline}>{headline}</h1> <h1 className={styles.headline}>{headline}</h1>
</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";
@ -9,60 +9,57 @@ import { Navbar } from "./navbar";
normalize(); normalize();
const styles = stylesheet({ const styles = stylesheet({
html: { html: {
height: "100%", height: "100%",
}, },
wrapper: { wrapper: {
height: "100%", height: "100%",
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
margin: 0, margin: 0,
}, },
main: { main: {
flex: 1, flex: 1,
}, },
mainBody: { mainBody: {
padding: "16px", padding: "16px",
}, },
navbar: { navbar: {
display: "flex", display: "flex",
flex: "0 0 auto", flex: "0 0 auto",
zIndex: 9, zIndex: 9,
}, },
}); });
const SITE_TITLE = "Example Site"; const SITE_TITLE = "Example Site";
export interface LayoutProps { export interface LayoutProps {
headline?: string; headline?: string;
} }
export const Layout: Component<LayoutProps> = ({ children, headline }) => ( export const Layout: Component<LayoutProps> = ({ children, headline }) => (
<html className={styles.html} lang="en-US"> <html className={styles.html} lang="en-US">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<title> <title>
{SITE_TITLE} {SITE_TITLE}
{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" <link rel="stylesheet" href={stylesheetPath} />
content="width=device-width, initial-scale=1" </head>
/>
<link rel="stylesheet" href={stylesheetPath} />
</head>
<body className={styles.wrapper}> <body className={styles.wrapper}>
<div className={styles.navbar}> <div className={styles.navbar}>
<Navbar /> <Navbar />
</div> </div>
<main className={styles.main}> <main className={styles.main}>
<Header headline={headline || SITE_TITLE} /> <Header headline={headline || SITE_TITLE} />
<div className={styles.mainBody}>{children}</div> <div className={styles.mainBody}>{children}</div>
</main> </main>
</body> </body>
</html> </html>
); );

View file

@ -1,43 +1,43 @@
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: {
minWidth: "140px", minWidth: "140px",
borderRight: "1px solid #ddd", borderRight: "1px solid #ddd",
background: "#fff", background: "#fff",
}, },
sectionTitle: { sectionTitle: {
color: "#333", color: "#333",
textAlign: "center", textAlign: "center",
borderBottom: "1px solid #333", borderBottom: "1px solid #333",
padding: "6px", padding: "6px",
margin: "0 4px", margin: "0 4px",
fontSize: "18px", fontSize: "18px",
}, },
linksList: { linksList: {
padding: "3px 16px 0", padding: "3px 16px 0",
}, },
linksListItem: { linksListItem: {
padding: "6px", padding: "6px",
}, },
}); });
const links = [ const links = [
{ title: "Home", href: "/" }, { title: "Home", href: "/" },
{ title: "Projects", href: "/projects" }, { title: "Projects", href: "/projects" },
]; ];
export const Navbar: Component = () => ( export const Navbar: Component = () => (
<nav className={styles.navbar}> <nav className={styles.navbar}>
<h2 className={styles.sectionTitle}>Navigation</h2> <h2 className={styles.sectionTitle}>Navigation</h2>
<ol className={styles.linksList}> <ol className={styles.linksList}>
{links.map(({ title, href }) => ( {links.map(({ title, href }) => (
<li className={styles.linksListItem}> <li className={styles.linksListItem}>
<a href={href}>{title}</a> <a href={href}>{title}</a>
</li> </li>
))} ))}
</ol> </ol>
</nav> </nav>
); );

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,14 @@
{ {
"name": "websnacks-example-personal-site", "name": "websnacks-example-personal-site",
"scripts": { "type": "module",
"build": "websnacks -r ts-node/register build", "scripts": {
"dev": "websnacks -r ts-node/register dev" "build": "websnacks -r ts-node/register build",
}, "dev": "websnacks -r ts-node/register dev"
"dependencies": { },
"csstips": "^1.2.0", "dependencies": {
"ts-node": "^8.10.1", "csstips": "^1.2.0",
"typestyle": "^2.1.0", "ts-node": "^8.10.1",
"websnacks": "../../" "typestyle": "^2.1.0",
} "websnacks": "../../"
}
} }

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,26 +1,26 @@
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";
const styles = stylesheet({ const styles = stylesheet({
projectsGrid: { projectsGrid: {
display: "flex", display: "flex",
flexWrap: "wrap", flexWrap: "wrap",
width: "25%", width: "25%",
}, },
}); });
export const page: Component = () => ( export const page: Component = () => (
<Layout> <Layout>
<h1>Projects</h1> <h1>Projects</h1>
<div className={styles.projectsGrid}> <div className={styles.projectsGrid}>
<div>Project 1</div> <div>Project 1</div>
<div>Project 2</div> <div>Project 2</div>
<div>Project 3</div> <div>Project 3</div>
<div>Project 4</div> <div>Project 4</div>
<div>Project 5</div> <div>Project 5</div>
</div> </div>
</Layout> </Layout>
); );

View file

@ -1,17 +1,17 @@
{ {
"compilerOptions": { "compilerOptions": {
"esModuleInterop": true, "esModuleInterop": true,
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"jsx": "react", "jsx": "react",
"jsxFactory": "createElement", "jsxFactory": "createElement",
"target": "ES2018", "target": "ES2018",
"lib": ["ES2018"], "lib": ["ES2018"],
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"include": ["components/**/*", "pages/**/*"] "include": ["components/**/*", "pages/**/*"]
} }

View file

@ -1,26 +1,25 @@
import { promises as fs } from "fs"; import { promises as fs } from "node:fs";
import * as path from "path"; import * as path from "node:path";
import { Config } from "websnacks"; import type { Config } from "websnacks";
import { stylesheetPath } from "./config"; import { stylesheetPath } from "./config";
const config: Config = { const config: Config = {
// Watch additional files and folders for changes when the dev server is // Watch additional files and folders for changes when the dev server is
// running. // running.
watch: ["components/", "config.ts"], watch: ["components/", "config.ts"],
// Hooks to execute after certain rendering events. Currently only // Hooks to execute after certain rendering events. Currently only
// afterSiteRender is supported. // afterSiteRender is supported.
hooks: { hooks: {
async afterSiteRender({ outDir }): Promise<void> { async afterSiteRender({ outDir }): Promise<void> {
// NOTE: we dynamically import typestyle so that the global style // NOTE: we dynamically import typestyle so that the global style
// registry is properly updated once all pages are reloaded in // registry is properly updated once all pages are reloaded in
// dev. We could also create a typestyle object in config.ts, // dev. We could also create a typestyle object in config.ts,
// or even multiple objects to split up our styles into e.g. a // or even multiple objects to split up our styles into e.g. a
// critical-path.css and noncrticial.css. // critical-path.css and noncrticial.css.
const { getStyles } = await import("typestyle"); const { getStyles } = await import("typestyle");
const styles = getStyles(); const styles = getStyles();
await fs.writeFile(path.join(outDir, stylesheetPath), styles); await fs.writeFile(path.join(outDir, stylesheetPath), styles);
}, },
}, },
}; };
export = config;

2404
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,54 +1,49 @@
{ {
"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.2.0", "version": "0.2.0",
"author": { "author": {
"name": "M. George Hansen", "name": "M. George Hansen",
"email": "mgeorge@technopolitica.com" "email": "mgeorge@technopolitica.com"
}, },
"license": "MPL-2.0", "license": "MPL-2.0",
"repository": "github:websnacksjs/websnacks", "repository": "github:websnacksjs/websnacks",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
"main": "dist/index.js", "main": "dist/index.js",
"types": "types.d.ts", "types": "types.d.ts",
"bin": "bin/websnacks.js", "bin": "bin/websnacks.js",
"files": [ "files": [
"/bin/websnacks.js", "/bin/websnacks.js",
"/dist/**/*.js", "/dist/**/*.js",
"/dist/**/*.d.ts", "/dist/**/*.d.ts",
"/dist/**/*.map", "/dist/**/*.map",
"/src/**/*.ts", "/src/**/*.ts",
"/index.d.ts" "/index.d.ts"
], ],
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"clean": "ts-node scripts/clean.ts", "check": "biome check .",
"prepublishOnly": "npm run reset && npm test", "clean": "ts-node scripts/clean.ts",
"pretest": "npm run build", "prepublishOnly": "npm run reset && npm test",
"preversion": "npm run reset && npm test", "pretest": "npm run build",
"release": "npm version", "preversion": "npm run reset && npm test",
"reset": "npm run clean && npm ci", "release": "npm version",
"test": "npm run test:unit && npm run test:e2e", "reset": "npm run clean && npm ci",
"test:unit": "cd test && ts-node --script-mode ./run-tests.ts", "test": "npm run test:unit && npm run test:e2e",
"test:e2e": "cd test && ts-node --script-mode ./run-e2e.ts" "test:unit": "cd test && ts-node --script-mode ./run-tests.ts",
}, "test:e2e": "cd test && ts-node --script-mode ./run-e2e.ts"
"devDependencies": { },
"@types/node": "~18", "devDependencies": {
"@types/ws": "^7.4.0", "@biomejs/biome": "1.8.3",
"@typescript-eslint/eslint-plugin": "^4.15.2", "@types/node": "~18",
"@typescript-eslint/parser": "^4.15.2", "@types/ws": "^7.4.0",
"eslint": "^7.21.0", "ts-node": "^10.9.2",
"eslint-config-prettier": "^8.1.0", "typescript": "~4.9.5"
"eslint-plugin-prettier": "^3.3.1", },
"eslint-plugin-react": "^7.22.0", "optionalDependencies": {
"prettier": "=2.2.1", "node-watch": "^0.7.1",
"ts-node": "^10.9.2", "ws": "^7.4.3"
"typescript": "~4.9.5" }
},
"optionalDependencies": {
"node-watch": "^0.7.1",
"ws": "^7.4.3"
}
} }

View file

@ -3,28 +3,28 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import * as fs from "fs"; import * as fs from "node:fs";
import * as path from "path"; import * as path from "node:path";
const ROOT_DIR = path.resolve(__dirname, ".."); const ROOT_DIR = path.resolve(__dirname, "..");
const DIST_DIR = path.join(ROOT_DIR, "dist"); const DIST_DIR = path.join(ROOT_DIR, "dist");
const TEST_DIR = path.join(ROOT_DIR, ".temp"); const TEST_DIR = path.join(ROOT_DIR, ".temp");
const rmdirRecursive = (dirPath: string): void => { const rmdirRecursive = (dirPath: string): void => {
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
return; return;
} }
const entryNames = fs.readdirSync(dirPath); const entryNames = fs.readdirSync(dirPath);
for (const entryName of entryNames) { for (const entryName of entryNames) {
const entryPath = path.join(dirPath, entryName); const entryPath = path.join(dirPath, entryName);
const dirent = fs.lstatSync(entryPath); const dirent = fs.lstatSync(entryPath);
if (dirent.isDirectory()) { if (dirent.isDirectory()) {
rmdirRecursive(entryPath); rmdirRecursive(entryPath);
} else { } else {
fs.unlinkSync(entryPath); fs.unlinkSync(entryPath);
} }
} }
fs.rmdirSync(dirPath); fs.rmdirSync(dirPath);
}; };
rmdirRecursive(DIST_DIR); rmdirRecursive(DIST_DIR);

View file

@ -3,78 +3,78 @@
* 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";
const renderPagesToHtml = async ({ const renderPagesToHtml = async ({
pagesDir, pagesDir,
outDir, outDir,
}: ConfigPaths): Promise<void> => { }: ConfigPaths): Promise<void> => {
const deferred = []; const deferred = [];
for await (const srcPath of walkDir(pagesDir)) { for await (const srcPath of walkDir(pagesDir)) {
const ext = path.extname(srcPath); const ext = path.extname(srcPath);
if (ext !== ".tsx") { if (ext !== ".tsx") {
continue; continue;
} }
// Ensure that we don't cache page modules when running in dev server. // Ensure that we don't cache page modules when running in dev server.
decacheModule(srcPath); decacheModule(srcPath);
// eslint-disable-next-line @typescript-eslint/no-var-requires // eslint-disable-next-line @typescript-eslint/no-var-requires
const pageSrc = require(srcPath); const pageSrc = require(srcPath);
if (!("page" in pageSrc)) { if (!("page" in pageSrc)) {
throw new Error( throw new Error(
`page source at ${srcPath} does not export a "page" variable`, `page source at ${srcPath} does not export a "page" variable`,
); );
} }
let compiledHtml; let compiledHtml: string;
try { try {
compiledHtml = renderPage(pageSrc.page()); compiledHtml = renderPage(pageSrc.page());
} catch (error) { } catch (error) {
throw new Error(`failed to compile ${srcPath}: ${error}`); throw new Error(`failed to compile ${srcPath}: ${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");
if (baseName === "index") { if (baseName === "index") {
baseName = ""; baseName = "";
} }
const destPath = path.join(outDir, relPath, baseName, "index.html"); const destPath = path.join(outDir, relPath, baseName, "index.html");
deferred.push( deferred.push(
(async () => { (async () => {
await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.writeFile(destPath, compiledHtml); await fs.writeFile(destPath, compiledHtml);
})(), })(),
); );
} }
await Promise.all(deferred); await Promise.all(deferred);
}; };
const copyStaticAssets = async ({ const copyStaticAssets = async ({
staticAssetsDir, staticAssetsDir,
outDir, outDir,
}: ConfigPaths): Promise<void> => { }: ConfigPaths): Promise<void> => {
try { try {
await fs.access(staticAssetsDir); await fs.access(staticAssetsDir);
} catch (error) { } catch (error) {
// Static assets folder doesn't exist, so no-op. // Static assets folder doesn't exist, so no-op.
return; return;
} }
const deferred = []; const deferred = [];
for await (const assetPath of walkDir(staticAssetsDir)) { for await (const assetPath of walkDir(staticAssetsDir)) {
const relPath = path.relative(staticAssetsDir, assetPath); const relPath = path.relative(staticAssetsDir, assetPath);
const destPath = path.join(outDir, relPath); const destPath = path.join(outDir, relPath);
deferred.push( deferred.push(
(async () => { (async () => {
await fs.mkdir(path.dirname(destPath), { recursive: true }); await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(assetPath, destPath); await fs.copyFile(assetPath, destPath);
})(), })(),
); );
} }
await Promise.all(deferred); await Promise.all(deferred);
}; };
/** /**
@ -84,6 +84,6 @@ const copyStaticAssets = async ({
* @param config Configuration for the site. * @param config Configuration for the site.
*/ */
export const renderSite = async ({ paths, hooks }: Config): Promise<void> => { export const renderSite = async ({ paths, hooks }: Config): Promise<void> => {
await Promise.all([renderPagesToHtml(paths), copyStaticAssets(paths)]); await Promise.all([renderPagesToHtml(paths), copyStaticAssets(paths)]);
await hooks.afterSiteRender(paths); await hooks.afterSiteRender(paths);
}; };

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]
@ -18,16 +18,16 @@ Args:
`; `;
interface BuildArgs { interface BuildArgs {
rootDir: string; rootDir: string;
} }
const parseArgs = (args: string[]): BuildArgs => { const parseArgs = (args: string[]): BuildArgs => {
if (args.length > 1) { if (args.length > 1) {
throw new UsageError("too many arguments provided", helpText); throw new UsageError("too many arguments provided", helpText);
} }
return { return {
rootDir: args[0] || process.cwd(), rootDir: args[0] || process.cwd(),
}; };
}; };
/** /**
@ -35,11 +35,11 @@ const parseArgs = (args: string[]): BuildArgs => {
* static files. * static files.
*/ */
const buildCommand: Command = { const buildCommand: Command = {
execute: async (args: string[]): Promise<void> => { execute: async (args: string[]): Promise<void> => {
const { rootDir } = parseArgs(args); const { rootDir } = parseArgs(args);
const config = await loadConfig(rootDir); const config = await loadConfig(rootDir);
await renderSite(config); await renderSite(config);
}, },
helpText, helpText,
}; };
export = buildCommand; export = buildCommand;

View file

@ -3,21 +3,21 @@
* 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 path from "path"; import * as path from "node:path";
import { renderSite } from "../../build"; import { renderSite } from "../../build";
import { Config, loadConfig } from "../../config"; import { type Config, loadConfig } from "../../config";
import { isErrnoException } from "../../utils/error"; import { isErrnoException } from "../../utils/error";
import { Command, UsageError } from "../types"; import { type Command, UsageError } from "../types";
const DEFAULT_SERVER_PORT = 8080; const DEFAULT_SERVER_PORT = 8080;
const injectLiveReloadScript = (htmlContents: string, port: number): string => const injectLiveReloadScript = (htmlContents: string, port: number): string =>
htmlContents.replace( htmlContents.replace(
"</html>", "</html>",
` `
<script> <script>
const ws = new WebSocket("ws://127.0.0.1:${port}"); const ws = new WebSocket("ws://127.0.0.1:${port}");
ws.onmessage = function() { ws.onmessage = function() {
@ -27,226 +27,226 @@ const injectLiveReloadScript = (htmlContents: string, port: number): string =>
</script> </script>
</html> </html>
`, `,
); );
const guessMimeType = (ext: string): string => { const guessMimeType = (ext: string): string => {
let mimeType; let mimeType: string;
switch (ext) { switch (ext) {
case ".apng": case ".apng":
mimeType = "image/apng"; mimeType = "image/apng";
break; break;
case ".bmp": case ".bmp":
mimeType = "image/bmp"; mimeType = "image/bmp";
break; break;
case ".css": case ".css":
mimeType = "text/css"; mimeType = "text/css";
break; break;
case ".eot": case ".eot":
mimeType = "application/vnd.ms-fontobject"; mimeType = "application/vnd.ms-fontobject";
break; break;
case ".gif": case ".gif":
mimeType = "image/gif"; mimeType = "image/gif";
break; break;
case ".htm": case ".htm":
case ".html": case ".html":
mimeType = "text/html"; mimeType = "text/html";
break; break;
case ".ico": case ".ico":
mimeType = "image/vnd.microsoft.icon"; mimeType = "image/vnd.microsoft.icon";
break; break;
case ".jpg": case ".jpg":
case ".jpeg": case ".jpeg":
mimeType = "image/jpeg"; mimeType = "image/jpeg";
break; break;
case ".js": case ".js":
case ".mjs": case ".mjs":
mimeType = "text/javascript"; mimeType = "text/javascript";
break; break;
case ".mp3": case ".mp3":
mimeType = "audio/mpeg"; mimeType = "audio/mpeg";
break; break;
case ".mpeg": case ".mpeg":
mimeType = "video/mpeg"; mimeType = "video/mpeg";
break; break;
case ".oga": case ".oga":
mimeType = "audio/ogg"; mimeType = "audio/ogg";
break; break;
case ".ogv": case ".ogv":
mimeType = "video/ogg"; mimeType = "video/ogg";
break; break;
case ".otf": case ".otf":
mimeType = "font/otf"; mimeType = "font/otf";
break; break;
case ".png": case ".png":
mimeType = "image/png"; mimeType = "image/png";
break; break;
case ".svg": case ".svg":
mimeType = "image/svg+xml"; mimeType = "image/svg+xml";
break; break;
case ".txt": case ".txt":
mimeType = "text/plain"; mimeType = "text/plain";
break; break;
case ".tif": case ".tif":
case ".tiff": case ".tiff":
mimeType = "image/tiff"; mimeType = "image/tiff";
break; break;
case ".ttf": case ".ttf":
mimeType = "font/ttf"; mimeType = "font/ttf";
break; break;
case ".wav": case ".wav":
mimeType = "audio/wav"; mimeType = "audio/wav";
break; break;
case ".weba": case ".weba":
mimeType = "audio/webm"; mimeType = "audio/webm";
break; break;
case ".webm": case ".webm":
mimeType = "video/webm"; mimeType = "video/webm";
break; break;
case ".webp": case ".webp":
mimeType = "image/webp"; mimeType = "image/webp";
break; break;
case ".woff": case ".woff":
mimeType = "font/woff"; mimeType = "font/woff";
break; break;
case ".woff2": case ".woff2":
mimeType = "font/woff2"; mimeType = "font/woff2";
break; break;
default: default:
// Default to binary mimetype which most browsers will be able to // Default to binary mimetype which most browsers will be able to
// correctly interpret in the right context. // correctly interpret in the right context.
mimeType = "application/octet-stream"; mimeType = "application/octet-stream";
} }
return mimeType; return mimeType;
}; };
const portFromServer = ( const portFromServer = (
addrInfo: { port: number } | object | string | undefined | null, addrInfo: { port: number } | object | string | undefined | null,
): number => { ): number => {
if ( if (
typeof addrInfo !== "object" || typeof addrInfo !== "object" ||
addrInfo == null || addrInfo == null ||
!("port" in addrInfo) !("port" in addrInfo)
) { ) {
throw new Error( throw new Error(
"server address does not have a valid port (this should never happen!)", "server address does not have a valid port (this should never happen!)",
); );
} }
return addrInfo.port; return addrInfo.port;
}; };
const startHttpServer = async (publicDir: string): Promise<http.Server> => { const startHttpServer = async (publicDir: string): Promise<http.Server> => {
const httpServer = http.createServer(async (req, res) => { const httpServer = http.createServer(async (req, res) => {
if (req.url == null) { if (req.url == null) {
res.writeHead(404); res.writeHead(404);
res.end(); res.end();
return; return;
} }
let reqExt = path.extname(req.url); let reqExt = path.extname(req.url);
let reqPath = req.url; let reqPath = req.url;
if (!reqExt) { if (!reqExt) {
reqPath = path.join(reqPath, "index.html"); reqPath = path.join(reqPath, "index.html");
reqExt = ".html"; reqExt = ".html";
} }
let contents; let contents: Buffer | string;
try { try {
contents = await fs.readFile(path.join(publicDir, reqPath)); contents = await fs.readFile(path.join(publicDir, reqPath));
} catch (error) { } catch (error) {
console.error(`unable to load file ${reqPath}`); console.error(`unable to load file ${reqPath}`);
res.writeHead(404); res.writeHead(404);
res.end(); res.end();
return; return;
} }
const mimeType = guessMimeType(reqExt); const mimeType = guessMimeType(reqExt);
if (mimeType === "text/html") { if (mimeType === "text/html") {
const port = portFromServer(req.socket.address()); const port = portFromServer(req.socket.address());
contents = injectLiveReloadScript(contents.toString("utf8"), port); contents = injectLiveReloadScript(contents.toString("utf8"), port);
} }
res.writeHead(200, { res.writeHead(200, {
"Content-Type": mimeType, "Content-Type": mimeType,
}); });
res.end(contents); res.end(contents);
}); });
const listen = async (port?: number): Promise<void> => 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))
.once("listening", () => resolve()) .once("listening", () => resolve())
.listen(port); .listen(port);
}); });
try { try {
await listen(DEFAULT_SERVER_PORT); await listen(DEFAULT_SERVER_PORT);
} catch (error) { } catch (error) {
if ( if (
error instanceof Error && error instanceof Error &&
isErrnoException(error) && isErrnoException(error) &&
error.code !== "EADDRINUSE" error.code !== "EADDRINUSE"
) { ) {
throw error; throw error;
} }
await listen(); await listen();
} }
const port = portFromServer(httpServer.address()); 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;
}; };
const startWebSocketServer = async ( 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 ( if (
error instanceof Error && error instanceof Error &&
isErrnoException(error) && isErrnoException(error) &&
error.code !== "MODULE_NOT_FOUND" error.code !== "MODULE_NOT_FOUND"
) { ) {
throw error; throw error;
} }
console.warn(`'ws' module not found, live-reloading will be disabled`); console.warn(`'ws' module not found, live-reloading will be disabled`);
return; return;
} }
const wsServer = new ws.Server({ server: httpServer }); const wsServer = new ws.Server({ server: httpServer });
wsServer.on("connection", () => { wsServer.on("connection", () => {
console.log("connected to dev site"); console.log("connected to dev site");
}); });
return wsServer; return wsServer;
}; };
const watchFolders = async ( const watchFolders = async (
folders: string[], folders: string[],
listener: (eventType?: "update" | "remove", fileName?: string) => void, listener: (eventType?: "update" | "remove", fileName?: string) => void,
): Promise<void> => { ): Promise<void> => {
// Try to load node-watch, falling back to fs watch if node-watch isn't // Try to load node-watch, falling back to fs watch if node-watch isn't
// available. // available.
try { try {
const nodeWatch = await import("node-watch"); const nodeWatch = await import("node-watch");
nodeWatch.default(folders, { recursive: true }, listener); nodeWatch.default(folders, { recursive: true }, listener);
return; return;
} catch (error) { } catch (error) {
if ( if (
error instanceof Error && error instanceof Error &&
isErrnoException(error) && isErrnoException(error) &&
error.code !== "MODULE_NOT_FOUND" 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 || undefined); listener("update", fileName || undefined);
}); });
} }
}; };
const helpText = `\ const helpText = `\
@ -259,16 +259,16 @@ Args:
`; `;
interface DevArgs { interface DevArgs {
rootDir: string; rootDir: string;
} }
const parseArgs = (args: string[]): DevArgs | null => { const parseArgs = (args: string[]): DevArgs | null => {
if (args.length > 1) { if (args.length > 1) {
throw new UsageError("too many arguments provided", helpText); throw new UsageError("too many arguments provided", helpText);
} }
return { return {
rootDir: args[0] || process.cwd(), rootDir: args[0] || process.cwd(),
}; };
}; };
/** /**
@ -277,39 +277,37 @@ const parseArgs = (args: string[]): DevArgs | null => {
* production static hosting environment as closely as possible. * production static hosting environment as closely as possible.
*/ */
const devCommand: Command = { const devCommand: Command = {
async execute(args: string[]): Promise<void> { async execute(args: string[]): Promise<void> {
const parsedArgs = parseArgs(args); const parsedArgs = parseArgs(args);
if (!parsedArgs) { if (!parsedArgs) {
return; return;
} }
const { rootDir } = parsedArgs; const { rootDir } = parsedArgs;
const rebuild = async (): Promise<Config> => { const rebuild = async (): Promise<Config> => {
const config = await loadConfig(rootDir); const config = await loadConfig(rootDir);
await renderSite(config); await renderSite(config);
return config; return config;
}; };
const config = await rebuild(); const config = await rebuild();
const { outDir } = config.paths; const { outDir } = config.paths;
const httpServer = await startHttpServer(outDir); const httpServer = await startHttpServer(outDir);
const wsServer = await startWebSocketServer(httpServer); const wsServer = await startWebSocketServer(httpServer);
const watchedFolders = config.watch.filter((filePath) => const watchedFolders = config.watch.filter((filePath) =>
existsSync(filePath), existsSync(filePath),
); );
await watchFolders(watchedFolders, async (event, filePath) => { await watchFolders(watchedFolders, async (event, filePath) => {
const filePathForLog = filePath || "<UNKNOWN_FILE>"; const filePathForLog = filePath || "<UNKNOWN_FILE>";
const eventForLog = event || "<UNKNOWN_EVENT>"; const eventForLog = event || "<UNKNOWN_EVENT>";
console.log( console.log(`${filePathForLog}:${eventForLog} triggering rebuild...`);
`${filePathForLog}:${eventForLog} triggering rebuild...`, await rebuild();
); if (wsServer != null) {
await rebuild(); console.log("rebuild finished, reloading browsers...");
if (wsServer != null) { for (const ws of wsServer.clients) {
console.log(`rebuild finished, reloading browsers...`); ws.send("reload");
for (const ws of wsServer.clients) { }
ws.send("reload"); }
} });
} },
}); helpText,
},
helpText,
}; };
export = devCommand; export = devCommand;

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>
@ -19,97 +19,94 @@ Commands:
`; `;
interface Options { interface Options {
showHelp: boolean; showHelp: boolean;
require: string[]; require: string[];
} }
const parseArgs = ( const parseArgs = (
args: string[], args: string[],
): { options: Options; commandName?: string; commandArgs: string[] } => { ): { options: Options; commandName?: string; commandArgs: string[] } => {
const options: Options = { const options: Options = {
showHelp: false, showHelp: false,
require: [], require: [],
}; };
// Look ahead for the first argument that doesn't start with a "-" to // Look ahead for the first argument that doesn't start with a "-" to
// indicate the end of option parsing. // indicate the end of option parsing.
while (args.length > 0 && args[0].indexOf("-") >= 0) { while (args.length > 0 && args[0].indexOf("-") >= 0) {
const opt = args.shift(); const opt = args.shift();
switch (opt) { switch (opt) {
case "-h": case "-h":
case "--help": { case "--help": {
options.showHelp = true; options.showHelp = true;
break; break;
} }
case "-r": case "-r":
case "--require": { case "--require": {
const moduleName = args.shift(); const moduleName = args.shift();
if (moduleName == null) { if (moduleName == null) {
throw new UsageError( throw new UsageError(
`-r requires a valid module name`, "-r requires a valid module name",
globalHelpText, globalHelpText,
); );
} }
options.require.push(moduleName); options.require.push(moduleName);
break; break;
} }
default: default:
throw new UsageError(`unknown option ${opt}`, globalHelpText); throw new UsageError(`unknown option ${opt}`, globalHelpText);
} }
} }
const commandName = args.shift(); const commandName = args.shift();
return { options, commandName, commandArgs: args }; return { options, commandName, commandArgs: args };
}; };
const _main = async (args: string[]): Promise<void> => { const _main = async (args: string[]): Promise<void> => {
const { options, commandName, commandArgs } = parseArgs(args); const { options, commandName, commandArgs } = parseArgs(args);
if (options.showHelp) { if (options.showHelp) {
console.log(`${globalHelpText}\n`); console.log(`${globalHelpText}\n`);
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);
} }
let command: Command; let command: Command;
switch (commandName) { switch (commandName) {
case "build": case "build":
command = await import("./commands/build"); command = await import("./commands/build");
break; break;
case "dev": case "dev":
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?
); for (const arg of commandArgs) {
} if (arg === "--help" || arg === "-h") {
// NOTE: Should this just delegate to the command? console.log(`${command.helpText}\n`);
for (const arg of commandArgs) { return;
if (arg === "--help" || arg === "-h") { }
console.log(`${command.helpText}\n`); }
return; await command.execute(commandArgs);
}
}
await command.execute(commandArgs);
}; };
/** /**
* Entrypoint of the CLI app. * Entrypoint of the CLI app.
*/ */
export const main = (): void => { export const main = (): void => {
_main(process.argv.slice(2)).catch((error) => { _main(process.argv.slice(2)).catch((error) => {
if (error instanceof UsageError) { if (error instanceof UsageError) {
console.error(`Error: ${error.message}\n`); console.error(`Error: ${error.message}\n`);
console.log(`${error.helpText}\n`); console.log(`${error.helpText}\n`);
} else { } else {
const errorMsg = const errorMsg =
error instanceof Error ? error.stack : JSON.stringify(error); error instanceof Error ? error.stack : JSON.stringify(error);
console.error(`Unexpected error: ${errorMsg}\n`); console.error(`Unexpected error: ${errorMsg}\n`);
} }
process.exit(1); process.exit(1);
}); });
}; };

View file

@ -7,16 +7,16 @@
* CLI command representing an action that the CLI program supports. * CLI command representing an action that the CLI program supports.
*/ */
export interface Command { export interface Command {
/** /**
* Execute the command with the specified arguments. * Execute the command with the specified arguments.
* *
* @param args List of CLI arguments to pass to the command. * @param args List of CLI arguments to pass to the command.
*/ */
execute(args: string[]): Promise<void>; execute(args: string[]): Promise<void>;
/** /**
* Help text for this command. * Help text for this command.
*/ */
helpText: string; helpText: string;
} }
/** /**
@ -24,11 +24,11 @@ export interface Command {
* text to guide the user to correct their mistake. * text to guide the user to correct their mistake.
*/ */
export class UsageError extends Error { export class UsageError extends Error {
public readonly helpText: string; public readonly helpText: string;
public constructor(message: string, helpText: string) { public constructor(message: string, helpText: string) {
super(message); super(message);
this.helpText = helpText; this.helpText = helpText;
} }
} }

View file

@ -7,46 +7,46 @@
* An in-memory representation of a renderable HTML element. * An in-memory representation of a renderable HTML element.
*/ */
export interface HTMLElement { export interface HTMLElement {
/** /**
* Name of the tag that gets output upon rendering. * Name of the tag that gets output upon rendering.
*/ */
tag: string; tag: string;
/** /**
* Record of attribute names and values that should be output in the opening * Record of attribute names and values that should be output in the opening
* tag. * tag.
*/ */
attributes: Record<string, string | number | boolean>; attributes: Record<string, string | number | boolean>;
/** /**
* Child elements to render nested within this HTML element. * Child elements to render nested within this HTML element.
*/ */
children: Element[]; children: Element[];
} }
/** /**
* All valid types of elements that can be rendered to HTML. * All valid types of elements that can be rendered to HTML.
*/ */
export type Element = export type Element =
| Element[] | Element[]
| HTMLElement | HTMLElement
| string | string
| number | number
| boolean | boolean
| undefined | undefined
| null; | null;
/** /**
* Custom HTMLElement factory that can be parameterized by props. * Custom HTMLElement factory that can be parameterized by props.
*/ */
export interface Component<P extends object = {}> { export interface Component<P extends object = Record<string, unknown>> {
( (
props: P & { props: P & {
children?: Element[]; children?: Element[];
}, },
): HTMLElement; ): HTMLElement;
} }
export const Fragment: Component<{}> = ({ children }) => ({ 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";
@ -11,40 +11,40 @@ import { decacheModule } from "./utils";
* Paths used during configuration. * Paths used during configuration.
*/ */
export interface ConfigPaths { export interface ConfigPaths {
rootDir: string; rootDir: string;
outDir: string; outDir: string;
pagesDir: string; pagesDir: string;
staticAssetsDir: string; staticAssetsDir: string;
} }
/** /**
* Hooks that allow user code to customize site rendering. * Hooks that allow user code to customize site rendering.
*/ */
export interface Hooks { export interface Hooks {
/** /**
* Hook that fires at the end of site rendering one all pages and assets are * Hook that fires at the end of site rendering one all pages and assets are
* fully rendered. * fully rendered.
*/ */
afterSiteRender(context: ConfigPaths): Promise<void> | void; afterSiteRender(context: ConfigPaths): Promise<void> | void;
} }
/** /**
* User-provided configuration options. * User-provided configuration options.
*/ */
export type UserConfig = { export type UserConfig = {
/** Hook implementations that allow customizing the rendering process. */ /** Hook implementations that allow customizing the rendering process. */
hooks?: Partial<Hooks>; hooks?: Partial<Hooks>;
/** Additional folders and files to watch by the development server. */ /** Additional folders and files to watch by the development server. */
watch?: string[]; watch?: string[];
}; };
/** /**
* Fully-realized configuration for a websnacks site. * Fully-realized configuration for a websnacks site.
*/ */
export interface Config { export interface Config {
paths: ConfigPaths; paths: ConfigPaths;
hooks: Hooks; hooks: Hooks;
watch: string[]; watch: string[];
} }
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
@ -58,42 +58,44 @@ 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 {
configPath = require.resolve(path.resolve(rootDir, "websnacks")); configPath = require.resolve(path.resolve(rootDir, "websnacks"));
decacheModule(configPath); decacheModule(configPath);
// TODO: validate user config. // TODO: validate user config.
userConfig = await import(configPath); userConfig = await import(configPath);
} catch (error) { } catch (error) {
// Use default config; // Use default config;
} }
const outDir = path.join(rootDir, "public"); const outDir = path.join(rootDir, "public");
const pagesDir = path.join(rootDir, "pages"); const pagesDir = path.join(rootDir, "pages");
const staticAssetsDir = path.join(rootDir, "static"); const staticAssetsDir = path.join(rootDir, "static");
const watch = [pagesDir, staticAssetsDir]; 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) {
for (const userWatch of userConfig.watch) { for (const userWatch of userConfig.watch) {
watch.push(path.relative(rootDir, userWatch)); watch.push(path.relative(rootDir, userWatch));
} }
} }
return { return {
paths: { paths: {
rootDir, rootDir,
outDir, outDir,
pagesDir, pagesDir,
staticAssetsDir, staticAssetsDir,
}, },
hooks: { hooks: {
afterSiteRender: noop, afterSiteRender: noop,
...userConfig.hooks, ...userConfig.hooks,
}, },
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";
/** /**
@ -18,9 +18,9 @@ import { flatDeep } from "./utils";
* @return Fully-realized HTMLElement, ready for rendering. * @return Fully-realized HTMLElement, ready for rendering.
*/ */
export function createElement<P extends object>( export function createElement<P extends object>(
comp: Component<P>, comp: Component<P>,
props: P, props: P,
...children: Element[] ...children: Element[]
): HTMLElement; ): HTMLElement;
/** /**
* Create an HTMLElement from a standard HTML5 tag. * Create an HTMLElement from a standard HTML5 tag.
@ -33,47 +33,44 @@ export function createElement<P extends object>(
* @return Fully-realized HTMLElement, ready for rendering. * @return Fully-realized HTMLElement, ready for rendering.
*/ */
export function createElement( export function createElement(
tag: string, tag: string,
attrs: HTMLAttributes | null, attrs: HTMLAttributes | null,
...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 {
// Flatten the children array so we can accept arrays as children. // Flatten the children array so we can accept arrays as children.
const normalizedChildren = flatDeep(children); const normalizedChildren = flatDeep(children);
if (type instanceof Function) { if (type instanceof Function) {
return type({ ...props, children: normalizedChildren }); return type({ ...props, children: normalizedChildren });
} }
if (type !== type.toLowerCase()) { if (type !== type.toLowerCase()) {
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`); console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
} }
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 (key === "dangerouslySetInnerHTML") {
if (children.length > 0) { if (children.length > 0) {
throw new Error( throw new Error(
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden', 'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
); );
} }
attrs[key] = value.__html; attrs[key] = value.__html;
continue; 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; return { tag: type, attributes: attrs, children: normalizedChildren };
}
return { tag: type, attributes: attrs, children: normalizedChildren };
} }

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,285 +4,285 @@
*/ */
// 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;
datatype?: string; datatype?: string;
inlist?: boolean; inlist?: boolean;
prefix?: string; prefix?: string;
property?: string; property?: string;
resource?: string; resource?: string;
typeof?: string; typeof?: string;
vocab?: string; vocab?: string;
} }
export interface MicrodataAttributes { export interface MicrodataAttributes {
itemProp?: string; itemProp?: string;
itemScope?: boolean; itemScope?: boolean;
itemType?: string; itemType?: string;
itemID?: string; itemID?: string;
itemRef?: string; itemRef?: string;
} }
export interface SetInnerHtmlAttributes { export interface SetInnerHtmlAttributes {
dangerouslySetInnerHTML?: { __html: string }; dangerouslySetInnerHTML?: { __html: string };
} }
export interface HTMLAttributes export interface HTMLAttributes
extends RdfaAttributes, extends RdfaAttributes,
MicrodataAttributes, MicrodataAttributes,
SetInnerHtmlAttributes { SetInnerHtmlAttributes {
accept?: string; accept?: string;
acceptCharset?: string; acceptCharset?: string;
accessKey?: string; accessKey?: string;
action?: string; action?: string;
allowFullScreen?: boolean; allowFullScreen?: boolean;
allowTransparency?: boolean; allowTransparency?: boolean;
alt?: string; alt?: string;
as?: string; as?: string;
async?: boolean; async?: boolean;
autoComplete?: string; autoComplete?: string;
autoCorrect?: string; autoCorrect?: string;
autoFocus?: boolean; autoFocus?: boolean;
autoPlay?: boolean; autoPlay?: boolean;
capture?: boolean; capture?: boolean;
cellPadding?: number | string; cellPadding?: number | string;
cellSpacing?: number | string; cellSpacing?: number | string;
charSet?: string; charSet?: string;
challenge?: string; challenge?: string;
checked?: boolean; checked?: boolean;
class?: string; class?: string;
className?: string; className?: string;
cols?: number; cols?: number;
colSpan?: number; colSpan?: number;
content?: string; content?: string;
contentEditable?: boolean; contentEditable?: boolean;
contextMenu?: string; contextMenu?: string;
controls?: boolean; controls?: boolean;
controlsList?: string; controlsList?: string;
coords?: string; coords?: string;
crossOrigin?: string; crossOrigin?: string;
data?: string; data?: string;
dateTime?: string; dateTime?: string;
default?: boolean; default?: boolean;
defer?: boolean; defer?: boolean;
dir?: "auto" | "rtl" | "ltr"; dir?: "auto" | "rtl" | "ltr";
disabled?: boolean; disabled?: boolean;
disableRemotePlayback?: boolean; disableRemotePlayback?: boolean;
download?: boolean | string; download?: boolean | string;
draggable?: boolean; draggable?: boolean;
encType?: string; encType?: string;
form?: string; form?: string;
formAction?: string; formAction?: string;
formEncType?: string; formEncType?: string;
formMethod?: string; formMethod?: string;
formNoValidate?: boolean; formNoValidate?: boolean;
formTarget?: string; formTarget?: string;
frameBorder?: number | string; frameBorder?: number | string;
headers?: string; headers?: string;
height?: number | string; height?: number | string;
hidden?: boolean; hidden?: boolean;
high?: number; high?: number;
href?: string; href?: string;
hrefLang?: string; hrefLang?: string;
for?: string; for?: string;
htmlFor?: string; htmlFor?: string;
httpEquiv?: string; httpEquiv?: string;
icon?: string; icon?: string;
id?: string; id?: string;
inputMode?: string; inputMode?: string;
integrity?: string; integrity?: string;
is?: string; is?: string;
keyParams?: string; keyParams?: string;
keyType?: string; keyType?: string;
kind?: string; kind?: string;
label?: string; label?: string;
lang?: string; lang?: string;
list?: string; list?: string;
loop?: boolean; loop?: boolean;
low?: number; low?: number;
manifest?: string; manifest?: string;
marginHeight?: number; marginHeight?: number;
marginWidth?: number; marginWidth?: number;
max?: number | string; max?: number | string;
maxLength?: number; maxLength?: number;
media?: string; media?: string;
mediaGroup?: string; mediaGroup?: string;
method?: string; method?: string;
min?: number | string; min?: number | string;
minLength?: number; minLength?: number;
multiple?: boolean; multiple?: boolean;
muted?: boolean; muted?: boolean;
name?: string; name?: string;
nonce?: string; nonce?: string;
noValidate?: boolean; noValidate?: boolean;
open?: boolean; open?: boolean;
optimum?: number; optimum?: number;
pattern?: string; pattern?: string;
placeholder?: string; placeholder?: string;
playsInline?: boolean; playsInline?: boolean;
poster?: string; poster?: string;
preload?: string; preload?: string;
radioGroup?: string; radioGroup?: string;
readOnly?: boolean; readOnly?: boolean;
rel?: string; rel?: string;
required?: boolean; required?: boolean;
role?: string; role?: string;
rows?: number; rows?: number;
rowSpan?: number; rowSpan?: number;
sandbox?: string; sandbox?: string;
scope?: string; scope?: string;
scoped?: boolean; scoped?: boolean;
scrolling?: string; scrolling?: string;
seamless?: boolean; seamless?: boolean;
selected?: boolean; selected?: boolean;
shape?: string; shape?: string;
size?: number; size?: number;
sizes?: string; sizes?: string;
slot?: string; slot?: string;
span?: number; span?: number;
spellcheck?: boolean; spellcheck?: boolean;
src?: string; src?: string;
srcset?: string; srcset?: string;
srcDoc?: string; srcDoc?: string;
srcLang?: string; srcLang?: string;
srcSet?: string; srcSet?: string;
start?: number; start?: number;
step?: number | string; step?: number | string;
style?: string | { [key: string]: string | number }; style?: string | { [key: string]: string | number };
summary?: string; summary?: string;
tabIndex?: number; tabIndex?: number;
target?: string; target?: string;
title?: string; title?: string;
type?: string; type?: string;
useMap?: string; useMap?: string;
value?: string | string[] | number; value?: string | string[] | number;
volume?: string | number; volume?: string | number;
width?: number | string; width?: number | string;
wmode?: string; wmode?: string;
wrap?: string; wrap?: string;
} }
declare global { declare global {
namespace JSX { namespace JSX {
type Element = HTMLElement; type Element = HTMLElement;
type IntrinsicElements = { type IntrinsicElements = {
a: HTMLAttributes; a: HTMLAttributes;
abbr: HTMLAttributes; abbr: HTMLAttributes;
address: HTMLAttributes; address: HTMLAttributes;
area: HTMLAttributes; area: HTMLAttributes;
article: HTMLAttributes; article: HTMLAttributes;
aside: HTMLAttributes; aside: HTMLAttributes;
audio: HTMLAttributes; audio: HTMLAttributes;
b: HTMLAttributes; b: HTMLAttributes;
base: HTMLAttributes; base: HTMLAttributes;
bdi: HTMLAttributes; bdi: HTMLAttributes;
bdo: HTMLAttributes; bdo: HTMLAttributes;
big: HTMLAttributes; big: HTMLAttributes;
blockquote: HTMLAttributes; blockquote: HTMLAttributes;
body: HTMLAttributes; body: HTMLAttributes;
br: HTMLAttributes; br: HTMLAttributes;
button: HTMLAttributes; button: HTMLAttributes;
canvas: HTMLAttributes; canvas: HTMLAttributes;
caption: HTMLAttributes; caption: HTMLAttributes;
cite: HTMLAttributes; cite: HTMLAttributes;
code: HTMLAttributes; code: HTMLAttributes;
col: HTMLAttributes; col: HTMLAttributes;
colgroup: HTMLAttributes; colgroup: HTMLAttributes;
data: HTMLAttributes; data: HTMLAttributes;
datalist: HTMLAttributes; datalist: HTMLAttributes;
dd: HTMLAttributes; dd: HTMLAttributes;
del: HTMLAttributes; del: HTMLAttributes;
details: HTMLAttributes; details: HTMLAttributes;
dfn: HTMLAttributes; dfn: HTMLAttributes;
dialog: HTMLAttributes; dialog: HTMLAttributes;
div: HTMLAttributes; div: HTMLAttributes;
dl: HTMLAttributes; dl: HTMLAttributes;
dt: HTMLAttributes; dt: HTMLAttributes;
em: HTMLAttributes; em: HTMLAttributes;
embed: HTMLAttributes; embed: HTMLAttributes;
fieldset: HTMLAttributes; fieldset: HTMLAttributes;
figcaption: HTMLAttributes; figcaption: HTMLAttributes;
figure: HTMLAttributes; figure: HTMLAttributes;
footer: HTMLAttributes; footer: HTMLAttributes;
form: HTMLAttributes; form: HTMLAttributes;
h1: HTMLAttributes; h1: HTMLAttributes;
h2: HTMLAttributes; h2: HTMLAttributes;
h3: HTMLAttributes; h3: HTMLAttributes;
h4: HTMLAttributes; h4: HTMLAttributes;
h5: HTMLAttributes; h5: HTMLAttributes;
h6: HTMLAttributes; h6: HTMLAttributes;
head: HTMLAttributes; head: HTMLAttributes;
header: HTMLAttributes; header: HTMLAttributes;
hgroup: HTMLAttributes; hgroup: HTMLAttributes;
hr: HTMLAttributes; hr: HTMLAttributes;
html: HTMLAttributes; html: HTMLAttributes;
i: HTMLAttributes; i: HTMLAttributes;
iframe: HTMLAttributes; iframe: HTMLAttributes;
img: HTMLAttributes; img: HTMLAttributes;
input: HTMLAttributes; input: HTMLAttributes;
ins: HTMLAttributes; ins: HTMLAttributes;
kbd: HTMLAttributes; kbd: HTMLAttributes;
keygen: HTMLAttributes; keygen: HTMLAttributes;
label: HTMLAttributes; label: HTMLAttributes;
legend: HTMLAttributes; legend: HTMLAttributes;
li: HTMLAttributes; li: HTMLAttributes;
link: HTMLAttributes; link: HTMLAttributes;
main: HTMLAttributes; main: HTMLAttributes;
map: HTMLAttributes; map: HTMLAttributes;
mark: HTMLAttributes; mark: HTMLAttributes;
marquee: HTMLAttributes; marquee: HTMLAttributes;
menu: HTMLAttributes; menu: HTMLAttributes;
menuitem: HTMLAttributes; menuitem: HTMLAttributes;
meta: HTMLAttributes; meta: HTMLAttributes;
meter: HTMLAttributes; meter: HTMLAttributes;
nav: HTMLAttributes; nav: HTMLAttributes;
noscript: HTMLAttributes; noscript: HTMLAttributes;
object: HTMLAttributes; object: HTMLAttributes;
ol: HTMLAttributes; ol: HTMLAttributes;
optgroup: HTMLAttributes; optgroup: HTMLAttributes;
option: HTMLAttributes; option: HTMLAttributes;
output: HTMLAttributes; output: HTMLAttributes;
p: HTMLAttributes; p: HTMLAttributes;
param: HTMLAttributes; param: HTMLAttributes;
picture: HTMLAttributes; picture: HTMLAttributes;
pre: HTMLAttributes; pre: HTMLAttributes;
progress: HTMLAttributes; progress: HTMLAttributes;
q: HTMLAttributes; q: HTMLAttributes;
rp: HTMLAttributes; rp: HTMLAttributes;
rt: HTMLAttributes; rt: HTMLAttributes;
ruby: HTMLAttributes; ruby: HTMLAttributes;
s: HTMLAttributes; s: HTMLAttributes;
samp: HTMLAttributes; samp: HTMLAttributes;
script: HTMLAttributes; script: HTMLAttributes;
section: HTMLAttributes; section: HTMLAttributes;
select: HTMLAttributes; select: HTMLAttributes;
slot: HTMLAttributes; slot: HTMLAttributes;
small: HTMLAttributes; small: HTMLAttributes;
source: HTMLAttributes; source: HTMLAttributes;
span: HTMLAttributes; span: HTMLAttributes;
strong: HTMLAttributes; strong: HTMLAttributes;
style: HTMLAttributes; style: HTMLAttributes;
sub: HTMLAttributes; sub: HTMLAttributes;
summary: HTMLAttributes; summary: HTMLAttributes;
sup: HTMLAttributes; sup: HTMLAttributes;
table: HTMLAttributes; table: HTMLAttributes;
tbody: HTMLAttributes; tbody: HTMLAttributes;
td: HTMLAttributes; td: HTMLAttributes;
textarea: HTMLAttributes; textarea: HTMLAttributes;
tfoot: HTMLAttributes; tfoot: HTMLAttributes;
th: HTMLAttributes; th: HTMLAttributes;
thead: HTMLAttributes; thead: HTMLAttributes;
time: HTMLAttributes; time: HTMLAttributes;
title: HTMLAttributes; title: HTMLAttributes;
tr: HTMLAttributes; tr: HTMLAttributes;
track: HTMLAttributes; track: HTMLAttributes;
u: HTMLAttributes; u: HTMLAttributes;
ul: HTMLAttributes; ul: HTMLAttributes;
var: HTMLAttributes; var: HTMLAttributes;
video: HTMLAttributes; video: HTMLAttributes;
wbr: HTMLAttributes; wbr: HTMLAttributes;
}; };
} }
} }

View file

@ -3,89 +3,87 @@
* 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;",
"<": "&lt;", "<": "&lt;",
">": "&gt;", ">": "&gt;",
}; };
const escapeHtml = (text: string): string => const escapeHtml = (text: string): string =>
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]); text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
const escapeAttr = (text: string): string => text.replace(/"/g, "&quot;"); const escapeAttr = (text: string): string => text.replace(/"/g, "&quot;");
const renderElement = (elem: Element): string => { const renderElement = (elem: Element): string => {
// Ignore null and true/false to support nicer JSX conditional syntax with // Ignore null and true/false to support nicer JSX conditional syntax with
// &&, ||, !! operators. // &&, ||, !! operators.
if (elem == null || typeof elem === "boolean") { if (elem == null || typeof elem === "boolean") {
return ""; return "";
} }
if (typeof elem === "number") { if (typeof elem === "number") {
return elem.toString(); return elem.toString();
} }
if (typeof elem === "string") { if (typeof elem === "string") {
return escapeHtml(elem); return escapeHtml(elem);
} }
if (Array.isArray(elem)) { if (Array.isArray(elem)) {
return elem.map((e) => renderElement(e)).join(""); return elem.map((e) => renderElement(e)).join("");
} }
let output = ""; let output = "";
output += startTag(elem); output += startTag(elem);
if (elem.attributes.dangerouslySetInnerHTML != null) { if (elem.attributes.dangerouslySetInnerHTML != null) {
output += elem.attributes.dangerouslySetInnerHTML; output += elem.attributes.dangerouslySetInnerHTML;
} else { } else {
for (const child of elem.children) { for (const child of elem.children) {
output += renderElement(child); output += renderElement(child);
} }
} }
output += endTag(elem); output += endTag(elem);
return output; return output;
}; };
const startTag = (elem: HTMLElement): string => { const startTag = (elem: HTMLElement): string => {
if (elem.tag === "#fragment") { if (elem.tag === "#fragment") {
return ""; return "";
} }
let output = `<${escapeHtml(elem.tag)}`; let output = `<${escapeHtml(elem.tag)}`;
for (const [attrName, attrValue] of Object.entries(elem.attributes)) { for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
// Handle boolean attributes with a false value by not outputting the // Handle boolean attributes with a false value by not outputting the
// attribute at all. // attribute at all.
if (attrValue === false) { if (attrValue === false) {
continue; continue;
} }
// Ignore the special attr for setting raw inner HTML. // Ignore the special attr for setting raw inner HTML.
if (attrName === "dangerouslySetInnerHTML") { if (attrName === "dangerouslySetInnerHTML") {
continue; continue;
} }
let normalizedAttrName = escapeHtml(attrName.toLowerCase()); let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") { if (normalizedAttrName === "classname") {
normalizedAttrName = "class"; normalizedAttrName = "class";
} }
if (attrValue === true) { if (attrValue === true) {
output += ` ${normalizedAttrName}=""`; output += ` ${normalizedAttrName}=""`;
} else { } else {
output += ` ${normalizedAttrName}="${escapeAttr( output += ` ${normalizedAttrName}="${escapeAttr(attrValue.toString())}"`;
attrValue.toString(), }
)}"`; }
}
}
output += ">"; output += ">";
return output; return output;
}; };
const endTag = (elem: HTMLElement): string => { const endTag = (elem: HTMLElement): string => {
if (elem.tag === "#fragment") { if (elem.tag === "#fragment") {
return ""; return "";
} }
return `</${escapeHtml(elem.tag)}>`; return `</${escapeHtml(elem.tag)}>`;
}; };
/** /**
@ -97,23 +95,23 @@ 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(
`root page element must be a valid HTMLElement, got ${JSON.stringify( `root page element must be a valid HTMLElement, got ${JSON.stringify(
rootElem, rootElem,
)}`, )}`,
); );
} }
if (rootElem.tag.toLowerCase() !== "html") { if (rootElem.tag.toLowerCase() !== "html") {
throw new Error( throw new Error(
`attempted to render page with non-HTML root element ${rootElem.tag}`, `attempted to render page with non-HTML root element ${rootElem.tag}`,
); );
} }
let output = "<!DOCTYPE html>"; let output = "<!DOCTYPE html>";
output += renderElement(rootElem); output += renderElement(rootElem);
return output; return output;
}; };

View file

@ -6,35 +6,35 @@
import { isErrnoException } from "./error"; 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 ( if (
error instanceof Error && error instanceof Error &&
isErrnoException(error) && isErrnoException(error) &&
error.code === "MODULE_NOT_FOUND" error.code === "MODULE_NOT_FOUND"
) { ) {
return; return;
} }
throw error; throw error;
} }
}; };
const removeParentModuleRef = (mod: NodeModule): void => { const removeParentModuleRef = (mod: NodeModule): void => {
const parent = mod.parent; const parent = mod.parent;
if (parent == null) { if (parent == null) {
return; return;
} }
const siblings = parent.children; const siblings = parent.children;
const nSiblings = siblings.length; const nSiblings = siblings.length;
for (let i = nSiblings - 1; i >= 0; i--) { for (let i = nSiblings - 1; i >= 0; i--) {
const sibling = siblings[i]; const sibling = siblings[i];
if (sibling.id === mod.id) { if (sibling.id === mod.id) {
siblings.splice(i, 1); siblings.splice(i, 1);
return; return;
} }
} }
}; };
/** /**
@ -47,29 +47,29 @@ const removeParentModuleRef = (mod: NodeModule): void => {
* @throws Error if the module could not be resolved due to filesystem error. * @throws Error if the module could not be resolved due to filesystem error.
*/ */
export const decacheModule = (importPath: string): void => { export const decacheModule = (importPath: string): void => {
const modulePath = resolveModulePath(importPath); const modulePath = resolveModulePath(importPath);
if (modulePath == null) { if (modulePath == null) {
return; return;
} }
// DFS the module dependency tree, using iteration to avoid stack size // DFS the module dependency tree, using iteration to avoid stack size
// exceeded exceptions. // exceeded exceptions.
const modsToCheck: NodeModule[] = []; const modsToCheck: NodeModule[] = [];
const visited: Set<string> = new Set(); const visited: Set<string> = new Set();
let currentMod: NodeModule | undefined = require.cache[modulePath]; let currentMod: NodeModule | undefined = require.cache[modulePath];
while (currentMod != null) { while (currentMod != null) {
if (visited.has(currentMod.id)) { if (visited.has(currentMod.id)) {
currentMod = modsToCheck.pop(); currentMod = modsToCheck.pop();
continue; continue;
} }
removeParentModuleRef(currentMod); removeParentModuleRef(currentMod);
delete require.cache[currentMod.id]; delete require.cache[currentMod.id];
for (const childMod of currentMod.children) { for (const childMod of currentMod.children) {
modsToCheck.push(childMod); modsToCheck.push(childMod);
} }
visited.add(currentMod.id); visited.add(currentMod.id);
currentMod = modsToCheck.pop(); currentMod = modsToCheck.pop();
} }
}; };

View file

@ -1,3 +1,3 @@
export const isErrnoException = ( export const isErrnoException = (
error: Error, error: Error,
): error is NodeJS.ErrnoException => "code" in 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";
@ -16,17 +16,17 @@ export { decacheModule } from "./decache-module";
* @return Generator that yields the files found while walking the directory. * @return Generator that yields the files found while walking the directory.
*/ */
export const walkDir = async function* ( export const walkDir = async function* (
dirPath: string, dirPath: string,
): AsyncGenerator<string> { ): AsyncGenerator<string> {
const dirEnts = await fs.readdir(dirPath, { withFileTypes: true }); const dirEnts = await fs.readdir(dirPath, { withFileTypes: true });
for (const dirEnt of dirEnts) { for (const dirEnt of dirEnts) {
if (dirEnt.isDirectory()) { if (dirEnt.isDirectory()) {
yield* walkDir(path.join(dirPath, dirEnt.name)); yield* walkDir(path.join(dirPath, dirEnt.name));
} }
if (dirEnt.isFile()) { if (dirEnt.isFile()) {
yield path.join(dirPath, dirEnt.name); yield path.join(dirPath, dirEnt.name);
} }
} }
}; };
export type Flattenable<T> = Array<T | Flattenable<T>>; export type Flattenable<T> = Array<T | Flattenable<T>>;
@ -39,13 +39,13 @@ export type Flattenable<T> = Array<T | Flattenable<T>>;
* @return Flattened array. * @return Flattened array.
*/ */
export const flatDeep = <T>(arr: Flattenable<T>): T[] => { export const flatDeep = <T>(arr: Flattenable<T>): T[] => {
const flattenedArr: T[] = []; const flattenedArr: T[] = [];
for (const val of arr) { for (const val of arr) {
if (Array.isArray(val)) { if (Array.isArray(val)) {
flattenedArr.push(...flatDeep(val)); flattenedArr.push(...flatDeep(val));
} else { } else {
flattenedArr.push(val); flattenedArr.push(val);
} }
} }
return flattenedArr; return flattenedArr;
}; };

View file

@ -3,149 +3,149 @@
* 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, WEBSNACKS_BIN_PATH,
runCommand, WEBSNACKS_REPO_ROOT,
WEBSNACKS_BIN_PATH, npmCmd,
WEBSNACKS_REPO_ROOT, runCommand,
withTempDir, withTempDir,
} from "../helpers/e2e"; } from "../helpers/e2e";
import { testSuite } from "../lib"; import { testSuite } from "../lib";
testSuite("build command", ({ test }) => { testSuite("build command", ({ test }) => {
test("runs without throwing error", async () => { test("runs without throwing error", async () => {
await withTempDir(async (tempDirPath) => { await withTempDir(async (tempDirPath) => {
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"), path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({ JSON.stringify({
compilerOptions: { compilerOptions: {
esModuleInterop: true, esModuleInterop: true,
module: "CommonJS", module: "CommonJS",
moduleResolution: "node", moduleResolution: "node",
jsx: "react", jsx: "react",
jsxFactory: "createElement", jsxFactory: "createElement",
target: "ES2018", target: "ES2018",
lib: ["ES2018"], lib: ["ES2018"],
strict: true, strict: true,
noUnusedLocals: true, noUnusedLocals: true,
noUnusedParameters: true, noUnusedParameters: true,
noImplicitReturns: true, noImplicitReturns: true,
noFallthroughCasesInSwitch: true, noFallthroughCasesInSwitch: true,
}, },
include: ["components/**/*", "pages/**/*"], include: ["components/**/*", "pages/**/*"],
}), }),
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "websnacks.ts"), path.join(tempDirPath, "websnacks.ts"),
` `
import { Config } from "websnacks"; import { Config } from "websnacks";
const config: Config = { const config: Config = {
watch: [], watch: [],
}; };
export = config; export = config;
`, `,
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
const pagesPath = path.join(tempDirPath, "pages"); const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath); await fs.mkdir(pagesPath);
await fs.writeFile( await fs.writeFile(
path.join(pagesPath, "index.tsx"), path.join(pagesPath, "index.tsx"),
` `
import { createElement } from "websnacks"; import { createElement } from "websnacks";
export const page = () => <html />; export const page = () => <html />;
`, `,
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "package.json"), path.join(tempDirPath, "package.json"),
JSON.stringify({ JSON.stringify({
devDependencies: { devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`, websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
}, },
}), }),
{ encoding: "utf8" }, { encoding: "utf8" },
); );
await runCommand(npmCmd, ["install", "--silent"], { await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath, cwd: tempDirPath,
}).complete; }).complete;
const cmd = runCommand( const cmd = runCommand(
"node", "node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"], [WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"],
{ {
cwd: tempDirPath, cwd: tempDirPath,
}, },
); );
await cmd.complete; await cmd.complete;
}); });
}); });
test("works without config file", async () => { test("works without config file", async () => {
await withTempDir(async (tempDirPath) => { await withTempDir(async (tempDirPath) => {
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"), path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({ JSON.stringify({
compilerOptions: { compilerOptions: {
esModuleInterop: true, esModuleInterop: true,
module: "CommonJS", module: "CommonJS",
moduleResolution: "node", moduleResolution: "node",
jsx: "react", jsx: "react",
jsxFactory: "createElement", jsxFactory: "createElement",
target: "ES2018", target: "ES2018",
lib: ["ES2018"], lib: ["ES2018"],
strict: true, strict: true,
noUnusedLocals: true, noUnusedLocals: true,
noUnusedParameters: true, noUnusedParameters: true,
noImplicitReturns: true, noImplicitReturns: true,
noFallthroughCasesInSwitch: true, noFallthroughCasesInSwitch: true,
}, },
include: ["components/**/*", "pages/**/*"], include: ["components/**/*", "pages/**/*"],
}), }),
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
const pagesPath = path.join(tempDirPath, "pages"); const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath); await fs.mkdir(pagesPath);
await fs.writeFile( await fs.writeFile(
path.join(pagesPath, "index.tsx"), path.join(pagesPath, "index.tsx"),
` `
import { createElement } from "websnacks"; import { createElement } from "websnacks";
export const page = () => <html />; export const page = () => <html />;
`, `,
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "package.json"), path.join(tempDirPath, "package.json"),
JSON.stringify({ JSON.stringify({
devDependencies: { devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`, websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
}, },
}), }),
{ encoding: "utf8" }, { encoding: "utf8" },
); );
await runCommand(npmCmd, ["install", "--silent"], { await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath, cwd: tempDirPath,
}).complete; }).complete;
const cmd = runCommand( const cmd = runCommand(
"node", "node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"], [WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "build"],
{ {
cwd: tempDirPath, cwd: tempDirPath,
}, },
); );
await cmd.complete; await cmd.complete;
}); });
}); });
}); });

View file

@ -3,158 +3,158 @@
* 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, WEBSNACKS_BIN_PATH,
runCommand, WEBSNACKS_REPO_ROOT,
wait, npmCmd,
WEBSNACKS_BIN_PATH, runCommand,
WEBSNACKS_REPO_ROOT, wait,
withTempDir, withTempDir,
} from "../helpers/e2e"; } from "../helpers/e2e";
import { testSuite } from "../lib"; import { testSuite } from "../lib";
testSuite("dev command", ({ test, expect }) => { testSuite("dev command", ({ test, expect }) => {
test("starts without throwing error", async () => { test("starts without throwing error", async () => {
await withTempDir(async (tempDirPath) => { await withTempDir(async (tempDirPath) => {
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"), path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({ JSON.stringify({
compilerOptions: { compilerOptions: {
esModuleInterop: true, esModuleInterop: true,
module: "CommonJS", module: "CommonJS",
moduleResolution: "node", moduleResolution: "node",
jsx: "react", jsx: "react",
jsxFactory: "createElement", jsxFactory: "createElement",
target: "ES2018", target: "ES2018",
lib: ["ES2018"], lib: ["ES2018"],
strict: true, strict: true,
noUnusedLocals: true, noUnusedLocals: true,
noUnusedParameters: true, noUnusedParameters: true,
noImplicitReturns: true, noImplicitReturns: true,
noFallthroughCasesInSwitch: true, noFallthroughCasesInSwitch: true,
}, },
include: ["components/**/*", "pages/**/*"], include: ["components/**/*", "pages/**/*"],
}), }),
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "websnacks.ts"), path.join(tempDirPath, "websnacks.ts"),
` `
import { Config } from "websnacks"; import { Config } from "websnacks";
const config: Config = { const config: Config = {
watch: [], watch: [],
}; };
export = config; export = config;
`, `,
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
const pagesPath = path.join(tempDirPath, "pages"); const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath); await fs.mkdir(pagesPath);
await fs.writeFile( await fs.writeFile(
path.join(pagesPath, "index.tsx"), path.join(pagesPath, "index.tsx"),
` `
import { createElement } from "websnacks"; import { createElement } from "websnacks";
export const page = () => <html />; export const page = () => <html />;
`, `,
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "package.json"), path.join(tempDirPath, "package.json"),
JSON.stringify({ JSON.stringify({
devDependencies: { devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`, websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
}, },
}), }),
{ encoding: "utf8" }, { encoding: "utf8" },
); );
await runCommand(npmCmd, ["install", "--silent"], { await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath, cwd: tempDirPath,
}).complete; }).complete;
const cmd = runCommand( const cmd = runCommand(
"node", "node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"], [WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"],
{ {
cwd: tempDirPath, cwd: tempDirPath,
}, },
); );
// FIXME: This test is a bit brittle due to relying on timeouts. // FIXME: This test is a bit brittle due to relying on timeouts.
await wait(10_000); await wait(10_000);
cmd.process.kill(); cmd.process.kill();
const stdout = await cmd.complete; const stdout = await cmd.complete;
expect(stdout).toStartWith("Listening at"); expect(stdout).toStartWith("Listening at");
}); });
}); });
test("works without config file", async () => { test("works without config file", async () => {
await withTempDir(async (tempDirPath) => { await withTempDir(async (tempDirPath) => {
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "tsconfig.json"), path.join(tempDirPath, "tsconfig.json"),
JSON.stringify({ JSON.stringify({
compilerOptions: { compilerOptions: {
esModuleInterop: true, esModuleInterop: true,
module: "CommonJS", module: "CommonJS",
moduleResolution: "node", moduleResolution: "node",
jsx: "react", jsx: "react",
jsxFactory: "createElement", jsxFactory: "createElement",
target: "ES2018", target: "ES2018",
lib: ["ES2018"], lib: ["ES2018"],
strict: true, strict: true,
noUnusedLocals: true, noUnusedLocals: true,
noUnusedParameters: true, noUnusedParameters: true,
noImplicitReturns: true, noImplicitReturns: true,
noFallthroughCasesInSwitch: true, noFallthroughCasesInSwitch: true,
}, },
include: ["components/**/*", "pages/**/*"], include: ["components/**/*", "pages/**/*"],
}), }),
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
const pagesPath = path.join(tempDirPath, "pages"); const pagesPath = path.join(tempDirPath, "pages");
await fs.mkdir(pagesPath); await fs.mkdir(pagesPath);
await fs.writeFile( await fs.writeFile(
path.join(pagesPath, "index.tsx"), path.join(pagesPath, "index.tsx"),
` `
import { createElement } from "websnacks"; import { createElement } from "websnacks";
export const page = () => <html />; export const page = () => <html />;
`, `,
{ {
encoding: "utf8", encoding: "utf8",
}, },
); );
await fs.writeFile( await fs.writeFile(
path.join(tempDirPath, "package.json"), path.join(tempDirPath, "package.json"),
JSON.stringify({ JSON.stringify({
devDependencies: { devDependencies: {
websnacks: `file:${WEBSNACKS_REPO_ROOT}`, websnacks: `file:${WEBSNACKS_REPO_ROOT}`,
}, },
}), }),
{ encoding: "utf8" }, { encoding: "utf8" },
); );
await runCommand(npmCmd, ["install", "--silent"], { await runCommand(npmCmd, ["install", "--silent"], {
cwd: tempDirPath, cwd: tempDirPath,
}).complete; }).complete;
const cmd = runCommand( const cmd = runCommand(
"node", "node",
[WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"], [WEBSNACKS_BIN_PATH, "-r", "ts-node/register", "dev"],
{ {
cwd: tempDirPath, cwd: tempDirPath,
}, },
); );
// FIXME: This test is a bit brittle due to relying on timeouts. // FIXME: This test is a bit brittle due to relying on timeouts.
await wait(10_000); await wait(10_000);
cmd.process.kill(); cmd.process.kill();
const stdout = await cmd.complete; const stdout = await cmd.complete;
expect(stdout).toStartWith("Listening at"); expect(stdout).toStartWith("Listening at");
}); });
}); });
}); });

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,
@ -15,9 +15,9 @@ import * as path from "path";
* @param timeMs Time in milliseconds to wait. * @param timeMs Time in milliseconds to wait.
*/ */
export const wait = async (timeMs: number): Promise<void> => { export const wait = async (timeMs: number): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => resolve(), timeMs); setTimeout(() => resolve(), timeMs);
}); });
}; };
const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp"); const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp");
@ -36,15 +36,15 @@ const TEMP_PATH = path.resolve(__dirname, "..", "..", ".temp");
* directory path as its only argument. * directory path as its only argument.
*/ */
export const withTempDir = async ( export const withTempDir = async (
op: (tempDirPath: string) => Promise<void> | void, op: (tempDirPath: string) => Promise<void> | void,
): Promise<void> => { ): Promise<void> => {
await fs.mkdir(TEMP_PATH, { recursive: true }); await fs.mkdir(TEMP_PATH, { recursive: true });
const tempDirPath = await fs.mkdtemp(`${TEMP_PATH}/`); const tempDirPath = await fs.mkdtemp(`${TEMP_PATH}/`);
try { try {
await op(tempDirPath); await op(tempDirPath);
} catch (error) { } catch (error) {
throw new Error(`(${tempDirPath}): ${error}`); throw new Error(`(${tempDirPath}): ${error}`);
} }
}; };
/** /**
@ -55,47 +55,47 @@ export const WEBSNACKS_REPO_ROOT = path.resolve(__dirname, "..", "..");
* Fully resolved path to the websnacks CLI script in this repository. * Fully resolved path to the websnacks CLI script in this repository.
*/ */
export const WEBSNACKS_BIN_PATH = path.join( export const WEBSNACKS_BIN_PATH = path.join(
WEBSNACKS_REPO_ROOT, WEBSNACKS_REPO_ROOT,
"bin", "bin",
"websnacks.js", "websnacks.js",
); );
/** /**
* A handle to an asynchronous shell command run in a subprocess. * A handle to an asynchronous shell command run in a subprocess.
*/ */
export interface AsyncCommand { export interface AsyncCommand {
/** /**
* Promise that resolves with the stdout of the subprocess once the * Promise that resolves with the stdout of the subprocess once the
* subprocess exits with a zero-code. * subprocess exits with a zero-code.
* *
* The promise rejects if the subprocess exits with a non-zero code, the * The promise rejects if the subprocess exits with a non-zero code, the
* subprocess writes to its stderr, or the command failed to spawn. * subprocess writes to its stderr, or the command failed to spawn.
*/ */
complete: Promise<string>; complete: Promise<string>;
/** /**
* Handle to to child process for event-based process manipulation. * Handle to to child process for event-based process manipulation.
*/ */
process: ChildProcess; process: ChildProcess;
} }
/** /**
* Options used to configure {@link runCommand}. * Options used to configure {@link runCommand}.
*/ */
export interface CliOptions { export interface CliOptions {
/** /**
* Working directory where the command should be run. Defaults to the * Working directory where the command should be run. Defaults to the
* current working directory. * current working directory.
*/ */
cwd?: string; cwd?: string;
/** /**
* Timeout in milliseconds after which a command that hasn't exited will * Timeout in milliseconds after which a command that hasn't exited will
* reject the promise and kill the subprocess. * reject the promise and kill the subprocess.
*/ */
timeoutMs?: number; timeoutMs?: number;
} }
const DEFAULT_CLI_OPTIONS = { const DEFAULT_CLI_OPTIONS = {
timeoutMs: 15_000, timeoutMs: 15_000,
}; };
/** /**
@ -111,60 +111,58 @@ const DEFAULT_CLI_OPTIONS = {
* @returns Command object for handling in client code. * @returns Command object for handling in client code.
*/ */
export const runCommand = ( export const runCommand = (
command: string, command: string,
args: string[] = [], args: string[] = [],
options?: CliOptions, options?: CliOptions,
): AsyncCommand => { ): AsyncCommand => {
const optionsWithDefaults = { ...DEFAULT_CLI_OPTIONS, ...options }; const optionsWithDefaults = { ...DEFAULT_CLI_OPTIONS, ...options };
const process = spawn(command, args, { const process = spawn(command, args, {
...optionsWithDefaults, ...optionsWithDefaults,
stdio: "pipe", stdio: "pipe",
}); });
const complete = new Promise<string>((resolve, reject) => { const complete = new Promise<string>((resolve, reject) => {
let threwError = false; let threwError = false;
let stdout = ""; let stdout = "";
process.stdout.on("data", (data) => { process.stdout.on("data", (data) => {
stdout += data.toString(); stdout += data.toString();
}); });
process.stderr.on("data", (data) => { process.stderr.on("data", (data) => {
threwError = true; threwError = true;
process.kill(); process.kill();
reject(new Error(`command output to stderr: ${data.toString()}`)); reject(new Error(`command output to stderr: ${data.toString()}`));
}); });
const timer = setTimeout(() => { const timer = setTimeout(() => {
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);
); process.on("exit", (code) => {
}, optionsWithDefaults.timeoutMs); if (threwError) {
process.on("exit", (code) => { return;
if (threwError) { }
return; clearTimeout(timer);
} if (code !== null && code !== 0) {
clearTimeout(timer); reject(new Error(`command exited with non-zero code: ${code}`));
if (code !== null && code !== 0) { return;
reject(new Error(`command exited with non-zero code: ${code}`)); }
return; resolve(stdout);
} });
resolve(stdout); process.on("error", (error) => {
}); clearTimeout(timer);
process.on("error", (error) => { if (!threwError) {
clearTimeout(timer); reject(new Error(`command errored: ${error}`));
if (!threwError) { threwError = true;
reject(new Error(`command errored: ${error}`)); }
threwError = true; });
} });
}); return {
}); complete,
return { process,
complete, };
process,
};
}; };
export const npmCmd = os.platform() === "win32" ? "npm.cmd" : "npm"; export const npmCmd = os.platform() === "win32" ? "npm.cmd" : "npm";

View file

@ -6,13 +6,13 @@
import { areEqual, displayValue, matches } from "./utils"; import { areEqual, displayValue, matches } from "./utils";
class ExpectError extends Error { class ExpectError extends Error {
public constructor(reason: string, expected: unknown, actual: unknown) { public constructor(reason: string, expected: unknown, actual: unknown) {
super( super(
`${reason}\n` + `${reason}\n` +
`\texpected: ${displayValue(expected)}\n` + `\texpected: ${displayValue(expected)}\n` +
`\tactual : ${displayValue(actual)}`, `\tactual : ${displayValue(actual)}`,
); );
} }
} }
/** /**
@ -24,138 +24,134 @@ class ExpectError extends Error {
* Expect. * Expect.
*/ */
export class Expect<T> { export class Expect<T> {
protected readonly value: T; protected readonly value: T;
/** /**
* Create a new expectation around a value. * Create a new expectation around a value.
* *
* @param value Value to place assertions upon. * @param value Value to place assertions upon.
*/ */
public constructor(value: T) { public constructor(value: T) {
this.value = value; this.value = value;
} }
/** /**
* Expect the value to equal an expected value. * Expect the value to equal an expected value.
* *
* Note that strict equality checking is used for primitives and structural * Note that strict equality checking is used for primitives and structural
* equality is used for objects. * equality is used for objects.
* *
* @param expected Expected value. * @param expected Expected value.
* *
* @throws ExpectError If the actual value does not equal the expected value. * @throws ExpectError If the actual value does not equal the expected value.
*/ */
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,
); );
} }
} }
} }
/** /**
* String-specific Expect assertions. * String-specific Expect assertions.
*/ */
export class StringExpect extends Expect<string> { export class StringExpect extends Expect<string> {
/** /**
* Expect the string value to match a RegExp pattern. * Expect the string value to match a RegExp pattern.
* *
* @param pattern Regular expression to match against. * @param pattern Regular expression to match against.
* *
* @throws ExpectError If the actual value does not match the expected * @throws ExpectError If the actual value does not match the expected
* RegExp pattern. * RegExp pattern.
*/ */
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,
); );
} }
} }
/** /**
* Expect the string value to start with a particular prefix. * Expect the string value to start with a particular prefix.
* *
* @param prefix Prefix that the string is expected to start with. * @param prefix Prefix that the string is expected to start with.
* *
* @throws ExpectError If the actual value does not start with the expected * @throws ExpectError If the actual value does not start with the expected
* prefix. * prefix.
*/ */
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,
); );
} }
} }
/** /**
* Expect the string value to end with a particular suffix. * Expect the string value to end with a particular suffix.
* *
* @param suffix Suffix that the string is expected to end with. * @param suffix Suffix that the string is expected to end with.
* *
* @throws ExpectError If the actual value does not end with the expected * @throws ExpectError If the actual value does not end with the expected
* suffix. * suffix.
*/ */
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,
); );
} }
} }
} }
/** /**
* Function-specific Expect assertions. * Function-specific Expect assertions.
*/ */
export class FunctionExpect<T> extends Expect<() => T> { export class FunctionExpect<T> extends Expect<() => T> {
/** /**
* Expect the function to throw an Error with error message matching a * Expect the function to throw an Error with error message matching a
* string or pattern. * string or pattern.
* *
* @param pattern String that exactly matches the error message or RegExp * @param pattern String that exactly matches the error message or RegExp
* that should match the error message. * that should match the error message.
* *
* @throws ExpectError If the function does not throw an error, throws a * @throws ExpectError If the function does not throw an error, throws a
* non-Error value, or throws an Error whose message does not match * non-Error value, or throws an Error whose message does not match
* the expected pattern. * the expected pattern.
*/ */
public toThrowErrorMatching(pattern: string | RegExp): void { public toThrowErrorMatching(pattern: string | RegExp): void {
try { try {
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, if (!matches(error.message, pattern)) {
error, throw new ExpectError(
); `thrown Error's message does not match pattern`,
} pattern,
if (!matches(error.message, pattern)) { error.message,
throw new ExpectError( );
`thrown Error's message does not match pattern`, }
pattern, return;
error.message, }
); throw new ExpectError(
} "function did not throw expected error",
return; pattern,
} null,
throw new ExpectError( );
`function did not throw expected error`, }
pattern,
null,
);
}
} }
/** /**
@ -202,11 +198,11 @@ export function expect<T>(fn: () => T): FunctionExpect<T>;
* @param value Value to place expectations upon. * @param value Value to place expectations upon.
*/ */
export function expect(value: unknown): Expect<unknown> { export function expect(value: unknown): Expect<unknown> {
if (typeof value === "string") { if (typeof value === "string") {
return new StringExpect(value); return new StringExpect(value);
} }
if (typeof value === "function") { if (typeof value === "function") {
return new FunctionExpect(value as () => unknown); return new FunctionExpect(value as () => unknown);
} }
return new Expect(value); return new Expect(value);
} }

View file

@ -7,68 +7,66 @@ import { expect } from "./expect";
import { displayValue, shuffle } from "./utils"; import { displayValue, shuffle } from "./utils";
interface Test { interface Test {
readonly name: string; readonly name: string;
runTest(): void | Promise<void>; runTest(): void | Promise<void>;
} }
type TestResult = { type TestResult = {
testName: string; testName: string;
} & ( } & (
| { | {
result: "pass"; result: "pass";
} }
| { | {
result: "fail"; result: "fail";
error: Error; error: Error;
} }
); );
const runTest = async (test: Test): Promise<TestResult> => { const runTest = async (test: Test): Promise<TestResult> => {
let result: TestResult; let result: TestResult;
try { try {
await test.runTest(); await test.runTest();
result = { result = {
testName: test.name, testName: test.name,
result: "pass", result: "pass",
}; };
} catch (error) { } catch (error) {
result = { result = {
testName: test.name, testName: test.name,
result: "fail", result: "fail",
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;
}; };
/** /**
* Context object that is passed into a test suite definition. * Context object that is passed into a test suite definition.
*/ */
export interface TestSuiteContext { export interface TestSuiteContext {
/** /**
* Define a test in this test suite. * Define a test in this test suite.
* *
* Tests are functions that pass if they are executed and don't throw (or * Tests are functions that pass if they are executed and don't throw (or
* that resolve for async tests), and that fail if they throw an error (or * that resolve for async tests), and that fail if they throw an error (or
* reject for async tests). * reject for async tests).
* *
* Note that tests are executed in a random order within a test suite in * Note that tests are executed in a random order within a test suite in
* order to prevent accidentally creating order dependencies between tests, * order to prevent accidentally creating order dependencies between tests,
* which can result in brittle tests and is a code smell that might indicate * which can result in brittle tests and is a code smell that might indicate
* that the code under test is also brittle. * that the code under test is also brittle.
*/ */
test: (name: string, def: () => void | Promise<void>) => void; test: (name: string, def: () => void | Promise<void>) => void;
/** /**
* Expectation builder function used to build human-readable assertions and * Expectation builder function used to build human-readable assertions and
* errors. * errors.
*/ */
expect: typeof expect; expect: typeof expect;
} }
/** /**
@ -85,36 +83,36 @@ export interface TestSuiteContext {
* @param def Function used to declare the tests * @param def Function used to declare the tests
*/ */
export const testSuite = ( export const testSuite = (
suiteName: string, suiteName: string,
def: (ctx: TestSuiteContext) => void, def: (ctx: TestSuiteContext) => void,
): void => { ): void => {
const tests: Test[] = []; const tests: Test[] = [];
const test = (name: string, runTest: () => void | Promise<void>): void => { const test = (name: string, runTest: () => void | Promise<void>): void => {
tests.push({ name, runTest }); tests.push({ name, runTest });
}; };
def({ test, expect }); def({ test, expect });
// Randomly shuffle the tests so that we can catch accidental order // Randomly shuffle the tests so that we can catch accidental order
// dependencies. // dependencies.
shuffle(tests); shuffle(tests);
(async () => { (async () => {
const results = await Promise.all(tests.map((test) => runTest(test))); const results = await Promise.all(tests.map((test) => runTest(test)));
let passed = 0; let passed = 0;
for (const testResult of results) { for (const testResult of results) {
if (testResult.result === "fail") { if (testResult.result === "fail") {
console.error( console.error(
`[TEST FAILURE] "${suiteName}": "${testResult.testName}": ` + `[TEST FAILURE] "${suiteName}": "${testResult.testName}": ` +
`${testResult.error.stack}\n`, `${testResult.error.stack}\n`,
); );
continue; continue;
} }
passed += 1; passed += 1;
} }
console.info( 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) { if (passed < tests.length) {
process.exitCode = 1; process.exitCode = 1;
} }
})(); })();
}; };

View file

@ -9,43 +9,43 @@
* @param arr Array to shuffle. * @param arr Array to shuffle.
*/ */
export const shuffle = <T>(arr: T[]): void => { export const shuffle = <T>(arr: T[]): void => {
let j: number; let j: number;
let x: T; let x: T;
for (let i = arr.length - 1; i > 0; i--) { for (let i = arr.length - 1; i > 0; i--) {
j = Math.floor(Math.random() * (i + 1)); j = Math.floor(Math.random() * (i + 1));
x = arr[i]; x = arr[i];
arr[i] = arr[j]; arr[i] = arr[j];
arr[j] = x; arr[j] = x;
} }
}; };
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++) {
if (!areEqual(a[i], b[i])) { if (!areEqual(a[i], b[i])) {
return false; return false;
} }
} }
return true; return true;
}; };
const areObjectsEqual = <T extends Record<string, unknown>>( const areObjectsEqual = <T extends Record<string, unknown>>(
a: T, a: T,
b: T, b: T,
): boolean => { ): boolean => {
const aKeys = Object.keys(a) as Array<keyof T>; const aKeys = Object.keys(a) as Array<keyof T>;
const bKeys = Object.keys(b) as Array<keyof T>; const bKeys = Object.keys(b) as Array<keyof T>;
if (aKeys.length !== bKeys.length) { if (aKeys.length !== bKeys.length) {
return false; return false;
} }
for (const key of aKeys) { for (const key of aKeys) {
if (!areEqual(a[key], b[key])) { if (!areEqual(a[key], b[key])) {
return false; return false;
} }
} }
return true; return true;
}; };
/** /**
@ -58,19 +58,19 @@ const areObjectsEqual = <T extends Record<string, unknown>>(
* @return Whether the two values are structurally equal. * @return Whether the two values are structurally equal.
*/ */
export const areEqual = <T>(a: T, b: T): boolean => { export const areEqual = <T>(a: T, b: T): boolean => {
if (Array.isArray(a) && Array.isArray(b)) { if (Array.isArray(a) && Array.isArray(b)) {
return areArraysEqual(a, b); return areArraysEqual(a, b);
} }
if (a instanceof RegExp && b instanceof RegExp) { if (a instanceof RegExp && b instanceof RegExp) {
return a.source === b.source; return a.source === b.source;
} }
if (typeof a === "object" && typeof b === "object") { if (typeof a === "object" && typeof b === "object") {
return areObjectsEqual( return areObjectsEqual(
a as Record<string, unknown>, a as Record<string, unknown>,
b as Record<string, unknown>, b as Record<string, unknown>,
); );
} }
return a === b; return a === b;
}; };
/** /**
@ -84,10 +84,10 @@ export const areEqual = <T>(a: T, b: T): boolean => {
* @param pattern String or RegExp pattern to match value against. * @param pattern String or RegExp pattern to match value against.
*/ */
export const matches = (value: string, pattern: string | RegExp): boolean => { export const matches = (value: string, pattern: string | RegExp): boolean => {
if (typeof pattern === "string") { if (typeof pattern === "string") {
return value === pattern; return value === pattern;
} }
return pattern.test(value); return pattern.test(value);
}; };
/** /**
@ -102,11 +102,11 @@ export const matches = (value: string, pattern: string | RegExp): boolean => {
* @return Rendered value to display. * @return Rendered value to display.
*/ */
export const displayValue = (value: unknown): string => { export const displayValue = (value: unknown): string => {
if (value === undefined) { if (value === undefined) {
return "undefined"; return "undefined";
} }
if (value instanceof RegExp) { if (value instanceof RegExp) {
return value.toString(); return value.toString();
} }
return JSON.stringify(value); return JSON.stringify(value);
}; };

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";
@ -14,10 +14,10 @@ const files = fs.readdirSync(TEST_SUITES_DIR);
// Shuffle test suites to detect ordering dependencies between them. // Shuffle test suites to detect ordering dependencies between them.
shuffle(files); shuffle(files);
for (const file of files) { for (const file of files) {
const fullPath = path.join(TEST_SUITES_DIR, file); const fullPath = path.join(TEST_SUITES_DIR, file);
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => { fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
if (code !== 0) { if (code !== 0) {
process.exitCode = 1; process.exitCode = 1;
} }
}); });
} }

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";
@ -14,10 +14,10 @@ const files = fs.readdirSync(TEST_SUITES_DIR);
// Shuffle test suites to detect ordering dependencies between them. // Shuffle test suites to detect ordering dependencies between them.
shuffle(files); shuffle(files);
for (const file of files) { for (const file of files) {
const fullPath = path.join(TEST_SUITES_DIR, file); const fullPath = path.join(TEST_SUITES_DIR, file);
fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => { fork(path.relative(process.cwd(), fullPath)).on("exit", (code) => {
if (code !== 0) { if (code !== 0) {
process.exitCode = 1; process.exitCode = 1;
} }
}); });
} }

View file

@ -3,151 +3,150 @@
* 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";
testSuite("renderPage", ({ test, expect }) => { testSuite("renderPage", ({ test, expect }) => {
test("throws an Error when root elem is not html tag", () => { test("throws an Error when root elem is not html tag", () => {
expect(() => renderPage(<div />)).toThrowErrorMatching( expect(() => renderPage(<div />)).toThrowErrorMatching(
"attempted to render page with non-HTML root element div", "attempted to render page with non-HTML root element div",
); );
}); });
test("outputs a HTML5 DOCTYPE declaration", () => { test("outputs a HTML5 DOCTYPE declaration", () => {
const html = renderPage(<html />); const html = renderPage(<html />);
expect(html).toStartWith("<!DOCTYPE html>"); expect(html).toStartWith("<!DOCTYPE html>");
}); });
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(
); "<!DOCTYPE html><html><div&gt;&lt;/div></div&gt;&lt;/div></html>",
expect(html).toEqual( );
"<!DOCTYPE html><html><div&gt;&lt;/div></div&gt;&lt;/div></html>", });
);
});
test("renders html attributes", () => { test("renders html attributes", () => {
const html = renderPage( const html = renderPage(
<html> <html>
<div className="test" id="1" /> <div className="test" id="1" />
</html>, </html>,
); );
expect(html).toEqual( expect(html).toEqual(
'<!DOCTYPE html><html><div class="test" id="1"></div></html>', '<!DOCTYPE html><html><div class="test" id="1"></div></html>',
); );
}); });
test("renders common html tags", () => { test("renders common html tags", () => {
const html = renderPage( const html = renderPage(
<html> <html>
<head> <head>
<title /> <title />
</head> </head>
<body> <body>
<div /> <div />
</body> </body>
</html>, </html>,
); );
expect(html).toEqual( 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", () => { 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", () => {
const nLights = 3; const nLights = 3;
const html = renderPage(<html>There are {nLights} lights!</html>); const html = renderPage(<html>There are {nLights} lights!</html>);
expect(html).toEqual("<!DOCTYPE html><html>There are 3 lights!</html>"); expect(html).toEqual("<!DOCTYPE html><html>There are 3 lights!</html>");
}); });
test("renders spliced arrays", () => { test("renders spliced arrays", () => {
const Light: Component<{ lightN: number }> = ({ lightN }) => ( const Light: Component<{ lightN: number }> = ({ lightN }) => (
<div>{lightN}</div> <div>{lightN}</div>
); );
const lights = [1, 2, 3]; const lights = [1, 2, 3];
const html = renderPage( const html = renderPage(
<html> <html>
There are{" "} There are{" "}
{lights.map((lightN) => ( {lights.map((lightN) => (
<Light lightN={lightN} /> <Light lightN={lightN} />
))}{" "} ))}{" "}
lights! lights!
</html>, </html>,
); );
expect(html).toEqual( 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>",
); );
}); });
test("renders components w/ custom properties", () => { test("renders components w/ custom properties", () => {
interface LightProps { interface LightProps {
nLights: number; nLights: number;
} }
const Light: Component<LightProps> = ({ nLights }) => ( const Light: Component<LightProps> = ({ nLights }) => (
<div>{nLights} lights</div> <div>{nLights} lights</div>
); );
const html = renderPage( const html = renderPage(
<html> <html>
There are <Light nLights={3} />! There are <Light nLights={3} />!
</html>, </html>,
); );
expect(html).toEqual( expect(html).toEqual(
"<!DOCTYPE html><html>There are <div>3 lights</div>!</html>", "<!DOCTYPE html><html>There are <div>3 lights</div>!</html>",
); );
}); });
test("renders fragment children only", () => { test("renders fragment children only", () => {
const html = renderPage( const html = renderPage(
<html> <html>
<Fragment> <Fragment>
<div>test of</div> <div>test of</div>
<div>fragments</div> <div>fragments</div>
</Fragment> </Fragment>
</html>, </html>,
); );
expect(html).toEqual( expect(html).toEqual(
"<!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", () => { test("renders unescaped HTML via dangerouslySetInnerHTML", () => {
const html = renderPage( const html = renderPage(
<html> <html>
<div <div
dangerouslySetInnerHTML={{ // biome-ignore lint/security/noDangerouslySetInnerHtml: explicit test
__html: "<div>red alert!</div>", dangerouslySetInnerHTML={{
}} __html: "<div>red alert!</div>",
/> }}
</html>, />
); </html>,
expect(html).toEqual( );
"<!DOCTYPE html><html><div><div>red alert!</div></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", () => { test("throws error when both dangerouslySetInnerHTML and children prop present", () => {
expect(() => expect(() =>
renderPage( renderPage(
<html> <html>
<div <div
dangerouslySetInnerHTML={{ // biome-ignore lint/security/noDangerouslySetInnerHtml: explicit test
__html: "<div>set phasers to kill</div>", // biome-ignore lint/security/noDangerouslySetInnerHtmlWithChildren: explicit test
}} dangerouslySetInnerHTML={{
> __html: "<div>set phasers to kill</div>",
<div>set phasers to stun</div> }}
</div> >
</html>, <div>set phasers to stun</div>
), </div>
).toThrowErrorMatching( </html>,
'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden', ),
); ).toThrowErrorMatching(
}); 'An element with children may not have a "dangerouslySetInnerHTML" prop since children would be overriden',
);
});
}); });

View file

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

View file

@ -1,13 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "CommonJS", "module": "CommonJS",
"moduleResolution": "node", "moduleResolution": "node",
"target": "ES2018", "target": "ES2018",
"lib": ["ES2018"], "lib": ["ES2018"],
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
} }
} }

View file

@ -1,9 +1,9 @@
{ {
"extends": "./tsconfig-base.json", "extends": "./tsconfig-base.json",
"compilerOptions": { "compilerOptions": {
"declaration": true, "declaration": true,
"sourceMap": true, "sourceMap": true,
"outDir": "./dist" "outDir": "./dist"
}, },
"include": ["src/**/*.ts"] "include": ["src/**/*.ts"]
} }