import remark from "remark"; import html from "remark-html"; import fs from "fs"; import { join, parse } from "path"; import matter from "gray-matter"; import toml from "@iarna/toml"; import { DateTime } from "luxon"; const directories = (dir) => { switch (dir) { case "blog": return join(process.cwd(), "content/blog"); case "events": return join(process.cwd(), "content/events"); case "docs": return join(process.cwd(), "content/docs"); case "grants": return join(process.cwd(), "content/grants"); case "media": return join(process.cwd(), "content/media"); case "updates": return join(process.cwd(), "content/updates"); case "/": return join(process.cwd(), "content"); default: return join(process.cwd(), `content/${dir}`); } }; export function getPostSlugs(key) { const dir = fs.readdirSync(directories(key), { withFileTypes: true }); return dir .filter((f) => f.isFile() && f.name !== "_index.md") .map((f) => f.name); } // NB Gavin: This could be much simpler if we converted everything to a more common standard, like YAML const options = { engines: { toml: toml.parse.bind(toml), }, language: "toml", delimiters: "+++", }; export function getPostBySlug(slug, fields = [], key) { const realSlug = slug.replace(/\.md$/, ""); const fullPath = join(directories(key), `${realSlug}.md`); const fileContents = fs.readFileSync(fullPath, "utf8"); const { data, content } = matter(fileContents, options); const items = {}; // Ensure only the minimal needed data is exposed fields.forEach((field) => { if (field === "slug") { items[field] = realSlug; } if (field === "content") { items[field] = content; } if (data[field]) { items[field] = data[field]; } }); return items; } export function getAllPosts(fields = [], key, sort = "date") { const slugs = getPostSlugs(key); const posts = slugs .map((slug) => getPostBySlug(slug, fields, key)) // sort posts by date in descending order .sort((post1, post2) => { if (sort === "date") { return DateTime.fromISO(post1.date) > DateTime.fromISO(post2.date) ? -1 : 1; } else if (sort === "weight") { return post1.weight > post2.weight ? -1 : 1; } }); return posts; } export function getAllEvents(fields = [], key) { const slugs = getPostSlugs(key); const posts = slugs .map((slug) => getPostBySlug(slug, fields, key)) // sort posts by date in descending order .sort((post1, post2) => DateTime.fromISO(post1.starts) > DateTime.fromISO(post2.starts) ? -1 : 1 ); return posts; } function getAllPaths(dirPath, arrayOfFiles) { let files = fs.readdirSync(dirPath); arrayOfFiles = arrayOfFiles || []; files.forEach(function (file) { if (fs.statSync(dirPath + "/" + file).isDirectory()) { arrayOfFiles = getAllPaths(dirPath + "/" + file, arrayOfFiles); } else { arrayOfFiles.push(join(dirPath, "/", file)); } }); return arrayOfFiles; } export function getDocsPaths() { return getAllPaths(directories("docs"), []); } export function getNextPost(slug, fields = [], key, sort = "date") { let resultPost = null; getAllPosts(fields, key, sort).forEach((post, index, array) => { if (post.slug === slug) { if (typeof array[index - 1] !== "undefined") { resultPost = array[index - 1]; } } }); return resultPost; } export function getPreviousPost(slug, fields = [], key, sort = "date") { let resultPost = null; getAllPosts(fields, key, sort).forEach((post, index, array) => { if (post.slug === slug) { if (typeof array[index + 1] !== "undefined") { resultPost = array[index + 1]; } } }); return resultPost; } export function formatDateWithTime(d) { const date = DateTime.fromISO(d, { setZone: true, }).toLocaleString(DateTime.DATETIME_FULL); return date; } export function capitalize(str) { return str.charAt(0).toUpperCase() + str.slice(1); } // Takes an ISO 8601 date string // Returns a time relative to the IANA timezone and iso8601 date export function generateDisplayDate(iso8601, zone = "America/Los_Angeles") { return DateTime.fromISO(iso8601, { zone }); } // Takes an ISO 8601 date string // Returns a time relative to user's current timezone export function generateRealtimeDate(iso8601) { return DateTime.fromISO(iso8601).setZone("system"); } // Formats a DateTime object to something like "July 15, 2021, 5:00 PM" export function formatDateAndTime(dateTimeObject) { return dateTimeObject.toLocaleString(DateTime.DATETIME_HUGE); } // Formats a DateTime object to something like "5:00 PM" export function formatTime(dateTimeObject) { const dt = dateTimeObject.toLocaleString(DateTime.TIME_SIMPLE); return dt.replace(/:00/g, ""); } export function formatTimeZone(dateTimeObject) { return dateTimeObject.offsetNameShort; } // Formats a DateTime object to something like "Jul 15, 2021" export function formatDate(dateTimeObject) { return dateTimeObject.toLocaleString(DateTime.DATE_FULL); } // export function generateCrumbs(path) { // const crumbs = path.split('/') // return crumbs // .filter(v => v !== '') // .map((crumb, index) => ({ // name: capitalize(crumb), // key: crumb, // path: crumbs.slice(0, index+2).join('/'), // })) // } /** * buildPageTree is written to provide a recursive index of posts * for nested directories for creating sidebars * @param {string} path Absolute path to the subdirectory. * @returns {Object.>} Title of the directory, child pages, and child directories (recursive structure). * */ // type PageTree = // { title: string, // pages: [{ // title: string, // base: string, // slug: string, // weight: number, // }], // children: { [string]: PageTree }, // } export function buildPageTree(path, ordering = "", content = false) { const metadata = matter(fs.readFileSync(join(path, "_index.md")), options); // get a list of contents at the path, with file types so that the item at the path can be identified as file or folder const children = fs.readdirSync(path, { withFileTypes: true }); // get a list of files const pages = children.filter((f) => f.isFile() && f.name !== "_index.md"); // get a list of folders const subdirs = children.filter((f) => f.isDirectory()); // return a pagetree datastructure return { title: metadata.data.title, pages: pages .map((page) => { // retrieve topmatter as JSON const { content, data } = matter( fs.readFileSync(join(path, page.name)), options ); return { title: data.title, base: page.name, slug: page.name.replace(/.md$/, ""), ...(ordering === "weight" && { weight: data?.weight ?? 0 }), ...(ordering === "date" && { date: data?.date ?? "2000-01-01T00:00:00.000Z", }), }; }) .sort((a, b) => a.weight - b.weight), children: Object.fromEntries( subdirs .sort((a, b) => { const aMetadata = matter( fs.readFileSync(join(path, a.name, "_index.md")), options ); const bMetadata = matter( fs.readFileSync(join(path, b.name, "_index.md")), options ); return aMetadata?.data?.weight - bMetadata?.data?.weight || 0; }) .map((subdir) => [ subdir.name, buildPageTree(join(path, subdir.name), ordering, content), ]) ), }; } export const getPage = (path) => { try { let fileContents = fs.readFileSync(`${path}.md`, "utf8"); if (fileContents) { const { data, content } = matter(fileContents, options); return { data, content }; } } catch { try { let fileContents = fs.readFileSync(`${path}/_index.md`, "utf8"); if (fileContents) { const { data, content } = matter(fileContents, options); return { data, content }; } } catch { console.error("no md file for slug"); } } }; // Returns an array of unique grants categories export function getGrantsCategories() { const paths = fs.readdirSync(directories("grants")); const uniqueCategories = paths // Get only markdown files .filter((path) => parse(path).ext === ".md") // Parse frontmatter .map((path) => matter(fs.readFileSync(join(directories("grants"), path)), options) ) // Each markdown file has an array of categories. Collapse these arrays into one array. .reduce((acc, metadata) => { return [...acc, ...metadata.data.taxonomies.grant_category]; }, []) // Deduplicate single array of categories such that the output is an array where each category is represented only once. .reduce((acc, uniqueItem) => { if (acc.includes(uniqueItem)) { return acc; } return [...acc, uniqueItem]; }, []); return uniqueCategories; } // Returns an array of unique grants types export function getGrantsTypes() { const paths = fs.readdirSync(directories("grants")); const uniqueTypes = paths // Get only markdown files .filter((path) => parse(path).ext === ".md") // Parse frontmatter to JSON .map((path) => matter(fs.readFileSync(join(directories("grants"), path)), options) ) // Each markdown file has an array of types. Collapse these arrays into one array. .reduce((acc, metadata) => { return [...acc, ...metadata.data.taxonomies.grant_type]; }, []) // Deduplicate single array of types such that the output is an array where each type is represented only once. .reduce((acc, uniqueItem) => { if (acc.includes(uniqueItem)) { return acc; } return [...acc, uniqueItem]; }, []); return uniqueTypes; } // Returns a number which represents how many grants are open, or uncompleted export function getOpenGrantsCount() { const paths = fs.readdirSync(directories("grants")); const count = paths // Get only markdown files .filter((path) => parse(path).ext === ".md") // Parse frontmatter to JSON .map((path) => matter(fs.readFileSync(join(directories("grants"), path)), options) ) // Count grants which are both uncompleted and not assigned .reduce((acc, metadata) => { if ( !metadata.data.extra.completed && !metadata.data.extra.canceled && metadata.data.extra.assignee === "" ) { return acc + 1; } return acc; }, 0); return count; }