Skip to content

Commit db5b51b

Browse files
Merge remote-tracking branch 'origin/main' into feat/user-groups
# Conflicts: # assets/css/tailwind.css # assets/fonts/fa-solid-subset.woff2 Co-authored-by: HeyItsGilbert <615265+HeyItsGilbert@users.noreply.github.com>
2 parents a52771b + e1393d7 commit db5b51b

40 files changed

Lines changed: 1392 additions & 54 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
2+
import path from 'node:path';
3+
4+
const GROUPS_FILE = path.join('data', 'meetup_groups.json');
5+
const CALENDAR_DIR = path.join('content', 'calendar');
6+
const DRY_RUN = process.argv.includes('--dry-run');
7+
8+
function yaml(value) {
9+
return JSON.stringify(value ?? '');
10+
}
11+
12+
function unescapeIcal(value = '') {
13+
return value
14+
.replace(/\\n/gi, '\n')
15+
.replace(/\\,/g, ',')
16+
.replace(/\\;/g, ';')
17+
.replace(/\\\\/g, '\\');
18+
}
19+
20+
function property(line) {
21+
const separator = line.indexOf(':');
22+
if (separator < 1) return null;
23+
const declaration = line.slice(0, separator);
24+
return { name: declaration.split(';', 1)[0], value: line.slice(separator + 1) };
25+
}
26+
27+
function eventsFromIcal(calendar) {
28+
const events = [];
29+
let event;
30+
let groupName;
31+
32+
for (const line of calendar.replace(/\r?\n[ \t]/g, '').split(/\r?\n/)) {
33+
if (line === 'BEGIN:VEVENT') {
34+
event = {};
35+
continue;
36+
}
37+
if (line === 'END:VEVENT') {
38+
if (event) events.push(event);
39+
event = undefined;
40+
continue;
41+
}
42+
43+
const parsed = property(line);
44+
if (!parsed) continue;
45+
if (event) event[parsed.name] = parsed.value;
46+
if (parsed.name === 'X-WR-CALNAME') groupName = unescapeIcal(parsed.value);
47+
}
48+
49+
return { events, groupName };
50+
}
51+
52+
function date(value, field, url) {
53+
const match = value?.match(/^(\d{4})(\d{2})(\d{2})/);
54+
if (!match) throw new Error(`Meetup event ${url} has no valid ${field}.`);
55+
return `${match[1]}-${match[2]}-${match[3]}`;
56+
}
57+
58+
function eventId(event, url) {
59+
const match = event.UID?.match(/^event_(.+?)@meetup\.com$/);
60+
if (match) return match[1];
61+
const urlMatch = url.match(/\/events\/([^/?#]+)/);
62+
if (urlMatch) return urlMatch[1];
63+
throw new Error(`Meetup calendar event has no recognized ID: ${url}`);
64+
}
65+
66+
function eventSchema(html, url) {
67+
const scripts = [...html.matchAll(/<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)];
68+
for (const [, script] of scripts) {
69+
const value = JSON.parse(script);
70+
const candidates = Array.isArray(value) ? value : value['@graph'] ?? [value];
71+
const event = candidates.find(({ '@type': type }) => type === 'Event');
72+
if (event) return event;
73+
}
74+
throw new Error(`Meetup event page has no Event structured data: ${url}`);
75+
}
76+
77+
function eventMetadata(event) {
78+
const virtual = /(?:Online|Mixed)EventAttendanceMode$/.test(event.eventAttendanceMode ?? '');
79+
const locations = Array.isArray(event.location) ? event.location : [event.location].filter(Boolean);
80+
const place = locations.find(({ '@type': type }) => type === 'Place');
81+
82+
if (!place) return { virtual, where: virtual ? 'Online' : '' };
83+
84+
const address = place.address;
85+
const addressParts = typeof address === 'string'
86+
? [address]
87+
: [address?.streetAddress, address?.addressLocality, address?.addressRegion, address?.addressCountry];
88+
const where = [place.name, ...addressParts].filter(Boolean).reduce(
89+
(parts, part) => parts.some((existing) => existing.toLowerCase().includes(part.toLowerCase())) ? parts : [...parts, part],
90+
[],
91+
).join(', ');
92+
return { virtual, where };
93+
}
94+
95+
function eventFile(event, metadata, groupName) {
96+
const url = event.URL;
97+
if (!url) throw new Error('Meetup calendar event has no URL.');
98+
99+
const startDate = date(event.DTSTART, 'start date', url);
100+
const endDate = event.DTEND ? date(event.DTEND, 'end date', url) : undefined;
101+
const endDateField = endDate && endDate !== startDate ? `endDate: ${yaml(endDate)}\n` : '';
102+
const description = unescapeIcal(event.DESCRIPTION).trim();
103+
104+
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`;
105+
}
106+
107+
async function groups() {
108+
const parsed = JSON.parse(await readFile(GROUPS_FILE, 'utf8'));
109+
if (!Array.isArray(parsed) || !parsed.every((group) => group && typeof group === 'object' && typeof group.urlname === 'string' && group.urlname)) {
110+
throw new Error(`${GROUPS_FILE} must be an array of Meetup group objects with a urlname.`);
111+
}
112+
return parsed;
113+
}
114+
115+
async function fetchGroupEvents(urlname) {
116+
const response = await fetch(`https://www.meetup.com/${urlname}/events/ical/`);
117+
if (!response.ok) throw new Error(`Meetup iCalendar feed returned ${response.status} for ${urlname}.`);
118+
119+
const calendar = eventsFromIcal(await response.text());
120+
const events = await Promise.all(calendar.events.map(async (event) => {
121+
if (event.STATUS === 'CANCELLED') return { event };
122+
if (!event.URL || !event.DTSTART || !event.SUMMARY) {
123+
throw new Error(`Meetup calendar event for ${urlname} is missing required fields.`);
124+
}
125+
126+
const page = await fetch(event.URL);
127+
if (!page.ok) throw new Error(`Meetup event page returned ${page.status}: ${event.URL}`);
128+
return { event, metadata: eventMetadata(eventSchema(await page.text(), event.URL)) };
129+
}));
130+
131+
return { events, groupName: calendar.groupName || urlname };
132+
}
133+
134+
async function calendarFiles() {
135+
const names = await readdir(CALENDAR_DIR);
136+
return Promise.all(names.filter((name) => name.endsWith('.md')).map(async (name) => ({
137+
file: path.join(CALENDAR_DIR, name),
138+
content: await readFile(path.join(CALENDAR_DIR, name), 'utf8'),
139+
})));
140+
}
141+
142+
async function main() {
143+
const configuredGroups = await groups();
144+
const groupEvents = await Promise.all(configuredGroups.map(({ urlname }) => fetchGroupEvents(urlname)));
145+
await mkdir(CALENDAR_DIR, { recursive: true });
146+
147+
const calendar = await calendarFiles();
148+
const managed = new Set(calendar.filter(({ content }) => content.includes('meetupSource: meetup')).map(({ file }) => file));
149+
const manualUrls = new Set(calendar
150+
.filter(({ content }) => !content.includes('meetupSource: meetup'))
151+
.map(({ content }) => content.match(/^externalUrl:\s*["']?([^\s"']+)/m)?.[1])
152+
.filter(Boolean));
153+
const desired = new Map();
154+
155+
for (const { events, groupName } of groupEvents) {
156+
for (const { event, metadata } of events) {
157+
if (event.STATUS === 'CANCELLED' || manualUrls.has(event.URL)) continue;
158+
desired.set(path.join(CALENDAR_DIR, `meetup-${eventId(event, event.URL)}.md`), eventFile(event, metadata, groupName));
159+
}
160+
}
161+
162+
const changes = [];
163+
for (const [file, content] of desired) {
164+
const current = calendar.find((entry) => entry.file === file)?.content;
165+
if (current !== content) {
166+
changes.push(`${current === undefined ? 'add' : 'update'} ${file}`);
167+
if (!DRY_RUN) await writeFile(file, content);
168+
}
169+
managed.delete(file);
170+
}
171+
172+
for (const file of managed) {
173+
changes.push(`remove ${file}`);
174+
if (!DRY_RUN) await rm(file);
175+
}
176+
177+
console.log(changes.length ? changes.join('\n') : 'Meetup events are already synchronized.');
178+
}
179+
180+
main().catch((error) => {
181+
console.error(error.message);
182+
process.exitCode = 1;
183+
});

.github/workflows/build.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,7 @@ jobs:
9696
--gc \
9797
--minify \
9898
--destination public
99+
100+
- name: Validate iCalendar feed
101+
run: node scripts/validate-calendar.mjs public/calendar/calendar.ics
102+

.github/workflows/meetup-sync.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Sync Meetup Events
2+
3+
on:
4+
schedule:
5+
- cron: '15 */6 * * *'
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
concurrency:
13+
group: sync-meetup-events
14+
cancel-in-progress: false
15+
16+
jobs:
17+
sync-meetup-events:
18+
runs-on: ubuntu-latest
19+
steps:
20+
- name: Checkout
21+
uses: actions/checkout@v4
22+
23+
- name: Setup Node.js
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
28+
- name: Sync upcoming Meetup events
29+
run: node .github/scripts/sync-meetup-events.mjs
30+
31+
- name: Create pull request for synchronized events
32+
uses: peter-evans/create-pull-request@v8.1.1
33+
with:
34+
add-paths: |
35+
content/calendar
36+
data/meetup_groups.json
37+
branch: automation/sync-meetup-events
38+
commit-message: Sync Meetup events
39+
title: Sync Meetup events
40+
body: |
41+
Automated synchronization of upcoming events from configured Meetup groups.
42+
delete-branch: true

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ npm run dev
3232
This serves the site at `http://localhost:1313` with hot-reload and draft posts
3333
visible. Save your Markdown file and the browser updates automatically.
3434

35+
### Syncing Meetup user-group events
36+
37+
The Community Calendar automatically syncs upcoming events from configured
38+
Meetup groups. Add the group's Meetup URL name to
39+
[`data/meetup_groups.json`](data/meetup_groups.json), then verify its public
40+
`https://www.meetup.com/<urlname>/events/ical/` feed contains the intended
41+
events. The scheduled **Sync Meetup Events** workflow reads that public feed
42+
and its linked event pages; no Meetup API key or OAuth token is required.
43+
44+
To preview the generated calendar changes locally without writing files:
45+
46+
```bash
47+
node .github/scripts/sync-meetup-events.mjs --dry-run
48+
```
49+
3550
## What the site includes
3651

3752
- **Home** — community stats and the latest content.

content/articles/2012-12-21-powershell-org-our-first-year-in-review.md

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@ In September 2012, we incorporated PowerShell.org, Inc., and founded PowerShell.
1313
By any measure, we've had a great first showing.
1414
We have more than a dozen shareholders in PowerShell.org, Inc., making this the first community-owned PowerShell organization ever. We've signed on three Platinum sponsors - [CBT Nuggets][1], [SAPIEN Technologies][2], and [Interface Technical Training][3]. We're now funded for 2-3 years of operation, including providing (upon request), gift cards to help local user groups pay for pizza and other monthly meeting expenses.
1515
PowerShell.org is now taking an average of 18,000 visits per month from more than 12,000 unique visitors, with a total of almost 57,000 monthly page views. Our forums have helped more than 760 people answer more than 850 questions.
16-
Microsoft's Scripting Guy, Ed Wilson, has handed off the [Scripting Games for 2013][4], and we're preparing for a small-scale "Winter Scripting Camp" trial run that will include a purpose-built platform for reviewing events, submitting entries, and judging. And by the looks of things, that platform will run on PowerShell itself.
16+
Microsoft's Scripting Guy, Ed Wilson, has handed off the Scripting Games for 2013, and we're preparing for a small-scale "Winter Scripting Camp" trial run that will include a purpose-built platform for reviewing events, submitting entries, and judging. And by the looks of things, that platform will run on PowerShell itself.
1717
We've announced our first [PowerShell Summit North America][5], and have completely sold out. We're already doing initial planning for 2014, aiming for a larger venue and hoping to accommodate twice as many attendees, and to fully cover speaker travel expenses.
18-
We've launched [PowerShell People][6], accessible via PowerShell.net, where you can write a PowerShell script to create and post your own profile and "brag" page about your PowerShell activities and accomplishments.
19-
We've launched three free PowerShell.org-branded [ebooks][7], and are preparing to launch our [PowerShell.org TechLetter][8] _monthly_ (!!!) e-mail newsletter complete with feature articles, news updates, and more. That's by (free) subscription only, so [sign up][8] if you haven't done so already! We've also had help from [Jason Hofferle][9] on our new Books page, rounding up all the free and commercial PowerShell books out there.
18+
We've launched PowerShell People, accessible via PowerShell.net, where you can write a PowerShell script to create and post your own profile and "brag" page about your PowerShell activities and accomplishments.
19+
We've launched three free PowerShell.org-branded [ebooks][7], and are preparing to launch our PowerShell.org TechLetter _monthly_ (!!!) e-mail newsletter complete with feature articles, news updates, and more. That's by (free) subscription only, so sign up if you haven't done so already! We've also had help from [Jason Hofferle][9] on our new Books page, rounding up all the free and commercial PowerShell books out there.
2020
It's been a whirlwind year, and it's all thanks to you for supporting it. By asking questions in the forums, offering answers, creating your People page, registering for the Summit, signing up for the Newsletter - all of these little activities spur us all on to new heights, and we appreciate all the feedback you've offered. There will be more to come - follow the [community on Twitter][10] (and the [Summit][11] too, while you're at it) for the latest announcements.If you'd like to contribute, just drop a note in the Suggestion Box [forum][12] - whether you want to help monitor a discussion forum, write book reviews, or whatever, there's always room to contribute.
2121
There have been some setbacks. [Will Steele][13], who had volunteered to populate our Events page, has had to step down due to health problems. Will has been a great contributor to the site and to the overall community, and we miss him. Our thoughts are with him and his family this holiday season.
2222
As we all wind down and look forward to the New Year, I wanted to personally express my gratitude to everyone who's helped make all of this happen. Happy Holidays, Happy New Year, and I'll see you again in 2013!
@@ -26,13 +26,10 @@ President and CEO, PowerShell.org, Inc.
2626
[1]: http://cbtnuggets.com
2727
[2]: http://sapien.com "Writing 10961: Remoting"
2828
[3]: http://interfacett.com
29-
[4]: /games/
3029
[5]: /summit/
31-
[6]: /people/
3230
[7]: http://powershellbooks.com
33-
[8]: /newsletter/
3431
[9]: http://twitter.com/jhofferle
3532
[10]: http://twitter.com/powershellorg
3633
[11]: https://twitter.com/PSHSummit
37-
[12]: /discuss/
34+
[12]: https://forums.powershell.org
3835
[13]: http://twitter.com/pen_test

content/articles/2013-01-28-the-2013-winter-scripting-camp.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ aliases:
99
- /2013/01/the-2013-winter-scripting-camp/
1010
---
1111

12-
We'll be announcing Winter Scripting Camp the first week of February. This is a special invite-only event that will be open to subscribers of the [PowerShell.org TechLetter][1]. It will work just like the Scripting Games, but will feature only a couple of events and will not include any prizes. We will, however, announce the top scorers.
12+
We'll be announcing Winter Scripting Camp the first week of February. This is a special invite-only event that will be open to subscribers of the PowerShell.org TechLetter. It will work just like the Scripting Games, but will feature only a couple of events and will not include any prizes. We will, however, announce the top scorers.
1313
Scripting Camp is primarily an opportunity for us to audition our new platform, to kick the tires, and make sure everything's ready for the official Games, which will kick off in April at the [PowerShell Summit 2013 North America][2].
1414
If you're interested in Camping with us, please sign up for the TechLetter this week (prior to Feb 1st). We'll be sending out a special notification to the TechLetter subscriber list with sign-up instructions.
1515

16-
[1]: /newsletter/ "First German PowerShell Community Conference"
1716
[2]: /summit/

content/articles/2013-02-01-winter-scripting-camp-opened-to-the-public.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ aliases:
1515
Scripting Camp is a precursor to the Scripting Games, which will kick off in late April. During Camp, you'll have the opportunity to participate in two events. We aren't offering any prizes, but we will announce winners in the PowerShell.org blog, on Twitter, and so on. Camp is really a way for us to kick the tires on our new software platform.
1616
If you want to participate, here's how:
1717

18-
* Start by visiting the [Games home page][1]. There, you'll find our competitor's guide, which includes best practices and scoring information. You'll also find instructions for providing feedback. Be sure to check back there frequently, as it's also where we'll be posting news and updates.
18+
* Start by visiting the Games home page. There, you'll find our competitor's guide, which includes best practices and scoring information. You'll also find instructions for providing feedback. Be sure to check back there frequently, as it's also where we'll be posting news and updates.
1919
* You will need a Microsoft Live account in order to sign-in and participate.
2020
* Visit [TheScriptingGames.com][2] to join in.
2121

@@ -24,5 +24,4 @@ The new platform isn't entirely feature-complete, but you should be able to get
2424
We are definitely interested in your feedback. For example, the schedule reflects that of the actual Games. Unlike prior years, we will be having non-overlapping events. You'll have about five days to review the event details and submit an entry - better reflecting the time pressures of a production environment. There will be a discussion forum on PowerShell.org for your feedback - please let us know what you think!
2525

2626

27-
[1]: /games/
2827
[2]: http://thescriptinggames.com

content/articles/2013-04-05-coming-tips-for-the-scripting-games.md

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,5 @@ aliases:
99
- /2013/04/coming-tips-for-the-scripting-games/
1010
---
1111

12-
In preparation for the upcoming Scripting Games, the April 2013 issue of the free [PowerShell.org TechLetter][1] will feature tips, examples, and advice for helping you do the best in the Games! Remember that the [Competitor Guide][2] is now available, so you can start reviewing how the Games will be graded (by the community) and judged this year.
13-
If you're not already receiving the TechLetter, [subscribe by April 15th][3] to receive the April issue in your Inbox!
14-
15-
[1]: /newsletter/ "PowerShell Script that Relaunches as Admin"
16-
[2]: /games/
17-
[3]: /newsletter/ "Manning Deal of the Day "“ April 6 2013"
12+
In preparation for the upcoming Scripting Games, the April 2013 issue of the free PowerShell.org TechLetter will feature tips, examples, and advice for helping you do the best in the Games! Remember that the Competitor Guide is now available, so you can start reviewing how the Games will be graded (by the community) and judged this year.
13+
If you're not already receiving the TechLetter, subscribe by April 15th to receive the April issue in your Inbox!

0 commit comments

Comments
 (0)