diff --git a/README.md b/README.md
index f8e6206..1530a1a 100644
--- a/README.md
+++ b/README.md
@@ -4,15 +4,15 @@
[](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.
-## 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 ?? [])