sitegen/src/q+a/clover-markdown.tsx
2025-06-15 13:11:21 -07:00

250 lines
6 KiB
TypeScript

function assert(condition: any, message?: string): asserts condition {
if (!condition) {
throw new Error(message || "Assertion failed");
}
}
export const customRules = defaultRules.clone();
/** A question mention, formatted like `#112233445566` */
customRules.insertBefore("em", {
name: "mentionQuestion",
match: inlineRegex(/^#([0-9]{10})(?:[0-9]{2})?/),
parse(capture) {
return {
id: capture[1],
};
},
});
/** A question mention, formatted like `@pheonix-write` */
customRules.insertBefore("em", {
name: "mentionArtifact",
match: inlineRegex(/^@([a-z0-9-]+)/i),
parse(capture) {
return {
id: capture[1],
};
},
});
/** The censor bar used for past deadnaming: #name# and #username# */
customRules.insertBefore("em", {
name: "deadname",
match: inlineRegex(/^#(name|username)#/i),
parse(capture) {
return {
kind: capture[1],
};
},
});
/** html block. allows arbitrary html, but rn only as an answer. formatted like `@html content here` */
customRules.insertBefore("paragraph", {
name: "html",
match: blockRegex(/^@html ((?:[^\n]|\n(?! *\n))+)(?:\n *)+\n/),
parse(capture) {
return {
data: capture[1],
};
},
});
/** for server-rendered search, mark can highlight the seen word */
customRules.insertBefore("paragraph", {
name: "mark",
match: inlineRegex(/^<!!mark!!>(.*?)<!!mark!!>/),
parse: parseCaptureInline,
});
/** Adjust autolinking to use my own components if possible */
customRules.insertBefore("url", {
name: "autolink_mention",
match: inlineRegex(
/^https?:\/\/(?:paper(?:dave|clover)\.net|localhost:\d+)\/([^\s<]+[^<.,:;"')\]\s])/,
),
parse(capture) {
const qaMatch = capture[1].match(/^q\+a\/([0-9]{10}(?:[0-9]{2})?)/);
if (qaMatch) {
return {
type: "mentionQuestion",
id: qaMatch[1],
};
}
return {
type: "link",
content: [
{
type: "text",
content: capture[1],
},
],
target: capture[1],
title: undefined,
};
},
});
export const questionRules = customRules.clone();
questionRules.remove("heading");
/** Input paragraphs. any paragraph prefixed with `i:` */
questionRules.insertBefore("paragraph", {
name: "question",
match: blockRegex(/^(?:q: [^\n]+(?:\n|$))+\s*/),
parse(capture, parse, state) {
const lines = capture[0]
.trim()
.split("\n")
.map((line) => line.trim())
.filter((x) => x.length > 0)
.map((x) => {
assert(x.startsWith("q: "));
return x.slice(3);
});
const content = lines.map((line, i) => {
const parsed = parseInline(parse, line, state);
if (i < lines.length - 1) {
parsed.push({ type: "br" });
}
return parsed;
}).flat();
return {
content,
};
},
});
const cloverParser = createParser(questionRules);
const basicNodeTagNames: Record<string, string> = {
mark: "mark",
question: "q-",
paragraph: "p",
hr: "hr",
blockQuote: "blockquote",
em: "em",
strong: "strong",
u: "u",
del: "del",
inlineCode: "code",
br: "br",
};
function BasicNode(node: ASTNode, children: any) {
const E = basicNodeTagNames[node.type];
return <E>{children}</E>;
}
function ListRenderer(node: ASTNode, children: any[]) {
const T = node.ordered ? "ol" : "ul";
return <T>{children.map((child) => <li>{child}</li>)}</T>;
}
function MarkdownLink(node: ASTNode, children: any[]) {
return <a href={node.target}>{children}</a>;
}
function Deadname(node: ASTNode) {
return <span class="dead">old {node.kind}</span>;
}
function QuestionRef(node: ASTNode) {
if (node.id.length === 12) node.id = node.id.slice(0, 10);
const timestamp = questionIdToTimestamp(node.id);
if (!timestamp) {
console.error(`Invalid question ref: ${node.id}`);
return <span class="question-ref">[Invalid Question Ref: #{node.id}]</span>;
}
return (
<a class="custom question-ref" href={`/q+a/${node.id}`}>
{formatQuestionTimestamp(timestamp)}
</a>
);
}
function ArtifactRef(node: ASTNode) {
const artifact = artifactMap[node.id];
if (!artifact) {
return <span class="artifact">[Invalid Artifact Ref: @{node.id}]</span>;
}
const [title, url, type] = artifact;
if (!url) {
return <span>{title}</span>;
}
return <a class={`custom artifact artifact-${type}`} href={url}>{title}</a>;
}
const br = <br />;
const ssrFunctions: any = {
// Default Renders
// heading: HeadingRenderer,
hr: BasicNode,
mark: BasicNode,
blockQuote: BasicNode,
// codeBlock: MarkdownCodeBlock,
list: ListRenderer,
// table: TableRenderer,
paragraph: BasicNode,
// tableSeparator: ???,
link: MarkdownLink,
// image: MarkdownImage,
em: BasicNode,
strong: BasicNode,
u: BasicNode,
del: BasicNode,
inlineCode: BasicNode,
br: () => br,
newline: () => br,
deadname: Deadname,
// Custom Elements
question: BasicNode,
html: (node: any) => ssr.html(node.data),
mentionQuestion: QuestionRef,
mentionArtifact: ArtifactRef,
};
function renderNode(node: string | ASTNode | ASTNode[]): any {
if (typeof node === "string") {
return node;
} else if (Array.isArray(node)) {
return node.map((item) => renderNode(item));
} else if (node.type === "text") {
return node.content;
} else {
const render = ssrFunctions[node.type];
if (!render) {
throw new Error(`No renderer for node type "${node.type}"`);
}
const content = node.content as any;
return render(node, content ? renderNode(content) : undefined);
}
}
export namespace CloverMarkdown {
export interface Props {
text: string;
inline?: boolean;
}
}
export function CloverMarkdown(props: CloverMarkdown.Props) {
const ast = cloverParser(props.text, { inline: !!props.inline });
return renderNode(ast);
}
import { artifactMap } from "./artifacts.ts";
import { formatQuestionTimestamp, questionIdToTimestamp } from "./format.ts";
import {
type ASTNode,
blockRegex,
createParser,
defaultRules,
inlineRegex,
parseCaptureInline,
parseInline,
} from "./simple-markdown.ts";
import * as ssr from "#ssr";