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
183 changes: 183 additions & 0 deletions .github/scripts/sync-meetup-events.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';

const GROUPS_FILE = path.join('data', 'meetup_groups.json');
const CALENDAR_DIR = path.join('content', 'calendar');
const DRY_RUN = process.argv.includes('--dry-run');

function yaml(value) {
return JSON.stringify(value ?? '');
}

function unescapeIcal(value = '') {
return value
.replace(/\\n/gi, '\n')
.replace(/\\,/g, ',')
.replace(/\\;/g, ';')
.replace(/\\\\/g, '\\');
}

function property(line) {
const separator = line.indexOf(':');
if (separator < 1) return null;
const declaration = line.slice(0, separator);
return { name: declaration.split(';', 1)[0], value: line.slice(separator + 1) };
}

function eventsFromIcal(calendar) {
const events = [];
let event;
let groupName;

for (const line of calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/)) {
if (line === 'BEGIN:VEVENT') {
event = {};
continue;
}
if (line === 'END:VEVENT') {
if (event) events.push(event);
event = undefined;
continue;
}

const parsed = property(line);
if (!parsed) continue;
if (event) event[parsed.name] = parsed.value;
if (parsed.name === 'X-WR-CALNAME') groupName = unescapeIcal(parsed.value);
}

return { events, groupName };
}

function date(value, field, url) {
const match = value?.match(/^(\d{4})(\d{2})(\d{2})/);
if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`);
return `${match[1]}-${match[2]}-${match[3]}`;
}

function eventId(event, url) {
const match = event.UID?.match(/^event_(.+?)@meetup\.com$/);
if (match) return match[1];
const urlMatch = url.match(/\/events\/([^/?#]+)/);
if (urlMatch) return urlMatch[1];
throw new Error(`Meetup calendar event has no recognized ID: ${url}`);
}

function eventSchema(html, url) {
const scripts = [...html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
for (const [, script] of scripts) {
const value = JSON.parse(script);
const candidates = Array.isArray(value) ? value : value['@graph'] ?? [value];
const event = candidates.find(({ '@type': type }) => type === 'Event');
if (event) return event;
}
throw new Error(`Meetup event page has no Event structured data: ${url}`);
}

function eventMetadata(event) {
const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? '');
const locations = Array.isArray(event.location) ? event.location : [event.location].filter(Boolean);
const place = locations.find(({ '@type': type }) => type === 'Place');

if (!place) return { virtual, where: virtual ? 'Online' : '' };

const address = place.address;
const addressParts = typeof address === 'string'
? [address]
: [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry];
const where = [place.name, ...addressParts].filter(Boolean).reduce(
(parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part],
[],
).join(', ');
return { virtual, where };
}

function eventFile(event, metadata, groupName) {
const url = event.URL;
if (!url) throw new Error('Meetup calendar event has no URL.');

const startDate = date(event.DTSTART, 'start date', url);
const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined;
const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : '';
const description = unescapeIcal(event.DESCRIPTION).trim();

return `---\nmeetupEventId: ${yaml(eventId(event, url))}\nmeetupSource: meetup\nstartDate: ${yaml(startDate)}\n${endDateField}title: ${yaml(unescapeIcal(event.SUMMARY))}\nexternalUrl: ${yaml(url)}\nvirtual: ${metadata.virtual}\nwhere: ${yaml(metadata.where || groupName)}\n---\n${description}\n`;
}

async function groups() {
const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8'));
if (!Array.isArray(parsed) || !parsed.every((group) => group && typeof group === 'object' && typeof group.urlname === 'string' && group.urlname)) {
throw new Error(`${GROUPS_FILE} must be an array of Meetup group objects with a urlname.`);
}
return parsed;
}

async function fetchGroupEvents(urlname) {
const response = await fetch(`https://www.meetup.com/${urlname}/events/ical/`);
if (!response.ok) throw new Error(`Meetup iCalendar feed returned ${response.status} for ${urlname}.`);

const calendar = eventsFromIcal(await response.text());
const events = await Promise.all(calendar.events.map(async (event) => {
if (event.STATUS === 'CANCELLED') return { event };
if (!event.URL || !event.DTSTART || !event.SUMMARY) {
throw new Error(`Meetup calendar event for ${urlname} is missing required fields.`);
}

const page = await fetch(event.URL);
if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`);
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) };
}));

return { events, groupName: calendar.groupName || urlname };
}

async function calendarFiles() {
const names = await readdir(CALENDAR_DIR);
return Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => ({
file: path.join(CALENDAR_DIR, name),
content: await readFile(path.join(CALENDAR_DIR, name), 'utf8'),
})));
}

async function main() {
const configuredGroups = await groups();
const groupEvents = await Promise.all(configuredGroups.map(({ urlname }) => fetchGroupEvents(urlname)));
await mkdir(CALENDAR_DIR, { recursive: true });

const calendar = await calendarFiles();
const managed = new Set(calendar.filter(({ content }) => content.includes('meetupSource: meetup')).map(({ file }) => file));
const manualUrls = new Set(calendar
.filter(({ content }) => !content.includes('meetupSource: meetup'))
.map(({ content }) => content.match(/^externalUrl:\s*["']?([^\s"']+)/m)?.[1])
.filter(Boolean));
const desired = new Map();

for (const { events, groupName } of groupEvents) {
for (const { event, metadata } of events) {
if (event.STATUS === 'CANCELLED' || manualUrls.has(event.URL)) continue;
desired.set(path.join(CALENDAR_DIR, `meetup-${eventId(event, event.URL)}.md`), eventFile(event, metadata, groupName));
}
}

const changes = [];
for (const [file, content] of desired) {
const current = calendar.find((entry) => entry.file === file)?.content;
if (current !== content) {
changes.push(`${current === undefined ? 'add' : 'update'} ${file}`);
if (!DRY_RUN) await writeFile(file, content);
}
managed.delete(file);
}

for (const file of managed) {
changes.push(`remove ${file}`);
if (!DRY_RUN) await rm(file);
}

console.log(changes.length ? changes.join('\n') : 'Meetup events are already synchronized.');
}

main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
42 changes: 42 additions & 0 deletions .github/workflows/meetup-sync.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Sync Meetup Events

on:
schedule:
- cron: '15 */6 * * *'
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: sync-meetup-events
cancel-in-progress: false

jobs:
sync-meetup-events:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Sync upcoming Meetup events
run: node .github/scripts/sync-meetup-events.mjs

- name: Create pull request for synchronized events
uses: peter-evans/create-pull-request@v8.1.1
with:
add-paths: |
content/calendar
data/meetup_groups.json
branch: automation/sync-meetup-events
commit-message: Sync Meetup events
title: Sync Meetup events
body: |
Automated synchronization of upcoming events from configured Meetup groups.
delete-branch: true
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,21 @@ npm run dev
This serves the site at `http://localhost:1313` with hot-reload and draft posts
visible. Save your Markdown file and the browser updates automatically.

### Syncing Meetup user-group events

The Community Calendar automatically syncs upcoming events from configured
Meetup groups. Add the group's Meetup URL name to
[`data/meetup_groups.json`](data/meetup_groups.json), then verify its public
`https://www.meetup.com/<urlname>/events/ical/` feed contains the intended
events. The scheduled **Sync Meetup Events** workflow reads that public feed
and its linked event pages; no Meetup API key or OAuth token is required.

To preview the generated calendar changes locally without writing files:

```bash
node .github/scripts/sync-meetup-events.mjs --dry-run
```

## What the site includes

- **Home** — community stats and the latest content.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
---
endDate: '2026-09-09'
externalUrl: https://www.meetup.com/swiss-powershell-user-group/events/315958850/
startDate: '2026-09-09'
title: Swiss PSUG 09/2026
meetupEventId: "315958850"
meetupSource: meetup
startDate: "2026-09-09"
title: "Swiss PSUG 09/2026"
externalUrl: "https://www.meetup.com/swiss-powershell-user-group/events/315958850/"
virtual: true
where: Bern, Switzerland
where: "isolutions AG, Schanzenstrasse 4c, Bern"
---
Swiss PowerShell User Group
Dear Swiss PSUG,
We have planned another exciting event.

**Place & Language:**
Place: Isolutions AG, Schanzenstrasse 4c, 3008 Bern (Hybrid with Teams)
Language: EN
Expand Down
23 changes: 23 additions & 0 deletions content/calendar/meetup-316283278.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
meetupEventId: "316283278"
meetupSource: meetup
startDate: "2026-09-02"
title: "NeoVIM for PowerShell!"
externalUrl: "https://www.meetup.com/research-triangle-powershell-users-group/events/316283278/"
virtual: true
where: "Online"
---
Research Triangle PowerShell Users Group
PowerShell development doesn't require VSCode. In this session, Rob shares his approach to building an efficient, terminal-first workflow using NeoVIM—and demonstrates how this setup powers real work in DevOps, Cloud Security, and Application Security environments.

This session explores alternative approaches to PowerShell development—specifically, building an efficient workflow without relying on traditional GUI-based IDEs.
Drawing from 13+ years across System Administration, DevOps, Cloud Security, and Application Security, Rob Pleau shares the tools, configurations, and strategies that enable productive PowerShell development in a terminal environment.

**Topics include:**

* Why terminal-first workflows can be more efficient for certain tasks
* Editor options and setup for cross-platform consistency
* Practical tooling and configurations
* Real-world examples from professional PowerShell work
* Tips applicable to any development environment
*
14 changes: 14 additions & 0 deletions data/meetup_groups.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[
{
"urlname": "research-triangle-powershell-users-group"
},
{
"urlname": "swiss-powershell-user-group"
},
{
"urlname": "powershell-usergroup-inn-salzach"
},
{
"urlname": "pacific-powershell-user-group"
}
]
Loading