Improved markdown rendering, allow hiding components

This commit is contained in:
Endeavorance 2025-03-20 16:51:34 -04:00
parent d200d48903
commit a9a979c5f8
10 changed files with 598 additions and 100 deletions

View file

@ -18,6 +18,7 @@ import { z } from "zod";
const Base = z.object({
$define: z.string(),
$hidden: z.boolean().default(false),
id: z.string(), // TODO: Validate ID shapes
name: z.string().default("Unnamed Component"),
description: z.string().default("No description provided"),
@ -306,9 +307,13 @@ function parseSpeciesDefinition(obj: unknown): ParsedComponent {
};
}
export function parsePlaybillComponent(obj: unknown): ParsedComponent {
export function parsePlaybillComponent(obj: unknown): ParsedComponent | null {
const baseParse = Base.parse(obj);
if (baseParse.$hidden) {
return null;
}
const type = parseComponentType(baseParse.$define);
const schema = SchemaMapping[type];
const component = schema.parse(obj);

View file

@ -33,9 +33,7 @@ async function processBinding({ inputFilePath, options }: CLIArguments) {
serializedPlaybill = binding.playbill.serialize();
break;
case "html":
serializedPlaybill = await renderPlaybillToHTML(binding.playbill, {
styles: binding.styles,
});
serializedPlaybill = await renderPlaybillToHTML(binding);
break;
default:
throw new CLIError(`Unknown renderer: ${options.renderer}`);

View file

@ -1,14 +1,47 @@
import path from "node:path";
import { type ParsedComponent, parsePlaybillComponent } from "define";
import slugify from "slugify";
import YAML, { YAMLParseError } from "yaml";
import { ZodError } from "zod";
import { loadFileOrFail } from "#util";
import { MalformedResourceFileError } from "./errors";
import { extractFrontmatter, extractMarkdown } from "./markdown";
import { toSlug } from "./slug";
type FileFormat = "yaml" | "markdown";
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
/**
* Attempt to parse YAML frontmatter from a mixed yaml/md doc
* @param content The raw markdown content
* @returns Any successfully parsed frontmatter
*/
function extractFrontmatter(content: string) {
// If it does not start with `---`, it is invalid for frontmatter
if (content.trim().indexOf("---") !== 0) {
return {};
}
if (FRONTMATTER_REGEX.test(content)) {
const frontmatterString = content.match(FRONTMATTER_REGEX)?.[0] ?? "";
const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim();
return YAML.parse(cleanFrontmatter);
}
return {};
}
/**
* Given a string of a markdown document, extract the markdown content
* @param content The raw markdown content
* @returns The markdown content without frontmatter
*/
function extractMarkdown(content: string): string {
if (content.trim().indexOf("---") !== 0) {
return content;
}
return content.replace(FRONTMATTER_REGEX, "").trim();
}
function parseYAMLResourceFile(
filePath: string,
text: string,
@ -33,7 +66,10 @@ function parseYAMLResourceFile(
for (const doc of parsedDocs) {
const raw = doc.toJS();
collection.push(parsePlaybillComponent(raw));
const parsedComponent = parsePlaybillComponent(raw);
if (parsedComponent !== null) {
collection.push(parsedComponent);
}
}
return collection;
@ -45,7 +81,7 @@ function parseMarkdownResourceFile(
): ParsedComponent[] {
try {
const defaultName = path.basename(filePath, ".md");
const defaultId = slugify(defaultName, { lower: true });
const defaultId = toSlug(defaultName);
const frontmatter = extractFrontmatter(text);
const markdown = extractMarkdown(text);
@ -57,6 +93,11 @@ function parseMarkdownResourceFile(
description: markdown,
});
// Null means hidden
if (together === null) {
return [];
}
return [together];
} catch (e) {
if (e instanceof YAMLParseError) {
@ -84,11 +125,13 @@ export class ComponentFile {
private _raw = "";
private _format: FileFormat;
private _components: ParsedComponent[] = [];
private _basename: string;
constructor(filePath: string) {
this._filePath = path.resolve(filePath);
const extension = path.extname(filePath).slice(1).toLowerCase();
this._basename = path.basename(filePath, `.${extension}`);
switch (extension) {
case "yaml":
@ -126,4 +169,8 @@ export class ComponentFile {
get path() {
return this._filePath;
}
get baseName() {
return this._basename;
}
}

View file

@ -1,4 +1,4 @@
export * from "./errors";
export * from "./markdown";
export * from "./pfm";
export * from "./component-file";
export * from "./binding";

View file

@ -1,61 +0,0 @@
import type { MarkedExtension } from "marked";
import { marked } from "marked";
import YAML from "yaml";
const plugin: MarkedExtension = {
renderer: {
heading({ tokens, depth }) {
const text = this.parser.parseInline(tokens);
const level = Math.max(depth, 3);
return `<h${level}>${text}</h${level}>`;
},
},
};
marked.use(plugin);
/**
* Given a string of markdown, convert it to HTML
*
* @param markdown The markdown to convert
* @returns A promise resolving to the HTML representation of the markdown
*/
export async function renderMarkdown(markdown: string): Promise<string> {
return await marked.parse(markdown, {
async: true,
gfm: true,
});
}
const FRONTMATTER_REGEX = /^---[\s\S]*?---/gm;
/**
* Attempt to parse YAML frontmatter from a mixed yaml/md doc
* @param content The raw markdown content
* @returns Any successfully parsed frontmatter
*/
export function extractFrontmatter(content: string) {
// If it does not start with `---`, it is invalid for frontmatter
if (content.trim().indexOf("---") !== 0) {
return {};
}
if (FRONTMATTER_REGEX.test(content)) {
const frontmatterString = content.match(FRONTMATTER_REGEX)?.[0] ?? "";
const cleanFrontmatter = frontmatterString.replaceAll("---", "").trim();
return YAML.parse(cleanFrontmatter);
}
return {};
}
/**
* Given a string of a markdown document, extract the markdown content
* @param content The raw markdown content
* @returns The markdown content without frontmatter
*/
export function extractMarkdown(content: string): string {
if (content.trim().indexOf("---") !== 0) {
return content;
}
return content.replace(FRONTMATTER_REGEX, "").trim();
}

47
src/lib/pfm.ts Normal file
View file

@ -0,0 +1,47 @@
import rehypeStringify from "rehype-stringify";
import remarkParse from "remark-parse";
import remarkRehype from "remark-rehype";
import { unified } from "unified";
import type { BoundPlaybill } from "./binding";
import remarkWikiLink from "remark-wiki-link";
import { toSlug } from "./slug";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import remarkGfm from "remark-gfm";
import rehypeShiftHeading from "rehype-shift-heading";
export type MarkdownParserFunction = (input: string) => Promise<string>;
export function createMarkdownRenderer(
binding: BoundPlaybill,
): MarkdownParserFunction {
// TODO: Allow specifying links to other files via file paths
// In this scenario, assume its a markdown-defined file and find the ID, use that for the link
// to allow for more natural Obsidian-like linking
// For now, this will mostly work, but if you need to specify a unique ID, it wont work in Obsidian
// despite being valid for Muse
const parser = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkWikiLink, {
aliasDivider: "|",
permalinks: binding.playbill
.allComponents()
.map((c) => `${c.component.id}`),
pageResolver: (permalink: string) => {
return [toSlug(permalink)];
},
hrefTemplate: (permalink: string) => `#component/${permalink}`,
})
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeShiftHeading, { shift: 1 })
.use(rehypeSanitize)
.use(rehypeStringify);
return async (input: string): Promise<string> => {
const parsed = await parser.process(input);
return String(parsed);
};
}

5
src/lib/slug.ts Normal file
View file

@ -0,0 +1,5 @@
import slugify from "slugify";
export function toSlug(str: string): string {
return slugify(str, { lower: true });
}

View file

@ -1,6 +1,6 @@
import { Playbill, type TaggedComponent } from "@proscenium/playbill";
import sortBy from "lodash-es/sortBy";
import { renderMarkdown } from "#lib";
import { createMarkdownRenderer, type BoundPlaybill } from "#lib";
import { GlossaryTable } from "./component/glossary";
import { PageHeader } from "./component/header";
import { ItemCard } from "./component/item";
@ -10,61 +10,64 @@ import { RuleSection } from "./component/rule";
import { SpeciesSection } from "./component/species";
import { renderToString } from "react-dom/server";
interface HTMLRenderOptions {
styles?: string;
}
async function processMarkdownInComponent(entry: TaggedComponent) {
entry.component.description = await renderMarkdown(entry.component.description);
if (entry.type === "resource" && entry.component.type === "table") {
const newData: string[][] = [];
for (const row of entry.component.data) {
const newRow: string[] = [];
for (const cell of row) {
newRow.push(await renderMarkdown(cell));
}
newData.push(newRow);
}
entry.component.data = newData;
}
}
export async function renderPlaybillToHTML(
playbillToRender: Playbill,
opts: HTMLRenderOptions = {},
boundPlaybill: BoundPlaybill,
): Promise<string> {
// Make a copy of the playbill to avoid modifying the original
// and compile all description markdown
const playbill = Playbill.fromSerialized(playbillToRender.serialize());
const playbill = Playbill.fromSerialized(boundPlaybill.playbill.serialize());
// Prepare a rendering function for markdown that is aware of the playbill binding
const renderMarkdown = createMarkdownRenderer(boundPlaybill);
// Define a processor function to iterate over all components and process their markdown
const processMarkdownInComponent = async (entry: TaggedComponent) => {
entry.component.description = await renderMarkdown(entry.component.description);
if (entry.type === "resource" && entry.component.type === "table") {
const newData: string[][] = [];
for (const row of entry.component.data) {
const newRow: string[] = [];
for (const cell of row) {
newRow.push(await renderMarkdown(cell));
}
newData.push(newRow);
}
entry.component.data = newData;
}
}
// Process all components
await playbill.processComponents(processMarkdownInComponent);
// Soprt rules by their order prop
const rulesByOrder = sortBy(Object.values(playbill.rules), "order");
// Prepare stylesheet
const css = opts.styles ? `<style>${opts.styles}</style>` : "";
const css = `<style>${boundPlaybill.styles}</style>`;
const body = renderToString(<>
<article id="species" className="view">
{Object.values(playbill.species).map((species) => <SpeciesSection key={species.id} species={species} playbill={playbill} />)}
</article>
<article id="methods" className="view">
<article id="methods" className="view" style={{ display: "none" }}>
{Object.values(playbill.methods).map((method) => <MethodSection key={method.id} method={method} playbill={playbill} />)}
</article>
<article id="items" className="view">
<article id="items" className="view" style={{ display: "none" }}>
{Object.values(playbill.items).map((item) => <ItemCard key={item.id} item={item} />)}
</article>
<article id="rules" className="view">
<article id="rules" className="view" style={{ display: "none" }}>
{rulesByOrder.map((rule) => <RuleSection key={rule.id} rule={rule} />)}
</article>
<article id="glossary" className="view">
<article id="glossary" className="view" style={{ display: "none" }}>
{<GlossaryTable glossary={playbill.glossary} />}
</article>
<article id="resources" className="view">
<article id="resources" className="view" style={{ display: "none" }}>
{Object.values(playbill.resources).map((resource) => <ResourceSection key={resource.id} resource={resource} />)}
</article>
</>);