Skip to content
Merged
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ When diagnosing a bug, especially in the production build, follow these rules wi
- **Buttons:** use raw `<button>` elements with the CSS utility classes defined in `src/index.css` (`.btn-primary`, `.btn-ghost`, `.btn-soft`, `.btn-inverse`, `.btn-ghost-inverse`). There is no `Button` component wrapper and no `@radix-ui/react-slot` dependency. See `styleguide.md` for which class to use on which background color.
- **Toasts:** if toast notifications are ever needed, install `sonner` and add `src/components/ui/sonner.tsx` (shadcn pattern). Mount `<Toaster>` in the nearest layout that actually triggers a toast. Do not install speculatively.
- **TooltipProvider** is intentionally not mounted in `Layout.tsx` until a call site exists. Wrap only the subtree that uses `<Tooltip>` with `<TooltipProvider>` at that point.
- **Author-controlled strings render through markdown.** Every YAML/TS field that holds prose written by a challenge author (e.g. `level.audience`, `tool.description`, `adventure.story`, `step.title`, `contributor.about`, `rewards.eligibility`, `tier.description`, `rewards.rankingNote`) must be rendered through `<MarkdownInline>` (single-line, no wrapping `<p>`) or `<MarkdownContent>` (multi-paragraph). Never render an author-controlled string as `{value}` directly. Plain prose passes through `<MarkdownInline>` unchanged, so this is safe for fields that may or may not contain markdown. Identifier fields (`id`, URLs, enum values like `difficulty`, emoji) are not author prose and are rendered directly.

### Component CSS patterns

Expand Down
3 changes: 2 additions & 1 deletion src/components/AdventureCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Layers } from "lucide-react";
import type { AdventureCardSummary } from "@/data/adventures";
import { DifficultyBadge } from "@/components/DifficultyBadge";
import { ContributorBadge } from "@/components/ContributorBadge";
import { MarkdownInline } from "@/components/MarkdownInline";

type AdventureCardProps = { adventure: AdventureCardSummary };

Expand All @@ -23,7 +24,7 @@ export const AdventureCard = ({ adventure }: AdventureCardProps): JSX.Element =>
<h3 className="text-lg font-semibold text-foreground group-hover:text-primary transition-colors">
{adventure.title}
</h3>
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{adventure.story}</p>
<p className="mt-2 text-sm text-muted-foreground line-clamp-2"><MarkdownInline source={adventure.story} noLinks /></p>

<div className="mt-4 flex flex-wrap items-center gap-2">
{adventure.levels.map((level) => (
Expand Down
3 changes: 2 additions & 1 deletion src/components/ChallengeBuildersSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link } from "react-router";
import { ADVENTURE_CONTRIBUTORS } from "@/data/adventures";
import { PersonNameLink } from "@/components/PersonNameLink";
import { SidebarLayout } from "@/components/SidebarLayout";
import { MarkdownInline } from "@/components/MarkdownInline";

export const ChallengeBuildersSection = ({ aside }: { aside?: ReactNode }): JSX.Element | null => {
if (ADVENTURE_CONTRIBUTORS.length === 0) {
Expand All @@ -23,7 +24,7 @@ export const ChallengeBuildersSection = ({ aside }: { aside?: ReactNode }): JSX.
>
<PersonNameLink name={contributor.name} url={contributor.url} />
{contributor.about && (
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">{contributor.about}</p>
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed"><MarkdownInline source={contributor.about} /></p>
)}
<p className="mt-6 mb-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">adventures created</p>
<ul className="space-y-3">
Expand Down
62 changes: 6 additions & 56 deletions src/components/MarkdownContent.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { useRef, useState, type JSX, type ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Check, Copy, ExternalLink } from "lucide-react";
import { Check, Copy } from "lucide-react";
import { getSectionIcon, slugify } from "@/lib/markdown";

const isExternalHref = (href: string | undefined): boolean =>
typeof href === "string" && /^https?:\/\//i.test(href);

const isLocalhostHref = (href: string | undefined): boolean =>
typeof href === "string" && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/i.test(href);
import { inlineComponents } from "@/components/markdownInlineComponents";

const childrenToText = (children: ReactNode): string => {
if (typeof children === "string" || typeof children === "number") return String(children);
Expand Down Expand Up @@ -75,7 +70,11 @@ const CodeBlock = ({ children, showCopy = true }: { children: ReactNode; showCop
);
};

// Inline-only renderers (links, inline code, strong, em) live in
// markdownInlineComponents.tsx so MarkdownInline can reuse them without
// dragging in the full block-level renderer set.
const components: Components = {
...inlineComponents,
h2: ({ children }) => {
const text = childrenToText(children);
const slug = slugify(text);
Expand Down Expand Up @@ -137,55 +136,6 @@ const components: Components = {
{children}
</li>
),
a: ({ href, children }) => {
// Block dangerous URI schemes (XSS protection)
if (typeof href === "string" && /^(javascript|data|vbscript):/i.test(href.trim())) {
return <span>{children}</span>;
}
// Render localhost URLs as plain text (not clickable)
if (isLocalhostHref(href)) {
return <code className="rounded bg-[hsl(var(--surface))] px-1.5 py-0.5 text-sm font-mono text-foreground">{children}</code>;
}
if (isExternalHref(href)) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="docs-ext-link"
>
{children}
<ExternalLink size={12} aria-hidden="true" className="shrink-0" />
<span className="sr-only"> (opens in new tab)</span>
</a>
);
}
return (
<a
href={href}
className="docs-ext-link"
>
{children}
</a>
);
},
strong: ({ children }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-[hsl(var(--text-secondary))]">{children}</em>
),
code: ({ className, children }) => {
const isBlock = typeof className === "string" && className.startsWith("language-");
if (isBlock) {
return <code className={className}>{children}</code>;
}
return (
<code className="rounded-sm border border-[hsl(var(--surface-border))] bg-[hsl(var(--surface))] px-1.5 py-0.5 font-mono text-[0.85em] text-foreground">
{children}
</code>
);
},
pre: ({ children }) => {
const child = Array.isArray(children) ? children[0] : children;
// Show copy for all fenced code blocks; language-tagged and bare blocks both wrap <code>
Expand Down
33 changes: 33 additions & 0 deletions src/components/MarkdownInline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { JSX, ReactNode } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { inlineComponents } from "@/components/markdownInlineComponents";

// Render author-controlled single-line strings as inline markdown. Block
// elements are unwrapped so the output is safe to drop inside <p>, <span>,
// <h*>, <button>, etc. without producing invalid HTML.
const components: Components = {
...inlineComponents,
p: ({ children }: { children?: ReactNode }) => <>{children}</>,
};

const DISALLOWED_BLOCK = ["h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "blockquote", "pre", "hr", "table", "img"];

type MarkdownInlineProps = {
source: string;
// Strip markdown links to plain text. Required when rendering inside
// another interactive element (<a>, <button>) since HTML forbids nesting
// interactive content.
noLinks?: boolean;
};

export const MarkdownInline = ({ source, noLinks = false }: MarkdownInlineProps): JSX.Element => (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={components}
disallowedElements={noLinks ? [...DISALLOWED_BLOCK, "a"] : DISALLOWED_BLOCK}
unwrapDisallowed
>
{source}
</ReactMarkdown>
);
7 changes: 4 additions & 3 deletions src/components/RewardsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ExternalLink, Trophy } from "lucide-react";
import type { AdventureRewards } from "@/data/adventures/types";
import { COMMUNITY_URL } from "@/data/constants";
import { formatDeadline } from "@/lib/utils";
import { MarkdownInline } from "@/components/MarkdownInline";

type RewardsCardProps = {
rewards: AdventureRewards;
Expand Down Expand Up @@ -39,7 +40,7 @@ export const RewardsCard = ({ rewards, compact = false, levelDeadline, deadlineP
</div>
) : (
<p className="text-xs text-[hsl(var(--text-secondary))] leading-relaxed mb-4">
{rewards.eligibility}
<MarkdownInline source={rewards.eligibility} />
</p>
)
)}
Expand All @@ -54,14 +55,14 @@ export const RewardsCard = ({ rewards, compact = false, levelDeadline, deadlineP
{rewards.tiers.map((tier) => (
<div key={tier.label}>
<p className="text-xs font-semibold text-foreground">{tier.label}</p>
<p className="text-xs text-[hsl(var(--text-secondary))]">{tier.description}</p>
<p className="text-xs text-[hsl(var(--text-secondary))]"><MarkdownInline source={tier.description} /></p>
</div>
))}
</div>

{!compact && rewards.rankingNote && (
<p className="text-xs text-[hsl(var(--text-faint))] leading-relaxed mt-4">
{rewards.rankingNote}{" "}
<MarkdownInline source={rewards.rankingNote} />{" "}
{rewards.rankingRulesUrl && (
<a
href={rewards.rankingRulesUrl}
Expand Down
3 changes: 2 additions & 1 deletion src/components/WalkthroughSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { JSX } from "react";
import { ChevronDown } from "lucide-react";
import { CollapsibleSection } from "@/components/CollapsibleSection";
import { MarkdownContent } from "@/components/MarkdownContent";
import { MarkdownInline } from "@/components/MarkdownInline";
import type { WalkthroughStep } from "@/data/adventures";

type WalkthroughSectionProps = {
Expand Down Expand Up @@ -40,7 +41,7 @@ export const WalkthroughSection = ({ steps }: WalkthroughSectionProps): JSX.Elem
{i + 1}
</span>
<span className="min-w-0 flex-1 text-sm font-semibold text-foreground">
{step.title ?? `Step ${i + 1}`}
{step.title ? <MarkdownInline source={step.title} noLinks /> : `Step ${i + 1}`}
</span>
<ChevronDown
size={16}
Expand Down
57 changes: 57 additions & 0 deletions src/components/markdownInlineComponents.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Components } from "react-markdown";
import { ExternalLink } from "lucide-react";

const isExternalHref = (href: string | undefined): boolean =>
typeof href === "string" && /^https?:\/\//i.test(href);

const isLocalhostHref = (href: string | undefined): boolean =>
typeof href === "string" && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?/i.test(href);

// Inline-only renderers (links, inline code, strong, em). Shared by
// MarkdownContent and MarkdownInline so all author prose styles consistently.
export const inlineComponents: Components = {
a: ({ href, children }) => {
if (typeof href === "string" && /^(javascript|data|vbscript):/i.test(href.trim())) {
return <span>{children}</span>;
}
if (isLocalhostHref(href)) {
return <code className="rounded bg-[hsl(var(--surface))] px-1.5 py-0.5 text-sm font-mono text-foreground">{children}</code>;
}
if (isExternalHref(href)) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="docs-ext-link"
>
{children}
<ExternalLink size={12} aria-hidden="true" className="shrink-0" />
<span className="sr-only"> (opens in new tab)</span>
</a>
);
}
return (
<a href={href} className="docs-ext-link">
{children}
</a>
);
},
strong: ({ children }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-[hsl(var(--text-secondary))]">{children}</em>
),
code: ({ className, children }) => {
const isBlock = typeof className === "string" && className.startsWith("language-");
if (isBlock) {
return <code className={className}>{children}</code>;
}
return (
<code className="rounded-sm border border-[hsl(var(--surface-border))] bg-[hsl(var(--surface))] px-1.5 py-0.5 font-mono text-[0.85em] text-foreground">
{children}
</code>
);
},
};
1 change: 1 addition & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,7 @@ html {
text-decoration-thickness: 2px;
text-underline-offset: 2px;
text-decoration-color: hsl(var(--primary));
text-decoration-skip-ink: none;
border-radius: 2px;
transition: color 200ms ease, text-decoration-color 200ms ease;
}
Expand Down
31 changes: 16 additions & 15 deletions src/pages/AdventureDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { RewardsCard } from "@/components/RewardsCard";
import { AdventureLeaderboard } from "@/components/AdventureLeaderboard";
import { ContributorBadge } from "@/components/ContributorBadge";
import { TagChips } from "@/components/TagChips";
import { MarkdownInline } from "@/components/MarkdownInline";
import { SITE_URL, BRAND_NAME } from "@/data/constants";
import { buildPageMeta } from "@/lib/meta";
import { isDeadlinePast } from "@/lib/utils";
Expand Down Expand Up @@ -139,7 +140,7 @@ const AdventureDetail = (): JSX.Element => {
)}
<TagChips tags={adventure.tags} />
</div>
<p className="text-[hsl(var(--text-secondary))] leading-relaxed max-w-3xl">{adventure.story}</p>
<p className="text-[hsl(var(--text-secondary))] leading-relaxed max-w-3xl"><MarkdownInline source={adventure.story} /></p>
</div>

{/* Two-column layout */}
Expand All @@ -148,6 +149,20 @@ const AdventureDetail = (): JSX.Element => {
{/* Main content */}
<div className="space-y-8">

{/* Overview */}
{adventure.overview && adventure.overview.length > 0 && (
<CollapsibleSection id="overview" title="Overview" defaultOpen={true}>
<ul className="space-y-2.5">
{adventure.overview.map((para, i) => (
<li key={i} className="flex items-start gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" />
<p className="text-sm text-[hsl(var(--text-secondary))] leading-relaxed">{para}</p>
</li>
))}
</ul>
</CollapsibleSection>
)}

{/* Challenges */}
<section aria-labelledby="challenges-heading">
<h2 id="challenges-heading" className="text-lg font-semibold text-foreground mb-5">
Expand Down Expand Up @@ -176,20 +191,6 @@ const AdventureDetail = (): JSX.Element => {
</div>
)}

{/* Your Mission */}
{adventure.overview && adventure.overview.length > 0 && (
<CollapsibleSection id="overview" title="Overview" defaultOpen={true}>
<ul className="space-y-2.5">
{adventure.overview.map((para, i) => (
<li key={i} className="flex items-start gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" />
<p className="text-sm text-[hsl(var(--text-secondary))] leading-relaxed">{para}</p>
</li>
))}
</ul>
</CollapsibleSection>
)}

{/* The Story */}
{adventure.backstory && adventure.backstory.length > 0 && (
<CollapsibleSection id="backstory" title="The Story" defaultOpen={true}>
Expand Down
5 changes: 3 additions & 2 deletions src/pages/ChallengeDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useParams, Link, useLoaderData } from "react-router";
import type { MetaFunction, LoaderFunctionArgs } from "react-router";
import { ArrowLeft, Check, ExternalLink } from "lucide-react";
import { MarkdownContent } from "@/components/MarkdownContent";
import { MarkdownInline } from "@/components/MarkdownInline";
import { ADVENTURES } from "@/data/adventures";
import { TagChips } from "@/components/TagChips";
import { CodespacesButton } from "@/components/CodespacesButton";
Expand Down Expand Up @@ -138,7 +139,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo
{level.audience && (
<CollapsibleSection id="audience" title="Best Suited For">
<p className="text-sm text-[hsl(var(--text-secondary))] leading-relaxed">
{level.audience}
<MarkdownInline source={level.audience} />
</p>
</CollapsibleSection>
)}
Expand Down Expand Up @@ -253,7 +254,7 @@ const StructuredLayout = ({ adventure, level, rewardsBelowFold }: StructuredLayo
) : (
<span className="font-medium text-foreground">{tool.name}</span>
)}
{tool.description && <>{" "}- {tool.description}</>}
{tool.description && <>{" "}- <MarkdownInline source={tool.description} /></>}
</span>
</li>
))}
Expand Down
Loading
Loading