initial commit (it all starts here...)

This commit is contained in:
M. George Hansen 2020-05-25 22:36:20 -07:00
commit 9e9842dc8c
36 changed files with 4550 additions and 0 deletions

90
src/build.ts Normal file
View file

@ -0,0 +1,90 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { promises as fs } from "fs";
import * as path from "path";
import { Config, ConfigPaths } from "./config";
import { renderPage } from "./render";
import { purgeModuleAndDepsFromCache, walkDir } from "./utils";
const renderPagesToHtml = async ({
pagesDir,
outDir,
}: ConfigPaths): Promise<void> => {
const deferred = [];
for await (const srcPath of walkDir(pagesDir)) {
const ext = path.extname(srcPath);
if (ext !== ".tsx") {
continue;
}
// Ensure that we don't cache page modules when running in dev server.
purgeModuleAndDepsFromCache(srcPath);
const pageSrc = require(srcPath);
if (!("page" in pageSrc)) {
throw new Error(
`page source at ${srcPath} does not export a "page" variable`
);
}
let compiledHtml;
try {
compiledHtml = renderPage(pageSrc.page());
} catch (error) {
throw new Error(
`failed to compile ${srcPath}: ${error.stack ?? error}`
);
}
const relPath = path.relative(pagesDir, path.dirname(srcPath));
let baseName = path.basename(srcPath, ".tsx");
if (baseName === "index") {
baseName = "";
}
const destPath = path.join(outDir, relPath, baseName, "index.html");
deferred.push(
(async () => {
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.writeFile(destPath, compiledHtml);
})()
);
}
await Promise.all(deferred);
};
const copyStaticAssets = async ({
staticAssetsDir,
outDir,
}: ConfigPaths): Promise<void> => {
try {
await fs.access(staticAssetsDir);
} catch (error) {
// Static assets folder doesn't exist, so no-op.
return;
}
const deferred = [];
for await (const assetPath of walkDir(staticAssetsDir)) {
const relPath = path.relative(staticAssetsDir, assetPath);
const destPath = path.join(outDir, relPath);
deferred.push(
(async () => {
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(assetPath, destPath);
})()
);
}
await Promise.all(deferred);
};
/**
* Fully render a websnacks site into a directory ready for serving by a static
* host.
*
* @param config Configuration for the site.
*/
export const renderSite = async ({ paths, hooks }: Config): Promise<void> => {
await Promise.all([renderPagesToHtml(paths), copyStaticAssets(paths)]);
await hooks.afterSiteRender(paths);
};

45
src/cli/commands/build.ts Normal file
View file

@ -0,0 +1,45 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { renderSite } from "../../build";
import { loadConfig } from "../../config";
import { Command, UsageError } from "../types";
const helpText = `\
Usage: websnacks build [ROOT_DIR]
Compile a site using websnacks JSX templates into a fully-functional,
production-ready static site.
Args:
ROOT_DIR Path to the websnacks project root directory.
`;
interface BuildArgs {
rootDir: string;
}
const parseArgs = (args: string[]): BuildArgs => {
if (args.length > 1) {
throw new UsageError("too many arguments provided", helpText);
}
return {
rootDir: args[0] || process.cwd(),
};
};
/**
* Build command used to build a websnacks site into a production-ready set of
* static files.
*/
const buildCommand: Command = {
execute: async (args: string[]): Promise<void> => {
const { rootDir } = parseArgs(args);
const config = await loadConfig(rootDir);
await renderSite(config);
},
helpText,
};
export = buildCommand;

268
src/cli/commands/dev.ts Normal file
View file

@ -0,0 +1,268 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { existsSync, promises as fs, watch } from "fs";
import * as http from "http";
import * as path from "path";
import { renderSite } from "../../build";
import { Config, loadConfig } from "../../config";
import { Command, UsageError } from "../types";
const SERVER_PORT = 8080;
const injectLiveReloadScript = (htmlContents: string): string =>
htmlContents.replace(
"</html>",
`
<script>
const ws = new WebSocket("ws://127.0.0.1:${SERVER_PORT}");
ws.onmessage = function() {
console.log('dev server requested reload, reloading...');
location.reload();
};
</script>
</html>
`
);
const guessMimeType = (ext: string): string => {
let mimeType;
switch (ext) {
case ".apng":
mimeType = "image/apng";
break;
case ".bmp":
mimeType = "image/bmp";
break;
case ".css":
mimeType = "text/css";
break;
case ".eot":
mimeType = "application/vnd.ms-fontobject";
break;
case ".gif":
mimeType = "image/gif";
break;
case ".htm":
case ".html":
mimeType = "text/html";
break;
case ".ico":
mimeType = "image/vnd.microsoft.icon";
break;
case ".jpg":
case ".jpeg":
mimeType = "image/jpeg";
break;
case ".js":
case ".mjs":
mimeType = "text/javascript";
break;
case ".mp3":
mimeType = "audio/mpeg";
break;
case ".mpeg":
mimeType = "video/mpeg";
break;
case ".oga":
mimeType = "audio/ogg";
break;
case ".ogv":
mimeType = "video/ogg";
break;
case ".otf":
mimeType = "font/otf";
break;
case ".png":
mimeType = "image/png";
break;
case ".svg":
mimeType = "image/svg+xml";
break;
case ".txt":
mimeType = "text/plain";
break;
case ".tif":
case ".tiff":
mimeType = "image/tiff";
break;
case ".ttf":
mimeType = "font/ttf";
break;
case ".wav":
mimeType = "audio/wav";
break;
case ".weba":
mimeType = "audio/webm";
break;
case ".webm":
mimeType = "video/webm";
break;
case ".webp":
mimeType = "image/webp";
break;
case ".woff":
mimeType = "font/woff";
break;
case ".woff2":
mimeType = "font/woff2";
break;
default:
// Default to binary mimetype which most browsers will be able to
// correctly interpret in the right context.
mimeType = "application/octet-stream";
}
return mimeType;
};
const serve = (publicDir: string): http.Server => {
const server = http.createServer(async (req, res) => {
if (req.url == null) {
res.writeHead(404);
res.end();
return;
}
let reqExt = path.extname(req.url);
let reqPath = req.url;
if (!reqExt) {
reqPath = path.join(reqPath, "index.html");
reqExt = ".html";
}
let contents;
try {
contents = await fs.readFile(path.join(publicDir, reqPath));
} catch (error) {
console.error(`unable to load file ${reqPath}`);
res.writeHead(404);
res.end();
return;
}
const mimeType = guessMimeType(reqExt);
if (mimeType === "text/html") {
contents = injectLiveReloadScript(contents.toString("utf8"));
}
res.writeHead(200, {
"Content-Type": mimeType,
});
res.end(contents);
});
return server;
};
const startWebSocketServer = async (
server: http.Server
): Promise<import("ws").Server | undefined> => {
// Attempt to load the ws module, aborting if it isn't available.
let ws;
try {
ws = await import("ws");
} catch (error) {
if (error.code !== "MODULE_NOT_FOUND") {
throw error;
}
console.warn(`'ws' module not found, live-reloading will be disabled`);
return;
}
const wsServer = new ws.Server({ server });
wsServer.on("connection", () => {
console.log("connected to dev site");
});
return wsServer;
};
const watchFolders = async (
folders: string[],
listener: (eventType: "update" | "remove", fileName: string) => void
): Promise<void> => {
// Try to load node-watch, falling back to fs watch if node-watch isn't
// available.
try {
const { default: watch } = await import("node-watch");
watch(folders, { recursive: true }, listener);
return;
} catch (error) {
if (error.code !== "MODULE_NOT_FOUND") {
throw error;
}
console.warn(
`'node-watch' module not found, falling back to fs.watch (may ` +
`result in file watch issues on some OSes)`
);
}
// NOTE: fs.watch has significant cross-platform issues, including
// triggering duplicate file events on some systems.
for (const folder of folders) {
watch(folder, { recursive: true }, (_, fileName) => {
listener("update", fileName);
});
}
};
const helpText = `\
Usage: websnacks dev [ROOT_DIR]
Start a live-reloading dev server for a websnacks project.
Args:
ROOT_DIR Path to the websnacks project root directory.
`;
interface DevArgs {
rootDir: string;
}
const parseArgs = (args: string[]): DevArgs | null => {
if (args.length > 1) {
throw new UsageError("too many arguments provided", helpText);
}
return {
rootDir: args[0] || process.cwd(),
};
};
/**
* Command to start up a live-reloading development server to allow for fast
* local development of a websnacks site. The dev server aims to mimic a
* production static hosting environment as closely as possible.
*/
const devCommand: Command = {
async execute(args: string[]): Promise<void> {
const parsedArgs = parseArgs(args);
if (!parsedArgs) {
return;
}
const { rootDir } = parsedArgs;
const rebuild = async (): Promise<Config> => {
const config = await loadConfig(rootDir);
await renderSite(config);
return config;
};
const config = await rebuild();
const { outDir } = config.paths;
const httpServer = serve(outDir);
const wsServer = await startWebSocketServer(httpServer);
httpServer.listen(SERVER_PORT, () => {
console.log(`Listening at http://127.0.0.1:${SERVER_PORT}`);
});
const watchedFolders = config.watch.filter((filePath) =>
existsSync(filePath)
);
watchFolders(watchedFolders, async (event, filePath) => {
console.log(`${filePath}:${event} triggering rebuild...`);
await rebuild();
if (wsServer != null) {
console.log(`rebuild finished, reloading browsers...`);
for (const ws of wsServer.clients) {
ws.send("reload");
}
}
});
},
helpText,
};
export = devCommand;

113
src/cli/index.ts Normal file
View file

@ -0,0 +1,113 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Command, UsageError } from "./types";
const globalHelpText = `\
Usage: websnacks [...globalOptions] <command>
Global Options:
-r|--require <module> Module to require before executing the command. May
be specified more than once to load multiple modules.
Commands:
build Build a static site that uses websnacks templates.
dev Start the live-reloading development server for a
site.
`;
interface Options {
showHelp: boolean;
require: string[];
}
const parseArgs = (
args: string[]
): { options: Options; commandName?: string; commandArgs: string[] } => {
const options: Options = {
showHelp: false,
require: [],
};
// Look ahead for the first argument that doesn't start with a "-" to
// indicate the end of option parsing.
while (args.length > 0 && args[0].indexOf("-") >= 0) {
const opt = args.shift();
switch (opt) {
case "-h":
case "--help":
options.showHelp = true;
break;
case "-r":
case "--require":
const moduleName = args.shift();
if (moduleName == null) {
throw new UsageError(
`-r requires a valid module name`,
globalHelpText
);
}
options.require.push(moduleName);
break;
default:
throw new UsageError(`unknown option ${opt}`, globalHelpText);
}
}
const commandName = args.shift();
return { options, commandName, commandArgs: args };
};
const _main = async (args: string[]): Promise<void> => {
const { options, commandName, commandArgs } = parseArgs(args);
if (options.showHelp) {
console.log(`${globalHelpText}\n`);
return;
}
if (commandName == null) {
throw new UsageError(`must specify a valid command`, globalHelpText);
}
for (const moduleName of options.require) {
await import(moduleName);
}
let command: Command;
switch (commandName) {
case "build":
command = await import("./commands/build");
break;
case "dev":
command = await import("./commands/dev");
break;
default:
throw new UsageError(
`unknown command ${commandName}`,
globalHelpText
);
}
// NOTE: Should this just delegate to the command?
for (const arg of commandArgs) {
if (arg === "--help" || arg === "-h") {
console.log(`${command.helpText}\n`);
return;
}
}
await command.execute(commandArgs);
};
/**
* Entrypoint of the CLI app.
*/
export const main = (): void => {
_main(process.argv.slice(2)).catch((error) => {
if (error instanceof UsageError) {
console.error(`Error: ${error.message}\n`);
console.log(`${error.helpText}\n`);
} else {
const errorMsg =
error instanceof Error ? error.stack : JSON.stringify(error);
console.error(`Unexpected error: ${errorMsg}\n`);
}
process.exit(1);
});
};

34
src/cli/types.ts Normal file
View file

@ -0,0 +1,34 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* CLI command representing an action that the CLI program supports.
*/
export interface Command {
/**
* Execute the command with the specified arguments.
*
* @param args List of CLI arguments to pass to the command.
*/
execute(args: string[]): Promise<void>;
/**
* Help text for this command.
*/
helpText: string;
}
/**
* Error that commands can issue to indicate incorrect usage along with help
* text to guide the user to correct their mistake.
*/
export class UsageError extends Error {
public readonly helpText: string;
public constructor(message: string, helpText: string) {
super(message);
this.helpText = helpText;
}
}

39
src/component.ts Normal file
View file

@ -0,0 +1,39 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
/**
* An in-memory representation of a renderable HTML element.
*/
export interface HTMLElement {
/**
* Name of the tag that gets output upon rendering.
*/
tag: string;
/**
* Record of attribute names and values that should be output in the opening
* tag.
*/
attributes: Record<string, string | number | boolean>;
/**
* Child elements to render nested within this HTML element.
*/
children: Element[];
}
/**
* All valid types of elements that can be rendered to HTML.
*/
export type Element = HTMLElement | string | boolean | undefined | null;
/**
* Custom HTMLElement factory that can be parameterized by props.
*/
export interface Component<P extends object = {}> {
(
props: P & {
children?: Element[];
}
): HTMLElement;
}

85
src/config.ts Normal file
View file

@ -0,0 +1,85 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import * as path from "path";
import { purgeModuleAndDepsFromCache } from "./utils";
/**
* Paths used during configuration.
*/
export interface ConfigPaths {
rootDir: string;
outDir: string;
pagesDir: string;
staticAssetsDir: string;
}
/**
* Hooks that allow user code to customize site rendering.
*/
export interface Hooks {
/**
* Hook that fires at the end of site rendering one all pages and assets are
* fully rendered.
*/
afterSiteRender(context: ConfigPaths): Promise<void> | void;
}
/**
* User-provided configuration options.
*/
export type UserConfig = {
/** Hook implementations that allow customizing the rendering process. */
hooks?: Partial<Hooks>;
/** Additional folders and files to watch by the development server. */
watch?: string[];
};
/**
* Fully-realized configuration for a websnacks site.
*/
export interface Config {
paths: ConfigPaths;
hooks: Hooks;
watch: string[];
}
const noop = () => {};
/**
* Load configuration from a websnacks.ts/js file.
*
* @param rootDir Path to the directory where the websnacks.ts/js config file.
*
* @return Fully-realized configuration.
*/
export const loadConfig = async (rootDir: string): Promise<Config> => {
const configPath = require.resolve(path.resolve(rootDir, "websnacks"));
purgeModuleAndDepsFromCache(configPath);
// TODO: validate user config.
const userConfig = await import(configPath);
const outDir = path.join(rootDir, "public");
const pagesDir = path.join(rootDir, "pages");
const staticAssetsDir = path.join(rootDir, "static");
return {
paths: {
rootDir,
outDir,
pagesDir,
staticAssetsDir,
},
hooks: {
afterSiteRender: noop,
...userConfig.hooks,
},
watch: [
...userConfig.watch.map((p: string) => path.relative(rootDir, p)),
path.relative(rootDir, configPath),
pagesDir,
staticAssetsDir,
],
};
};

54
src/create-element.ts Normal file
View file

@ -0,0 +1,54 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Component, Element, HTMLElement } from "./component";
import { HTMLAttributes } from "./jsx";
/**
* Create an HTMLElement from a custom Component.
*
* @param comp Component responsible for constructing the HTMLElement.
* @param props Properties passed to the Component that parameterize the
* HTMLElement construction.
* @param children Child elements that exist under this component.
*
* @return Fully-realized HTMLElement, ready for rendering.
*/
export function createElement<P extends object>(
comp: Component<P>,
props: P,
...children: Element[]
): HTMLElement;
/**
* Create an HTMLElement from a standard HTML5 tag.
*
* @param tag Lower-case name of the HTML5 tag this HTML element represents.
* @param attrs Standard HTML5 attributes to add to the resulting tag when
* rendered.
* @param children Child elements that exist under this tag.
*
* @return Fully-realized HTMLElement, ready for rendering.
*/
export function createElement(
tag: string,
attrs: HTMLAttributes | null,
...children: Element[]
): HTMLElement;
export function createElement(
type: string | Component<any>,
props: (HTMLAttributes & Record<string, any>) | null,
...children: Element[]
): HTMLElement {
// Flatten the children array so we can accept arrays as children.
const normalizedChildren = children.flat();
if (type instanceof Function) {
return type({ ...props, children: normalizedChildren });
}
if (type !== type.toLowerCase()) {
console.warn(`constructed HTML5 tag with non-lowercase name ${type}`);
}
return { tag: type, attributes: props || {}, children: normalizedChildren };
}

8
src/index.ts Normal file
View file

@ -0,0 +1,8 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
export { HTMLElement, Component } from "./component";
export { UserConfig as Config } from "./config";
export { createElement } from "./create-element";

273
src/jsx.ts Normal file
View file

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

85
src/render.ts Normal file
View file

@ -0,0 +1,85 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { Element, HTMLElement } from "./component";
const HTML_ESCAPES: { [char: string]: string } = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
};
const escapeHtml = (text: string): string =>
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
const escapeAttr = (text: string): string => text.replace(/"/g, "&quot;");
const renderElement = (elem: Element): string => {
// Ignore null and true/false to support nicer JSX conditional syntax with
// &&, ||, !! operators.
if (elem == null || typeof elem === "boolean") {
return "";
}
if (typeof elem === "string") {
return escapeHtml(elem);
}
let output = "";
output += startTag(elem);
for (const child of elem.children) {
output += renderElement(child);
}
output += endTag(elem);
return output;
};
const startTag = (elem: HTMLElement): string => {
let output = `<${escapeHtml(elem.tag)}`;
for (const [attrName, attrValue] of Object.entries(elem.attributes)) {
// Handle boolean attributes with a false value by not outputting the
// attribute at all.
if (attrValue === false) {
continue;
}
let normalizedAttrName = escapeHtml(attrName.toLowerCase());
if (normalizedAttrName === "classname") {
normalizedAttrName = "class";
}
if (attrValue === true) {
output += ` ${normalizedAttrName}=""`;
} else {
output += ` ${normalizedAttrName}="${escapeAttr(
attrValue.toString()
)}"`;
}
}
output += ">";
return output;
};
const endTag = (elem: HTMLElement): string => `</${escapeHtml(elem.tag)}>`;
/**
* Render a complete HTML page from an HTMLElement. Note that the root element
* must represent a valid HTML tag.
*
* @param rootElem HTML element representing the root of the document.
*
* @return Fully rendered HTML document as a string.
*/
export const renderPage = (rootElem: HTMLElement): string => {
if (rootElem.tag.toLowerCase() !== "html") {
throw new Error(
`attempted to render page with non-HTML root element ${rootElem.tag}`
);
}
let output = "<!DOCTYPE html>";
output += renderElement(rootElem);
return output;
};

50
src/utils.ts Normal file
View file

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