mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
64062af9a1
A Guided Review can now leave Plannotator: as a single self-contained HTML file that renders exactly like the in-app guide, as an encrypted-by-default share link on guides.show, or authored by any agent through the new guide CLI. Highlights: packages/guide-viewer extracted from review-editor at the injection seam (read-only host, no third renderer); guides.show Worker with R2-backed share storage, per-IP rate limiting on creation, delete tokens hashed at rest, and 128-bit ids; portable exports pin the viewer by SRI hash with budget and manifest gates in PR CI and at deploy; two-runtime parity across Bun and Pi verified; v0.27.x saved guides load unchanged. Retention is indefinite by explicit decision, to revisit with the lean sharing refactor. Decision record: adr/decisions/007-portable-guided-reviews-20260815.md
105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import React from 'react';
|
|
|
|
/**
|
|
* Renders simple inline markdown: `code`, **bold**, *italic*, _italic_, and
|
|
* fenced code blocks (```...```). Enough for review comments.
|
|
*/
|
|
export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
|
const nodes: React.ReactNode[] = [];
|
|
let key = 0;
|
|
|
|
// Split by fenced code blocks first
|
|
const parts = text.split(/(```[\s\S]*?```)/g);
|
|
|
|
for (const part of parts) {
|
|
if (part.startsWith('```') && part.endsWith('```')) {
|
|
const inner = part.slice(3, -3);
|
|
// Strip optional language identifier on first line
|
|
const newlineIdx = inner.indexOf('\n');
|
|
const code = newlineIdx >= 0 ? inner.slice(newlineIdx + 1) : inner;
|
|
nodes.push(
|
|
<pre key={key++} className="inline-code-block">
|
|
<code>{code.trim()}</code>
|
|
</pre>
|
|
);
|
|
} else {
|
|
// Process inline markdown
|
|
nodes.push(...renderInline(part, key));
|
|
key++;
|
|
}
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
function renderInline(text: string, startKey: number): React.ReactNode[] {
|
|
const nodes: React.ReactNode[] = [];
|
|
let key = startKey;
|
|
|
|
// Match inline patterns: [text](url), `code`, **bold**, *italic*, _italic_, bare URLs
|
|
const regex = /(\[([^\]]+)\]\((https?:\/\/[^)]+)\)|`[^`]+`|\*\*[^*]+\*\*|(?<!\w)_([^_\s](?:[\s\S]*?[^_\s])?)_(?!\w)|\*[^*]+\*|https?:\/\/[^\s<)\]]+)/g;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = regex.exec(text)) !== null) {
|
|
// A `[text](url)` match preceded by `!` is a markdown image ``.
|
|
const isImage = !!(match[1] && match[2] && match[3]) && text[match.index - 1] === '!';
|
|
|
|
// Text before match (drop the trailing '!' that marks an image).
|
|
if (match.index > lastIndex) {
|
|
let before = text.slice(lastIndex, match.index);
|
|
if (isImage && before.endsWith('!')) before = before.slice(0, -1);
|
|
if (before) nodes.push(before);
|
|
}
|
|
|
|
const token = match[0];
|
|
if (match[1] && match[2] && match[3]) {
|
|
if (isImage) {
|
|
// Markdown image: . Broken sources hide themselves.
|
|
nodes.push(
|
|
<img
|
|
key={key++}
|
|
src={match[3]}
|
|
alt={match[2]}
|
|
loading="lazy"
|
|
className="max-w-full h-auto rounded my-1"
|
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
|
/>
|
|
);
|
|
} else {
|
|
// Markdown link: [text](url)
|
|
nodes.push(
|
|
<a key={key++} href={match[3]} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
|
{match[2]}
|
|
</a>
|
|
);
|
|
}
|
|
} else if (token.startsWith('`')) {
|
|
nodes.push(<code key={key++} className="inline-code">{token.slice(1, -1)}</code>);
|
|
} else if (token.startsWith('**')) {
|
|
nodes.push(<strong key={key++}>{token.slice(2, -2)}</strong>);
|
|
} else if (token.startsWith('_')) {
|
|
const italicText = match[4];
|
|
nodes.push(<em key={key++}>{italicText}</em>);
|
|
} else if (token.startsWith('*')) {
|
|
nodes.push(<em key={key++}>{token.slice(1, -1)}</em>);
|
|
} else if (token.startsWith('http')) {
|
|
// Bare URL
|
|
nodes.push(
|
|
<a key={key++} href={token} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline break-all">
|
|
{token}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
lastIndex = match.index + token.length;
|
|
}
|
|
|
|
// Remaining text
|
|
if (lastIndex < text.length) {
|
|
nodes.push(text.slice(lastIndex));
|
|
}
|
|
|
|
return nodes;
|
|
}
|