Improved HTML output

This commit is contained in:
Endeavorance 2025-03-08 20:23:29 -05:00
parent d8ddff01a6
commit 2032103412
16 changed files with 480 additions and 105 deletions

View file

@ -10,6 +10,10 @@ import { loadYAMLFileOrFail } from "./files";
import { loadResourceFile } from "./resource";
import { FileNotFoundError } from "./errors";
const HTMLRenderOptionsSchema = z.object({
stylesheet: z.string().optional(),
});
const BindingFileSchema = z.object({
$binding: z.literal("playbill"),
extends: z.string().optional(),
@ -19,6 +23,8 @@ const BindingFileSchema = z.object({
description: z.string().optional(),
version: z.string(),
files: z.array(z.string()).default(["**/*.yaml", "**/*.md"]),
html: HTMLRenderOptionsSchema.optional(),
});
type BindingFileContent = z.infer<typeof BindingFileSchema>;
@ -28,6 +34,7 @@ export interface PlaybillBinding {
// File information
bindingFilePath: string;
bindingFileDirname: string;
includedFiles: string[];
// Binding properties
@ -37,6 +44,8 @@ export interface PlaybillBinding {
version: string;
description: string;
html?: z.infer<typeof HTMLRenderOptionsSchema>;
playbill: Playbill;
}
@ -126,6 +135,7 @@ export async function loadFromBinding(
return {
_raw: binding,
bindingFilePath: bindingPath,
bindingFileDirname,
includedFiles: filePathsWithoutBindingFile,
id: binding.id,
@ -134,6 +144,8 @@ export async function loadFromBinding(
description: binding.description ?? "",
version: binding.version,
html: binding.html,
playbill,
};
}

View file

@ -1,9 +1,13 @@
import { parseArgs } from "node:util";
import { ValidatedPlaybillSchema } from "@proscenium/playbill";
import {
serializePlaybill,
ValidatedPlaybillSchema,
} from "@proscenium/playbill";
import chalk from "chalk";
import { loadFromBinding, resolveBindingPath } from "./binding";
import { usage } from "./usage";
import { CLIError, MuseError } from "./errors";
import { renderAsHTML } from "./renderers/html";
enum ExitCode {
Success = 0,
@ -45,6 +49,11 @@ async function main(): Promise<number> {
default: false,
type: "boolean",
},
renderer: {
short: "r",
default: "json",
type: "string",
},
},
strict: true,
allowPositionals: true,
@ -103,20 +112,27 @@ async function main(): Promise<number> {
return ExitCode.Success;
}
// -- SERIALIZE TO JSON --//
const finalizedPlaybill = JSON.stringify(
validatedPlaybill.data,
null,
options.minify ? undefined : 2,
);
// -- SERIALIZE USING RENDERER --//
let serializedPlaybill = "";
switch (options.renderer) {
case "json":
serializedPlaybill = serializePlaybill(validatedPlaybill.data);
break;
case "html":
serializedPlaybill = await renderAsHTML(validatedPlaybill.data, binding);
break;
default:
throw new CLIError(`Unknown renderer: ${options.renderer}`);
}
// -- WRITE TO DISK OR STDOUT --//
if (options.write) {
await Bun.write(options.outfile, finalizedPlaybill);
await Bun.write(options.outfile, serializedPlaybill);
return ExitCode.Success;
}
console.log(finalizedPlaybill);
console.log(serializedPlaybill);
return ExitCode.Success;
}

View file

@ -1,3 +1,8 @@
import rehypeStringify from "rehype-stringify";
import remarkGfm from "remark-gfm";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import YAML from "yaml";
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
@ -48,3 +53,14 @@ ${YAML.stringify(frontmatterToWrite)}
${markdown.trim()}
`;
}
export async function markdownToHtml(markdown: string): Promise<string> {
const rendered = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeStringify)
.process(markdown);
return String(rendered);
}

184
src/renderers/html.ts Normal file
View file

@ -0,0 +1,184 @@
import path from "node:path";
import type {
Ability,
Guide,
Method,
Rule,
ValidatedPlaybill,
} from "@proscenium/playbill";
import { markdownToHtml } from "../markdown";
import type { PlaybillBinding } from "../binding";
import rehypeParse from "rehype-parse";
import { unified } from "unified";
import rehypeFormat from "rehype-format";
import rehypeStringify from "rehype-stringify";
import capitalize from "lodash-es/capitalize";
import { FileNotFoundError } from "../errors";
async function renderGuide(guide: Guide): Promise<string> {
const html = await markdownToHtml(guide.description);
return `<section class="guide flow">
<h2>${guide.name}</h2>
${html}
</section>`;
}
async function renderRule(rule: Rule): Promise<string> {
const html = await markdownToHtml(rule.description);
return `<section class="rule flow">
<h2>${rule.name}</h2>
${html}
</section>`;
}
async function renderAbility(ability: Ability): Promise<string> {
const html = await markdownToHtml(ability.description);
const costs: string[] = [];
if (ability.ap > 0) {
costs.push(`<li>AP Cost: ${ability.ap}</li>`);
}
if (ability.hp > 0) {
costs.push(`<li>HP Cost: ${ability.hp}</li>`);
}
if (ability.ep > 0) {
costs.push(`<li>EP Cost: ${ability.ep}</li>`);
}
const costList = costs.length > 0 ? `<ul>${costs.join("\n")}</ul>` : "";
return `
<div class="ability flow">
<h4>${ability.name}</h4>
<small>${ability.type} / ${ability.xp} XP</small>
${costList}
${html}
</div>`.trim();
}
async function renderMethod(method: Method, playbill: ValidatedPlaybill) {
const descriptionHTML = await markdownToHtml(method.description);
let ranksHTML = "";
let rankNumber = 1;
for (const rank of method.abilities) {
ranksHTML += `<h3>Rank ${rankNumber}</h3><div class="method-rank flow">`;
for (const ability of rank) {
const html = await renderAbility(playbill.ability[ability]);
ranksHTML += html;
}
ranksHTML += "</div>";
rankNumber++;
}
return `<section class="method flow">
<h2>${method.curator}'s Method of ${method.name}</h2>
${descriptionHTML}
${ranksHTML}
</section>`;
}
async function renderItemTags(playbill: ValidatedPlaybill) {
let tagsTableHTML = `
<table>
<thead>
<tr>
<th>Tag</th>
<th>Item Types</th>
<th>Description</th>
</tr>
</thead>
<tbody>
`.trim();
for (const tag of Object.values(playbill.tag)) {
tagsTableHTML += `
<tr>
<td>${tag.name}</td>
<td>${tag.types.map(capitalize).join(", ")}</td>
<td>${await markdownToHtml(tag.description)}</td>
</tr>
`.trim();
}
tagsTableHTML += "</tbody></table>";
return `<section class="tags flow"><h2>Item Tags</h2>${tagsTableHTML}</section>`;
}
async function renderDocument(
playbill: ValidatedPlaybill,
binding: PlaybillBinding,
): Promise<string> {
const guides = await Promise.all(
Object.values(playbill.guide).map(renderGuide),
);
const methods = await Promise.all(
Object.values(playbill.method).map((method) => {
return renderMethod(method, playbill);
}),
);
const rules = await Promise.all(Object.values(playbill.rule).map(renderRule));
let css = "";
if (binding.html?.stylesheet) {
const cssFilePath = path.resolve(
binding.bindingFileDirname,
binding.html.stylesheet,
);
const cssFile = Bun.file(cssFilePath);
const cssFileExists = await cssFile.exists();
if (cssFileExists) {
css = `<style>
${await cssFile.text()}
</style>`;
} else {
throw new FileNotFoundError(cssFilePath);
}
}
return `
<!doctype html>
<html lang="en">
<head>
${css}
</head>
<body>
<article>
<h1>${playbill.name}</h1>
<small>By ${playbill.author}</small>
<blockquote>${playbill.description}</blockquote>
${guides.join("\n")}
${methods.join("\n")}
${await renderItemTags(playbill)}
${rules.join("\n")}
</article>
</body>
</html>
`.trim();
}
export async function renderAsHTML(
playbill: ValidatedPlaybill,
binding: PlaybillBinding,
): Promise<string> {
const doc = await renderDocument(playbill, binding);
const formatted = await unified()
.use(rehypeParse)
.use(rehypeFormat)
.use(rehypeStringify)
.process(doc);
return String(formatted);
}