diff --git a/.github/workflows/run-typescript.yml b/.github/workflows/run-typescript.yml new file mode 100644 index 0000000000..0633acd089 --- /dev/null +++ b/.github/workflows/run-typescript.yml @@ -0,0 +1,36 @@ +name: Run Typescript Checks + +on: + pull_request: + branches: [main] + workflow_dispatch: + secrets: + AIRTABLE_TOKEN: + required: true + +jobs: + typescript-check: + runs-on: ubuntu-latest + env: + AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }} + AIRTABLE_PEOPLE_BASE_ID: appk2btw36qEO3vFo + AIRTABLE_RESEARCH_BASE_ID: appTv9J1zxqaNgBHi + AIRTABLE_EVENTS_BASE_ID: tbl6CURONRn8ML6le + AIRTABLE_POSTS_BASE_ID: appsY0VXF7pbv3mKR + AIRTABLE_MITH_BASE_ID: appMWsw8HKjjokBg2 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - name: Install dependencies and build + run: | + npm ci + npm run build + - name: Check for gatsby-types.d.ts + run: | + ls -l src/gatsby-types.d.ts + cat src/gatsby-types.d.ts | head -n 20 + - name: Run TypeScript check + run: npm run check diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 3e7ff332f0..59f6d5fdfa 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -12,8 +12,11 @@ jobs: secrets: AIRTABLE_TOKEN: ${{ secrets.AIRTABLE_TOKEN }} + typescript-check: + uses: ./.github/workflows/run-typescript.yml + build: - needs: test + needs: [test, typescript-check] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 7d1f355732..ffa3cc4767 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +gatsby-types.d.ts + # Logs logs *.log diff --git a/gatsby-browser.js b/gatsby-browser.js deleted file mode 100644 index b1e5c316b7..0000000000 --- a/gatsby-browser.js +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Implement Gatsby's Browser APIs in this file. - * - * See: https://www.gatsbyjs.org/docs/browser-apis/ - */ - -// You can delete this file if you're not using it diff --git a/gatsby-config.js b/gatsby-config.ts similarity index 90% rename from gatsby-config.js rename to gatsby-config.ts index 7786e562a6..9762c4904f 100644 --- a/gatsby-config.js +++ b/gatsby-config.ts @@ -1,10 +1,14 @@ -require("dotenv").config(); +import "dotenv/config" +import type { GatsbyConfig } from "gatsby" -const baseId = process.env.AIRTABLE_MITH_BASE_ID; +const baseId = process.env.AIRTABLE_MITH_BASE_ID const basePath = process.env.BASEPATH -module.exports = { +const config: GatsbyConfig = { pathPrefix: basePath, + graphqlTypegen: { + generateOnBuild: true, + }, siteMetadata: { title: `MITH`, siteUrl: "https://mith.umd.edu", @@ -76,7 +80,7 @@ module.exports = { tableView: `All Research Items`, queryName: `ResearchItems`, separateNodeType: true, - mapping: { + mapping: { image: `fileNode`, description: `text/markdown`, excerpt: `text/markdown`, @@ -98,8 +102,8 @@ module.exports = { `linked_sponsors`, `linked_posts`, `linked_events`, - `related_research` - ] + `related_research`, + ], }, { baseId, @@ -107,10 +111,10 @@ module.exports = { tableView: `All Events`, queryName: `Events`, separateNodeType: true, - mapping: { + mapping: { excerpt: `text/markdown`, description: `text/markdown`, - image: `fileNode` + image: `fileNode`, }, tableLinks: [ `linked_research_item`, @@ -126,8 +130,8 @@ module.exports = { `methods`, `disciplines`, `tags`, - `event_types` - ] + `event_types`, + ], }, { baseId, @@ -135,10 +139,7 @@ module.exports = { tableView: `All Links`, queryName: `Links`, separateNodeType: true, - tableLinks: [ - `linked_research_items`, - `linked_events` - ] + tableLinks: [`linked_research_items`, `linked_events`], }, { baseId, @@ -146,22 +147,22 @@ module.exports = { tableView: `All Partners & Sponsors`, queryName: `PartnersSponsors`, separateNodeType: true, - mapping: { + mapping: { logo: `fileNode`, }, tableLinks: [ `linked_research_items_as_partner`, `linked_events_as_partner`, `linked_research_items_as_sponsor`, - `linked_events_as_sponsor` - ] + `linked_events_as_sponsor`, + ], }, { baseId, tableName: `People`, tableView: `All People`, queryName: `People`, // optionally default is false - makes all records in this table a separate node type, based on your tableView, or if not present, tableName, e.g. a table called "Fruit" would become "allAirtableFruit". Useful when pulling many airtables with similar structures or fields that have different types. See https://github.com/jbolda/gatsby-source-airtable/pull/52. - mapping: { + mapping: { headshot: `fileNode`, bio: `text/markdown`, }, // optional, e.g. "text/markdown", "fileNode" @@ -172,7 +173,7 @@ module.exports = { `linked_featured_research`, `linked_research_as_participant`, `events_as_participant`, - `events_as_speaker` + `events_as_speaker`, ], // optional, for deep linking to records across tables. separateNodeType: true, // boolean, default is false, see the documentation on naming conflicts for more information // separateMapType: false, // boolean, default is false, see the documentation on using markdown and attachments for more information @@ -183,7 +184,7 @@ module.exports = { tableView: `All Identities`, queryName: `Identities`, separateNodeType: true, - mapping: { + mapping: { linked_person_bio: `text/markdown`, person_bio: `text/markdown`, }, @@ -195,8 +196,8 @@ module.exports = { `linked_research_as_internal`, `linked_research_as_external`, `linked_events_as_speaker`, - `linked_events_as_participant` - ] + `linked_events_as_participant`, + ], }, { baseId, @@ -204,10 +205,7 @@ module.exports = { tableView: `All Groups`, queryName: `Groups`, separateNodeType: true, - tableLinks: [ - `linked_people`, - `linked_affiliations` - ] + tableLinks: [`linked_people`, `linked_affiliations`], }, { baseId, @@ -220,8 +218,8 @@ module.exports = { `linked_events`, `linked_people`, `disciplines`, - `methods` - ] + `methods`, + ], }, { baseId, @@ -229,10 +227,7 @@ module.exports = { tableView: `All Tags`, queryName: `Tags`, separateNodeType: true, - tableLinks: [ - `linked_research`, - `linked_events` - ] + tableLinks: [`linked_research`, `linked_events`], }, { baseId, @@ -240,10 +235,7 @@ module.exports = { tableView: `All Research Types`, queryName: `ResearchTypes`, separateNodeType: true, - tableLinks: [ - `linked_research`, - `linked_events` - ] + tableLinks: [`linked_research`, `linked_events`], }, { baseId, @@ -255,8 +247,8 @@ module.exports = { `linked_research`, `linked_events`, `methods`, - `disciplines` - ] + `disciplines`, + ], }, { baseId, @@ -270,17 +262,17 @@ module.exports = { `linked_events_methods`, `linked_events_disciplines`, `linked_posts_methods`, - `linked_posts_disciplines` - ] + `linked_posts_disciplines`, + ], }, - ] - } + ], + }, }, { resolve: `gatsby-plugin-plausible`, options: { domain: `mith.umd.edu`, - excludePaths: ["/mith-static/*"] + excludePaths: ["/mith-static/*"], }, }, { @@ -320,7 +312,7 @@ module.exports = { }, }, ], - pedantic: false + pedantic: false, }, }, { @@ -438,3 +430,6 @@ module.exports = { }, ], } + +export default config + diff --git a/gatsby-node.js b/gatsby-node.ts similarity index 63% rename from gatsby-node.js rename to gatsby-node.ts index 7fc56123e2..1b8ba8db36 100644 --- a/gatsby-node.js +++ b/gatsby-node.ts @@ -1,20 +1,31 @@ -const path = require('path') - -exports.createPages = async ({ actions: { createPage }, graphql }) => { - await makePeople(createPage, graphql) - await makePosts(createPage, graphql) - await makePostIndex(createPage, graphql) - await makeResearch(createPage, graphql) - await makeResearchIndex(createPage, graphql) - await makeEvents(createPage, graphql) - await makeEventIndex(createPage, graphql) - await makeDialogues(createPage, graphql) - await makeDialogueIndex(createPage, graphql) +import type { Actions, CreatePagesArgs, GatsbyNode } from "gatsby"; +import path from "path"; + +interface IMakePages { + createPage: Actions["createPage"] + graphql: CreatePagesArgs["graphql"] } -async function makePeople(createPage, graphql) { +type PeopleImage = NonNullable["headshot"] + +export const createPages: GatsbyNode["createPages"] = async ({ actions: { createPage }, graphql }) => { + + const utils: IMakePages = {createPage, graphql}; + + await makePeople(utils) + await makePosts(utils) + await makePostIndex(utils) + await makeResearch(utils) + await makeResearchIndex(utils) + await makeEvents(utils) + await makeEventIndex(utils) + await makeDialogues(utils) + await makeDialogueIndex(utils) +} + +async function makePeople({createPage, graphql}: IMakePages) { const results = await graphql(` - query PagePeopleQuery { + query PagePeople { allAirtablePeople(filter: {data: {group_type: {eq: "Staff"}}}) { nodes { data { @@ -58,15 +69,13 @@ async function makePeople(createPage, graphql) { } `) - for (const node of results.data.allAirtablePeople.nodes) { + const {nodes} = (results.data as Queries.PagePeopleQuery).allAirtablePeople; + + for (const node of nodes) { const person = node.data - // Simplify fields - if (person.bio) { - person.bio = person.bio.childMarkdownRemark.html - } createPage({ - path: `/people/${person.id}/`, - component: require.resolve(`./src/templates/person.js`), + path: `/people/${person?.id}/`, + component: path.resolve(`./src/templates/person.tsx`), context: { ...person } @@ -74,9 +83,9 @@ async function makePeople(createPage, graphql) { } } -async function makePosts(createPage, graphql) { +async function makePosts({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PagePosts { allFile(filter: {sourceInstanceName: {eq: "news"}}) { nodes { childMarkdownRemark { @@ -87,13 +96,18 @@ async function makePosts(createPage, graphql) { } } `) + + const {nodes} = (results.data as Queries.PagePostsQuery).allFile; - for (const _post of results.data.allFile.nodes) { + for (const _post of nodes) { const post = _post.childMarkdownRemark - const slug = path.basename(post.fileAbsolutePath, '.md') + if (!post?.fileAbsolutePath) { + console.error(`No markdown path for post.`) + } + const slug = path.basename(post?.fileAbsolutePath || "", '.md') createPage({ path: `/news/${slug}/`, - component: require.resolve(`./src/templates/post.js`), + component: path.resolve(`./src/templates/post.tsx`), context: { slug, ...post @@ -102,10 +116,10 @@ async function makePosts(createPage, graphql) { } } -async function makePostIndex(createPage, graphql) { +async function makePostIndex({createPage, graphql}: IMakePages) { console.log(`making post index`) const results = await graphql(` - query { + query PagePostIndex { allFile(filter: {sourceInstanceName: {eq: "news"}}) { pageInfo { itemCount @@ -114,14 +128,14 @@ async function makePostIndex(createPage, graphql) { } `) - const numPosts = results.data.allFile.pageInfo.itemCount + const numPosts = (results.data as Queries.PagePostIndexQuery).allFile.pageInfo.itemCount const postsPerPage = 25 const numPages = Math.ceil(numPosts / postsPerPage) Array.from({ length: numPages }).forEach((_, i) => { createPage({ path: i === 0 ? `/news` : `/news/${i + 1}`, - component: path.resolve("./src/templates/post-index.js"), + component: path.resolve("./src/templates/post-index.tsx"), context: { limit: postsPerPage, skip: i * postsPerPage, @@ -132,9 +146,9 @@ async function makePostIndex(createPage, graphql) { }) } -async function makeResearchIndex(createPage, graphql) { +async function makeResearchIndex({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PageResearchIndex { allAirtableResearchItems { pageInfo { itemCount @@ -143,14 +157,14 @@ async function makeResearchIndex(createPage, graphql) { } `) - const numItems = results.data.allAirtableResearchItems.pageInfo.itemCount + const numItems = (results.data as Queries.PageResearchIndexQuery).allAirtableResearchItems.pageInfo.itemCount const itemsPerPage = 30 const numPages = Math.ceil(numItems / itemsPerPage) Array.from({ length: numItems }).forEach((_, i) => { createPage({ path: i === 0 ? `/research` : `/research/${i + 1}/`, - component: path.resolve("./src/templates/research-index.js"), + component: path.resolve("./src/templates/research-index.tsx"), context: { limit: itemsPerPage, skip: i * itemsPerPage, @@ -161,9 +175,9 @@ async function makeResearchIndex(createPage, graphql) { }) } -async function makeResearch(createPage, graphql) { +async function makeResearch({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PageResearch { allAirtableResearchItems { nodes { data { @@ -229,7 +243,7 @@ async function makeResearch(createPage, graphql) { id } } - } + } id } linked_directors { @@ -317,24 +331,38 @@ async function makeResearch(createPage, graphql) { } `) - for (const node of results.data.allAirtableResearchItems.nodes) { - const item = node.data + for (const node of (results.data as Queries.PageResearchQuery).allAirtableResearchItems.nodes) { + + // These extended types are defined to accommodate the data merging below that brings together participants with their affiliations. + // TODO: Can these types be simplified? + type Affiliation = + NonNullable["linked_internal_participant_affiliations"]>[number] + | NonNullable["linked_external_participant_affiliations"]>[number] + type ExtendedLinkedParticipant = NonNullable["linked_participants"]>[number] &{ + affiliations?: Affiliation[] + } + type ExtendedPageResearchQuery = Queries.PageResearchQuery["allAirtableResearchItems"]["nodes"][number]["data"] & { + participants?: ExtendedLinkedParticipant[] + directors?: ExtendedLinkedParticipant[] + } + + const item = node.data as ExtendedPageResearchQuery if (item.linked_participants) { item.participants = item.linked_participants.map(p => { - const new_p = Object.assign({}, p); - const id = p.data.id + const new_p: ExtendedLinkedParticipant = Object.assign({}, p); + const id = p?.data?.id - if (p.data.group_type.includes("Staff") || p.data.group_type.includes("Past")) { + if (p?.data?.group_type?.includes("Staff") || p?.data?.group_type?.includes("Past")) { // Lookup staff members in internal participants if (item.linked_internal_participant_affiliations) { for (const aff of item.linked_internal_participant_affiliations) { - if (!aff.data.linked_person[0]) continue; - if (aff.data.linked_person[0].data.id === id) { - if (new_p.data.affiliations) { - new_p.data.affiliations.push(aff) + if (!aff?.data?.linked_person?.[0]) continue; + if (aff?.data?.linked_person[0].data?.id === id) { + if (new_p.affiliations) { + new_p.affiliations.push(aff) } else { - new_p.data.affiliations = [aff] + new_p.affiliations = [aff] } break; } @@ -344,12 +372,12 @@ async function makeResearch(createPage, graphql) { // Lookup other people in external participants if (item.linked_external_participant_affiliations) { for (const aff of item.linked_external_participant_affiliations) { - if (!aff.data.linked_person[0].data) continue; - if (aff.data.linked_person[0].data.id === id) { - if (new_p.data.affiliations) { - new_p.data.affiliations.push(aff) + if (!aff?.data?.linked_person?.[0]?.data) continue; + if (aff?.data?.linked_person[0].data?.id === id) { + if (new_p.affiliations) { + new_p.affiliations.push(aff) } else { - new_p.data.affiliations = [aff] + new_p.affiliations = [aff] } break; } @@ -361,16 +389,16 @@ async function makeResearch(createPage, graphql) { } if (item.linked_directors) { item.directors = item.linked_directors.map(p => { - const new_p = Object.assign({}, p); - const id = p.data.id + const new_p: ExtendedLinkedParticipant = Object.assign({}, p); + const id = p?.data?.id if (item.linked_director_affiliations) { for (const aff of item.linked_director_affiliations) { - if (!aff.data.linked_person[0].data) continue; + if (!aff?.data?.linked_person?.[0]?.data) continue; if (aff.data.linked_person[0].data.id === id) { - if (new_p.data.affiliations) { - new_p.data.affiliations.push(aff) + if (new_p.affiliations) { + new_p.affiliations.push(aff as unknown as Affiliation) } else { - new_p.data.affiliations = [aff] + new_p.affiliations = [aff as unknown as Affiliation] } break; } @@ -382,7 +410,7 @@ async function makeResearch(createPage, graphql) { createPage({ path: `/research/${item.id}/`, - component: require.resolve(`./src/templates/research.js`), + component: path.resolve(`./src/templates/research.tsx`), context: { ...item } @@ -391,9 +419,9 @@ async function makeResearch(createPage, graphql) { } -async function makeEventIndex(createPage, graphql) { +async function makeEventIndex({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PageEventIndex { allAirtableEvents { pageInfo { itemCount @@ -402,14 +430,15 @@ async function makeEventIndex(createPage, graphql) { } `) - const numItems = results.data.allAirtableEvents.pageInfo.itemCount + + const numItems = (results.data as Queries.PageEventIndexQuery).allAirtableEvents.pageInfo.itemCount const itemsPerPage = 30 const numPages = Math.ceil(numItems / itemsPerPage) Array.from({ length: numItems }).forEach((_, i) => { createPage({ path: i === 0 ? `/events/` : `/events/${i + 1}/`, - component: path.resolve("./src/templates/event-index.js"), + component: path.resolve("./src/templates/event-index.tsx"), context: { limit: itemsPerPage, skip: i * itemsPerPage, @@ -421,9 +450,9 @@ async function makeEventIndex(createPage, graphql) { } -async function makeEvents(createPage, graphql) { +async function makeEvents({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PageEvent { allAirtableEvents { nodes { data { @@ -540,6 +569,7 @@ async function makeEvents(createPage, graphql) { backgroundColor: "rgba(255,255,255,0)" ) } + publicURL } } } @@ -614,22 +644,34 @@ async function makeEvents(createPage, graphql) { } `) - for (const node of results.data.allAirtableEvents.nodes) { - const item = node.data + // These extended types are defined to accommodate the data merging below that brings together participants with their affiliations. + // TODO: Can these types be simplified? + type Affiliation = + NonNullable["linked_participant_affiliations"]>[number] + type ExtendedLinkedParticipantEvent = NonNullable["linked_participants"]>[number] & { + affiliations?: Affiliation[] + } + type ExtendedPageEventQuery = Queries.PageEventQuery["allAirtableEvents"]["nodes"][number]["data"] & { + participants?: ExtendedLinkedParticipantEvent[] + directors?: ExtendedLinkedParticipantEvent[] + } + + for (const node of (results.data as Queries.PageEventQuery).allAirtableEvents.nodes) { + const item = node.data as ExtendedPageEventQuery // Attach linked participant affiliations if (item.linked_participants) { item.participants = item.linked_participants.map(p => { - const new_p = Object.assign({}, p); - const id = p.data.id + const new_p: ExtendedLinkedParticipantEvent = Object.assign({}, p); + const id = p?.data?.id // Lookup staff members in internal participants if (item.linked_participant_affiliations) { for (const aff of item.linked_participant_affiliations) { - if (!aff.data.linked_person[0]) continue; - if (aff.data.linked_person[0].data.id === id) { - if (new_p.data.affiliations) { - new_p.data.affiliations.push(aff) + if (!aff?.data?.linked_person?.[0]) continue; + if (aff.data.linked_person?.[0]?.data?.id === id) { + if (new_p.affiliations) { + new_p.affiliations.push(aff) } else { - new_p.data.affiliations = [aff] + new_p.affiliations = [aff] } break; } @@ -639,34 +681,42 @@ async function makeEvents(createPage, graphql) { }) } + type ExtendedSpeakerData = NonNullable["speakers"]>[number] & { + headshot: PeopleImage + bio: NonNullable["linked_person"]>[number]>["data"]>["bio"] + } + // Attach headshot and speakers bio from people and identities table if (item.speakers) { item.speakers.forEach(sp => { - results.data.allAirtablePeople.nodes.map(_pers => { + (results.data as Queries.PageEventQuery).allAirtablePeople.nodes.map(_pers => { const pers = _pers.data - if (pers.slug === sp.data.slug) { - if (pers.headshot) { - sp.data.headshot = pers.headshot + if (pers?.slug === sp?.data?.slug) { + if (pers?.headshot && sp?.data) { + (sp.data as unknown as ExtendedSpeakerData).headshot = pers?.headshot } } - }) - results.data.allAirtableIdentities.nodes.map(_pers => { + }); + + (results.data as Queries.PageEventQuery).allAirtableIdentities.nodes.map(_pers => { const pers = _pers.data - if (pers.linked_person[0].data.slug === sp.data.slug) { - if (pers.linked_person[0].data.bio) { - sp.data.bio = pers.linked_person[0].data.bio + if (pers?.linked_person?.[0]?.data?.slug === sp?.data?.slug) { + if (pers?.linked_person?.[0]?.data?.bio && sp?.data) { + (sp.data as unknown as ExtendedSpeakerData).bio = pers.linked_person[0].data.bio } } }) // Lookup speaker affiliation + // TODO: typing here is a little fudged. if (item.speaker_affiliations) { for (const aff of item.speaker_affiliations) { - if (!aff.data.linked_person[0].data) continue; - if (aff.data.linked_person[0].data.slug === sp.data.slug) { - if (sp.data.affiliations) { - sp.data.affiliations.push(aff) + if (!aff?.data?.linked_person?.[0]?.data) continue; + if (aff.data.linked_person[0].data.slug === sp?.data?.slug) { + const spData = sp.data as unknown as ExtendedLinkedParticipantEvent + if (spData.affiliations) { + spData.affiliations.push(aff as Affiliation) } else { - sp.data.affiliations = [aff] + spData.affiliations = [aff as Affiliation] } break; } @@ -674,19 +724,24 @@ async function makeEvents(createPage, graphql) { } }) } + + type ExtendedResearchItem = NonNullable & { + image?: PeopleImage + } + if (item.linked_research_item) { item.linked_research_item.forEach(ri => { - results.data.allAirtableResearchItems.nodes.map(_r => { + (results.data as Queries.PageEventQuery).allAirtableResearchItems.nodes.map(_r => { const r = _r.data - if (ri.data.id === r.id) { - ri.data.image = r.image + if (ri?.data?.id === r?.id && ri?.data && r) { + (ri.data as unknown as ExtendedResearchItem).image = r.image } }) }) } createPage({ path: `/events/${item.id}/`, - component: require.resolve(`./src/templates/event.js`), + component: path.resolve(`./src/templates/event.tsx`), context: { ...item } @@ -695,9 +750,9 @@ async function makeEvents(createPage, graphql) { } -async function makeDialogueIndex(createPage, graphql) { +async function makeDialogueIndex({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PageDialogueIndex { allAirtableEvents { pageInfo { itemCount @@ -723,19 +778,23 @@ async function makeDialogueIndex(createPage, graphql) { } `) - const numItems = results.data.allAirtableEvents.pageInfo.itemCount + const numItems = (results.data as Queries.PageDialogueIndexQuery).allAirtableEvents.pageInfo.itemCount const itemsPerPage = 10 const numPages = Math.ceil(numItems / itemsPerPage) - const headshots = results.data.allAirtablePeople.nodes.reduce((people, node) => { - people[node.data.slug] = node.data.headshot ? node.data.headshot : undefined + const peopleAccumulator: {[key: string]: PeopleImage | undefined} = {} + + const headshots = (results.data as Queries.PageDialogueIndexQuery).allAirtablePeople.nodes.reduce((people, node) => { + if (node?.data?.slug) { + people[node.data.slug] = node.data.headshot ? node.data.headshot : undefined + } return people - }, {}) + }, peopleAccumulator) Array.from({ length: numItems }).forEach((_, i) => { createPage({ path: i === 0 ? `/digital-dialogues/` : `/digital-dialogues/${i + 1}/`, - component: path.resolve("./src/templates/dialogue-index.js"), + component: path.resolve("./src/templates/dialogue-index.tsx"), context: { limit: itemsPerPage, skip: i * itemsPerPage, @@ -748,9 +807,9 @@ async function makeDialogueIndex(createPage, graphql) { } -async function makeDialogues(createPage, graphql) { +async function makeDialogues({createPage, graphql}: IMakePages) { const results = await graphql(` - query { + query PageDialogue { allAirtableEvents( filter: {data: {event_type: {eq: "Digital Dialogue"}}} sort: {data: {start_date: DESC}} @@ -888,46 +947,60 @@ async function makeDialogues(createPage, graphql) { } `) - for (const node of results.data.allAirtableEvents.nodes) { +type ExtendedSpeakerData = NonNullable["speakers"]>[number]> & { + headshot: PeopleImage + bio: NonNullable["linked_person"]>[number]>["data"]>["bio"] +} + +type Affiliation = + NonNullable["speaker_affiliations"]>[number] +type ExtendedSpeakers = NonNullable["speakers"]>[number] & { + affiliations?: Affiliation[] +} + + for (const node of (results.data as Queries.PageDialogueQuery).allAirtableEvents.nodes) { const item = node.data - if (item.speakers) { + if (item?.speakers) { // Attach headshot and speakers bio from people and identities table item.speakers.forEach(sp => { - results.data.allAirtablePeople.nodes.map(_pers => { + (results.data as Queries.PageDialogueQuery).allAirtablePeople.nodes.map(_pers => { const pers = _pers.data - if (pers.slug === sp.data.slug) { - if (pers.headshot) { - sp.data.headshot = pers.headshot + if (pers?.slug === sp?.data?.slug) { + if (pers?.headshot && sp?.data) { + (sp.data as unknown as ExtendedSpeakerData).headshot = pers?.headshot } } - }) - results.data.allAirtableIdentities.nodes.map(_pers => { + }); + (results.data as Queries.PageDialogueQuery).allAirtableIdentities.nodes.map(_pers => { const pers = _pers.data - if (pers.linked_person[0].data.slug === sp.data.slug) { - if (pers.linked_person[0].data.bio) { - sp.data.bio = pers.linked_person[0].data.bio + if (pers?.linked_person?.[0]?.data?.slug === sp?.data?.slug) { + if (pers?.linked_person?.[0]?.data?.bio && sp?.data) { + (sp.data as unknown as ExtendedSpeakerData).bio = pers.linked_person[0].data.bio } } }) // Lookup speaker affiliation + // TODO: typing here is a little fudged. if (item.speaker_affiliations) { for (const aff of item.speaker_affiliations) { - if (!aff.data.linked_person[0].data) continue; - if (aff.data.linked_person[0].data.slug === sp.data.slug) { - if (sp.data.affiliations) { - sp.data.affiliations.push(aff) + if (!aff?.data?.linked_person?.[0]?.data) continue; + if (aff.data.linked_person[0].data.slug === sp?.data?.slug) { + const spData = sp.data as unknown as ExtendedSpeakers + if (spData.affiliations) { + spData.affiliations.push(aff as Affiliation) } else { - sp.data.affiliations = [aff] + spData.affiliations = [aff as Affiliation] } break; } } } + }) } createPage({ - path: `/digital-dialogues/${item.id}/`, - component: require.resolve(`./src/templates/dialogue.js`), + path: `/digital-dialogues/${item?.id}/`, + component: path.resolve(`./src/templates/dialogue.tsx`), context: { ...item } diff --git a/gatsby-ssr.js b/gatsby-ssr.tsx similarity index 58% rename from gatsby-ssr.js rename to gatsby-ssr.tsx index d461b9ed2f..b0755560e7 100644 --- a/gatsby-ssr.js +++ b/gatsby-ssr.tsx @@ -1,17 +1,12 @@ -/** - * Implement Gatsby's SSR (Server Side Rendering) APIs in this file. - * - * See: https://www.gatsbyjs.org/docs/ssr-apis/ - */ import React from 'react'; +import type { GatsbySSR } from "gatsby"; const UMDBrandComponent = [ -] +]; -export const onRenderBody = ( - {setPostBodyComponents}, - pluginOptions +export const onRenderBody: GatsbySSR["onRenderBody"] = ( + {setPostBodyComponents} ) => { setPostBodyComponents(UMDBrandComponent); }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 77649e77a9..9d5ab05de3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@types/react-helmet": "^6.1.11", "gatsby": "^5.13.6", "gatsby-plugin-feed": "^5.13.1", "gatsby-plugin-google-analytics": "^5.13.1", @@ -36,7 +37,7 @@ }, "devDependencies": { "@playwright/test": "^1.45.1", - "@types/node": "^20.14.10", + "@types/node": "^22.0.0", "airtable": "^0.12.2", "chalk": "^5.3.0", "dayjs": "^1.11.11", @@ -44,6 +45,7 @@ "gatsby-source-airtable": "^2.4.2", "gh-pages": "^6.1.1", "prettier": "^3.3.2", + "typescript": "^5.5.4", "unzipper": "^0.12.1", "winston": "^3.13.0" } @@ -4315,11 +4317,11 @@ } }, "node_modules/@types/node": { - "version": "20.14.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz", - "integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.0.0.tgz", + "integrity": "sha512-VT7KSYudcPOzP5Q0wfbowyNLaVR8QWUdw+088uFWwfvpY6uCWaXpqV6ieLAu9WBcnTa7H4Z5RLK8I5t2FuOcqw==", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.11.1" } }, "node_modules/@types/node-fetch": { @@ -4363,6 +4365,14 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-helmet": { + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/@types/react-helmet/-/react-helmet-6.1.11.tgz", + "integrity": "sha512-0QcdGLddTERotCXo3VFlUSWO3ztraw8nZ6e3zJSgG7apwV5xt+pJUS8ewPBqT4NYB1optGLprNQzFleIY84u/g==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/responselike": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", @@ -18483,10 +18493,9 @@ } }, "node_modules/typescript": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", - "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", - "peer": true, + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18557,9 +18566,9 @@ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.11.1.tgz", + "integrity": "sha512-mIDEX2ek50x0OlRgxryxsenE5XaQD4on5U2inY7RApK3SOJpofyw7uW2AyfMKkhAxXIceo2DeWGVGwyvng1GNQ==" }, "node_modules/unherit": { "version": "1.1.3", diff --git a/package.json b/package.json index bf22942d9c..d422127789 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build-pages": "BASEPATH=/mith-static gatsby build --prefix-paths", "develop": "gatsby develop", "test": "playwright test", + "check": "tsc --noEmit", "clean": "gatsby clean", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md}\"" }, @@ -29,6 +30,7 @@ "@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@types/react-helmet": "^6.1.11", "gatsby": "^5.13.6", "gatsby-plugin-feed": "^5.13.1", "gatsby-plugin-google-analytics": "^5.13.1", @@ -51,7 +53,7 @@ }, "devDependencies": { "@playwright/test": "^1.45.1", - "@types/node": "^20.14.10", + "@types/node": "^22.0.0", "airtable": "^0.12.2", "chalk": "^5.3.0", "dayjs": "^1.11.11", @@ -59,6 +61,7 @@ "gatsby-source-airtable": "^2.4.2", "gh-pages": "^6.1.1", "prettier": "^3.3.2", + "typescript": "^5.5.4", "unzipper": "^0.12.1", "winston": "^3.13.0" } diff --git a/src/components/event-time.js b/src/components/event-time.js deleted file mode 100644 index a79eda3374..0000000000 --- a/src/components/event-time.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react' -import dayjs from 'dayjs' -import localizedFormat from 'dayjs/plugin/localizedFormat' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' - -dayjs.extend(localizedFormat) - -const EventTime = ({start, end, icon}) => { - - const iconCalendar = icon ? : '' - const iconClock = icon ? : '' - - start = dayjs(start) - - let startEl = '' - if (start.hour() === 0) { - startEl = - } else { - startEl = - } - - let endEl = '' - if (end) { - end = dayjs(end) - if (end.hour() === 0) { - endEl = - } else { - endEl = - } - } - - if (startEl && endEl) { - return {startEl}{endEl} - } else { - return {startEl} - } -} - -export default EventTime diff --git a/src/components/event-time.tsx b/src/components/event-time.tsx new file mode 100644 index 0000000000..998828f652 --- /dev/null +++ b/src/components/event-time.tsx @@ -0,0 +1,84 @@ +import React from "react" +import dayjs from "dayjs" +import localizedFormat from "dayjs/plugin/localizedFormat" +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" + +dayjs.extend(localizedFormat) + +interface EventTimeProps { + start: number + end?: number + icon?: string +} + +const EventTime = ({ start, end, icon }: EventTimeProps) => { + const iconCalendar = icon ? : "" + const iconClock = icon ? : "" + + const startDate = dayjs(start) + + let startEl: JSX.Element | undefined + if (startDate.hour() === 0) { + startEl = ( + + ) + } else { + startEl = ( + + ) + } + + let endEl: JSX.Element | undefined + if (end) { + const endDate = dayjs(end) + if (endDate.hour() === 0) { + endEl = ( + + ) + } else { + endEl = ( + + ) + } + } + + if (startEl && endEl) { + return ( + + {startEl} + {endEl} + + ) + } else { + return {startEl} + } +} + +export default EventTime diff --git a/src/components/footer.js b/src/components/footer.js deleted file mode 100644 index f964977677..0000000000 --- a/src/components/footer.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react' - -import './footer.css' - -class Footer extends React.Component { - render() { - return( - - ) - } -} - -export default Footer \ No newline at end of file diff --git a/src/components/footer.tsx b/src/components/footer.tsx new file mode 100644 index 0000000000..0252116ccb --- /dev/null +++ b/src/components/footer.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +import './footer.css' + +const Footer = () => ( + +) + +export default Footer \ No newline at end of file diff --git a/src/components/header.js b/src/components/header.js deleted file mode 100644 index 1758db6509..0000000000 --- a/src/components/header.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Link } from 'gatsby' -import PropTypes from 'prop-types' -import React from 'react' - -import './header.css' -import Logo from '../svg/MITH-logostack-blk.svg' - -class Header extends React.Component { - render() { - return ( -
-
- - - -
-
- ) - } -} - -Header.propTypes = { - siteTitle: PropTypes.string, -} - -Header.defaultProps = { - siteTitle: ``, -} - -export default Header diff --git a/src/components/header.tsx b/src/components/header.tsx new file mode 100644 index 0000000000..50439c9f88 --- /dev/null +++ b/src/components/header.tsx @@ -0,0 +1,18 @@ +import { Link } from "gatsby" +import React from "react" + +import "./header.css" +import Logo from "../svg/MITH-logostack-blk.svg" + +const Header = () => ( + // siteTitle currently unused. +
+
+ + + +
+
+) + +export default Header diff --git a/src/components/layout.js b/src/components/layout.js deleted file mode 100644 index efb5977299..0000000000 --- a/src/components/layout.js +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { useStaticQuery, graphql } from 'gatsby' - -import Header from './header' -import Nav from './nav' -import Footer from './footer' -import './layout.css' - -import { library } from '@fortawesome/fontawesome-svg-core' -import { fab } from '@fortawesome/free-brands-svg-icons' -const { fas } = require('@fortawesome/free-solid-svg-icons') - -// add just what we need from font-awesome - -library.add( - fab.faTwitter, - fas.faEnvelope, - fas.faMobileAlt, - fas.faBars, - fas.faGlobe, - fas.faCalendar, - fas.faCalendarAlt, - fas.faClock, - fas.faMapMarker, - fas.faMapMarkerAlt, - fas.faRss, - fas.faPlayCircle, - fas.faPlay -) - -const Layout = ({ children }) => { - const data = useStaticQuery(graphql` - query SiteTitleQuery { - site { - siteMetadata { - title - } - } - } - `) - - return ( - <> - Skip to main content -
-
-
- - ) -} - -Layout.propTypes = { - children: PropTypes.node.isRequired, -} - -export default Layout diff --git a/src/components/layout.tsx b/src/components/layout.tsx new file mode 100644 index 0000000000..6887e232ca --- /dev/null +++ b/src/components/layout.tsx @@ -0,0 +1,55 @@ +import React from "react" +import PropTypes from "prop-types" + +import Header from "./header" +import Nav from "./nav" +import Footer from "./footer" +import "./layout.css" + +import { library } from "@fortawesome/fontawesome-svg-core" +import { fab } from "@fortawesome/free-brands-svg-icons" +const { fas } = require("@fortawesome/free-solid-svg-icons") + +// add just what we need from font-awesome + +library.add( + fab.faTwitter, + fas.faEnvelope, + fas.faMobileAlt, + fas.faBars, + fas.faGlobe, + fas.faCalendar, + fas.faCalendarAlt, + fas.faClock, + fas.faMapMarker, + fas.faMapMarkerAlt, + fas.faRss, + fas.faPlayCircle, + fas.faPlay, +) + +interface LayoutProps { + children: JSX.Element[] +} + +const Layout = ({ children }: LayoutProps) => { + return ( + <> + + Skip to main content + +
+
+
+ + ) +} + +Layout.propTypes = { + children: PropTypes.node.isRequired, +} + +export default Layout diff --git a/src/components/nav.js b/src/components/nav.tsx similarity index 82% rename from src/components/nav.js rename to src/components/nav.tsx index 47129fe68a..76bfd91e13 100644 --- a/src/components/nav.js +++ b/src/components/nav.tsx @@ -4,6 +4,11 @@ import { useStaticQuery, graphql } from 'gatsby' import './nav.css' +interface NavLink { + link: string + name: string +} + const Nav = () => { const data = useStaticQuery(graphql` { @@ -21,7 +26,7 @@ const Nav = () => { return(