Complete Forgejo discussion MVP

This commit is contained in:
kacper 2026-04-13 18:19:50 -04:00
parent d84a885fdb
commit 51706d2d11
17 changed files with 1708 additions and 127 deletions

View file

@ -3,6 +3,7 @@ import { useEffect, useState } from "preact/hooks";
import { MarkdownContent, stripLeadingTitleHeading } from "./MarkdownContent";
import type {
AuthState,
ContentAsset,
CourseCard,
CourseChapter,
CourseLesson,
@ -49,6 +50,26 @@ function parseDiscussionRoute(pathname: string): number | null {
return Number.isFinite(issueId) ? issueId : null;
}
function parseDiscussionRepoRoute(
pathname: string,
): { owner: string; repo: string; number: number } | null {
const match = normalizePathname(pathname).match(/^\/discussions\/([^/]+)\/([^/]+)\/(\d+)$/);
if (!match) {
return null;
}
const issueNumber = Number(match[3]);
if (!Number.isFinite(issueNumber)) {
return null;
}
return {
owner: decodeURIComponent(match[1]),
repo: decodeURIComponent(match[2]),
number: issueNumber,
};
}
function isSignInRoute(pathname: string): boolean {
return normalizePathname(pathname) === "/signin";
}
@ -179,6 +200,17 @@ function findLessonByRoute(
return undefined;
}
function findDiscussionByRoute(
discussions: DiscussionCard[],
route: { owner: string; repo: string; number: number },
): DiscussionCard | undefined {
const routeRepo = `${normalizeRouteKey(route.owner)}/${normalizeRouteKey(route.repo)}`;
return discussions.find(
(discussion) =>
normalizeRouteKey(discussion.repo) === routeRepo && discussion.number === route.number,
);
}
function usePathname() {
const [pathname, setPathname] = useState(() => normalizePathname(window.location.pathname));
@ -424,7 +456,7 @@ function EventItem(props: { event: EventCard }) {
function DiscussionPreviewItem(props: {
discussion: DiscussionCard;
onOpenDiscussion: (id: number) => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
}) {
const { discussion, onOpenDiscussion } = props;
@ -433,7 +465,7 @@ function DiscussionPreviewItem(props: {
type="button"
className="discussion-preview-card"
onClick={() => {
onOpenDiscussion(discussion.id);
onOpenDiscussion(discussion);
}}
>
<h3>{discussion.title}</h3>
@ -500,6 +532,42 @@ async function postDiscussionReply(
return (await response.json()) as DiscussionReply;
}
interface DiscussionCreateContext {
owner?: string;
repo?: string;
contextPath?: string;
contextUrl?: string;
contextTitle?: string;
}
async function postDiscussion(
title: string,
body: string,
context?: DiscussionCreateContext,
): Promise<DiscussionCard> {
const response = await fetch("/api/discussions", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title,
body,
owner: context?.owner,
repo: context?.repo,
context_path: context?.contextPath,
context_url: context?.contextUrl,
context_title: context?.contextTitle,
}),
});
if (!response.ok) {
throw await responseError(response, `Discussion creation failed with ${response.status}`);
}
return (await response.json()) as DiscussionCard;
}
function ComposeBox(props: {
discussion: DiscussionCard;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
@ -578,6 +646,193 @@ function ComposeBox(props: {
);
}
function DiscussionCreateBox(props: {
auth: AuthState;
context?: DiscussionCreateContext;
titlePlaceholder: string;
bodyPlaceholder: string;
submitLabel: string;
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
const {
auth,
context,
titlePlaceholder,
bodyPlaceholder,
submitLabel,
onGoSignIn,
onDiscussionCreated,
} = props;
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const trimmedTitle = title.trim();
const trimmedBody = body.trim();
const canCreate = canUseInteractiveAuth(auth);
async function submitDiscussion(event: SubmitEvent) {
event.preventDefault();
if (!canCreate) {
setError("Sign in before starting a discussion.");
return;
}
if (!trimmedTitle || !trimmedBody || isSubmitting) {
return;
}
setError(null);
setIsSubmitting(true);
try {
const discussion = await postDiscussion(trimmedTitle, trimmedBody, context);
setTitle("");
setBody("");
onDiscussionCreated(discussion);
} catch (createError) {
const message =
createError instanceof Error ? createError.message : "Discussion could not be created.";
setError(message);
} finally {
setIsSubmitting(false);
}
}
return (
<form className="compose-box" onSubmit={submitDiscussion}>
{!canCreate ? (
<div className="signin-callout">
<p>
{auth.authenticated
? "Discussion creation is unavailable for this session."
: "Sign in before starting a discussion."}
</p>
{!auth.authenticated ? (
<button type="button" className="secondary-button" onClick={onGoSignIn}>
Sign in
</button>
) : null}
</div>
) : null}
<input
className="token-input"
placeholder={titlePlaceholder}
value={title}
disabled={!canCreate}
onInput={(event) => {
setTitle(event.currentTarget.value);
}}
/>
<textarea
className="compose-input"
placeholder={bodyPlaceholder}
value={body}
disabled={!canCreate}
onInput={(event) => {
setBody(event.currentTarget.value);
}}
/>
<div className="compose-actions">
<button
type="submit"
className="compose-button"
disabled={!canCreate || !trimmedTitle || !trimmedBody || isSubmitting}
>
{isSubmitting ? "Creating..." : submitLabel}
</button>
</div>
{error ? <p className="compose-error">{error}</p> : null}
</form>
);
}
function AssetsPanel(props: { assets: ContentAsset[] }) {
if (props.assets.length === 0) {
return null;
}
return (
<article className="panel">
<header className="subsection-header">
<h2>Downloads</h2>
</header>
<div className="asset-list">
{props.assets.map((asset) => (
<a
key={asset.path}
className="asset-link"
href={asset.download_url || asset.html_url}
target="_blank"
rel="noreferrer"
>
<span>{asset.name}</span>
<span className="meta-line">{asset.path}</span>
</a>
))}
</div>
</article>
);
}
function lessonPath(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson): string {
return `/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`;
}
function absoluteSiteUrl(path: string): string {
return new URL(path, window.location.origin).toString();
}
function discussionMatchesContent(
discussion: DiscussionCard,
routePath: string,
contentPath: string,
): boolean {
const normalizedRoute = normalizeRouteKey(routePath);
const normalizedContentPath = normalizeRouteKey(contentPath);
return (discussion.links || []).some((link) => {
const linkPath = normalizeRouteKey(link.path || "");
const linkContentPath = normalizeRouteKey(link.content_path || "");
return (
linkPath === normalizedRoute ||
linkContentPath === normalizedContentPath ||
normalizedContentPath.startsWith(`${linkContentPath}/`)
);
});
}
function RelatedDiscussionsPanel(props: {
discussions: DiscussionCard[];
routePath: string;
contentPath: string;
onOpenDiscussion: (discussion: DiscussionCard) => void;
}) {
const relatedDiscussions = props.discussions.filter((discussion) =>
discussionMatchesContent(discussion, props.routePath, props.contentPath),
);
return (
<article className="panel">
<header className="subsection-header">
<h2>Related discussions</h2>
<p className="meta-line">{relatedDiscussions.length}</p>
</header>
{relatedDiscussions.length > 0 ? (
<div className="reply-list">
{relatedDiscussions.map((discussion) => (
<DiscussionPreviewItem
key={discussion.id}
discussion={discussion}
onOpenDiscussion={props.onOpenDiscussion}
/>
))}
</div>
) : (
<EmptyState copy="No linked discussions yet." />
)}
</article>
);
}
function CoursePage(props: {
course: CourseCard;
onGoHome: () => void;
@ -644,10 +899,26 @@ function LessonPage(props: {
course: CourseCard;
chapter: CourseChapter;
lesson: CourseLesson;
auth: AuthState;
discussions: DiscussionCard[];
onGoCourse: () => void;
onGoSignIn: () => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
const { course, chapter, lesson, onGoCourse } = props;
const {
course,
chapter,
lesson,
auth,
discussions,
onGoCourse,
onGoSignIn,
onOpenDiscussion,
onDiscussionCreated,
} = props;
const lessonBody = stripLeadingTitleHeading(lesson.body, lesson.title);
const routePath = lessonPath(course, chapter, lesson);
return (
<section className="thread-view">
@ -670,18 +941,60 @@ function LessonPage(props: {
<h2>Lesson</h2>
</header>
{lessonBody ? (
<MarkdownContent markdown={lessonBody} className="lesson-body" />
<MarkdownContent
markdown={lessonBody}
className="lesson-body"
baseUrl={lesson.raw_base_url}
/>
) : (
<EmptyState copy="This lesson file is empty or could not be read from Forgejo." />
)}
</article>
<AssetsPanel assets={lesson.assets} />
<RelatedDiscussionsPanel
discussions={discussions}
routePath={routePath}
contentPath={lesson.path}
onOpenDiscussion={onOpenDiscussion}
/>
<article className="panel">
<header className="subsection-header">
<h2>Start a lesson discussion</h2>
</header>
<DiscussionCreateBox
auth={auth}
context={{
owner: course.owner,
repo: course.name,
contextPath: lesson.file_path || lesson.path,
contextUrl: absoluteSiteUrl(routePath),
contextTitle: lesson.title,
}}
titlePlaceholder="What should this lesson discussion be called?"
bodyPlaceholder="Describe the blocker, question, or project note."
submitLabel="Start discussion"
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
/>
</article>
</section>
);
}
function PostPage(props: { post: PostCard }) {
const { post } = props;
function PostPage(props: {
post: PostCard;
auth: AuthState;
discussions: DiscussionCard[];
onGoSignIn: () => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
const { post, auth, discussions, onGoSignIn, onOpenDiscussion, onDiscussionCreated } = props;
const postBody = stripLeadingTitleHeading(post.body, post.title);
const routePath = postPath(post);
return (
<section className="thread-view">
@ -700,11 +1013,45 @@ function PostPage(props: { post: PostCard }) {
<h2>Post</h2>
</header>
{postBody ? (
<MarkdownContent markdown={postBody} className="thread-copy" />
<MarkdownContent
markdown={postBody}
className="thread-copy"
baseUrl={post.raw_base_url}
/>
) : (
<EmptyState copy="This post file is empty or could not be read from Forgejo." />
)}
</article>
<AssetsPanel assets={post.assets} />
<RelatedDiscussionsPanel
discussions={discussions}
routePath={routePath}
contentPath={post.path}
onOpenDiscussion={onOpenDiscussion}
/>
<article className="panel">
<header className="subsection-header">
<h2>Start a post discussion</h2>
</header>
<DiscussionCreateBox
auth={auth}
context={{
owner: post.owner,
repo: post.name,
contextPath: post.file_path || post.path,
contextUrl: absoluteSiteUrl(routePath),
contextTitle: post.title,
}}
titlePlaceholder="What should this post discussion be called?"
bodyPlaceholder="Share a question, project note, or blocker."
submitLabel="Start discussion"
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
/>
</article>
</section>
);
}
@ -785,12 +1132,42 @@ function CoursesView(props: { data: PrototypeData; onOpenCourse: (course: Course
);
}
function DiscussionsView(props: { data: PrototypeData; onOpenDiscussion: (id: number) => void }) {
const { data, onOpenDiscussion } = props;
function DiscussionsView(props: {
data: PrototypeData;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
showComposer?: boolean;
}) {
const { data, onOpenDiscussion, onGoSignIn, onDiscussionCreated, showComposer = true } = props;
const generalDiscussionConfigured =
data.discussion_settings?.general_discussion_configured ?? false;
return (
<section className="page-section">
<SectionHeader title="Discussions" />
{showComposer && generalDiscussionConfigured ? (
<article className="discussion-create-panel">
<header className="subsection-header">
<h2>Start a discussion</h2>
</header>
<DiscussionCreateBox
auth={data.auth}
titlePlaceholder="What should the discussion be called?"
bodyPlaceholder="Share a project update, blocker, or question."
submitLabel="Start discussion"
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
/>
</article>
) : null}
{showComposer && !generalDiscussionConfigured ? (
<article className="discussion-create-panel">
<p className="muted-copy">
General discussion creation needs a configured public org repo.
</p>
</article>
) : null}
{data.recent_discussions.length > 0 ? (
<div className="stack">
{data.recent_discussions.map((discussion) => (
@ -812,9 +1189,12 @@ function HomeView(props: {
data: PrototypeData;
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenDiscussion: (id: number) => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onGoSignIn: () => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
}) {
const { data, onOpenCourse, onOpenPost, onOpenDiscussion } = props;
const { data, onOpenCourse, onOpenPost, onOpenDiscussion, onGoSignIn, onDiscussionCreated } =
props;
return (
<>
@ -856,7 +1236,13 @@ function HomeView(props: {
</div>
</section>
<DiscussionsView data={data} onOpenDiscussion={onOpenDiscussion} />
<DiscussionsView
data={data}
onOpenDiscussion={onOpenDiscussion}
onGoSignIn={onGoSignIn}
onDiscussionCreated={onDiscussionCreated}
showComposer={false}
/>
</>
);
}
@ -912,6 +1298,22 @@ async function fetchPrototypeData(signal?: AbortSignal): Promise<PrototypeData>
return (await response.json()) as PrototypeData;
}
async function fetchDiscussionDetail(
route: { owner: string; repo: string; number: number },
signal?: AbortSignal,
): Promise<DiscussionCard> {
const response = await fetch(
`/api/discussions/${encodeURIComponent(route.owner)}/${encodeURIComponent(route.repo)}/${route.number}`,
{ signal },
);
if (!response.ok) {
throw new Error(`Discussion request failed with ${response.status}`);
}
return (await response.json()) as DiscussionCard;
}
function appendDiscussionReply(
currentData: PrototypeData | null,
discussionId: number,
@ -938,6 +1340,23 @@ function appendDiscussionReply(
};
}
function prependDiscussion(
currentData: PrototypeData | null,
discussion: DiscussionCard,
): PrototypeData | null {
if (!currentData) {
return currentData;
}
return {
...currentData,
recent_discussions: [
discussion,
...currentData.recent_discussions.filter((entry) => entry.id !== discussion.id),
],
};
}
interface ActivityEntry {
id: string;
title: string;
@ -959,6 +1378,15 @@ function postPath(post: PostCard): string {
return `/posts/${encodeURIComponent(post.owner)}/${encodeURIComponent(post.name)}/${encodeURIComponent(post.slug)}`;
}
function discussionPath(discussion: DiscussionCard): string {
const repo = repoParts(discussion.repo);
if (!repo || discussion.number < 1) {
return `/discussions/${discussion.id}`;
}
return `/discussions/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repo)}/${discussion.number}`;
}
function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
const activities: ActivityEntry[] = [];
@ -992,7 +1420,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `Discussion updated: ${discussion.title}`,
detail: `${discussion.repo} · ${discussion.replies} replies`,
timestamp: discussion.updated_at,
route: `/discussions/${discussion.id}`,
route: discussionPath(discussion),
});
for (const reply of discussion.comments) {
@ -1001,7 +1429,7 @@ function buildActivityFeed(data: PrototypeData): ActivityEntry[] {
title: `${reply.author} replied`,
detail: discussion.title,
timestamp: reply.created_at,
route: `/discussions/${discussion.id}`,
route: discussionPath(discussion),
});
}
}
@ -1066,10 +1494,11 @@ interface AppContentProps {
onOpenCourse: (course: CourseCard) => void;
onOpenPost: (post: PostCard) => void;
onOpenLesson: (course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) => void;
onOpenDiscussion: (id: number) => void;
onOpenDiscussion: (discussion: DiscussionCard) => void;
onOpenRoute: (route: string) => void;
onGoSignIn: () => void;
onReplyCreated: (discussionId: number, reply: DiscussionReply) => void;
onDiscussionCreated: (discussion: DiscussionCard) => void;
onGoHome: () => void;
onGoCourses: () => void;
onGoDiscussions: () => void;
@ -1087,7 +1516,16 @@ function PostRouteView(
);
}
return <PostPage post={selectedPost} />;
return (
<PostPage
post={selectedPost}
auth={props.data.auth}
discussions={props.data.recent_discussions}
onGoSignIn={props.onGoSignIn}
onOpenDiscussion={props.onOpenDiscussion}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
function LessonRouteView(
@ -1130,9 +1568,14 @@ function LessonRouteView(
course={selectedCourse}
chapter={selectedLesson.chapter}
lesson={selectedLesson.lesson}
auth={props.data.auth}
discussions={props.data.recent_discussions}
onGoCourse={() => {
props.onOpenCourse(selectedCourse);
}}
onGoSignIn={props.onGoSignIn}
onOpenDiscussion={props.onOpenDiscussion}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
@ -1185,6 +1628,66 @@ function DiscussionRouteView(props: AppContentProps & { discussionId: number })
);
}
function DiscussionRepoRouteView(
props: AppContentProps & { route: { owner: string; repo: string; number: number } },
) {
const indexedDiscussion = findDiscussionByRoute(props.data.recent_discussions, props.route);
const [loadedDiscussion, setLoadedDiscussion] = useState<DiscussionCard | null>(null);
const [error, setError] = useState<string | null>(null);
const selectedDiscussion = indexedDiscussion || loadedDiscussion;
useEffect(() => {
if (indexedDiscussion) {
setLoadedDiscussion(null);
setError(null);
return;
}
const controller = new AbortController();
fetchDiscussionDetail(props.route, controller.signal)
.then((discussion) => {
setLoadedDiscussion(discussion);
setError(null);
})
.catch((fetchError) => {
if (controller.signal.aborted) {
return;
}
setError(fetchError instanceof Error ? fetchError.message : "Discussion did not load.");
});
return () => {
controller.abort();
};
}, [indexedDiscussion, props.route.owner, props.route.repo, props.route.number]);
if (error) {
return (
<section className="page-message">
<h1>Discussion did not load.</h1>
<p className="muted-copy">{error}</p>
</section>
);
}
if (!selectedDiscussion) {
return (
<section className="page-message">
<h1>Loading discussion.</h1>
</section>
);
}
return (
<DiscussionPage
discussion={selectedDiscussion}
auth={props.data.auth}
onGoHome={props.onGoDiscussions}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
/>
);
}
function AppContent(props: AppContentProps) {
if (isSignInRoute(props.pathname)) {
return <SignInPage auth={props.data.auth} />;
@ -1199,7 +1702,19 @@ function AppContent(props: AppContentProps) {
}
if (isDiscussionsIndexRoute(props.pathname)) {
return <DiscussionsView data={props.data} onOpenDiscussion={props.onOpenDiscussion} />;
return (
<DiscussionsView
data={props.data}
onOpenDiscussion={props.onOpenDiscussion}
onGoSignIn={props.onGoSignIn}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
const discussionRepoRoute = parseDiscussionRepoRoute(props.pathname);
if (discussionRepoRoute !== null) {
return <DiscussionRepoRouteView {...props} route={discussionRepoRoute} />;
}
const postRoute = parsePostRoute(props.pathname);
@ -1225,6 +1740,8 @@ function AppContent(props: AppContentProps) {
onOpenCourse={props.onOpenCourse}
onOpenPost={props.onOpenPost}
onOpenDiscussion={props.onOpenDiscussion}
onGoSignIn={props.onGoSignIn}
onDiscussionCreated={props.onDiscussionCreated}
/>
);
}
@ -1261,6 +1778,7 @@ function LoadedApp(
onOpenRoute={props.onOpenRoute}
onGoSignIn={props.onGoSignIn}
onReplyCreated={props.onReplyCreated}
onDiscussionCreated={props.onDiscussionCreated}
onGoHome={props.onGoHome}
onGoCourses={props.onGoCourses}
onGoDiscussions={props.onGoDiscussions}
@ -1281,10 +1799,9 @@ function AppStatusPage(props: { title: string; copy?: string }) {
);
}
export default function App() {
function usePrototypeData() {
const [data, setData] = useState<PrototypeData | null>(null);
const [error, setError] = useState<string | null>(null);
const { pathname, navigate } = usePathname();
async function loadPrototype(signal?: AbortSignal) {
try {
@ -1310,7 +1827,24 @@ export default function App() {
};
}, []);
const openDiscussion = (id: number) => navigate(`/discussions/${id}`);
useEffect(() => {
const eventSource = new EventSource("/api/events/stream");
eventSource.addEventListener("content-updated", () => {
loadPrototype();
});
return () => {
eventSource.close();
};
}, []);
return { data, setData, error, loadPrototype };
}
export default function App() {
const { data, setData, error, loadPrototype } = usePrototypeData();
const { pathname, navigate } = usePathname();
const openDiscussion = (discussion: DiscussionCard) => navigate(discussionPath(discussion));
function goSignIn() {
window.location.assign(forgejoSignInUrl(pathname));
@ -1329,15 +1863,18 @@ export default function App() {
const openPost = (post: PostCard) => navigate(postPath(post));
function openLesson(course: CourseCard, chapter: CourseChapter, lesson: CourseLesson) {
navigate(
`/courses/${encodeURIComponent(course.owner)}/${encodeURIComponent(course.name)}/lessons/${encodeURIComponent(chapter.slug)}/${encodeURIComponent(lesson.slug)}`,
);
navigate(lessonPath(course, chapter, lesson));
}
function addReplyToDiscussion(discussionId: number, reply: DiscussionReply) {
setData((currentData) => appendDiscussionReply(currentData, discussionId, reply));
}
function addDiscussion(discussion: DiscussionCard) {
setData((currentData) => prependDiscussion(currentData, discussion));
navigate(discussionPath(discussion));
}
const goHome = () => navigate("/");
async function signOut() {
@ -1367,6 +1904,7 @@ export default function App() {
onOpenRoute={navigate}
onGoSignIn={goSignIn}
onReplyCreated={addReplyToDiscussion}
onDiscussionCreated={addDiscussion}
onGoHome={goHome}
onGoCourses={goCourses}
onGoDiscussions={goDiscussions}

View file

@ -20,7 +20,7 @@ function escapeHtml(value: string): string {
.replace(/'/g, "&#39;");
}
function normalizeLinkTarget(value: string): string | null {
function normalizeLinkTarget(value: string, baseUrl?: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
@ -31,7 +31,7 @@ function normalizeLinkTarget(value: string): string | null {
}
try {
const url = new URL(trimmed);
const url = new URL(trimmed, baseUrl || undefined);
if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString());
}
@ -42,7 +42,7 @@ function normalizeLinkTarget(value: string): string | null {
return null;
}
function renderInline(markdown: string): string {
function renderInline(markdown: string, baseUrl?: string): string {
const codeTokens: string[] = [];
let rendered = escapeHtml(markdown);
@ -51,10 +51,21 @@ function renderInline(markdown: string): string {
codeTokens.push(`<code>${code}</code>`);
return token;
});
rendered = rendered.replace(
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) {
return label;
}
return `<img src="${safeHref}" alt="${label}" loading="lazy" />`;
},
);
rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href);
const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) {
return label;
}
@ -83,12 +94,12 @@ function createParserState(): ParserState {
};
}
function flushParagraph(state: ParserState) {
function flushParagraph(state: ParserState, baseUrl?: string) {
if (state.paragraphLines.length === 0) {
return;
}
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "))}</p>`);
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "), baseUrl)}</p>`);
state.paragraphLines.length = 0;
}
@ -104,13 +115,13 @@ function flushList(state: ParserState) {
state.listType = null;
}
function flushBlockquote(state: ParserState) {
function flushBlockquote(state: ParserState, baseUrl?: string) {
if (state.blockquoteLines.length === 0) {
return;
}
state.output.push(
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "))}</p></blockquote>`,
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "), baseUrl)}</p></blockquote>`,
);
state.blockquoteLines.length = 0;
}
@ -131,10 +142,10 @@ function flushCodeBlock(state: ParserState) {
state.codeLines.length = 0;
}
function flushInlineBlocks(state: ParserState) {
flushParagraph(state);
function flushInlineBlocks(state: ParserState, baseUrl?: string) {
flushParagraph(state, baseUrl);
flushList(state);
flushBlockquote(state);
flushBlockquote(state, baseUrl);
}
function handleCodeBlockLine(state: ParserState, line: string): boolean {
@ -151,124 +162,129 @@ function handleCodeBlockLine(state: ParserState, line: string): boolean {
return true;
}
function handleFenceStart(state: ParserState, line: string): boolean {
function handleFenceStart(state: ParserState, line: string, baseUrl?: string): boolean {
if (!line.trim().startsWith("```")) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim();
return true;
}
function handleBlankLine(state: ParserState, line: string): boolean {
function handleBlankLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (line.trim()) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
return true;
}
function handleHeadingLine(state: ParserState, line: string): boolean {
function handleHeadingLine(state: ParserState, line: string, baseUrl?: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
const level = headingMatch[1].length;
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim())}</h${level}>`);
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim(), baseUrl)}</h${level}>`);
return true;
}
function handleRuleLine(state: ParserState, line: string): boolean {
function handleRuleLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
state.output.push("<hr />");
return true;
}
function handleListLine(state: ParserState, line: string, listType: ListType): boolean {
function handleListLine(
state: ParserState,
line: string,
listType: ListType,
baseUrl?: string,
): boolean {
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const match = line.match(pattern);
if (!match) {
return false;
}
flushParagraph(state);
flushBlockquote(state);
flushParagraph(state, baseUrl);
flushBlockquote(state, baseUrl);
if (state.listType !== listType) {
flushList(state);
state.listType = listType;
}
state.listItems.push(renderInline(match[1].trim()));
state.listItems.push(renderInline(match[1].trim(), baseUrl));
return true;
}
function handleBlockquoteLine(state: ParserState, line: string): boolean {
function handleBlockquoteLine(state: ParserState, line: string, baseUrl?: string): boolean {
const match = line.match(/^>\s?(.*)$/);
if (!match) {
return false;
}
flushParagraph(state);
flushParagraph(state, baseUrl);
flushList(state);
state.blockquoteLines.push(match[1].trim());
return true;
}
function handleParagraphLine(state: ParserState, line: string) {
function handleParagraphLine(state: ParserState, line: string, baseUrl?: string) {
flushList(state);
flushBlockquote(state);
flushBlockquote(state, baseUrl);
state.paragraphLines.push(line.trim());
}
function processMarkdownLine(state: ParserState, line: string) {
function processMarkdownLine(state: ParserState, line: string, baseUrl?: string) {
if (handleCodeBlockLine(state, line)) {
return;
}
if (handleFenceStart(state, line)) {
if (handleFenceStart(state, line, baseUrl)) {
return;
}
if (handleBlankLine(state, line)) {
if (handleBlankLine(state, line, baseUrl)) {
return;
}
if (handleHeadingLine(state, line)) {
if (handleHeadingLine(state, line, baseUrl)) {
return;
}
if (handleRuleLine(state, line)) {
if (handleRuleLine(state, line, baseUrl)) {
return;
}
if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) {
if (handleListLine(state, line, "ul", baseUrl) || handleListLine(state, line, "ol", baseUrl)) {
return;
}
if (handleBlockquoteLine(state, line)) {
if (handleBlockquoteLine(state, line, baseUrl)) {
return;
}
handleParagraphLine(state, line);
handleParagraphLine(state, line, baseUrl);
}
function markdownToHtml(markdown: string): string {
function markdownToHtml(markdown: string, baseUrl?: string): string {
const state = createParserState();
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const line of lines) {
processMarkdownLine(state, line);
processMarkdownLine(state, line, baseUrl);
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
flushCodeBlock(state);
return state.output.join("");
}
@ -288,8 +304,8 @@ export function stripLeadingTitleHeading(markdown: string, title: string): strin
return markdown;
}
export function MarkdownContent(props: { markdown: string; className?: string }) {
const html = markdownToHtml(props.markdown);
export function MarkdownContent(props: { markdown: string; className?: string; baseUrl?: string }) {
const html = markdownToHtml(props.markdown, props.baseUrl);
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;

View file

@ -296,6 +296,14 @@ textarea {
gap: 0.75rem;
}
.discussion-create-panel {
margin-bottom: 1rem;
border: 0.0625rem solid var(--accent-border);
border-radius: 0.75rem;
padding: 1rem;
background: var(--accent-soft);
}
.discussion-preview-card {
width: 100%;
padding: 1rem;
@ -546,6 +554,12 @@ textarea {
color: var(--accent);
}
.markdown-content img {
max-width: 100%;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
}
.markdown-content hr {
width: 100%;
height: 0.0625rem;
@ -559,6 +573,32 @@ textarea {
gap: 0.85rem;
}
.asset-list {
display: grid;
gap: 0.75rem;
}
.asset-link {
display: grid;
gap: 0.2rem;
border: 0.0625rem solid var(--border);
border-radius: 0.75rem;
padding: 0.85rem;
color: inherit;
background: var(--card);
text-decoration: none;
}
.asset-link:hover,
.asset-link:focus-visible {
background: var(--panel-hover);
outline: none;
}
.asset-link .meta-line {
margin-top: 0;
}
.signin-page {
display: grid;
}

View file

@ -18,6 +18,13 @@ export interface AuthState {
oauth_configured: boolean;
}
export interface ContentAsset {
name: string;
path: string;
html_url: string;
download_url: string;
}
export interface CourseCard {
title: string;
owner: string;
@ -44,6 +51,8 @@ export interface CourseLesson {
path: string;
file_path: string;
html_url: string;
raw_base_url: string;
assets: ContentAsset[];
summary: string;
body: string;
}
@ -59,6 +68,8 @@ export interface PostCard {
path: string;
file_path: string;
html_url: string;
raw_base_url: string;
assets: ContentAsset[];
body: string;
updated_at: string;
}
@ -85,6 +96,7 @@ export interface DiscussionCard {
html_url: string;
labels: string[];
comments: DiscussionReply[];
links: DiscussionLink[];
}
export interface DiscussionReply {
@ -96,9 +108,25 @@ export interface DiscussionReply {
html_url: string;
}
export interface DiscussionLink {
kind: "post" | "lesson";
path: string;
owner: string;
repo: string;
slug?: string;
chapter?: string;
lesson?: string;
content_path: string;
}
export interface DiscussionSettings {
general_discussion_configured: boolean;
}
export interface PrototypeData {
hero: HeroData;
auth: AuthState;
discussion_settings: DiscussionSettings;
source_of_truth: SourceOfTruthCard[];
featured_courses: CourseCard[];
recent_posts: PostCard[];