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

@ -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 });
}