Skip to content
Open
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 docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const config = {
path: './release',
},
],
// Generates /community/meetings (list) and /community/meetings/:slug (detail)
// pages from the meeting Markdown files in static/data/meetings/notes/.
require('./plugins/meetings-plugin'),
],
presets: [
[
Expand Down
163 changes: 163 additions & 0 deletions plugins/meetings-plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// @ts-check
'use strict';

const path = require('path');
const fs = require('fs');
const { parseMeetingMarkdown } = require('./meetings-utils');

/**
* Reads all meeting folders from the canonical notes directory and parses
* each one into a structured record.
*
* Folder discovery rules:
* - Must be a directory (not a file)
* - Name must match YYYY-MM-DD exactly
* - Must contain an index.md file
*
* Folders that do not match or whose index.md cannot be parsed are
* logged as warnings and skipped rather than crashing the build.
*
* @param {string} notesDir Absolute path to static/data/meetings/notes/
* @returns {Array<object>} Parsed meeting records, sorted ascending by isoDate
*/
function discoverMeetings(notesDir) {
/** @type {fs.Dirent[]} */
let entries;
try {
entries = fs.readdirSync(notesDir, { withFileTypes: true });
} catch (err) {
throw new Error(
`[meetings-plugin] Cannot read meetings directory at "${notesDir}": ${err.message}`,
);
}

const slugs = entries
.filter(d => d.isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(d.name))
.map(d => d.name)
.sort(); // ascending chronological order

const meetings = [];
for (const slug of slugs) {
const mdPath = path.join(notesDir, slug, 'index.md');
if (!fs.existsSync(mdPath)) {
console.warn(
`[meetings-plugin] No index.md found for meeting "${slug}" — skipping.`,
);
continue;
}

const rawContent = fs.readFileSync(mdPath, 'utf-8');
try {
meetings.push(parseMeetingMarkdown(rawContent, slug));
} catch (err) {
console.warn(
`[meetings-plugin] Failed to parse meeting "${slug}": ${err.message} — skipping.`,
);
}
}

return meetings;
}

/**
* Attaches previous/next navigation slugs to each meeting record.
* Mutates the records in-place for efficiency.
*
* Navigation is chronological: "previous" means the meeting that occurred
* before this one in time; "next" means the one that occurred after.
*
* @param {Array<object>} meetings Sorted ascending by isoDate
*/
function attachNavigation(meetings) {
for (let i = 0; i < meetings.length; i++) {
meetings[i].prevSlug = i > 0 ? meetings[i - 1].slug : null;
meetings[i].prevDateLabel = i > 0 ? meetings[i - 1].dateLabel : null;
meetings[i].nextSlug =
i < meetings.length - 1 ? meetings[i + 1].slug : null;
meetings[i].nextDateLabel =
i < meetings.length - 1 ? meetings[i + 1].dateLabel : null;
}
}

/**
* Custom Docusaurus v2 plugin that generates a dedicated page for every
* community meeting and a listing/archive page for all meetings.
*
* Routes created:
* /community/meetings — MeetingListPage component
* /community/meetings/:slug (×N) — MeetingDetailPage component
*
* Global data set (readable via usePluginData('meetings-plugin')):
* Array of MeetingMeta objects (metadata only, no rawContent)
* Used by CommunityMeetingsCardGrid to show recent meetings on /community.
*
* @param {import('@docusaurus/types').LoadContext} context
* @returns {import('@docusaurus/types').Plugin}
*/
function meetingsPlugin(context) {
const notesDir = path.join(
context.siteDir,
'static',
'data',
'meetings',
'notes',
);

return {
name: 'meetings-plugin',

// ─── Phase 1: Load ──────────────────────────────────────────────────────
async loadContent() {
const meetings = discoverMeetings(notesDir);
attachNavigation(meetings);
return meetings;
},

// ─── Phase 2: Generate routes ────────────────────────────────────────────
async contentLoaded({ content, actions }) {
/** @type {Array<Record<string, unknown>>} */
const meetings = /** @type {any} */ (content);
const { addRoute, createData, setGlobalData } = actions;

// Share lightweight metadata with the community page via global data.
// rawContent is intentionally excluded from global data — it is large
// and only needed on individual meeting pages.
const meetingsMeta = meetings.map(
({ rawContent: _raw, ...meta }) => meta,
);
setGlobalData(meetingsMeta);

// ── List page ─────────────────────────────────────────────────────────
const listDataPath = await createData(
'meetings-list.json',
JSON.stringify(meetingsMeta),
);
addRoute({
path: '/community/meetings',
component:
'@site/src/components/community/meetings/MeetingListPage',
modules: { meetings: listDataPath },
exact: true,
});

// ── Individual meeting pages ──────────────────────────────────────────
for (const rawMeeting of meetings) {
const meeting = /** @type {any} */ (rawMeeting);
const meetingDataPath = await createData(
// Use a filename that is safe across all operating systems
`meeting-${meeting.slug}.json`,
JSON.stringify(meeting),
);
addRoute({
path: `/community/meetings/${meeting.slug}`,
component:
'@site/src/components/community/meetings/MeetingDetailPage',
modules: { meeting: meetingDataPath },
exact: true,
});
}
},
};
}

module.exports = meetingsPlugin;
217 changes: 217 additions & 0 deletions plugins/meetings-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// @ts-check
'use strict';

/**
* Mapping of month name → 1-based month number.
* Used to parse human-readable date headings inside Markdown files.
*/
const MONTH_NAMES = [
'january', 'february', 'march', 'april', 'may', 'june',
'july', 'august', 'september', 'october', 'november', 'december',
];

/**
* Formats a year/month/day triple into a human-readable string.
* Example: formatDateLabel(2026, 8, 4) → "August 4, 2026"
* @param {number} year
* @param {number} month 1-based month
* @param {number} day
* @returns {string}
*/
function formatDateLabel(year, month, day) {
const name = MONTH_NAMES[month - 1];
const capitalized = name.charAt(0).toUpperCase() + name.slice(1);
return `${capitalized} ${day}, ${year}`;
}

/**
* Strips Jekyll/YAML front matter (--- … ---) from the top of a file.
* Older meeting files were authored for a Jekyll-based website and contain
* front matter that should not be rendered on the new Docusaurus site.
* @param {string} content Raw file content
* @returns {string} Content with front matter removed
*/
function stripFrontMatter(content) {
const lines = content.split('\n');
if (!lines[0] || lines[0].trim() !== '---') {
return content;
}
// Find the closing ---
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') {
return lines.slice(i + 1).join('\n');
}
}
// Malformed front matter — return as-is so we don't silently discard content
return content;
}

/**
* Determines the meeting type by inspecting the first H1 heading.
* If the H1 contains "Cabal" (case-insensitive) → 'cabal', otherwise 'community'.
* This is deliberately conservative: everything that is not explicitly a Cabal
* meeting is classified as a community meeting.
* @param {string} body Markdown body (front matter already stripped)
* @returns {'community' | 'cabal'}
*/
function detectMeetingType(body) {
const h1Match = body.match(/^# (.+)$/m);
if (h1Match && /cabal/i.test(h1Match[1])) {
return 'cabal';
}
return 'community';
}

/**
* Extracts the human-readable title from the first H1 heading.
* Falls back to a sensible default so the page is never untitled.
* @param {string} body
* @param {'community' | 'cabal'} type
* @returns {string}
*/
function extractTitle(body, type) {
const h1Match = body.match(/^# (.+)$/m);
if (h1Match && h1Match[1].trim() && h1Match[1].trim() !== '{{ page.title }}') {
return h1Match[1].trim();
}
return type === 'cabal'
? 'Podman Community Cabal Notes'
: 'Podman Community Meeting Notes';
}

/**
* Tries to extract a human-readable date string from the Markdown body.
* Meeting files contain headings like:
* ## August 4, 2026 11:00 a.m. Eastern (UTC-4)
* ## November 3, 2020 11:00 a.m. Eastern
* ## October 21, 2021 11:00 a.m. Eastern
*
* We extract only the date portion (Month D, YYYY) and strip the time.
* Falls back to formatting the slug date so we always return a value.
*
* @param {string} body
* @param {number} year
* @param {number} month
* @param {number} day
* @returns {string}
*/
function extractDateLabel(body, year, month, day) {
// Match H2 headings that start with a capitalized month name followed by a date
const h2Match = body.match(
/^## (January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s+\d{4}/im,
);
if (h2Match) {
// Strip time component (everything after the year)
const raw = h2Match[0].replace(/^## /, '').trim();
const dateOnly = raw.match(
/^([A-Za-z]+ \d{1,2}, \d{4})/,
);
if (dateOnly) {
return dateOnly[1];
}
}
return formatDateLabel(year, month, day);
}

/**
* Extracts the URL of the meeting recording from the Markdown body.
* Handles all known historical formats:
*
* Format A (2022+): Video [Recording](https://youtu.be/…)
* Format B (2021–2022): [Recording](https://bluejeans.com/…)
* Format C (some files): BlueJeans [Recording](https://…)
* Format D (older): ### BlueJeans [Recording](https://…)
* Format E (some files): [Watch Recording](https://…)
*
* Returns null if no recording link is found (e.g. upcoming meetings,
* meetings whose recordings were not preserved).
*
* @param {string} body
* @returns {string | null}
*/
function extractRecordingUrl(body) {
const patterns = [
// Format A: "Video [Recording](URL)"
/Video\s+\[Recording\]\((https?:\/\/[^)]+)\)/i,
// Format B/C/D: "[Recording](URL)" optionally preceded by "BlueJeans"
/\[Recording\]\((https?:\/\/[^)]+)\)/i,
// Format E: "[Watch Recording](URL)"
/\[Watch Recording\]\((https?:\/\/[^)]+)\)/i,
// Generic fallback: any link anchor containing "record"
/\[(?:[^\]]*[Rr]ecord[^\]]*)\]\((https?:\/\/[^)]+)\)/,
];

for (const pattern of patterns) {
const match = body.match(pattern);
if (match) {
return match[1];
}
}
return null;
}

/**
* Parses a single meeting's `index.md` content and returns a structured record.
*
* The slug (YYYY-MM-DD folder name) is the authoritative source for the meeting
* date — it is always well-formed and never requires interpretation. The
* human-readable date label is derived secondarily from the Markdown headings.
*
* @param {string} rawContent Full contents of the meeting's index.md file
* @param {string} slug Folder name in YYYY-MM-DD format
* @returns {{
* slug: string,
* isoDate: string,
* dateLabel: string,
* title: string,
* type: 'community' | 'cabal',
* recordingUrl: string | null,
* rawContent: string,
* }}
* @throws {Error} if slug is not a valid YYYY-MM-DD date
*/
function parseMeetingMarkdown(rawContent, slug) {
// Validate slug format — this is our authoritative date source
const slugMatch = slug.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!slugMatch) {
throw new Error(
`[meetings-plugin] Invalid slug format: "${slug}". Expected YYYY-MM-DD.`,
);
}

const year = parseInt(slugMatch[1], 10);
const month = parseInt(slugMatch[2], 10);
const day = parseInt(slugMatch[3], 10);
const isoDate = slug; // Already in YYYY-MM-DD form

let body = stripFrontMatter(rawContent).trim();
const type = detectMeetingType(body);
const title = extractTitle(body, type);

// Replace legacy Jekyll {{ page.title }} tags with the actual resolved title
body = body.replace(/\{\{\s*page\.title\s*\}\}/g, title);

const dateLabel = extractDateLabel(body, year, month, day);
const recordingUrl = extractRecordingUrl(body);

return {
slug,
isoDate,
dateLabel,
title,
type,
recordingUrl,
rawContent: body,
};
}

module.exports = {
parseMeetingMarkdown,
// Exported individually for unit testing
stripFrontMatter,
detectMeetingType,
extractTitle,
extractDateLabel,
extractRecordingUrl,
formatDateLabel,
};
Loading