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
4 changes: 3 additions & 1 deletion src/layouts/PageLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import { SITE } from "@consts";
import type { OpenGraphData } from "@lib/opengraph";
import { stripMarkdown } from "@lib/markdown";

type Props = {
title: string;
Expand All @@ -12,12 +13,13 @@ type Props = {
};

const { title, description, ogData } = Astro.props;
const plainTitle = stripMarkdown(title);
---

<!doctype html>
<html lang="en">
<head>
<Head title={`${title} | ${SITE.NAME}`} description={description} ogData={ogData} />
<Head title={`${plainTitle} | ${SITE.NAME}`} description={description} ogData={ogData} />
</head>
<body>
<Header />
Expand Down
40 changes: 33 additions & 7 deletions src/lib/markdown.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export function renderInlineMarkdown(text: string): string {
if (!text) return "";

// Helper function to escape HTML entities
const escapeHtml = (str: string): string => {
return str
Expand All @@ -10,26 +10,52 @@ export function renderInlineMarkdown(text: string): string {
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};

// Process inline markdown patterns
let html = text
// Code: `text` - escape content inside backticks, then wrap in <code>
.replace(/`([^`]+)`/g, (_, content) => `<code>${escapeHtml(content)}</code>`)

// Bold: **text** or __text__ - escape content, then wrap in <strong>
.replace(/\*\*([^*]+)\*\*/g, (_, content) => `<strong>${escapeHtml(content)}</strong>`)
.replace(/__([^_]+)__/g, (_, content) => `<strong>${escapeHtml(content)}</strong>`)

// Italic: *text* or _text_ (but not part of bold) - escape content, then wrap in <em>
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, (_, content) => `<em>${escapeHtml(content)}</em>`)
.replace(/(?<!_)_([^_]+)_(?!_)/g, (_, content) => `<em>${escapeHtml(content)}</em>`)

// Strikethrough: ~~text~~ - escape content, then wrap in <del>
.replace(/~~([^~]+)~~/g, (_, content) => `<del>${escapeHtml(content)}</del>`);

// Escape any remaining unprocessed text (text outside of markdown patterns)
// This is tricky because we need to avoid escaping the HTML we just created
// For now, we'll leave plain text unescaped since Astro should handle it

return html;
}

/**
* Strip markdown and HTML from text, returning plain text.
* Useful for meta tags, alt text, and other contexts where plain text is needed.
*/
export function stripMarkdown(text: string): string {
if (!text) return "";

return text
// Remove code: `text`
.replace(/`([^`]+)`/g, "$1")

// Remove bold: **text** or __text__
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/__([^_]+)__/g, "$1")

// Remove italic: *text* or _text_
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "$1")
.replace(/(?<!_)_([^_]+)_(?!_)/g, "$1")

// Remove strikethrough: ~~text~~
.replace(/~~([^~]+)~~/g, "$1")

// Remove HTML tags (in case any slipped through)
.replace(/<[^>]*>/g, "");
}
35 changes: 18 additions & 17 deletions src/lib/opengraph.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CollectionEntry } from "astro:content";
import { SITE } from "@consts";
import { stripMarkdown } from "./markdown";

export interface OpenGraphData {
title: string;
Expand Down Expand Up @@ -68,25 +69,25 @@ export function getPostOGData(
url: string,
siteUrl: string
): OpenGraphData {
const ogTitle = post.data.ogTitle || post.data.title;
const ogTitle = stripMarkdown(post.data.ogTitle || post.data.title);
const ogDescription = post.data.ogDescription || post.data.description;

let ogImage = post.data.ogImage;
if (!ogImage && !post.data.noOgImage) {
ogImage = generateTailgraphURL({
title: post.data.cardTitle || post.data.title,
subtitle: post.data.date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
title: stripMarkdown(post.data.cardTitle || post.data.title),
subtitle: post.data.date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
}),
author: "plx",
theme: "dark",
backgroundImage: "gradient",
logo: `${siteUrl}/default-og-image.jpg`
});
}

return {
title: ogTitle,
description: ogDescription,
Expand Down Expand Up @@ -116,21 +117,21 @@ export function getBriefOGData(
url: string,
siteUrl: string
): OpenGraphData {
const ogTitle = brief.data.ogTitle || brief.data.title;
const ogTitle = stripMarkdown(brief.data.ogTitle || brief.data.title);
const ogDescription = brief.data.ogDescription || brief.data.description;

let ogImage = brief.data.ogImage;
if (!ogImage && !brief.data.noOgImage) {
ogImage = generateTailgraphURL({
title: brief.data.cardTitle || brief.data.title,
subtitle: category?.titlePrefix || category?.displayName || "Brief",
title: stripMarkdown(brief.data.cardTitle || brief.data.title),
subtitle: stripMarkdown(category?.titlePrefix || category?.displayName || "Brief"),
author: "plx",
theme: "dark",
backgroundImage: "gradient",
logo: `${siteUrl}/default-og-image.jpg`
});
}

return {
title: ogTitle,
description: ogDescription,
Expand Down Expand Up @@ -159,21 +160,21 @@ export function getProjectOGData(
url: string,
siteUrl: string
): OpenGraphData {
const ogTitle = project.data.ogTitle || project.data.title;
const ogTitle = stripMarkdown(project.data.ogTitle || project.data.title);
const ogDescription = project.data.ogDescription || project.data.description;

let ogImage = project.data.ogImage;
if (!ogImage && !project.data.noOgImage) {
ogImage = generateTailgraphURL({
title: project.data.title,
title: stripMarkdown(project.data.title),
subtitle: "Project",
author: "plx",
theme: "dark",
backgroundImage: "gradient",
logo: `${siteUrl}/default-og-image.jpg`
});
}

return {
title: ogTitle,
description: ogDescription,
Expand Down
6 changes: 3 additions & 3 deletions src/pages/blog/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import FormattedDate from "@components/FormattedDate.astro";
import { readingTime } from "@lib/utils";
import BackToPrev from "@components/BackToPrev.astro";
import { getPostOGData } from "@lib/opengraph";
import { renderInlineMarkdown } from "@lib/markdown";

export async function getStaticPaths() {
const posts = (await getCollection("blog"))
Expand All @@ -22,6 +23,7 @@ const post = Astro.props;
const { Content } = await post.render();

const ogData = getPostOGData(post, Astro.url.toString(), Astro.site?.toString() || "");
const renderedTitle = renderInlineMarkdown(post.data.title);
---

<PageLayout title={post.data.title} description={post.data.description} ogData={ogData}>
Expand All @@ -41,9 +43,7 @@ const ogData = getPostOGData(post, Astro.url.toString(), Astro.site?.toString()
{readingTime(post.body)}
</div>
</div>
<div class="animate text-2xl font-semibold text-black dark:text-white">
{post.data.title}
</div>
<h1 class="animate text-2xl font-semibold text-black dark:text-white" set:html={renderedTitle}></h1>

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Escape title text before using set:html

The updated title rendering binds renderedTitle directly into set:html, but renderInlineMarkdown purposely leaves any text outside of markdown markers unescaped. If a post title contains literal < characters or HTML (from frontmatter or a CMS), the HTML will now be injected into the page and can execute scripts, whereas the previous {post.data.title} rendering was safely escaped. This creates an XSS vector for untrusted or malformed titles. The same pattern appears in the briefs and projects templates; the HTML output should be sanitized or escaped before using set:html.

Useful? React with 👍 / 👎.

</div>
<article class="animate">
<Content />
Expand Down
5 changes: 2 additions & 3 deletions src/pages/briefs/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const categorySlug = extractCategoryFromSlug(brief.slug);
const category = categorySlug ? getCategory(categorySlug, `src/content/briefs/${categorySlug}`) : null;
const ogData = getBriefOGData(brief, category, Astro.url.toString(), Astro.site?.toString() || "");
const renderedTitlePrefix = category?.titlePrefix ? renderInlineMarkdown(category.titlePrefix) : null;
const renderedTitle = renderInlineMarkdown(brief.data.title);
---

<PageLayout title={brief.data.title} description={brief.data.description} ogData={ogData}>
Expand All @@ -49,9 +50,7 @@ const renderedTitlePrefix = category?.titlePrefix ? renderInlineMarkdown(categor
</>
)}
</div>
<div class="animate text-2xl font-semibold text-black dark:text-white">
{brief.data.title}
</div>
<h1 class="animate text-2xl font-semibold text-black dark:text-white" set:html={renderedTitle}></h1>
</div>
<article class="animate">
<Content />
Expand Down
6 changes: 3 additions & 3 deletions src/pages/projects/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { readingTime } from "@lib/utils";
import BackToPrev from "@components/BackToPrev.astro";
import Link from "@components/Link.astro";
import { getProjectOGData } from "@lib/opengraph";
import { renderInlineMarkdown } from "@lib/markdown";

export async function getStaticPaths() {
const projects = (await getCollection("projects"))
Expand All @@ -23,6 +24,7 @@ const project = Astro.props;
const { Content } = await project.render();

const ogData = getProjectOGData(project, Astro.url.toString(), Astro.site?.toString() || "");
const renderedTitle = renderInlineMarkdown(project.data.title);
---

<PageLayout title={project.data.title} description={project.data.description} ogData={ogData}>
Expand All @@ -42,9 +44,7 @@ const ogData = getProjectOGData(project, Astro.url.toString(), Astro.site?.toStr
{readingTime(project.body)}
</div>
</div>
<div class="animate text-2xl font-semibold text-black dark:text-white">
{project.data.title}
</div>
<h1 class="animate text-2xl font-semibold text-black dark:text-white" set:html={renderedTitle}></h1>
{(project.data.demoURL || project.data.repoURL) && (
<nav class="animate flex gap-1">
{project.data.demoURL && (
Expand Down