diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b14ae46..6f5ba31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 @@ -22,9 +22,6 @@ jobs: - name: Install dependencies run: npm ci - - name: Verify versions - run: npm run verify - - name: Lint run: npm run lint diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 03d64b2..12b8701 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,12 +24,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Setup Node.js uses: actions/setup-node@v6 with: - node-version: '20' + node-version: '22' cache: 'npm' - name: Install dependencies diff --git a/scripts/pre-push.ts b/scripts/pre-push.ts index 333be45..518f465 100644 --- a/scripts/pre-push.ts +++ b/scripts/pre-push.ts @@ -177,10 +177,6 @@ async function runContentValidation(): Promise { return run('npx tsx scripts/ai-validate.ts'); } -async function runVersionCheck(): Promise { - return run('npm run verify'); -} - // ============================================================================ // Main Execution // ============================================================================ @@ -196,7 +192,6 @@ async function main() { const stages = [ { name: 'Git Status Check', fn: checkGitStatus, skip: false }, - { name: 'Package Versions', fn: runVersionCheck, skip: false }, { name: 'Lint', fn: runLint, skip: false }, { name: 'Type Check', fn: runTypeCheck, skip: false }, { name: 'Content Validation', fn: runContentValidation, skip: false }, diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro index 5275065..ab97826 100644 --- a/src/components/SiteFooter.astro +++ b/src/components/SiteFooter.astro @@ -1,60 +1,48 @@ --- /** * Site Footer - Global Footer - * - * The main site footer with navigation and copyright. - * Extracted as a component for composition in different layouts. */ ---
diff --git a/src/components/event/EventInviteText.astro b/src/components/event/EventInviteText.astro new file mode 100644 index 0000000..8d21ad9 --- /dev/null +++ b/src/components/event/EventInviteText.astro @@ -0,0 +1,190 @@ +--- +/** + * EventInviteText + * + * Renders a small clipboard icon button that copies a pre-formatted + * plain-text meeting invite to the clipboard. Invite text is generated + * at build time and stored in a data attribute — no visible preview. + */ +import type { CollectionEntry } from 'astro:content'; +import { stripMarkdown } from '../../utils/text'; +import { siteConfig, dateTimeFormats, getVenueTimezone } from '../../site.config'; + +interface Props { + event: CollectionEntry<'events'>; + slug: string; + venueConfig?: { name: string; address: string }; + virtualUrl?: string; + hasInPerson: boolean; + hasVirtual: boolean; +} + +const { event, slug, venueConfig, virtualUrl, hasInPerson, hasVirtual } = Astro.props; +const { title, eventDate, startTime, endTime, eventLink, venue, timezone } = event.data; + +// Resolve timezone: explicit → venue → site default +const tz = timezone ?? getVenueTimezone(venue); + +// Format date as "Tuesday, April 21, 2026" +const dateStr = new Intl.DateTimeFormat(siteConfig.locale, { + ...dateTimeFormats.dateLong, + timeZone: tz, +}).format(eventDate); + +// Timezone abbreviation +const tzAbbr = + new Intl.DateTimeFormat(siteConfig.locale, { + timeZone: tz, + timeZoneName: 'short', + }) + .formatToParts(eventDate) + .find((p) => p.type === 'timeZoneName')?.value ?? 'PT'; + +const timeStr = + startTime && endTime + ? `${startTime} – ${endTime} ${tzAbbr}` + : startTime + ? `${startTime} ${tzAbbr}` + : ''; + +// Build location lines +const locationLines: string[] = []; +if (hasInPerson && venueConfig) { + locationLines.push(`In person: ${venueConfig.name}, ${venueConfig.address}`); +} +if (hasVirtual && virtualUrl) { + locationLines.push(`Online: ${virtualUrl}`); +} + +// Strip markdown from body +const body = event.body ? stripMarkdown(event.body) : ''; + +// Assemble invite text +const pageUrl = `https://microhams.com/events/${slug}`; +const sections: string[] = [title, '', timeStr ? `${dateStr} | ${timeStr}` : dateStr]; + +if (locationLines.length > 0) { + sections.push('', ...locationLines); +} + +if (body) { + sections.push('', '---', '', body, '', '---'); +} + +sections.push('', eventLink ? `More info: ${eventLink}` : `Full details: ${pageUrl}`); + +const inviteText = sections + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +--- + + + + + + diff --git a/src/pages/events/[slug].astro b/src/pages/events/[slug].astro index f7302d2..b1eb005 100644 --- a/src/pages/events/[slug].astro +++ b/src/pages/events/[slug].astro @@ -9,6 +9,7 @@ import { getCollection, getEntry } from 'astro:content'; import type { GetStaticPaths } from 'astro'; import PageLayout from '../../layouts/PageLayout.astro'; import EventLogistics from '../../components/event/EventLogistics.astro'; +import EventInviteText from '../../components/event/EventInviteText.astro'; import EventDateTime from '../../components/event/EventDateTime.astro'; import { publishedFilter } from '../../utils/content'; import { formatEventDate } from '../../utils/event-time'; @@ -41,9 +42,6 @@ const onlineMeetingConfig = event.data.onlineMeeting ? getOnlineMeeting(event.data.onlineMeeting) : undefined; -// Resolve location: frontmatter overrides venue config -const location = event.data.location || venueConfig?.address; - // Determine event type const isExternal = !!event.data.eventLink; const hasInPerson = !!(event.data.venue || event.data.location); @@ -67,16 +65,26 @@ const virtualUrl = onlineMeetingConfig?.link || event.data.teams?.link || event.

{event.data.title}

- - + +
+ + +
@@ -152,8 +160,6 @@ const virtualUrl = onlineMeetingConfig?.link || event.data.teams?.link || event. class="stack-3 text-sm" style="border-block-start: 1px solid var(--color-border); padding-block-start: var(--space-4); color: var(--color-text-muted);" > - {location &&
{location}
} - { event.data.contactPerson && (
@@ -186,3 +192,11 @@ const virtualUrl = onlineMeetingConfig?.link || event.data.teams?.link || event. + + diff --git a/src/utils/text.test.ts b/src/utils/text.test.ts new file mode 100644 index 0000000..87c06d7 --- /dev/null +++ b/src/utils/text.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { stripMarkdown } from './text'; + +describe('stripMarkdown', () => { + it('removes bold markers', () => { + expect(stripMarkdown('Hello **world**')).toBe('Hello world'); + }); + + it('removes bold with underscores', () => { + expect(stripMarkdown('Hello __world__')).toBe('Hello world'); + }); + + it('removes italic markers', () => { + expect(stripMarkdown('Hello *world*')).toBe('Hello world'); + }); + + it('removes heading markers', () => { + expect(stripMarkdown('## Heading')).toBe('Heading'); + expect(stripMarkdown('### Sub heading')).toBe('Sub heading'); + expect(stripMarkdown('# H1')).toBe('H1'); + }); + + it('converts links to link text only', () => { + expect(stripMarkdown('[groundwave.io](https://groundwave.io)')).toBe('groundwave.io'); + }); + + it('removes image syntax entirely', () => { + expect(stripMarkdown('![alt text](https://example.com/img.png)')).toBe(''); + }); + + it('preserves list dashes', () => { + expect(stripMarkdown('- item one')).toBe('- item one'); + }); + + it('preserves horizontal rules', () => { + expect(stripMarkdown('---')).toBe('---'); + }); + + it('handles mixed formatting in one string', () => { + expect(stripMarkdown('**6:00 PM** - [Teams](https://teams.microsoft.com)')).toBe( + '6:00 PM - Teams' + ); + }); + + it('collapses 3+ consecutive newlines to 2', () => { + expect(stripMarkdown('line1\n\n\n\nline2')).toBe('line1\n\nline2'); + }); + + it('trims the result', () => { + expect(stripMarkdown(' \n## Hello\n ')).toBe('Hello'); + }); + + it('handles empty string', () => { + expect(stripMarkdown('')).toBe(''); + }); +}); diff --git a/src/utils/text.ts b/src/utils/text.ts new file mode 100644 index 0000000..704e022 --- /dev/null +++ b/src/utils/text.ts @@ -0,0 +1,26 @@ +/** + * Strip Markdown formatting from a string, producing clean plain text. + * Intended for generating email-friendly plain text from Markdown content. + */ +export function stripMarkdown(raw: string): string { + return ( + raw + // Remove images before links (avoid matching image alt as link text) + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '') + // Convert links: [text](url) → text + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + // Remove heading markers (## Heading → Heading) + .replace(/^#{1,6}\s+/gm, '') + // Remove bold (**text** or __text__) + .replace(/\*\*(.*?)\*\*/gs, '$1') + .replace(/__(.*?)__/gs, '$1') + // Remove italic (*text* or _text_), but not list dashes + .replace(/(?