diff --git a/README.md b/README.md index f8e6206..1530a1a 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ [![Netlify Status](https://api.netlify.com/api/v1/badges/4df86a71-2a3f-40f9-9bd5-b6dacd4f420c/deploy-status)](https://app.netlify.com/sites/piech-dev/deploys) -My personal page. Over time it turned into a complex project itself: it supports loading all projects' information directly from GitHub, renders GitHub's markdown, the whole page is pre-rendered and served with zero JS. It also includes dynamic tags for each project page, including individual og:image tags with sizes. +My personal page. Over time it turned into a complex project itself: it supports loading all projects' information directly from GitHub, renders GitHub's markdown, the whole page is pre-rendered and served with zero JS. It also includes dynamic tags for each project page, including individual og:image tags with sizes. All routes have appropriate JSON-LD objects with relevant information. Lighthouse results -## Dynamic, GitHub-based project list and details +## Dynamic project list pulled from GitHub-based -- Projects pull metadata and READMEs directly from GitHub at build time +- The projects/ page is managed via minimal configuration, just based on repository names. Projects metadata and READMEs are fetched directly from GitHub at build time. - Markdown rendering transforms relative links to proper URLs and handles videos, so that you can see video previews of my projects without leaving my site. -- GitHub topics automatically become \ keywords. +- GitHub repository topics automatically become \ keywords. - GitHub information, as well as images are dynamically pulled to each project's \ into appropriate og: tags, allowing for custom preview card of each project in social media and on messengers. ## React pre-rendering with zero JavaScript served to the user diff --git a/package-lock.json b/package-lock.json index a081a17..566fc99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "rimraf": "^6.0.1", "rollup-plugin-visualizer": "^6.0.5", "sass-embedded": "^1.93.2", + "schema-dts": "^1.1.5", "stylelint": "^16.25.0", "stylelint-config-recommended": "^17.0.0", "stylelint-config-recommended-scss": "^16.0.2", @@ -18379,6 +18380,13 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/schema-dts": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/schema-dts/-/schema-dts-1.1.5.tgz", + "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", diff --git a/package.json b/package.json index 3bf25ae..9b498cd 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "rimraf": "^6.0.1", "rollup-plugin-visualizer": "^6.0.5", "sass-embedded": "^1.93.2", + "schema-dts": "^1.1.5", "stylelint": "^16.25.0", "stylelint-config-recommended": "^17.0.0", "stylelint-config-recommended-scss": "^16.0.2", diff --git a/src/features/Projects/Projects.tsx b/src/features/Projects/Projects.tsx index 68ae26e..efb9d7a 100644 --- a/src/features/Projects/Projects.tsx +++ b/src/features/Projects/Projects.tsx @@ -9,9 +9,7 @@ const Project = (): React.JSX.Element => {

Projects

-

- Non-commercial projects I built in my free time. -

+

Projects I built in my free time.

{PROJECTS.map( diff --git a/src/routes/contact.tsx b/src/routes/contact.tsx index ffa0c13..1408057 100644 --- a/src/routes/contact.tsx +++ b/src/routes/contact.tsx @@ -1,5 +1,8 @@ import React from 'react'; import type { MetaFunction } from 'react-router'; +import type { BreadcrumbList, Graph, WebPage, ImageObject } from 'schema-dts'; + +import { PERSON, PERSON_ID, WEBSITE, WEBSITE_ID } from './index'; import { DEFAULT_KEYWORDS, @@ -8,10 +11,69 @@ import { } from 'app/appConstants'; import Contact from 'features/Contact/Contact'; import { getImageSize } from 'utils/getImageSize'; +import { REPOSITORY_INFO } from 'utils/githubData'; + +const breadcrumbList: BreadcrumbList = { + '@type': 'BreadcrumbList', + '@id': 'https://piech.dev/contact/#breadcrumb', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Home', + item: 'https://piech.dev/', + }, + { + '@type': 'ListItem', + position: 2, + name: 'Contact', + item: 'https://piech.dev/contact/', + }, + ], +}; export const meta: MetaFunction = () => { const ogImage = 'piech.dev_contact.jpg'; const size = getImageSize(`${LOCAL_OG_IMAGES_DIRECTORY}${ogImage}`); + const imageObj: ImageObject = { + '@type': 'ImageObject', + '@id': 'https://piech.dev/contact/#image', + contentUrl: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + url: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + width: { + '@type': 'QuantitativeValue', + value: size.width, + unitText: 'px', + }, + height: { + '@type': 'QuantitativeValue', + value: size.height, + unitText: 'px', + }, + caption: 'Screenshot of contact links for Piotr Piech.', + }; + + const pageId = 'https://piech.dev/contact/#page'; + const contactPage: WebPage = { + '@type': ['WebPage', 'ContactPage'] as unknown as 'WebPage', + '@id': pageId, + url: 'https://piech.dev/contact/', + name: 'Contact | piech.dev', + description: 'Contact Piotr Piech (email, LinkedIn, GitHub, Telegram).', + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: { '@id': PERSON_ID }, + breadcrumb: { '@id': 'https://piech.dev/contact/#breadcrumb' }, + primaryImageOfPage: { '@id': 'https://piech.dev/contact/#image' }, + image: { '@id': 'https://piech.dev/contact/#image' }, + datePublished: REPOSITORY_INFO['piech.dev']?.createdDatetime, + dateModified: REPOSITORY_INFO['piech.dev']?.lastCommitDatetime, + }; + + const graph: Graph = { + '@context': 'https://schema.org', + '@graph': [WEBSITE, contactPage, breadcrumbList, PERSON, imageObj], + }; return [ { title: 'Contact | piech.dev' }, @@ -42,6 +104,7 @@ export const meta: MetaFunction = () => { rel: 'canonical', href: 'https://piech.dev/contact/', }, + { 'script:ld+json': JSON.stringify(graph) }, ]; }; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 463ae2a..9482419 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,5 +1,14 @@ import React from 'react'; import type { MetaFunction } from 'react-router'; +import type { + EducationalOrganization, + Person, + WebSite, + Graph, + WebPage, + ContactPoint, + ImageObject, +} from 'schema-dts'; import { DEFAULT_KEYWORDS, @@ -7,11 +16,159 @@ import { PRODUCTION_OG_IMAGES_DIRECTORY, } from 'app/appConstants'; import About from 'features/About/About'; +import { PROJECTS } from 'features/Projects/projectsList'; +import { TECHNOLOGIES } from 'features/Projects/technologies'; import { getImageSize } from 'utils/getImageSize'; +import { REPOSITORY_INFO } from 'utils/githubData'; + +const alumniOf: EducationalOrganization = { + '@type': 'EducationalOrganization', + name: 'Lublin University of Technology', +}; + +export const PERSON_ID = 'https://piech.dev/#person'; +export const WEBSITE_ID = 'https://piech.dev/#website'; +export const ABOUT_ID = 'https://piech.dev/#about'; +export const PIOTR_IMAGE_ID = 'https://piech.dev/#piotr-image'; + +const CONTACT_POINTS: ContactPoint[] = [ + { + '@type': 'ContactPoint', + contactType: 'general inquiries', + email: 'piotr@piech.dev', + availableLanguage: ['en', 'pl', 'ru'], + }, + { + '@type': 'ContactPoint', + contactType: 'social', + url: 'https://www.linkedin.com/in/ppiech', + availableLanguage: ['en', 'pl', 'ru'], + }, + { + '@type': 'ContactPoint', + contactType: 'code repositories', + url: 'https://github.com/Tenemo', + availableLanguage: ['en', 'pl', 'ru'], + }, + { + '@type': 'ContactPoint', + contactType: 'general inquiries', + url: 'https://t.me/tenemo', + availableLanguage: ['en', 'pl', 'ru'], + }, +]; + +export const PERSON: Person = { + '@type': 'Person', + '@id': PERSON_ID, + url: 'https://piech.dev/', + jobTitle: 'Engineering Manager', + name: 'Piotr Piech', + givenName: 'Piotr', + familyName: 'Piech', + image: 'https://piech.dev/media/projects/og_images/piotr.jpg', + email: 'piotr@piech.dev', + description: + 'Engineering Manager & Software Architect specializing in full-stack web development.', + alumniOf, + sameAs: [ + 'https://github.com/Tenemo', + 'https://www.linkedin.com/in/ppiech', + 'https://t.me/tenemo', + ], + address: { + '@type': 'PostalAddress', + addressLocality: 'Lublin', + addressCountry: 'PL', + }, + knowsLanguage: ['en', 'pl', 'ru'], + knowsAbout: Array.from( + new Set( + PROJECTS.flatMap((p) => p.technologies).map( + (t) => TECHNOLOGIES[t].fullName, + ), + ), + ), + nationality: { + '@type': 'Country', + name: 'Poland', + }, + workLocation: { + '@type': 'Place', + address: { + '@type': 'PostalAddress', + addressLocality: 'Lublin', + addressCountry: 'PL', + }, + }, + contactPoint: CONTACT_POINTS, + mainEntityOfPage: { '@id': ABOUT_ID }, +}; + +export const WEBSITE: WebSite = { + '@type': 'WebSite', + '@id': WEBSITE_ID, + name: 'piech.dev', + alternateName: 'Piotr Piech — piech.dev', + url: 'https://piech.dev/', + inLanguage: 'en', + description: "Piotr's personal page.", + author: { '@id': PERSON_ID }, + publisher: { '@id': PERSON_ID }, + copyrightHolder: { '@id': PERSON_ID }, + datePublished: REPOSITORY_INFO['piech.dev']?.createdDatetime, + dateModified: REPOSITORY_INFO['piech.dev']?.lastCommitDatetime, +}; export const meta: MetaFunction = () => { const ogImage = 'piotr.jpg'; const size = getImageSize(`${LOCAL_OG_IMAGES_DIRECTORY}${ogImage}`); + const portrait: ImageObject = { + '@type': 'ImageObject', + '@id': PIOTR_IMAGE_ID, + contentUrl: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + url: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + width: { + '@type': 'QuantitativeValue', + value: size.width, + unitText: 'px', + }, + height: { + '@type': 'QuantitativeValue', + value: size.height, + unitText: 'px', + }, + caption: 'Portrait photo of Piotr Piech.', + }; + + const aboutWebPage: WebPage = { + '@type': [ + 'WebPage', + 'AboutPage', + 'ProfilePage', + ] as unknown as 'WebPage', + '@id': ABOUT_ID, + url: 'https://piech.dev/', + name: 'About Piotr Piech', + description: "Piotr's personal page.", + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: { '@id': PERSON_ID }, + primaryImageOfPage: { '@id': PIOTR_IMAGE_ID }, + image: { '@id': PIOTR_IMAGE_ID }, + datePublished: REPOSITORY_INFO['piech.dev']?.createdDatetime, + dateModified: REPOSITORY_INFO['piech.dev']?.lastCommitDatetime, + }; + + const personNode: Person = { + ...PERSON, + image: { '@id': PIOTR_IMAGE_ID }, + }; + + const graph: Graph = { + '@context': 'https://schema.org', + '@graph': [WEBSITE, aboutWebPage, personNode, portrait], + }; return [ { title: 'piech.dev' }, @@ -37,6 +194,7 @@ export const meta: MetaFunction = () => { content: 'Portrait photo of Piotr Piech.', }, { tagName: 'link', rel: 'canonical', href: 'https://piech.dev/' }, + { 'script:ld+json': JSON.stringify(graph) }, ]; }; diff --git a/src/routes/project-item.tsx b/src/routes/project-item.tsx index 6a85c9d..b291fa2 100644 --- a/src/routes/project-item.tsx +++ b/src/routes/project-item.tsx @@ -1,5 +1,14 @@ import React from 'react'; import type { MetaFunction } from 'react-router'; +import type { + BreadcrumbList, + SoftwareSourceCode, + Graph, + WebPage, + ImageObject, +} from 'schema-dts'; + +import { PERSON, PERSON_ID, WEBSITE, WEBSITE_ID } from './index'; import { DEFAULT_KEYWORDS, @@ -33,6 +42,99 @@ export const meta: MetaFunction = (args) => { const ogImageAlt = projectEntry.ogImageAlt; const size = getImageSize(`${LOCAL_OG_IMAGES_DIRECTORY}${ogImage}`); + const imageObj: ImageObject = { + '@type': 'ImageObject', + '@id': `https://piech.dev/projects/${repo}#image`, + contentUrl: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + url: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + width: { + '@type': 'QuantitativeValue', + value: size.width, + unitText: 'px', + }, + height: { + '@type': 'QuantitativeValue', + value: size.height, + unitText: 'px', + }, + caption: ogImageAlt, + }; + + const breadcrumbList: BreadcrumbList = { + '@type': 'BreadcrumbList', + '@id': `https://piech.dev/projects/${repo}#breadcrumb`, + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Home', + item: 'https://piech.dev/', + }, + { + '@type': 'ListItem', + position: 2, + name: 'Projects', + item: 'https://piech.dev/projects/', + }, + { + '@type': 'ListItem', + position: 3, + name: repo, + item: `https://piech.dev/projects/${repo}`, + }, + ], + }; + + const codeId = `https://piech.dev/projects/${repo}#code`; + const codeNode: SoftwareSourceCode = { + '@type': 'SoftwareSourceCode', + '@id': codeId, + name: repo, + description: desc, + url: `https://piech.dev/projects/${repo}`, + codeRepository: `https://github.com/Tenemo/${repo}`, + programmingLanguage: 'TypeScript', + image: { '@id': `https://piech.dev/projects/${repo}#image` }, + keywords: info?.topics, + dateCreated: info?.createdDatetime, + dateModified: info?.lastCommitDatetime, + license: info?.license, + author: { '@id': PERSON_ID }, + creator: { '@id': PERSON_ID }, + maintainer: { '@id': PERSON_ID }, + mainEntityOfPage: { '@id': `https://piech.dev/projects/${repo}#page` }, + }; + + const itemPage: WebPage = { + '@type': ['WebPage', 'ItemPage'] as unknown as 'WebPage', + '@id': `https://piech.dev/projects/${repo}#page`, + url: `https://piech.dev/projects/${repo}`, + name: `${repo} | piech.dev`, + description: desc, + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + mainEntity: { '@id': codeId }, + breadcrumb: { '@id': `https://piech.dev/projects/${repo}#breadcrumb` }, + primaryImageOfPage: { + '@id': `https://piech.dev/projects/${repo}#image`, + }, + image: { '@id': `https://piech.dev/projects/${repo}#image` }, + datePublished: info?.createdDatetime, + dateModified: info?.lastCommitDatetime, + }; + + const graph: Graph = { + '@context': 'https://schema.org', + '@graph': [ + WEBSITE, + itemPage, + codeNode, + breadcrumbList, + PERSON, + imageObj, + ], + }; + return [ { title }, { name: 'description', content: desc }, @@ -53,6 +155,7 @@ export const meta: MetaFunction = (args) => { rel: 'canonical', href: `https://piech.dev/projects/${repo}`, }, + { 'script:ld+json': JSON.stringify(graph) }, ]; }; diff --git a/src/routes/projects.tsx b/src/routes/projects.tsx index 7328716..ccb8ea3 100644 --- a/src/routes/projects.tsx +++ b/src/routes/projects.tsx @@ -1,5 +1,16 @@ import React from 'react'; import type { MetaFunction } from 'react-router'; +import type { + ListItem, + SoftwareSourceCode, + ItemList, + BreadcrumbList, + Graph, + WebPage, + ImageObject, +} from 'schema-dts'; + +import { PERSON, PERSON_ID, WEBSITE, WEBSITE_ID } from './index'; import { DEFAULT_KEYWORDS, @@ -7,25 +18,109 @@ import { PRODUCTION_OG_IMAGES_DIRECTORY, } from 'app/appConstants'; import Projects from 'features/Projects/Projects'; +import { PROJECTS } from 'features/Projects/projectsList'; import { getImageSize } from 'utils/getImageSize'; +import { REPOSITORY_INFO } from 'utils/githubData'; + +const projectsItemList: ItemList = { + '@type': 'ItemList', + '@id': 'https://piech.dev/projects/#list', + itemListOrder: 'https://schema.org/ItemListOrderAscending', + numberOfItems: PROJECTS.length, + itemListElement: PROJECTS.map(({ repoName, project }, i) => { + const name = repoName ?? project; + const code: SoftwareSourceCode = { + '@type': 'SoftwareSourceCode', + '@id': `https://piech.dev/projects/${name}#code`, + name, + url: `https://piech.dev/projects/${name}`, + codeRepository: `https://github.com/Tenemo/${name}`, + programmingLanguage: 'TypeScript', + author: { '@id': PERSON_ID }, + }; + return { + '@type': 'ListItem', + position: i + 1, + item: code, + }; + }), +}; + +const breadcrumbList: BreadcrumbList = { + '@type': 'BreadcrumbList', + '@id': 'https://piech.dev/projects/#breadcrumb', + itemListElement: [ + { + '@type': 'ListItem', + position: 1, + name: 'Home', + item: 'https://piech.dev/', + }, + { + '@type': 'ListItem', + position: 2, + name: 'Projects', + item: 'https://piech.dev/projects/', + }, + ], +}; export const meta: MetaFunction = () => { const ogImage = 'piech.dev_projects.jpg'; const size = getImageSize(`${LOCAL_OG_IMAGES_DIRECTORY}${ogImage}`); + const imageObj: ImageObject = { + '@type': 'ImageObject', + '@id': 'https://piech.dev/projects/#main-image', + contentUrl: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + url: `${PRODUCTION_OG_IMAGES_DIRECTORY}${ogImage}`, + width: { + '@type': 'QuantitativeValue', + value: size.width, + unitText: 'px', + }, + height: { + '@type': 'QuantitativeValue', + value: size.height, + unitText: 'px', + }, + caption: 'Preview image for piech.dev projects.', + }; + + const collectionPage: WebPage = { + '@type': ['WebPage', 'CollectionPage'] as unknown as 'WebPage', + '@id': 'https://piech.dev/projects/#page', + url: 'https://piech.dev/projects/', + name: 'Projects | piech.dev', + description: 'Projects built by Piotr Piech', + inLanguage: 'en', + isPartOf: { '@id': WEBSITE_ID }, + author: { '@id': PERSON_ID }, + breadcrumb: { '@id': 'https://piech.dev/projects/#breadcrumb' }, + mainEntity: projectsItemList, + primaryImageOfPage: { '@id': 'https://piech.dev/projects/#main-image' }, + image: { '@id': 'https://piech.dev/projects/#main-image' }, + datePublished: REPOSITORY_INFO['piech.dev']?.createdDatetime, + dateModified: REPOSITORY_INFO['piech.dev']?.lastCommitDatetime, + }; + + const graph: Graph = { + '@context': 'https://schema.org', + '@graph': [WEBSITE, collectionPage, breadcrumbList, PERSON, imageObj], + }; return [ { title: 'Projects | piech.dev' }, { name: 'description', content: - 'Non-commercial projects I built in my free time: small tools, libraries, and experiments in React, TypeScript, cryptography, and more.', + 'Projects I built in my free time: small tools, libraries, and experiments in React, TypeScript, cryptography, and more.', }, { name: 'keywords', content: DEFAULT_KEYWORDS }, { property: 'og:title', content: 'Projects | piech.dev' }, { property: 'og:description', content: - 'Non-commercial projects I built in my free time: small tools, libraries, and experiments in React, TypeScript, cryptography, and more.', + 'Projects I built in my free time: small tools, libraries, and experiments in React, TypeScript, cryptography, and more.', }, { property: 'og:type', content: 'website' }, { @@ -44,6 +139,7 @@ export const meta: MetaFunction = () => { rel: 'canonical', href: 'https://piech.dev/projects/', }, + { 'script:ld+json': JSON.stringify(graph) }, ]; }; diff --git a/src/types/github-data.ts b/src/types/github-data.ts index 9ccb736..60a6360 100644 --- a/src/types/github-data.ts +++ b/src/types/github-data.ts @@ -3,6 +3,8 @@ export type RepositoryInfo = { description: string; topics?: string[]; createdDatetime: string; + lastCommitDatetime?: string; + license?: string; }; export type GithubData = { diff --git a/src/utils/fetchGithubData.ts b/src/utils/fetchGithubData.ts index 7cad95a..10796f4 100644 --- a/src/utils/fetchGithubData.ts +++ b/src/utils/fetchGithubData.ts @@ -30,6 +30,8 @@ type RepoInfo = { description: string; topics?: string[]; createdDatetime: string; + lastCommitDatetime?: string; + license?: string; }; export type GithubData = { METADATA: { fetchedDatetime: string }; @@ -106,6 +108,75 @@ async function getReadme(owner: string, repo: string): Promise { return '# README not found\n'; } +async function getPackageJsonLicenseFromMaster( + owner: string, + repo: string, +): Promise { + // Spec: license from package.json on master HEAD commit, if present. + // We intentionally only check the 'master' branch as requested, + // and do not fallback to 'main' here. + try { + const raw = await fetchText( + `https://raw.githubusercontent.com/${owner}/${repo}/master/package.json`, + ); + try { + const pkg = JSON.parse(raw) as unknown; + if ( + pkg && + typeof pkg === 'object' && + 'license' in (pkg as Record) + ) { + const lic = (pkg as Record).license; + if (typeof lic === 'string') return lic; + if ( + lic && + typeof lic === 'object' && + 'type' in (lic as Record) && + typeof (lic as Record).type === 'string' + ) + return (lic as Record).type as string; + } + // Support legacy `licenses` array + if (pkg && typeof pkg === 'object') { + const anyPkg = pkg as Record; + const licensesRaw = anyPkg.licenses; + if (Array.isArray(licensesRaw) && licensesRaw.length > 0) { + const first = licensesRaw[0] as { type?: unknown }; + if (typeof first.type === 'string') return first.type; + } + } + } catch { + // ignore JSON parsing errors and fall through + } + } catch { + // master/package.json not found or inaccessible + } + return undefined; +} + +async function getLastCommitDatetime( + owner: string, + repo: string, + token: string | undefined, + branch: string, +): Promise { + // Use the commits API to get the head commit for the branch. + // If this fails (e.g. branch doesn't exist), callers will try fallbacks. + try { + const res = await fetchJson<{ + sha: string; + commit?: { author?: { date?: string } }; + }>( + `https://api.github.com/repos/${owner}/${repo}/commits/${branch}`, + token, + ); + const date = res.commit?.author?.date; + return typeof date === 'string' ? date : undefined; + } catch { + return undefined; + } +} + export async function fetchGithubData(options?: { refetch?: boolean; }): Promise { @@ -165,6 +236,7 @@ export async function fetchGithubData(options?: { name?: string; description?: string; created_at?: string; + default_branch?: string; }>(`https://api.github.com/repos/${OWNER}/${repo}`, token), fetchJson<{ names?: string[] }>( `https://api.github.com/repos/${OWNER}/${repo}/topics`, @@ -175,21 +247,67 @@ export async function fetchGithubData(options?: { if (infoRes.status === 'fulfilled') { const repoData = infoRes.value; + // Attempt to populate lastCommitDatetime using the default branch when available. + let lastCommitDatetime: string | undefined; + if (repoData.default_branch) { + lastCommitDatetime = await getLastCommitDatetime( + OWNER, + repo, + token, + repoData.default_branch, + ); + } else { + // Fallback to trying known branch names. + for (const b of BRANCHES) { + lastCommitDatetime = await getLastCommitDatetime( + OWNER, + repo, + token, + b, + ); + if (lastCommitDatetime) break; + } + } + + // License from master/package.json only (per spec) + const license = await getPackageJsonLicenseFromMaster(OWNER, repo); + infoObject[repo] = { name: repoData.name ?? repo, description: repoData.description ?? 'No description available', createdDatetime: repoData.created_at ?? EPOCH_ISO, + lastCommitDatetime, + license, topics: topicsRes.status === 'fulfilled' ? (topicsRes.value.names ?? []) : undefined, }; } else { + // Even if repo info failed, still attempt best-effort for lastCommitDatetime and license + let lastCommitDatetime: string | undefined; + for (const b of BRANCHES) { + // Try known branches; ignore failures + const maybe = await getLastCommitDatetime( + OWNER, + repo, + token, + b, + ); + if (maybe) { + lastCommitDatetime = maybe; + break; + } + } + const license = await getPackageJsonLicenseFromMaster(OWNER, repo); + infoObject[repo] = { name: repo, description: 'No description available', // Fallback to epoch to make failures obvious createdDatetime: EPOCH_ISO, + lastCommitDatetime, + license, topics: topicsRes.status === 'fulfilled' ? (topicsRes.value.names ?? [])