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
7 changes: 5 additions & 2 deletions app/api-reference/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { loadOpenApi, hasOpenApiSpec, apiSpecPath } from "@/lib/openapi";
import { ApiOperationPage } from "@/components/docs/api/operation-page";
import { MarklineApiRef } from "@/components/docs/api/reference/markline-apiref";
import { buildApiRefView, tagSlug, parseOpenApiTag } from "@/lib/apiref-view";
import { mdToText } from "@/lib/md-desc";
import { mdxComponents } from "@/components/docs/mdx";
import { getHighlighter, shellEnhancer } from "@/lib/shiki";
import { contentRoot } from "@/lib/paths";
Expand Down Expand Up @@ -142,10 +143,12 @@ export async function generateMetadata({ params }: { params: Promise<{ slug?: st
const first = rest[0];
if (!first) return meta("API reference");
const tag = doc.tags.find((t) => tagSlug(t.name) === first);
if (tag) return meta(`${tag.name} · API reference`, tag.description);
// Descriptions may be markdown; meta/OG tags need plain text, so syntax like
// `code` or [links](url) doesn't surface verbatim in a snippet.
if (tag) return meta(`${tag.name} · API reference`, mdToText(tag.description));
const op = doc.operationsById[first];
if (!op) return {};
return meta(`${op.summary ?? op.operationId} · API`, op.description);
return meta(`${op.summary ?? op.operationId} · API`, mdToText(op.description));
}

export default async function ApiReferencePage({ params }: { params: Promise<{ slug?: string[] }> }) {
Expand Down
11 changes: 11 additions & 0 deletions app/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -934,3 +934,14 @@ textarea.ml-pg-input { height: auto; padding: 8px 10px; line-height: 1.55; resiz
.ml-l-showcase-title { margin-left: 8px; font-family: var(--mono); font-size: 11px; color: rgb(var(--c-panel-muted)); }
.ml-l-showcase-body { padding: 16px 20px; overflow-x: auto; font-size: 13px; line-height: 1.7; font-family: var(--mono); }
.ml-l-showcase-body pre { background: transparent !important; margin: 0; }

/* Markdown rendered inside OpenAPI spec descriptions (operation / tag /
parameter descriptions — see lib/md-desc.ts). Keeps tables, lists and
inline code legible without pulling in the full docs-prose styles. */
.api-desc table, .ml-param-desc table, .ml-eplist-desc table { border-collapse: collapse; margin: 0.5em 0; font-size: 0.95em; display: block; overflow-x: auto; max-width: 100%; }
.api-desc th, .api-desc td, .ml-param-desc th, .ml-param-desc td, .ml-eplist-desc th, .ml-eplist-desc td { border: 1px solid color-mix(in srgb, currentColor 22%, transparent); padding: 0.3em 0.6em; text-align: left; }
.api-desc ul, .api-desc ol, .ml-param-desc ul, .ml-param-desc ol, .ml-eplist-desc ul, .ml-eplist-desc ol { margin: 0.4em 0 0.4em 1.25em; padding: 0; }
.api-desc p, .ml-param-desc p, .ml-eplist-desc p { margin: 0.35em 0; }
.api-desc p:first-child, .ml-param-desc p:first-child, .ml-eplist-desc p:first-child { margin-top: 0; }
.api-desc p:last-child, .ml-param-desc p:last-child, .ml-eplist-desc p:last-child { margin-bottom: 0; }
.api-desc code, .ml-param-desc code, .ml-eplist-desc code { font-size: 0.92em; background: color-mix(in srgb, currentColor 10%, transparent); padding: 0.08em 0.35em; border-radius: 4px; }
3 changes: 2 additions & 1 deletion components/docs/api/endpoint-list.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Link from "next/link";
import { loadOpenApi, operationHref, type OpenAPITag } from "@/lib/openapi";
import { mdToHtml } from "@/lib/md-desc";
import { MethodBadge } from "./method-badge";

/**
Expand Down Expand Up @@ -27,7 +28,7 @@ function TagSection({ tag }: { tag: OpenAPITag }) {
<section className="ml-eplist-tag">
<h2>{capitalize(tag.name)}</h2>
{tag.description && (
<p className="ml-eplist-desc">{tag.description}</p>
<div className="ml-eplist-desc" dangerouslySetInnerHTML={{ __html: mdToHtml(tag.description) }} />
)}
<ul className="ml-eplist-ops">
{tag.operations.map((op) => (
Expand Down
7 changes: 5 additions & 2 deletions components/docs/api/operation-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { buildPlaygroundSpec, sampleParam } from "@/lib/playground-spec";
import { MethodBadge } from "./method-badge";
import { EndpointPath } from "./endpoint-path";
import { SchemaTable, ParamRow } from "./schema-table";
import { mdToHtml } from "@/lib/md-desc";
import { RequestPanel, ResponsePanel } from "./code-panel";
import {
PlaygroundProvider, RequestConsole, ParamInput, AuthInput, BodyEditor,
Expand Down Expand Up @@ -66,7 +67,9 @@ export function ApiOperationPage({
)}

<h1 className="api-title">{op.summary ?? op.operationId}</h1>
{op.description && <p className="api-desc">{op.description}</p>}
{op.description && (
<div className="api-desc" dangerouslySetInnerHTML={{ __html: mdToHtml(op.description) }} />
)}

<EndpointPath method={op.method} path={op.path} />

Expand Down Expand Up @@ -174,7 +177,7 @@ export function ApiOperationPage({
<div key={r.status} className="api-resp-row">
<div className="api-resp-head">
<StatusPill status={r.status} />
<span className="desc">{r.description}</span>
<span className="desc" dangerouslySetInnerHTML={{ __html: mdToHtml(r.description || "") }} />
</div>
{resolved && <SchemaTable schema={resolved} />}
</div>
Expand Down
6 changes: 5 additions & 1 deletion components/docs/api/schema-table.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { JSONSchema } from "@/lib/openapi";
import { mdToHtml } from "@/lib/md-desc";

export function ParamRow({
name,
Expand Down Expand Up @@ -28,7 +29,10 @@ export function ParamRow({
{schema?.format && <span className="ml-param-fmt">· {schema.format}</span>}
</div>
{(description || schema?.description) && (
<p className="ml-param-desc">{description ?? schema?.description}</p>
<div
className="ml-param-desc"
dangerouslySetInnerHTML={{ __html: mdToHtml((description ?? schema?.description)!) }}
/>
)}
{enumValues && enumValues.length > 0 && (
<div className="ml-param-enum">
Expand Down
64 changes: 64 additions & 0 deletions lib/md-desc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeSanitize from "rehype-sanitize";
import rehypeStringify from "rehype-stringify";

// Descriptions come from the OpenAPI spec and are injected via
// dangerouslySetInnerHTML, so the output is sanitized: raw HTML in the source
// is already dropped (no rehype-raw), and rehype-sanitize's default (GitHub)
// schema strips unsafe URL schemes like `javascript:` while keeping the
// elements GFM produces (links, code, tables, lists).
const mdProcessor = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify);

/** Render markdown from spec descriptions to sanitized HTML. Unwraps single paragraphs to keep inline flow. */
export function mdToHtml(s: string): string;
export function mdToHtml(s?: string): string | undefined;
export function mdToHtml(s?: string): string | undefined {
if (!s) return s;
try {
const html = String(mdProcessor.processSync(s)).trim();
const m = html.match(/^<p>([\s\S]*)<\/p>$/);
return m && !m[1].includes("<p>") ? m[1] : html;
} catch {
return s;
}
}

/**
* Plain-text rendition of a markdown description, for `meta`/OG/Twitter tags.
* Renders the markdown and strips the markup, so constructs that carry their
* meaning in the syntax (`code`, [links](url), **bold**) degrade to their text
* instead of reaching a search snippet raw. Collapsed to one line and truncated
* on a word boundary.
*/
export function mdToText(s: string, max?: number): string;
export function mdToText(s?: string, max?: number): string | undefined;
export function mdToText(s?: string, max = 160): string | undefined {
if (!s) return s;
const text = (mdToHtml(s) ?? "")
.replace(/<[^>]+>/g, " ")
// Decode after tag-stripping, and &amp; last, so a literal "&lt;b&gt;" in
// the source can't reappear as a tag.
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#(?:39|x27);/g, "'")
.replace(/&amp;/g, "&")
.replace(/\s+/g, " ")
// Tags become spaces so block boundaries still separate words, which leaves
// a gap where an inline tag closed before punctuation (`code`. → "code .").
.replace(/\s+([,.;:!?)\]])/g, "$1")
.replace(/([(\[])\s+/g, "$1")
.trim();
if (text.length <= max) return text;
const cut = text.slice(0, max);
const sp = cut.lastIndexOf(" ");
return (sp > max * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s,;:.]+$/, "") + "…";
}
54 changes: 53 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,14 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rehype-pretty-code": "0.14.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"shiki": "1.22.2",
"typescript": "5.6.3"
"typescript": "5.6.3",
"unified": "^11.0.5"
},
"devDependencies": {
"eslint": "9.14.0",
Expand Down
48 changes: 48 additions & 0 deletions test/md-desc.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { mdToHtml, mdToText } from "../lib/md-desc.ts";

test("mdToHtml renders inline markdown, unwrapping a single paragraph", () => {
// A lone paragraph is unwrapped so the description stays inline.
assert.equal(mdToHtml("plain text"), "plain text");
assert.equal(mdToHtml("**bold** and `code`"), "<strong>bold</strong> and <code>code</code>");
assert.equal(mdToHtml("a [link](https://example.com)"), 'a <a href="https://example.com">link</a>');
});

test("mdToHtml keeps block wrappers for multi-block content", () => {
const html = mdToHtml("| a | b |\n| - | - |\n| 1 | 2 |");
assert.match(html, /<table>/);
const list = mdToHtml("- one\n- two");
assert.match(list, /<ul>/);
// Multiple paragraphs are not unwrapped into invalid inline flow.
assert.match(mdToHtml("para one\n\npara two"), /^<p>para one<\/p>\s*<p>para two<\/p>$/);
});

test("mdToHtml drops raw HTML and neutralizes unsafe links", () => {
// Raw HTML never reaches the output (no rehype-raw): a <script> is dropped.
assert.doesNotMatch(mdToHtml("<script>alert(1)</script>"), /<script/i);
assert.doesNotMatch(mdToHtml("<img src=x onerror=alert(1)>"), /onerror/i);
// rehype-sanitize strips a javascript: href but keeps the link text.
const link = mdToHtml("[x](javascript:alert(1))");
assert.doesNotMatch(link, /javascript:/i);
assert.match(link, />?x<?/);
// A normal https link survives.
assert.match(mdToHtml("[x](https://example.com)"), /href="https:\/\/example\.com"/);
});

test("mdToHtml passes empty/undefined through", () => {
assert.equal(mdToHtml(undefined), undefined);
assert.equal(mdToHtml(""), "");
});

test("mdToText strips markup and collapses to plain text", () => {
assert.equal(mdToText("**bold** and `code`"), "bold and code");
assert.equal(mdToText("a [link](https://example.com) here"), "a link here");
});

test("mdToText truncates on a word boundary with an ellipsis", () => {
const out = mdToText("one two three four five six seven eight nine ten", 20);
assert.ok(out!.length <= 21, `expected <=21 chars, got ${out!.length}`);
assert.ok(out!.endsWith("…"));
assert.ok(!out!.includes(" "));
});