initial commit (it all starts here...)
This commit is contained in:
commit
13cbc07c11
36 changed files with 4550 additions and 0 deletions
90
src/build.ts
Normal file
90
src/build.ts
Normal 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
45
src/cli/commands/build.ts
Normal 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
268
src/cli/commands/dev.ts
Normal 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
113
src/cli/index.ts
Normal 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
34
src/cli/types.ts
Normal 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
39
src/component.ts
Normal 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
85
src/config.ts
Normal 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
54
src/create-element.ts
Normal 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
8
src/index.ts
Normal 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
273
src/jsx.ts
Normal 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
85
src/render.ts
Normal 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 } = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
};
|
||||
|
||||
const escapeHtml = (text: string): string =>
|
||||
text.replace(/[&<>]/g, (t) => HTML_ESCAPES[t]);
|
||||
|
||||
const escapeAttr = (text: string): string => text.replace(/"/g, """);
|
||||
|
||||
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
50
src/utils.ts
Normal 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];
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue