Improved markdown rendering, allow hiding components
This commit is contained in:
parent
d200d48903
commit
a9a979c5f8
10 changed files with 598 additions and 100 deletions
|
@ -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);
|
||||
|
|
|
@ -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}`);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export * from "./errors";
|
||||
export * from "./markdown";
|
||||
export * from "./pfm";
|
||||
export * from "./component-file";
|
||||
export * from "./binding";
|
||||
|
|
|
@ -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
47
src/lib/pfm.ts
Normal 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
5
src/lib/slug.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import slugify from "slugify";
|
||||
|
||||
export function toSlug(str: string): string {
|
||||
return slugify(str, { lower: true });
|
||||
}
|
|
@ -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>
|
||||
</>);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue