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
3 changes: 3 additions & 0 deletions .cspell/project-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Keep entries lowercase unless the cased form is the one that appears
# in source (proper nouns, acronyms).
apikey
autolinks
citty
# Employers named in event frontmatter / tests
KDAB
Expand All @@ -25,8 +26,10 @@ opengraph
Schniz
urlname
vaul
walkthrough
VCALENDAR
VEVENT
webcal
winget
youtu
intraword
7 changes: 7 additions & 0 deletions .cspell/serbian-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,13 @@ trifunovic
# Common Serbian words (diacritic-less Latin; the config's diacritic
# regex catches anything with č/ć/ž/š/đ so those don't need to be here)
duboko
generalizacija
grupe
koda
metaprogramiranjem
modernizaciju
okupljanje
podrutina
poslednji
Prebaci
Propustili
Expand All @@ -45,3 +48,7 @@ srpski
Srpski
uranjanje
Vidimo
Pozivamo
prvi
susreli
templejt
26 changes: 26 additions & 0 deletions lib/event-validation.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
// @vitest-environment node
// (events-server imports bare `fs`, which the default jsdom environment can't resolve)

import fs from "node:fs";
import path from "node:path";

import matter from "gray-matter";
import { describe, expect, it } from "vitest";

import { getAllEventsServer } from "./events-server";
import { getSpeaker, type Speaker, SPEAKERS } from "./speakers";

const EVENTS_DIR = path.join(process.cwd(), "events");
Expand Down Expand Up @@ -160,3 +164,25 @@ describe("Speaker registry", () => {
expect(url).toMatch(/^https?:\/\//);
});
});

// The extracted description renders as plain text everywhere it is consumed — event
// cards, <meta> descriptions, OpenGraph, JSON-LD — so leftover markdown would show
// literally ("**Beer Wednesday**" on a card). Guards the stripInlineMarkdown pass in
// lib/events-server.ts against new markdown constructs appearing in event bodies.
describe("Event descriptions are plain text", () => {
const events = getAllEventsServer();

it("extracts a description for every event", () => {
expect(events.length).toBeGreaterThan(0);
});

it.each(events.map((e) => ({ slug: e.slug, description: e.description })))(
"$slug — description carries no markdown markup",
({ slug, description }) => {
expect(
description,
`${slug}: description still contains markdown markup.\n Value: ${description}\n Hint: extend stripInlineMarkdown in lib/strip-markdown.ts`
).not.toMatch(/\*\*|__|\[[^\]]+\]\(|!\[|~~|<https?:/);
}
);
});
11 changes: 8 additions & 3 deletions lib/events-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import matter from "gray-matter";
import path from "path";

import { type EventSpeaker, resolveSpeakers, type SpeakerRef, withEventAvatar } from "./speakers";
import { stripInlineMarkdown } from "./strip-markdown";
import {
dateToPlainDate,
dateToZonedDateTime,
Expand Down Expand Up @@ -145,8 +146,11 @@ function parseEventFile(fileName: string): Event | null {
eventHeader.isOnline ||
false;

// Extract description from content (first paragraph after title)
let description = eventHeader.description || "";
// Extract description from content (first paragraph after title). The description
// is consumed as plain text everywhere (cards, <meta>, OpenGraph, JSON-LD), so
// inline markdown comes off here — and before truncation, which could otherwise
// cut a link in half.
let description = stripInlineMarkdown(eventHeader.description || "");
if (!description && content) {
// Extract first meaningful paragraph from content
const lines = content.split("\n");
Expand All @@ -159,7 +163,8 @@ function parseEventFile(fileName: string): Event | null {
!trimmed.startsWith("-") &&
trimmed.length > 50
) {
description = trimmed.substring(0, 200) + (trimmed.length > 200 ? "..." : "");
const plain = stripInlineMarkdown(trimmed);
description = plain.substring(0, 200) + (plain.length > 200 ? "..." : "");
break;
}
}
Expand Down
74 changes: 74 additions & 0 deletions lib/strip-markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it } from "vitest";

import { stripInlineMarkdown } from "./strip-markdown";

describe("stripInlineMarkdown", () => {
it("leaves plain text untouched", () => {
expect(stripInlineMarkdown("Pozivamo vas na okupljanje.")).toBe("Pozivamo vas na okupljanje.");
});

it("unwraps bold", () => {
expect(stripInlineMarkdown("Pozivamo vas na prvi **C++ Serbia Beer Wednesday**!")).toBe(
"Pozivamo vas na prvi C++ Serbia Beer Wednesday!"
);
expect(stripInlineMarkdown("a __bold__ word")).toBe("a bold word");
});

it("unwraps italics with either marker", () => {
expect(stripInlineMarkdown("an *italic* and an _italic_ word")).toBe(
"an italic and an italic word"
);
});

it("unwraps bold italics — the visible *** case", () => {
expect(stripInlineMarkdown("this is ***important*** here")).toBe("this is important here");
});

it("keeps intraword underscores — identifiers are not emphasis", () => {
expect(stripInlineMarkdown("std::lower_bound and upper_bound differ")).toBe(
"std::lower_bound and upper_bound differ"
);
});

it("reduces links to their text", () => {
expect(
stripInlineMarkdown(
"susreli sa [templejt metaprogramiranjem](https://en.wikipedia.org/wiki/Template_metaprogramming)"
)
).toBe("susreli sa templejt metaprogramiranjem");
});

it("handles angle-bracketed link destinations with parentheses in the URL", () => {
expect(
stripInlineMarkdown(
"generalizacija podrutina ([subroutines](<https://en.wikipedia.org/wiki/Subroutine_(computer_science)>))"
)
).toBe("generalizacija podrutina (subroutines)");
});

it("collapses images to their alt text", () => {
expect(stripInlineMarkdown("before ![a banner](https://example.org/x.png) after")).toBe(
"before a banner after"
);
});

it("unwraps inline code", () => {
expect(stripInlineMarkdown("call `co_await` here")).toBe("call co_await here");
});

it("bares autolinks", () => {
expect(stripInlineMarkdown("see <https://cppserbia.org> for more")).toBe(
"see https://cppserbia.org for more"
);
});

it("unwraps strikethrough", () => {
expect(stripInlineMarkdown("it is ~~cancelled~~ rescheduled")).toBe(
"it is cancelled rescheduled"
);
});

it("strips markup nested inside a link", () => {
expect(stripInlineMarkdown("[**bold link**](https://example.org)")).toBe("bold link");
});
});
31 changes: 31 additions & 0 deletions lib/strip-markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Reduces inline Markdown to plain text: `[text](url)` becomes `text`, emphasis and
* code markers are removed, images collapse to their alt text.
*
* Event descriptions are excerpted from Markdown bodies but consumed as plain text
* everywhere — cards, `<meta>` descriptions, OpenGraph, JSON-LD — so the markup has
* to come off at extraction time, before the excerpt is truncated. Stripping first
* also keeps truncation from cutting a link in half and leaving `[text](https://…`
* in the output.
*
* Inline constructs only: block-level Markdown (headings, list markers, tables)
* never reaches the extractor, which walks paragraph lines.
*/
export function stripInlineMarkdown(text: string): string {
return (
text
// Images first so the link pass doesn't eat `![alt](url)` as a link
.replace(/!\[([^\]]*)\]\((?:<[^>]*>|[^)]*)\)/g, "$1")
// `<url>` destinations exist to protect parentheses inside the URL
.replace(/\[([^\]]+)\]\((?:<[^>]*>|[^)]*)\)/g, "$1")
.replace(/<(https?:\/\/[^>\s]+)>/g, "$1")
// `***text***` sheds the bold pair here and the italic pair below
.replace(/(\*\*|__)(?=\S)([^*_](?:.*?\S)?)\1/g, "$2")
.replace(/~~(?=\S)((?:[^~]*\S)?)~~/g, "$1")
.replace(/\*(?=\S)((?:[^*]*\S)?)\*/g, "$1")
// Word-boundary guards keep identifiers like `lower_bound` intact
.replace(/(?<![\w\\])_(?=\S)((?:[^_]*\S)?)_(?!\w)/g, "$1")
.replace(/`([^`]*)`/g, "$1")
.trim()
);
}
Loading