250 lines
6 KiB
TypeScript
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";
|