Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
938 changes: 721 additions & 217 deletions github/package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions github/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"devDependencies": {
"eslint": "^9.39.4",
"prettier": "^3.8.3",
"tsx": "^4.23.1",
"typescript-eslint": "^8.60.0"
}
}
16 changes: 16 additions & 0 deletions github/routes/_.context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { simple_user } from "../types/components/schemas/simple-user.js";
import type { Context as GistsContext } from "./gists/_.context.js";
import type { Context as EmojisContext } from "./emojis/_.context.js";
import type { Context as LicensesContext } from "./licenses/_.context.js";
import type { Context as MarkdownContext } from "./markdown/_.context.js";
import type { Context as ReposContext } from "./repos/_.context.js";
import type { Context as UsersContext } from "./users/_.context.js";

Expand All @@ -30,6 +31,10 @@ export class Context {
return this.loadContext("/licenses") as LicensesContext;
}

private markdownContext(): MarkdownContext {
return this.loadContext("/markdown") as MarkdownContext;
}

saveEmoji(...args: Parameters<EmojisContext["saveEmoji"]>) {
return this.emojisContext().saveEmoji(...args);
}
Expand All @@ -50,6 +55,17 @@ export class Context {
return this.licensesContext().listLicenses(...args);
}

renderMarkdown(...args: Parameters<MarkdownContext["renderMarkdown"]>) {
return this.markdownContext().renderMarkdown(...args);
}

renderRaw(...args: Parameters<MarkdownContext["renderRaw"]>) {
return this.markdownContext().renderRaw(...args);
}

commonMarkerVersion() {
return this.markdownContext().commonMarkerVersion();
}
private usersContext(): UsersContext {
return this.loadContext("/users") as UsersContext;
}
Expand Down
12 changes: 11 additions & 1 deletion github/routes/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import type { markdownRender } from "../types/paths/markdown.types.js";

export const POST: markdownRender = async ($) => {
return $.response[200].random();
const html = $.context.renderMarkdown(
$.body.text,
$.body.mode,
$.body.context,
);
const version = $.context.commonMarkerVersion();
return $.response[200]
.header("Content-Type", "text/html; charset=utf-8")
.header("Content-Length", String(Buffer.byteLength(html, "utf8")))
.header("X-CommonMarker-Version", version)
.html(html);
};
172 changes: 172 additions & 0 deletions github/routes/markdown/_.context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { Context$ } from "../../types/_.context.js";

const COMMONMARKER_VERSION = "0.23.4";

const escapeHtml = (text: string): string =>
text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");

const renderInline = (text: string): string =>
text
.replace(
/`([^`]+)`/g,
(_, code: string) => `<code>${escapeHtml(code)}</code>`,
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_]+)__/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/_([^_]+)_/g, "<em>$1</em>")
.replace(/~~([^~]+)~~/g, "<del>$1</del>")
.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(_, alt: string, src: string) =>
`<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}">`,
)
.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(_, label: string, href: string) =>
`<a href="${escapeHtml(href)}">${label}</a>`,
);

const renderIssueReferences = (text: string, repoContext: string): string =>
text.replace(/#(\d+)/g, (_, num: string) => {
const url = `https://github.com/${repoContext}/issues/${num}`;
return `<a href="${url}">#${num}</a>`;
});

const renderBlock = (
lines: string[],
mode: "markdown" | "gfm",
repoContext?: string,
): string => {
const result: string[] = [];
let index = 0;

while (index < lines.length) {
const line = lines[index] ?? "";

// Fenced code block
const fenceMatch = /^(`{3,}|~{3,})(.*)$/.exec(line);
if (fenceMatch) {
const fence = fenceMatch[1] ?? "";
const lang = (fenceMatch[2] ?? "").trim();
const codeLines: string[] = [];
index++;
while (index < lines.length && !lines[index]?.startsWith(fence)) {
codeLines.push(lines[index] ?? "");
index++;
}
index++;
const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : "";
result.push(
`<pre><code${langAttr}>${escapeHtml(codeLines.join("\n"))}</code></pre>`,
);
continue;
}

// ATX headings
const headingMatch = /^(#{1,6})\s+(.+)$/.exec(line);
if (headingMatch) {
const level = (headingMatch[1] ?? "").length;
const content = headingMatch[2] ?? "";
result.push(`<h${level}>${renderInline(content)}</h${level}>`);
index++;
continue;
}

// Horizontal rule
if (/^(?:-{3,}|_{3,}|\*{3,})$/.test(line.trim())) {
result.push("<hr>");
index++;
continue;
}

// Blockquote
if (line.startsWith("> ")) {
const quoteLines: string[] = [];
while (
index < lines.length &&
(lines[index]?.startsWith("> ") ?? false)
) {
quoteLines.push((lines[index] ?? "").slice(2));
index++;
}
result.push(
`<blockquote>\n${renderBlock(quoteLines, mode, repoContext)}\n</blockquote>`,
);
continue;
}

// Unordered list
if (/^[*\-+]\s+/.test(line)) {
const listItems: string[] = [];
while (index < lines.length && /^[*\-+]\s+/.test(lines[index] ?? "")) {
listItems.push((lines[index] ?? "").replace(/^[*\-+]\s+/, ""));
index++;
}
const items = listItems
.map((item) => `<li>${renderInline(item)}</li>`)
.join("\n");
result.push(`<ul>\n${items}\n</ul>`);
continue;
}

// Ordered list
if (/^\d+\.\s+/.test(line)) {
const listItems: string[] = [];
while (index < lines.length && /^\d+\.\s+/.test(lines[index] ?? "")) {
listItems.push((lines[index] ?? "").replace(/^\d+\.\s+/, ""));
index++;
}
const items = listItems
.map((item) => `<li>${renderInline(item)}</li>`)
.join("\n");
result.push(`<ol>\n${items}\n</ol>`);
continue;
}

// Blank line — skip
if (line.trim() === "") {
index++;
continue;
}

// Paragraph — collect until blank line
const paraLines: string[] = [];
while (index < lines.length && (lines[index]?.trim() ?? "") !== "") {
paraLines.push(lines[index] ?? "");
index++;
}
let paraText = paraLines.join(" ");
if (mode === "gfm" && repoContext) {
paraText = renderIssueReferences(paraText, repoContext);
}
result.push(`<p>${renderInline(paraText)}</p>`);
}

return result.join("\n");
};

export class Context {
constructor(private readonly $: Context$) {}

renderMarkdown(
text: string,
mode: "markdown" | "gfm" = "markdown",
repoContext?: string,
): string {
const lines = text.split("\n");
return renderBlock(lines, mode, repoContext);
}

renderRaw(text: string): string {
return this.renderMarkdown(text, "markdown");
}

commonMarkerVersion(): string {
return COMMONMARKER_VERSION;
}
}
5 changes: 4 additions & 1 deletion github/routes/markdown/raw.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { markdownRenderRaw } from "../../types/paths/markdown/raw.types.js";

export const POST: markdownRenderRaw = async ($) => {
return $.response[200].random();
const text = ($ as unknown as { body: string }).body ?? "";
const html = $.context.renderRaw(text);
const version = $.context.commonMarkerVersion();
return $.response[200].header("X-CommonMarker-Version", version).html(html);
};
7 changes: 7 additions & 0 deletions github/test-support/create-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Context } from "../routes/_.context.ts";
import { Context as EmojisContext } from "../routes/emojis/_.context.ts";
import { Context as GistsContext } from "../routes/gists/_.context.ts";
import { Context as LicensesContext } from "../routes/licenses/_.context.ts";
import { Context as MarkdownContext } from "../routes/markdown/_.context.ts";
import { Context as ReposContext } from "../routes/repos/_.context.ts";
import { Context as UsersContext } from "../routes/users/_.context.ts";

Expand Down Expand Up @@ -40,6 +41,12 @@ export const createContextHarness = () => {
readJson: async () => ({}),
});
break;
case "/markdown":
created = new MarkdownContext({
loadContext,
readJson: async () => ({}),
});
break;
case "/users":
created = new UsersContext({ loadContext, readJson: async () => ({}) });
break;
Expand Down
32 changes: 32 additions & 0 deletions github/test-support/create-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
type RouteResult = {
status: number;
body?: unknown;
headers: Record<string, string>;
};

export const createResponse = () => {
const makeBuilder = (
status: number,
headers: Record<string, string> = {},
): never => {
const builder = {
status,
headers,
header: (name: string, value: string) =>
makeBuilder(status, { ...headers, [name]: value }),
html: (body: unknown): RouteResult => ({ status, headers, body }),
text: (body: unknown): RouteResult => ({ status, headers, body }),
json: (body: unknown): RouteResult => ({ status, headers, body }),
empty: (): RouteResult => ({ status, headers }),
random: (): RouteResult => ({ status, headers }),
};
return builder as never;
};

return new Proxy(
{},
{
get: (_, key) => makeBuilder(Number(key)),
},
) as never;
};
Loading
Loading