Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"fssync",
"gitlens",
"hrefs",
"HTMX",
"jpoissonnier",
"julr",
"jvitor",
Expand Down
48 changes: 21 additions & 27 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Run tests",
"type": "shell",
"command": "npm test --silent",
"problemMatcher": [
"$tsc",
"$eslint-stylish"
]
},
{
"label": "Run tests",
"type": "shell",
"command": "npm test --silent",
"problemMatcher": [
"$tsc",
"$eslint-stylish"
]
},
{
"label": "Typecheck",
"type": "shell",
"command": "npm run tsc --silent"
}
]
}
"version": "2.0.0",
"tasks": [
{
"label": "Run tests",
"type": "shell",
"command": "npm test --silent",
"problemMatcher": ["$tsc", "$eslint-stylish"]
},
{
"label": "Run tests",
"type": "shell",
"command": "npm test --silent",
"problemMatcher": ["$tsc", "$eslint-stylish"]
},
{
"label": "Typecheck",
"type": "shell",
"command": "npm run tsc --silent"
}
]
}
82 changes: 74 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,89 @@

[![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 <meta> tags for each project page, including individual og:image tags with sizes. All routes have appropriate JSON-LD objects with relevant information.
My personal page. Over time it turned into a complex project itself:

<img src="public/media/projects/piech.dev.webp" alt="Lighthouse results" title="Lighthouse results" width="500" />
- Despite being written in React, the whole page is pre-rendered and served with zero JavaScript!
- It supports loading all projects' information directly from GitHub.
- GitHub's markdown from the projects' readmes is rendered within the page, including media.
- Includes dynamic \<meta> tags for each project page, including individual og:image tags with sizes.
- All routes have appropriate JSON-LD objects with all relevant information, even if Google or other search engines currently don't support some of them.
- Thanks to the above and a bunch of other optimizations, all routes, including the heaviest /projects route, score a perfect 100/100/100/100 on [Google Lighthouse](https://pagespeed.web.dev/) mobile, which throttles to a slow 4G network connection.

## Dynamic project list pulled from GitHub-based
_Not a single word of this readme was written with AI. Letting you know just in case you don't enjoy reading AI-generated documentation._

- 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.
<img src="public/media/projects/piech.dev.webp" alt="Projects page of piech.dev" title="Projects page of piech.dev" width="500" />

## GitHub-based personal projects browser

- The projects/ page is managed via minimal configuration, just based on repository names which are then fetched during the pre-build step. 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 repository topics automatically become \<meta> keywords.
- GitHub information, as well as images are dynamically pulled to each project's \<head> 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
## Pre-rendering React with zero JavaScript served to the user

- The whole site, including all project routes, is pre-rendered with React Router in framework mode into HTML.
- The site ships without ANY client-side JavaScript despite being built in React.
- For performance reasons, the whole CSS is inlined into HTML, as there is so little of it and it significantly sped up page load times and it eliminates the awful Flash of Unstyled Content no matter the connection speed.
- The site ships without ANY client-side JavaScript despite being built in React. The build tools have been fighting me on this, so I'm traversing the build directory and deleting all JavaScript even if it wouldn't be linked to, "just to be safe" :)
- For performance reasons, all CSS is inlined into HTML, as there is so little of it (in the grand scheme of things and network payloads). It significantly sped up page load times and it eliminates the awful Flash of Unstyled Content no matter the user's connection speed.
- Images are utilizing Netlify Image CDN to speed up their load times and to avoid serving oversized images.
- Thanks to the above and a ton of other optimizations, all routes, including the heaviest /projects route, score a perfect 100/100/100/100 on [Google Lighthouse](https://pagespeed.web.dev/) mobile, which throttles to a slow 4G network connection.

<img src="public/media/readme/lighthouse.webp" alt="Lighthouse results" title="Lighthouse results" width="500" />

## Post-build scripts

As of 2025, if you want to use React for generating static HTML sites and you don't rely on all-batteries-included frameworks/services, the ecosystem support is quite poor. React-router v7 with its framework mode and pre-rendering support changed the situation for the better. Still, it is assumed you will serve the .html static assets and then hydrate the page with JavaScript. If you want to have a true zero-JS output, you have do some of the wiring yourself. Moreover, most popular NPM packages for inlining CSS inline it into each html element's tags, which doesn't support media queries.

This resulted in the project using the following post-build steps:

1. Transforming image paths to use Netlify Image CDN, scans all .html files and looks for images matching the criteria (it doesn't touch the og:image assets, for instance).
2. Inlining all CSS. Finds all .html files, resolves the files and dumps all their content into a single \<style> tag within \<head>.
3. Removing `dist/server`, couldn't make Vite + React Router not output it. We are only serving the client "app", which consists of static .html files after build.
4. Removing `dist/client/assets`, we don't need CSS/JS assets after CSS has been inlined.
5. Traversing `dist/client` to remove all .js files from it. Again, could not make Vite + React Router not output some .js files, even if they were unused and wouldn't be served to the users anyway. Why waste network traffic and time on deploying those?
6. Generating a sitemap with an in-house script using the `sitemap` package. `vite-plugin-sitemap` doesn't work with URLs with dots, apparently, which some of my projects have in their paths, e.g. it truncates /aliases.sh to /aliases.

Technically, I could also flatten the output directory from `dist/client` to `dist`, but it's solved with one line of Netlify config and is not bothering me enough to reconfigure all the build/post-build scripts and Netlify deployment configuration.

### Why use React at all?

One might ask, if you don't want to bundle any JavaScript, why bother using React at all, just write HTML & CSS or use tools meant for the purpose.

First of all, I want a framework to provide me with a reusable component system and state management logic during the pre-render step and I find React easy to use for this purpose.

There are most likely better libraries/frameworks to do what I'm doing, but the second simple reason why I went with React is that I'm just comfortable with it and I wanted to save time. Did I end up saving time writing all this post-rendering logic and fighting the build tools not to require JS? Probably not, but I embrace the sunk-cost fallacy for my personal site and plan to continue with React :)

Another reason is that I initially intended for the whole page to be hydrated - and I might still start doing that if I need more interactivity - but I realized all that I'm doing can be served without any JavaScript. It's unlikely I'll do it, but it's good to always have that option: with minimal changes to the project, this static-content webpage can be transformed into a client-side React app.

## Development

Notes for myself (Piotr) to follow when making changes to the project. At the time of writing it seems super redundant and obvious, but there's a nonzero chance that future Piotr will be very grateful for the below sections.

### Adding a new route

Steps to follow when adding a new route to the app:

1. Write the new route as a feature or sub-feature in `src/features/`.
2. Create a new route file in `src/routes/` that uses the new component(s) and add at least the og:image tag image asset to `public/media/projects/og_images/`.
3. Add the route to `src/routes.ts`.
4. Add the route path to the prerender() array in react-router.config.ts
5. Add `<link as="document" href="/.../" rel="prefetch" />` for the new route in `src/root.tsx`.
6. Update Header with the new route navigation, if needed.
7. `netlify.toml`:
- If the route replaces any previous route, add a legacy redirect.
- If there are new media assets for the new route, add appropriate headers.
8. Add files to sitemap extensions in `src/utils/generateSitemap.ts` if needed.

### Adding a new project

Steps to follow when adding a new project to `/projects/`:

1. Make sure the GitHub repo is publicly accessible, has topics ("keywords"/"tags"), repo description (the short, character-limited one), and a descriptive README.md, preferably with some media assets to make it more interesting.
2. Add a video (preferable) or an image to `public/media/projects/` that will be project's preview on the project card.
3. Add an image to be used for og:image and in JSON-LD to `public/media/projects/og_images/`.
4. If there are any new technologies used, add them to `src/features/Projects/technologies.ts`. Add their logos to `public/media/logos/`.
5. Add the new project to `src/features/Projects/projectsList.ts`.

The project should appear in the `/projects/` route at that point.

At the moment only GitHub-based projects are supported.
38 changes: 38 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"dev": "react-router dev",
"prebuild": "node --experimental-strip-types src/utils/fetchGithubData.ts && npm run lint && npm run tsc && npm test",
"build": "react-router build",
"postbuild": "node --experimental-strip-types src/utils/transformImagePaths.ts && node --experimental-strip-types src/utils/inlineCss.ts && rimraf dist/server && rimraf dist/client/assets && rimraf --glob \"dist/client/**/*.js\"",
"postbuild": "node --experimental-strip-types src/utils/transformImagePaths.ts && node --experimental-strip-types src/utils/inlineCss.ts && rimraf dist/server && rimraf dist/client/assets && rimraf --glob \"dist/client/**/*.js\" && node --experimental-strip-types src/utils/generateSitemap.ts",
"prebuild:skip": "node --experimental-strip-types src/utils/fetchGithubData.ts",
"build:skip": "react-router build",
"postbuild:skip": "npm run postbuild",
Expand Down Expand Up @@ -106,6 +106,7 @@
"rollup-plugin-visualizer": "^6.0.5",
"sass-embedded": "^1.93.2",
"schema-dts": "^1.1.5",
"sitemap": "^8.0.0",
"stylelint": "^16.25.0",
"stylelint-config-recommended": "^17.0.0",
"stylelint-config-recommended-scss": "^16.0.2",
Expand Down
4 changes: 2 additions & 2 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
User-agent: *
Disallow:
User-agent: *
Allow: /
7 changes: 0 additions & 7 deletions src/catchall.tsx

This file was deleted.

46 changes: 0 additions & 46 deletions src/features/Projects/ProjectCard/ProjectCard.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,52 +6,6 @@ import ProjectCard from './ProjectCard';
import styles from './projectCard.module.scss';

import { renderWithProviders } from 'utils/testUtils';
// Provide a default export to align with JSON default import usage in code
vi.mock('../../../../temp/githubData', () => ({
default: {
REPOSITORY_INFO: {
'test-project': {
name: 'test-project',
description: 'Test description',
},
'img-test': {
name: 'img-test',
description: 'Image test',
createdDatetime: '2023-09-12T08:00:00.000Z',
},
'video-test': {
name: 'video-test',
description: 'Video test',
createdDatetime: '2022-01-20T15:30:00.000Z',
},
'right-test': {
name: 'right-test',
description: 'Right image test',
createdDatetime: '2024-06-01T00:00:00.000Z',
},
'custom-repo': {
name: 'custom-repo',
description: 'Custom repo test',
createdDatetime: '2020-12-31T23:59:59.000Z',
},
'cached-repo': {
name: 'cached-repo',
description: 'Cached repo',
createdDatetime: '2019-03-10T10:00:00.000Z',
},
'link-test': {
name: 'link-test',
description: 'Link test',
createdDatetime: '2018-07-07T07:07:07.000Z',
},
'epoch-test': {
name: 'epoch-test',
description: 'Epoch fallback',
createdDatetime: '1970-01-01T00:00:00.000Z',
},
},
},
}));

describe('ProjectCard', () => {
beforeEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/features/Projects/ProjectCard/ProjectCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { TECHNOLOGIES } from '../technologies';
import styles from './projectCard.module.scss';
import ProjectTechnologies from './ProjectTechnologies/ProjectTechnologies';

import { REPOSITORY_INFO } from 'utils/githubData';
import { repositoriesData } from 'utils/githubData';

type ProjectCardProps = {
projectPreview: string;
Expand All @@ -26,7 +26,7 @@ const ProjectCard = ({
repoName,
}: ProjectCardProps): React.JSX.Element => {
const githubRepository = repoName ?? project;
const repositoryInfo = REPOSITORY_INFO[githubRepository];
const repositoryInfo = repositoriesData[githubRepository];
const createdIso = repositoryInfo?.createdDatetime;
const createdLabel = createdIso
? format(new Date(createdIso), 'MMMM yyyy')
Expand Down
4 changes: 4 additions & 0 deletions src/features/Projects/ProjectCard/projectCard.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
transition: box-shadow 0.3s ease;
position: relative;

&:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}

@media (max-width: 768px) {
flex-direction: column !important;
min-height: auto;
Expand Down
10 changes: 0 additions & 10 deletions src/features/Projects/ProjectItem/ProjectItem.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import ProjectItem from './ProjectItem';

import { renderWithProviders } from 'utils/testUtils';
// The app imports JSON via default import, so the mock must provide a default export
vi.mock('../../../../temp/githubData', () => ({
default: {
README_CONTENT: {
'test-repo': '# Test Readme Content',
'cached-project': '# Cached Readme Content',
'new-content-project': '# New Readme Content',
},
},
}));

const mockUseParams = vi.fn().mockReturnValue({ repo: 'test-repo' });
vi.mock('react-router', async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/features/Projects/ProjectItem/ProjectItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Link, useParams } from 'react-router';
import styles from './projectItem.module.scss';
import ProjectMarkdown from './ProjectMarkdown/ProjectMarkdown';

import { README_CONTENT } from 'utils/githubData';
import { repositoriesData } from 'utils/githubData';

const ProjectItemDetails: React.FC = (): React.JSX.Element => {
const { repo } = useParams<{ repo: string }>();
Expand All @@ -19,7 +19,7 @@ const ProjectItemDetails: React.FC = (): React.JSX.Element => {
);
}

const markdown = README_CONTENT[repo] ?? '';
const markdown = repositoriesData[repo]?.readme_content ?? '';

return (
<main className={styles.container}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import remarkGfm from 'remark-gfm';

import styles from './projectMarkdown.module.scss';

import { REPOSITORY_INFO } from 'utils/githubData';
import { repositoriesData } from 'utils/githubData';

const OWNER = 'tenemo';
const BRANCH = 'master';
Expand All @@ -26,7 +26,7 @@ const ProjectMarkdown = ({
markdown,
repo,
}: ProjectMarkdownProps): React.JSX.Element => {
const createdIso = REPOSITORY_INFO[repo]?.createdDatetime;
const createdIso = repositoriesData[repo]?.createdDatetime;
const createdLabel = createdIso
? format(new Date(createdIso), 'MMMM yyyy')
: undefined;
Expand Down
Loading