Compare commits

..

1 commit

Author SHA1 Message Date
d6e8b857b8 ci: improve pipeline 2025-01-27 13:10:15 +00:00
79 changed files with 4941 additions and 12697 deletions

View file

@ -1,2 +0,0 @@
# NEXT_PUBLIC_CONTENT_API_BASE_URL=http://localhost:8055
NEXT_PUBLIC_CONTENT_API_BASE_URL=https://cms.aaronjy.me

View file

@ -1,3 +1,7 @@
{
"extends": ["next/core-web-vitals"]
"extends": "next/core-web-vitals",
"rules": {
"@stylistic/jsx/jsx-pascal-case": "off",
"@next/next/no-html-link-for-pages": "off"
}
}

74
.github/workflows/docker-image.yml vendored Normal file
View file

@ -0,0 +1,74 @@
name: Create and publish a Docker image
# Configures this workflow to run every time a change is pushed to the branch called `release`.
on:
push:
branches: ['main']
# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds.
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
jobs:
build-and-push-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install node dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm run test
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate artifact attestation
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true

8
.gitignore vendored
View file

@ -34,11 +34,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
deploy-vars.sh
node_modules
.env
.env.production
tmp/*
!.gitkeep

View file

@ -1 +1,3 @@
npx lint-staged
echo Formatting...
npm run format
echo Successfully formatted.

2
.nvmrc
View file

@ -1 +1 @@
v22.15.0
v18.20.5

19
.vscode/settings.json vendored
View file

@ -23,25 +23,16 @@
"Sitecore",
"sportank",
"Umbraco",
"Yarborough",
"Yarbz"
],
"files.autoSave": "off",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"standard.autoFixOnSave": true,
"prettier.enable": false,
"editor.defaultFormatter": "standard.vscode-standard",
"[javascript]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[javascriptreact]": {
"editor.defaultFormatter": "vscode.typescript-language-features"
},
"[css]": {
"editor.defaultFormatter": "vscode.css-language-features"
},
"[json]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
}
}
}

View file

@ -1,4 +1,4 @@
FROM node:22-alpine AS base
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
@ -42,9 +42,6 @@ ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
ENV NEXT_TELEMETRY_DISABLED=1s
ARG NEXT_PUBLIC_CONTENT_API_BASE_URL
ENV NEXT_PUBLIC_CONTENT_API_BASE_URL=$NEXT_PUBLIC_CONTENT_API_BASE_URL
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

View file

@ -0,0 +1,46 @@
/* eslint-env jest */
import { render } from '@testing-library/react'
import Article from '../../src/components/Article/Article'
import '@testing-library/jest-dom'
import { formatDate } from '@/lib/helpers'
describe('Article', () => {
it('renders title', () => {
const props = generateArticleProps()
const { getByText } = render(<Article {...props} />)
const titleElement = getByText(props.attributes.title)
expect(titleElement).toBeInTheDocument()
})
it('renders description', () => {
const props = generateArticleProps()
const { getByText } = render(<Article {...props} />)
const descriptionElement = getByText(props.attributes.desc)
expect(descriptionElement).toBeInTheDocument()
})
it('renders pubdate if available', () => {
const props = generateArticleProps()
const { getByText } = render(<Article {...props} />)
const pubdateElement = getByText(formatDate(props.attributes.pubdate))
expect(pubdateElement).toBeInTheDocument()
})
it('renders content', () => {
const props = generateArticleProps()
const { container } = render(<Article {...props} />)
const contentElement = container.querySelector('[data-test=content]')
expect(contentElement.innerHTML).toBe(props.html)
})
})
function generateArticleProps () {
return {
attributes: {
title: 'My title',
desc: 'My description',
pubdate: new Date().toUTCString()
},
html: '<p>This is my content!</p>'
}
}

View file

@ -0,0 +1,28 @@
/* eslint-env jest */
import { render, screen } from '@testing-library/react'
import ExternalLink from '../../src/components/ExternalLink/ExternalLink'
import '@testing-library/jest-dom'
describe('ExternalLink', () => {
const props = {
href: 'https://example.com',
children: 'Test Link'
}
it('renders without crashing', () => {
render(<ExternalLink {...props} />)
})
it('renders correct href and rel attributes', () => {
render(<ExternalLink {...props} />)
const link = screen.getByText(props.children)
expect(link).toHaveAttribute('href', props.href)
expect(link).toHaveAttribute('rel', 'nofollow noopener')
expect(link).toHaveAttribute('target', '_blank')
})
it('renders children correctly', () => {
render(<ExternalLink {...props} />)
expect(screen.getByText(props.children)).toBeInTheDocument()
})
})

View file

@ -0,0 +1,10 @@
/* eslint-env jest */
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import Footer from '@/components/Footer/Footer'
describe('Footer', () => {
it('renders without crashing', () => {
render(<Footer />)
})
})

View file

@ -0,0 +1,16 @@
/* eslint-env jest */
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import Grid from '@/components/Grid/Grid'
describe('Grid', () => {
it('renders without crashing', () => {
const { container } = render(<Grid />)
expect(container.firstChild).toHaveClass('grid')
})
it('renders its children', () => {
const { getByText } = render(<Grid><div>Child</div></Grid>)
expect(getByText('Child')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,19 @@
/* eslint-env jest */
import { render, screen } from '@testing-library/react'
import Header from '../../src/components/Header/Header'
import '@testing-library/jest-dom'
describe('Header', () => {
it('renders without crashing', () => {
render(
<Header />)
})
it('renders correct navigation links', () => {
render(<Header />)
const links = ['Home', 'Writing', 'CV']
links.forEach(link => {
expect(screen.getByText(link)).toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,63 @@
/* eslint-env jest */
import { render } from '@testing-library/react'
import Resume from '../../src/components/Resume/Resume'
import '@testing-library/jest-dom'
describe('Resume', () => {
const props = {
competencies: ['Competency 1', 'Competency 2'],
education: 'My education history',
certifications: ['Certification 1', 'Certification 2'],
languages: [{ name: 'English', proficiency: 'Fluent' }],
experience: [
{
employer: 'Employer 1',
position: 'Position 1',
start: 'Start date 1',
end: 'End date 1',
desc: 'Description 1'
}
]
}
it('renders without crashing', () => {
render(<Resume {...props} />)
})
it('renders competencies', () => {
const { getByText } = render(<Resume {...props} />)
props.competencies.forEach(competency => {
expect(getByText(competency)).toBeInTheDocument()
})
})
it('renders education', () => {
const { getByText } = render(<Resume {...props} />)
expect(getByText(props.education)).toBeInTheDocument()
})
it('renders certifications', () => {
const { getByText } = render(<Resume {...props} />)
props.certifications.forEach(certification => {
expect(getByText(certification)).toBeInTheDocument()
})
})
it('renders languages', () => {
const { getByText } = render(<Resume {...props} />)
props.languages.forEach(language => {
expect(getByText(`${language.name} - ${language.proficiency}`)).toBeInTheDocument()
})
})
it('renders experience', () => {
const { getByText } = render(<Resume {...props} />)
props.experience.forEach(exp => {
expect(getByText(exp.employer)).toBeInTheDocument()
expect(getByText(exp.position)).toBeInTheDocument()
expect(getByText(exp.start)).toBeInTheDocument()
expect(getByText(exp.end)).toBeInTheDocument()
expect(getByText(exp.desc)).toBeInTheDocument()
})
})
})

View file

@ -0,0 +1,29 @@
/* eslint-env jest */
import { render, screen } from '@testing-library/react'
import DefaultLayout from '../../src/layouts/DefaultLayout/DefaultLayout'
import '@testing-library/jest-dom'
describe('DefaultLayout', () => {
const props = {
children: <div>Test Content</div>
}
it('renders without crashing', () => {
render(<DefaultLayout {...props} />)
})
it('renders Header', () => {
render(<DefaultLayout {...props} />)
expect(screen.getByTestId('header')).toBeInTheDocument()
})
it('renders Footer', () => {
render(<DefaultLayout {...props} />)
expect(screen.getByTestId('footer')).toBeInTheDocument()
})
it('renders children correctly', () => {
render(<DefaultLayout {...props} />)
expect(screen.getByText('Test Content')).toBeInTheDocument()
})
})

View file

@ -0,0 +1,69 @@
/* eslint-env jest */
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import Writing, { Title, Description } from '../../src/pages/writing'
jest.mock('next/head', () => {
return {
__esModule: true,
default: ({ children }) => {
return <>{children}</>
}
}
})
describe('Writing', () => {
it('renders without crashing', () => {
render(
getStubWritingComponent())
})
it('renders meta title', async () => {
const { container } = render(getStubWritingComponent())
expect(container.querySelector('title')).toHaveTextContent(Title)
})
it('renders opengraph title', () => {
const { container } = render(getStubWritingComponent())
expect(container.querySelector('meta[property="og:title"]'))
.toHaveAttribute('content', Title)
})
it('renders meta description', () => {
const { container } = render(getStubWritingComponent())
expect(container.querySelector('meta[name="description"]'))
.toHaveAttribute('content', Description)
})
it('renders opengraph description', () => {
const { container } = render(getStubWritingComponent())
expect(container.querySelector('meta[property="og:description"]'))
.toHaveAttribute('content', Description)
})
})
function getStubWritingComponent () {
const entries = [
{
attributes: {
title: 'My title one',
pubdate: 'Mon, 18 Mar 2024 16:47:32 GMT',
desc: 'This is my description.'
},
html: '<p>This is some content</p>',
slug: 'my-title-one'
},
{
attributes: {
title: 'My title Two',
pubdate: 'Mon, 19 Mar 2024 16:47:32 GMT',
desc: 'This is my description.'
},
html: '<p>This is some content</p>',
slug: 'my-title-two'
}
]
return <Writing entries={entries} urlPrefix='/' />
}

View file

@ -1,2 +1,2 @@
// eslint-disable-next-line import/no-anonymous-default-export
export default { extends: ["@commitlint/config-conventional"] };
export default { extends: ['@commitlint/config-conventional'] }

14
content/home.md Normal file
View file

@ -0,0 +1,14 @@
---
title: Awesome kitties
date: 2019-03-17T19:31:20.591Z
cats:
- description: 'Maru is a Scottish Fold from Japan, and he loves boxes.'
name: Maru (まる)
- description: Lil Bub is an American celebrity cat known for her unique appearance.
name: Lil Bub
- description: 'Grumpy cat is an American celebrity cat known for her grumpy appearance.'
name: Grumpy cat (Tardar Sauce)
---
Welcome to my awesome page about cats of the internet.
This page is built with NextJS, and content is managed in Decap CMS

177
content/pages/cv.yml Normal file
View file

@ -0,0 +1,177 @@
competencies:
- Software Development
- Software Architecture
- UI/UX Design
- Full-Stack Development
- Team Leadership
- Recruitment and Onboarding
- Web Application Development
- Cloud Hosting (AWS, GCP)
- Database Management
- User Interface Design
- Project Management
education:
- 10 GCSEs @ Duchess Community High School
certifications:
- Sitecore Professional Developer Certification (Sitecore 8.2) - Aug. 2017
languages:
- name: English
proficiency: Native
- name: German
proficiency: Professional Working Proficiency
- name: Arabic (Levantine)
proficiency: Elementary
experience:
- position: Lead Consultant
employer: Hippo
start: Feb. 2024
end: Present
desc: >-
* Leading a technical delivery on a UK public sector project for the Department for Education (DfE)
* Designing and documenting architectural changes in line with GDS architectural and technology standards
* Planning and managing releases to production
* Working with key stakeholders at both the project and policy level to explain, advise on and plan key decisions
**Skills:** ASP.NET Core · Microsoft Azure · Azure Data Factory · Git · C# · Full-Stack Development · Umbraco · Web Development · Microsoft SQL Server · Cloud Development · Microservices · Technical Requirements · Agile Methodologies
- position: Software Development Tutor
employer: Yarbz Digital Ltd
start: Sep. 2023
end: Mar. 2024
desc: I taught students of all levels modern software development, including
coding fundamentals, computer science theory and modern software
technologies.
- position: Freelance Software Consultant
employer: Yarbz Digital Ltd
start: Aug. 2021
end: Mar. 2024
desc: >-
* Designed and developed the front-end and back-end of the innovative
recruitment platform "Radr" using Angular 13, Node.js, TypeScript,
MongoDB, and hosted on Google Cloud Platform.
* Developed Fifty Five and Five's flagship website (fiftyfiveandfive.com) using PHP, WordPress, and Tailwind, enhancing its online presence.
* Built front-end of sportank.com, a dynamic social network catering to American Football enthusiasts and athletes, using Angular 10, Tailwind, SCSS, and TypeScript.
* Designed and built a proof-of-concept using Angular, Node.js, and gRPC, enabling real-time voice input streaming from web browsers to medical speech recognition software, leading to its active use by doctors for efficient communication.
* Maintained and improved the Integra Planner event management platform used by thousands of people for multi-day events, specifically for GALA Choruses.
* Enabled efficient event planning and management by utilizing Angular, Ionic for mobile apps, AWS, SQL Server, and .NET Core.
**Skills:** ASP.NET · Amazon Elastic Container Registry (ECR) · Angular · TypeScript · Amazon ECS · Python (Programming Language) · Node.js · ASP.NET MVC · Content Management Systems (CMS) · Amazon Web Services (AWS) · Next.js · Microsoft Azure · Git · React.js · C# · Full-Stack Development · Umbraco · WordPress · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · PHP · NoSQL · AWS Lambda · HTML · Microservices · Technical Requirements · Firebase · ASP.NET Core · Agile Methodologies · Google Cloud Platform (GCP) · MongoDB · User Interface Programming
- position: Contract Software Engineer
employer: The Data Shed
start: Jan. 2023
end: Aug. 2023
desc: >-
* Facilitated the recovery of funds for more than 100,000 customers
affected by loan mis-selling by developing a robust web application using
Next.js, TypeScript, React, Node.js, AWS Cognito, and AWS ECS.
* Implemented a wide range of features, including voting, bank details collection, messaging functionality, claims processing, and document management.
**Skills:** Amazon Elastic Container Registry (ECR) · TypeScript · Amazon ECS · Tailwind CSS · Node.js · Amazon Web Services (AWS) · Next.js · React.js · docker · Front-End Development · NoSQL · AWS Lambda · HTML · Agile Methodologies · User Interface Programming
- position: Software Architect
employer: T101
start: Sep. 2020
end: Jul. 2021
desc: >-
* As well as fulfilling the engineering responsibilities required by my
previous role, my responsibilities now additionally entail designing,
documenting and leading on architectural changes.As well as fulfilling the
engineering responsibilities required by my previous role, my
responsibilities now additionally entail designing, documenting and
leading on architectural changes.
**Skills:** ASP.NET · Angular · TypeScript · Amazon ECS · ASP.NET MVC · Kubernetes · Amazon Web Services (AWS) · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · NoSQL · HTML · .NET Core · Microservices · Technical Requirements · ASP.NET Core · Agile Methodologies · Google Cloud Platform (GCP) · User Interface Programming
- position: Senior Full-stack Developer
employer: T101
start: Feb. 2020
end: Sep. 2020
desc: >-
* Drove the complete platform re-architecture and development for Recon, a
UK-based dating app with 200,000 monthly active users.
* Modernized and scaled the app by using .NET Core, Firestore, SignalR, Angular 10/TypeScript, gRPC, SQL Server, MySQL, microservices and Kubernetes on GCP.
**Skills:** ASP.NET · Angular · TypeScript · Amazon ECS · ASP.NET MVC · Kubernetes · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · NoSQL · HTML · .NET Core · Microservices · Technical Requirements · Agile Methodologies · User Interface Programming
- position: Senior Software Developer
employer: Datatrial
start: Apr. 2019
end: Feb. 2020
desc: >-
* Worked on developing new/improving existing functional modules for
Datatrial's Nucleus offering, which aims to provide a web platform for
facilitating clinical trials.
**Skills:** ASP.NET · TypeScript · ASP.NET MVC · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Aurelia · HTML · Agile Methodologies · User Interface Programming
- position: Software Engineer
employer: pipdig
start: Aug 2018
end: Apr 2019
desc: >-
* Developing a mix of commercial sites and bespoke blogs, I was
responsible for the entire product life-cycle. This included requirements
gathering, development, management of the project and ultimately
delivering and maintaining the product. Responsibilities also included
improving internal software development practices and working to increase
efficiency across a wide range of small, fast-paced projects.
**Skills:** TypeScript · Content Management Systems (CMS) · Git · Full-Stack Development · WordPress · Web Development · JavaScript · Front-End Development · MySQL · Microsoft SQL Server · Cloud Development · PHP · HTML · Agile Methodologies · User Interface Programming
- position: Senior Software Developer
employer: The Works
start: Apr. 2018
end: Aug. 2018
desc: >-
* I was the sole developer for an event management platform at Newcastle
University, enhancing it by developing key features.
**Skills:** ASP.NET · ASP.NET MVC · Content Management Systems (CMS) · Git · C# · Full-Stack Development · Web Development · JavaScript · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming · Umbraco
- position: Software Developer
employer: Orangebus
start: Jan. 2017
end: Apr. 2018
desc: >-
* My responsibilities involved developing and managing a variety of
different projects across different industries.
**Skills:** ASP.NET MVC · Git · C# · Full-Stack Development · JavaScript · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming
- position: Junior Software Developer
employer: True Potential LLP
start: Oct. 2015
end: Dec. 2016
desc: >-
* Primarily a web developer, my responsibilities included developing
interactivity on the front-end, back-end services and designing database
structures for large-scale web applications that are in-use by over 2
million clients as of November 2016.
**Skills:** ASP.NET MVC · Git · C# · Full-Stack Development · Web Development · JavaScript · Visual Basic .NET (VB.NET) · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming
- position: IT Support / Trainee Software Developer
employer: Innovation Property (UK)
start: Jan. 2013
end: Sep. 2015
desc: >-
* I worked as an IT Support Technician and Developer Trainee. My
responsibilities included dealing with IT issues via an IT help desk
system. I also worked on improvements to internally-developed software
that was used by our Arboricultural staff. I also provided updates to an
internal MVC application used by office staff to log data, arrange
appointments for external staff and contact clients.
**Skills:** ASP.NET MVC · Git · C# · Full-Stack Development · JavaScript · Front-End Development · Microsoft SQL Server · HTML · Agile Methodologies · User Interface Programming

View file

@ -0,0 +1,133 @@
---
title: Deploying aaronjy.me on a Google Storage bucket
pubdate: 2024-05-01T00:00:00.000Z
desc: "Google Cloud Storage is an effective solution for hosting static sites, offering a simple and scalable way to manage web assets. A manual deployment strategy involves four key steps: backing up existing files to a backup bucket, removing sensitive files for security, uploading the latest site files from the build directory, and invalidating Googles global cache to ensure users access updated content."
---
Google actually has [documentation](https://cloud.google.com/storage/docs/hosting-static-website) on how to deploy a static site to a storage bucket, but I wanted to talk about how I handle deployments, as Google doesn't covert that!
## Networking
This site is just a collection of static assets (HTML, JS, CSS and images) that live inside a Google Cloud Storage bucket. When you load the site, the below route is taken once your request reaches GCP.
![Route diagram showing networking path from user to destination on GCP](/img/screenshot-2024-03-13-at-11.58.55.png "Route diagram showing networking path from year to destination on GCP")
1. As you can see, you:
2. Hit a load balancer, which then
3. Directs you to a backend service, which then
4. Decides either to either a) serve content directly from the storage bucket, or
b) service it from the cache (if available)
The setup is pretty simple, and doesn't really deviate from Google's suggested setup configuration for static sites hosted from a bucket.
## Deploying
Setting up a seamless deployment strategy gets a little tricker, however. I opted to set up a manual deployment strategy, which involves calling `npm run deploy` to kick off the deployment. This in turn calls a bash script that handles the deployment.
The script consists of 4 deployment steps:
1. Backup existing bucket files to a backup bucket
2. Remove sensitive files before deploying (e.g. `admin/index.html` for Decap CMS)
3. Upload the latest files to the hosting bucket
4. Invalidate Google's cache, so users receive the latest version of the site
### Step 1 - Backing up existing files
Before we do anything, we need to back up what we have already. I created a storage bucket specifically for holding backup files for this purpose, and use the gcloud CLI to copy the live files across to the backup bucket.
```
BUCKET_URL="gs://aaronjy-www"
BACKUP_BUCKET_URL="gs://aaronjy-www-backup"
echo "------------------------------"
echo "BACKUP CURRENT SITE FILES"
echo "------------------------------"
TIMESTAMP=$(date +%Y-%m-%d_%H:%M:%S)
gcloud transfer jobs create $BUCKET_URL $BACKUP_BUCKET_URL/$(date +%Y-%m-%d_%H:%M:%S)/ --no-async --delete-from=source-after-transfer;
```
The backed-up files are copied into a dated folder, and the `--delete-from` flag ensures the live websites files are deleted from the hosting bucket once they've been backed up.
### Step 2 - Removing sensitive files
Because I'm using Decap CMS for content management locally, I need to manually remove the `admin/` folder where Decap lives, as I don't want that to be available on the live site.
```
echo "------------------------------"
echo "REMOVE SENSITIVE FILES"
echo "------------------------------"
rm -rfv ./out/admin/
```
### Step 3 - Upload files to hosting bucket
Now we come to actually uploading the new files to the live site. I take everything from the `/out` directory (where Next.js throws its build output) and upload them directly to the hosting bucket.
```
echo "------------------------------"
echo "UPLOADING NEW SITE FILES"
echo "------------------------------"
gcloud storage cp --recursive ./out/* $BUCKET_URL --gzip-in-flight-all
```
The `--gzip-in-flight-all` is a handy edition, as the cli will apply gzip compression locally, and Google will uncompress them before dumping them in the bucket on the other end, resulting in a lower upload size/quicker deployment time.
### Step 3 - Invalidate the global cache
As Google uses a global cache for bucket files, we must invalidate it to ensure users get the latest website version.
```
echo "------------------------------"
echo "INVALIDATING GLOBAL CACHE"
echo "------------------------------"
echo "WARNING: This is an async operation that can take upwards of 10 minutes depending on how fast Google Cloud CDN invalidates its cache. It does take around 10 minutes on average."
gcloud compute url-maps invalidate-cdn-cache lb-aaronjy-www --path "/*" --async
```
This can take anywhere between 7-10 minutes, so the `--async` flag has been applied because we don't need to sit and wait for it.
### Full deployment script
Here's the deployment script in full:
```
BUCKET_URL="gs://aaronjy-www"
BACKUP_BUCKET_URL="gs://aaronjy-www-backup"
echo "------------------------------"
echo "BACKUP CURRENT SITE FILES"
echo "------------------------------"
TIMESTAMP=$(date +%Y-%m-%d_%H:%M:%S)
gcloud transfer jobs create $BUCKET_URL $BACKUP_BUCKET_URL/$(date +%Y-%m-%d_%H:%M:%S)/ --no-async --delete-from=source-after-transfer;
echo "------------------------------"
echo "REMOVE SENSITIVE FILES"
echo "------------------------------"
rm -rfv ./out/admin/
echo "Removed all sensitive files."
echo "------------------------------"
echo "UPLOADING NEW SITE FILES"
echo "------------------------------"
gcloud storage cp --recursive ./out/* $BUCKET_URL --gzip-in-flight-all
echo "------------------------------"
echo "INVALIDATING GLOBAL CACHE"
echo "------------------------------"
echo "WARNING: This is an async operation that can take upwards of 10 minutes depending on how fast Google Cloud CDN invalidates its cache. It does take around 10 minutes on average."
gcloud compute url-maps invalidate-cdn-cache lb-aaronjy-www --path "/*" --async
echo "------------------------------"
echo "DONE!"
echo "------------------------------"
```

View file

@ -0,0 +1,66 @@
---
title: Supporting content file structure changes on a static site
pubdate: 2024-03-18T16:47:32.150Z
desc: Static site generators (SSGs) convert complex site sources into HTML, CSS, and JS, allowing flexible hosting options. While they offer benefits like speed and low costs, updating content can be challenging for non-technical users. A solution involves assigning unique identifiers to articles and creating a URL mapping file to simplify restructuring and managing content links.
---
Static site generators (SSGs) are great. They take your complex site source and distil it down to the web's native language: HTML, CSS and JS. You can host your files anywhere: in cloud-native storage buckets; on low-cost CPanel hosting; on global CDNs; your old Lenovo ThinkPad in your cupboard running an Apache server that hasn't been patched since 2008; the list goes on. Wanna go further and throw away your CMS? Cool, you can use markdown files and a text editor as your CMS.
Simplicity is great, and SSGs bring loads of benefits (speed, a great developer experience, low cost overheads), but they're not a silver bullet, and can present a bit of a learning curve to the non-technical tasked with updating your site's content.
## The problem
Say you have an SSG that uses MD files for the site's content. In order to edit the site's content, you simply edit the MD files, build the site, and upload your new HTML/JS/CSS files.
Your folder structure could look like this:
```
site/
├─ content/
│ ├─ index.md <-- homepage
│ ├─ recipes/
│ │ ├─ pizza.md
│ │ ├─ chilli.md
├─ src/ <-- site source code
├─ public/ <-- static assets (images, videos, PDFs, etc.)
```
And your `content/index.md` looks like this:
```md
# Welcome!
This is my site! Want some fire recipes? Check [/recipes](these bad boys out)
```
Your site homepage would contain a link to the recipe listing page at `/recipes` using Markdown to generate the anchor tag.
Assuming your `content/` folder informs your site's URL structure, there must be a file at `content/recipes/index.md`, or the link would send you to a 404.
Now, imagine you want to restructure your site's link hierarchy. Maybe you want to house all of your recipes under a new URL: `www.site.com/yummy/recipes` With a small site like ours, the time required to do this would be trivial. We'd lift and shift everything under `content/recipes/` to `content/yummy/recipes`, and we'd also have to manually update the link in `content/index.md` to `/yummy/recipes`; no big deal.
But what if we had hundreds of files, and we wanted to restructure our file system in the same way? We'd potentially have hundreds of MD files that all need to be manually updated to have their links point to a new URL. For a developer, we could probably write some funky regex or do a mass find & replace to find and update the links en masse, but what if a non-technical editor wants to make the same change? They would potentially have to manually work through each MD file and update the links by hand.
## Map those URLs!
Essentially, the problem is that there's no way to uniquely identify a particlar piece of content/article *other than* its link, and the link changes based on the content's position in the file system.
The solution is to give each article a uniqiue identifier, and keep track of any previous links. Then all we need to do is tell our SSG to generate static files for both the current link and *all previous links*.
Here's the gist of it:
* **Generate unique IDs:** Instead of relying on file paths, assign a unique identifier (an "id" property) to the [frontmatter](https://dpericich.medium.com/what-is-front-matter-and-how-is-it-used-to-create-dynamic-webpages-9d8dc053b457) of each markdown file.
* **URL mapping for flexibility:** Create a central mapping file that acts like a translator, mapping each content ID to all the different URLs (slugs/paths) that can access it.
* **Say goodbye to path dependence:** When a visitor requests a URL, the system checks the url-map.json file. If it finds a matching ID, it grabs the "canonical path" (the preferred URL) and uses that to locate the actual file. This lets you access the same content through multiple URLs!
Carrying on from our previous example, this is what our mapping file might look like:
```json
{
"892c5a5c-1f77-43ce-a13a-b9d8bd02971c": [
"yummy/recipes", <-- The canonical/latest path
"recipes" <-- The previous path
]
}
```
There's some work in keeping the mapping file up-to-date, but depending on your workflow, you could automate this using Git hooks or a CI pipeline like GitHub actions or Drone.

View file

@ -1,10 +0,0 @@
source ./deploy-vars.sh
echo "Host: $SSH_HOST"
echo "Path: $SSH_UPLOAD_PATH"
echo "Deploying..."
rsync -r --delete ./out/ root@$SSH_HOST:$SSH_UPLOAD_PATH
echo "Done!"

View file

@ -1,17 +1,17 @@
import nextJest from "next/jest.js";
import nextJest from 'next/jest.js'
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: "./",
});
dir: './'
})
// Add any custom config to be passed to Jest
const config = {
coverageProvider: "v8",
testEnvironment: "jsdom",
coverageProvider: 'v8',
testEnvironment: 'jsdom'
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
};
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config);
export default createJestConfig(config)

View file

@ -2,8 +2,6 @@
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
},
"checkJs": true,
"jsx": "preserve"
}
}
}

View file

@ -1,51 +1,6 @@
const fs = require("fs");
const fm = require("front-matter");
const siteUrl = process.env.SITE_URL || "https://www.aaronjy.me";
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl,
changefreq: "weekly",
generateRobotsTxt: true,
autoLastmod: false,
generateIndexSitemap: false,
exclude: ["/server-sitemap-index.xml"], // <= exclude here
robotsTxtOptions: {
policies: [
{
userAgent: "*",
allow: "/",
},
],
additionalSitemaps: [
`${siteUrl}/server-sitemap-index.xml`, // <==== Add here
],
},
transform: async (config, path) => {
const metadata = {
loc: path,
};
if (isHomepage(path)) {
metadata.priority = 1;
} else if (isBasePage(path)) {
metadata.priority = 0.8;
} else if (isArticle(path)) {
metadata.priority = 0.6;
}
return metadata;
},
};
function isHomepage(path) {
return path === "/";
}
function isBasePage(path) {
return path.split("/").length === 2;
}
function isArticle(path) {
return path.startsWith("/writing/") || path.startsWith("/library/");
siteUrl: process.env.SITE_URL || 'https://www.aaronjy.me',
generateRobotsTxt: true // (optional)
// ...other options
}

View file

@ -1,10 +1,8 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: "standalone",
images: {
unoptimized: true,
},
};
output: 'standalone',
trailingSlash: true // ensure pages get their own directory in output
}
export default nextConfig;
export default nextConfig

14641
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,29 @@
{
"name": "www-aaronjy-me",
"version": "2.4.1",
"name": "www-aaronjy-2024",
"version": "1.0.0",
"private": true,
"type": "module",
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"scripts": {
"dev": "next dev",
"server": "npx decap-server",
"build": "next build",
"docker:build": "docker build -t www-aaronjy-2024 .",
"postbuild": "next-sitemap --config next-sitemap.config.cjs",
"start": "next start",
"link": "echo NOT CONFIGURED",
"format": "prettier . --write",
"link": "npx standard",
"format": "npx standard --fix",
"prepare": "husky",
"test": "jest --verbose --passWithNoTests",
"lint": "next lint",
"export:books": "node ./util/books-as-json.js > ./tmp/books.json",
"export:writing": "node ./util/writing-as-json.js > ./tmp/writing.json"
"deploy": "./util/pre-deploy.sh && ./util/deploy-gcloud.sh",
"test": "jest --verbose",
"lint": "next lint"
},
"dependencies": {
"@highlightjs/cdn-assets": "^11.11.1",
"@mdx-js/mdx": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"camelcase-keys": "^9.1.3",
"date-fns": "^4.1.0",
"highlight.js": "^11.11.0",
"i": "^0.3.7",
"next": "^15.3.1",
"next-mdx-remote-client": "^1.1.0",
"feather-icons": "^4.29.2",
"next": "^14.2.6",
"next-seo": "^6.5.0",
"node-html-parser": "^7.0.1",
"npm": "^11.3.0",
"react": "^18",
"react-dom": "^18",
"rehype-code-titles": "^1.2.0",
"rehype-prism-plus": "^2.0.1",
"remark-gfm": "^4.0.1"
"react-dom": "^18"
},
"devDependencies": {
"@babel/core": "^7.24.0",
@ -44,23 +31,20 @@
"@babel/preset-react": "^7.24.7",
"@commitlint/cli": "^19.1.0",
"@commitlint/config-conventional": "^19.1.0",
"@next/eslint-plugin-next": "^15.3.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^16.0.0",
"@types/jest": "^29.5.12",
"babel-jest": "^29.7.0",
"eslint": "8.57.1",
"eslint-config-next": "15.3.1",
"eslint": "^9.9.0",
"front-matter": "^4.0.2",
"frontmatter-markdown-loader": "^3.7.0",
"husky": "^9.0.11",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"js-yaml": "^4.1.0",
"lint-staged": "^15.5.1",
"next-sitemap": "^4.0.9",
"prettier": "3.5.3",
"react-test-renderer": "^18.3.1",
"showdown": "^2.1.0"
"showdown": "^2.1.0",
"standard": "^17.1.0"
}
}

79
public/admin/config.yml Normal file
View file

@ -0,0 +1,79 @@
local_backend: true
backend:
name: git-gateway
branch: main # Branch to update (optional; defaults to master)
media_folder: public/img
public_folder: img
collections:
- name: recipes
label: Recipes
folder: content/recipes
create: true
fields:
- {label: Title, name: title, widget: string}
- {label: "Publish Date", name: pubdate, widget: datetime, required: false, default: "" }
- {label: Description, name: desc, widget: text}
- {label: Body, name: body, widget: markdown }
- name: writing
label: Writing
folder: content/writing
create: true
fields:
- {label: Title, name: title, widget: string}
- {label: "Publish Date", name: pubdate, widget: datetime, required: false, default: "" }
- {label: Description, name: desc, widget: text}
- {label: Body, name: body, widget: markdown }
- name: fun
label: Fun
folder: content/fun
create: true
fields:
- {label: Title, name: title, widget: string}
- {label: "Publish Date", name: pubdate, widget: datetime, required: false, default: "" }
- {label: Description, name: desc, widget: text}
- {label: Body, name: body, widget: markdown }
- name: "pages"
label: "Pages"
files:
- label: "CV"
name: "cv"
file: "content/pages/cv.yml"
fields:
- label: Core competencies
widget: list
name: competencies
allow_add: true
- label: Education history
widget: list
name: education
allow_add: true
- label: Certifications
widget: list
name: certifications
allow_add: true
- label: Languages
widget: list
name: languages
allow_add: true
fields:
- {label: "Name", name: name, widget: string }
- {label: "Proficiency", name: proficiency, widget: string }
- label: Technical skills
widget: list
name: tech-skills
- label: Professional experience
name: experience
widget: list
fields:
- { label: Position, name: position, widget: string }
- { label: Employer, name: employer, widget: string }
- { label: Start date, name: start, widget: string }
- { label: End date, name: end, widget: string }
- { label: Description, name: desc, widget: markdown }

13
public/admin/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Content Manager</title>
<!-- <script src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script> -->
</head>
<body>
<!-- Include the script that builds the page and powers Decap CMS -->
<script src="https://unpkg.com/decap-cms@^3.0.0/dist/decap-cms.js"></script>
</body>
</html>

3
public/img/bg.svg Normal file
View file

@ -0,0 +1,3 @@
<svg class="topography-shape js-shape" width="1200px" height="580px" viewBox="0 0 1200 580" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero"></path>
<path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 0; fill: rgb(214, 242, 255);" transform="translate(-1800, 60) scale(2.8, 2.8) skewX(30) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 1; fill: rgb(199, 225, 243);" transform="translate(-1650, 55) scale(2.65, 2.65) skewX(27.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 2; fill: rgb(184, 207, 230);" transform="translate(-1500, 50) scale(2.5, 2.5) skewX(25) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 3; fill: rgb(169, 190, 218);" transform="translate(-1350, 45) scale(2.3499999999999996, 2.3499999999999996) skewX(22.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 4; fill: rgb(154, 173, 206);" transform="translate(-1200, 40) scale(2.2, 2.2) skewX(20) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 5; fill: rgb(139, 155, 193);" transform="translate(-1050, 35) scale(2.05, 2.05) skewX(17.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 6; fill: rgb(125, 138, 181);" transform="translate(-900, 30) scale(1.9, 1.9) skewX(15) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 7; fill: rgb(110, 121, 169);" transform="translate(-750, 25) scale(1.75, 1.75) skewX(12.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 8; fill: rgb(95, 103, 156);" transform="translate(-600, 20) scale(1.6, 1.6) skewX(10) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 9; fill: rgb(80, 86, 144);" transform="translate(-450, 15) scale(1.45, 1.45) skewX(7.5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 10; fill: rgb(65, 69, 132);" transform="translate(-300, 10) scale(1.3, 1.3) skewX(5) "></path><path d="M734.567 34.372c-28.692 61.724-23.266 100.422 16.275 116.094 59.313 23.508 200.347 32.911 259.299 83.906 58.95 50.994 238.697 11.572 269.438-75.95C1310.32 70.9 1365.669-64 1073.808-64c-194.576 0-307.654 32.79-339.24 98.372h-.001z" fill="#FFFA72" fill-rule="nonzero" style="position: relative; z-index: 11; fill: rgb(50, 51, 119);" transform="translate(-150, 5) scale(1.15, 1.15) skewX(2.5) "></path></svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

View file

@ -7,4 +7,3 @@ Host: https://www.aaronjy.me
# Sitemaps
Sitemap: https://www.aaronjy.me/sitemap.xml
Sitemap: https://www.aaronjy.me/server-sitemap-index.xml

8
public/sitemap-0.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://www.aaronjy.me/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://www.aaronjy.me/cv/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://www.aaronjy.me/writing/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://www.aaronjy.me/writing/static-site-on-google-cloud/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
<url><loc>https://www.aaronjy.me/writing/support-content-filte-structure-changes-on-a-static-site/</loc><lastmod>2025-01-27T13:04:24.469Z</lastmod><changefreq>daily</changefreq><priority>0.7</priority></url>
</urlset>

View file

@ -1,44 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:mobile="http://www.google.com/schemas/sitemap-mobile/1.0" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url><loc>https://www.aaronjy.me/cv</loc><priority>0.8</priority></url>
<url><loc>https://www.aaronjy.me/library</loc><priority>0.8</priority></url>
<url><loc>https://www.aaronjy.me/tags</loc><priority>0.8</priority></url>
<url><loc>https://www.aaronjy.me/writing</loc><priority>0.8</priority></url>
<url><loc>https://www.aaronjy.me/library/the-alchemist</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-invisible-man</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/wintering-the-power-of-rest-and-retreat-in-difficult-times</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-time-machine</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/when-the-moon-hits-your-eye</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-song-of-achilles</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/to-be-taught-if-fortunate</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/on-tyranny</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-dangers-of-smoking-in-bed</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-midnight-library</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/a-night-to-remember</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/sex-punishment</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/a-monster-calls</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/diary-of-an-oxygen-thief</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/1984</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/alices-adventures-in-wonderland</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-nature-of-alexander</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/eleven-kinds-of-loneliness</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/star-maker</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/stray-reflections</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/stasiland-stories-from-behind-the-berlin-wall</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/cities-that-shaped-the-ancient-world</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/animal-farm</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/a-wizard-of-earthsea</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/one-thousand-and-one-nights-a-retelling</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-tombs-of-atuan</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/childhoods-end</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/library/the-farthest-shore</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/about</loc><priority>0.8</priority></url>
<url><loc>https://www.aaronjy.me</loc><priority>1</priority></url>
<url><loc>https://www.aaronjy.me/writing/migrating-from-github-to-forgejo</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/writing/performance-considerations-when-writing-a-tcp-game-server-in-dotnet</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/writing/deploying-aaronjy-me-on-a-google-storage-bucket</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/writing/supporting-content-file-structure-changes-on-a-static-site</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/writing/attitudes-to-reading-and-how-mine-have-changed</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/writing/a-reflection-on-wintering-by-katherine-may</loc><priority>0.6</priority></url>
<url><loc>https://www.aaronjy.me/writing/how-my-adhd-makes-handling-relationships-difficult</loc><priority>0.6</priority></url>
</urlset>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://www.aaronjy.me/sitemap-0.xml</loc></sitemap>
</sitemapindex>

View file

@ -1,40 +1,39 @@
import { formatDate } from "@/lib/helpers";
import { NextSeo } from "next-seo";
import Link from "next/link";
import React, { useEffect } from "react";
import { formatDate } from '@/lib/helpers'
import { NextSeo } from 'next-seo'
import Link from 'next/link'
import React from 'react'
import * as feather from 'feather-icons'
import "highlight.js/styles/atom-one-dark.css";
import hljs from "highlight.js";
function Article({ title, excerpt, datePublished, tags, html }) {
useEffect(() => {
hljs.highlightAll();
}, [html]);
function Article ({ attributes, html }) {
return (
<>
<h1>{title}</h1>
<article>
<NextSeo
title={title}
description={excerpt}
openGraph={{
title,
description: excerpt,
type: "article",
article: {
publishedTime: datePublished ?? null,
},
}}
/>
<div>
<Link href="./">Back...</Link>
{datePublished && <p>{formatDate(datePublished)}</p>}
<div data-test="content" dangerouslySetInnerHTML={{ __html: html }} />
{tags && <p>Tags: {tags.join(", ")}</p>}
</div>
</article>
</>
);
<section>
<NextSeo
title={attributes.title} description={attributes.desc} openGraph={
{
title: attributes.title,
description: attributes.desc,
type: 'article',
article: {
publishedTime: attributes.pubdate ?? null
}
}
}
/>
<div>
<Link href='./'>
<p className='row'>
<span className='icon icon-left' dangerouslySetInnerHTML={{ __html: feather.icons['arrow-left'].toSvg() }} />
<span>Go back</span>
</p>
</Link>
<h1>{attributes.title}</h1>
<p>{attributes.desc}</p>
{attributes.pubdate && <p>{formatDate(attributes.pubdate)}</p>}
<hr />
<div data-test='content' dangerouslySetInnerHTML={{ __html: html }} />
</div>
</section>
)
}
export default Article;
export default Article

View file

@ -1,80 +0,0 @@
import { formatDate } from "@/lib/helpers";
import { NextSeo } from "next-seo";
import Image from "next/image";
import Link from "next/link";
import React from "react";
import style from "./BookReview.module.css";
import ExternalLink from "../ExternalLink/ExternalLink";
function BookReview({ review, html }) {
const { title, image, author, description, url, tags, rating, readDate } =
review;
const imageUrl = image
? `${process.env.NEXT_PUBLIC_CONTENT_API_BASE_URL}/assets/${image}`
: undefined;
return (
<>
<h1>
{title} <small>- {author}</small>
</h1>
<Link href="./">Back...</Link>
<article>
<NextSeo
title={title}
description={description}
openGraph={{
title,
description,
type: "article",
article: {
publishedTime: readDate ?? null,
},
}}
/>
<div>
<div className={style.layout}>
{imageUrl && (
<Image
src={imageUrl}
width={250}
height={580}
alt=""
className={style.thumbnail}
/>
)}
<div>
<div
data-test="content"
dangerouslySetInnerHTML={{
__html: html || "<p>(no review)</p>",
}}
/>
<p>
{tags?.length && (
<>
<span className="bold">Genres:</span>&nbsp;{tags.join(",")}
<br />
</>
)}
<span className="bold">Rating:</span>&nbsp;{rating}/5
<br />
<span className="bold">Read on:</span>&nbsp;
{formatDate(readDate)}
</p>
<p>
<ExternalLink href={url}>View on The StoryGraph</ExternalLink>
</p>
</div>
</div>
</div>
</article>
</>
);
}
export default BookReview;

View file

@ -1,33 +0,0 @@
.layout {
display: flex;
flex-direction: row;
gap: 1.25rem;
margin: 1.25rem 0;
}
.layout:first-child {
flex-grow: 0;
flex-shrink: 0;
}
.layout:last-child {
flex-grow: 1;
}
.layout p:first-child {
margin-top: 0;
}
.thumbnail {
border-radius: 8px;
}
@media screen and (max-width: 650px) {
.layout {
flex-direction: column-reverse;
}
.thumbnail {
width: 100%;
}
}

View file

@ -1,57 +0,0 @@
import style from "./BookReviewItem.module.css";
import Link from "next/link";
export default function BookReviewItem({
href,
title,
author,
rating,
image,
tags,
}) {
const imageUrl = image
? `${process.env.NEXT_PUBLIC_CONTENT_API_BASE_URL}/assets/${image}`
: undefined;
return (
<div className={style.item}>
<Link href={href}>
<div
className={style.thumb}
style={{
backgroundImage: `url(${imageUrl ?? "/img/book-placeholder.jpg"})`,
}}
>
<div className={style.rating}>
<Star />
&nbsp;{rating}
</div>
</div>
</Link>
<div>
<h2 className={style.heading}>
<Link href={href}>{title}</Link>
</h2>
<p className={style.author}>{author}</p>
{tags?.length && <p>{tags.join(", ")}</p>}
</div>
</div>
);
}
function Star() {
return (
<svg
style={{
fill: "currentColor",
}}
height="15"
width="15"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 47.94 47.94"
xmlSpace="preserve"
>
<path d="m26.285 2.486 5.407 10.956a2.58 2.58 0 0 0 1.944 1.412l12.091 1.757c2.118.308 2.963 2.91 1.431 4.403l-8.749 8.528a2.582 2.582 0 0 0-.742 2.285l2.065 12.042c.362 2.109-1.852 3.717-3.746 2.722l-10.814-5.685a2.585 2.585 0 0 0-2.403 0l-10.814 5.685c-1.894.996-4.108-.613-3.746-2.722l2.065-12.042a2.582 2.582 0 0 0-.742-2.285L.783 21.014c-1.532-1.494-.687-4.096 1.431-4.403l12.091-1.757a2.58 2.58 0 0 0 1.944-1.412l5.407-10.956c.946-1.919 3.682-1.919 4.629 0z" />
</svg>
);
}

View file

@ -1,39 +0,0 @@
.item {
height: 100%;
display: flex;
flex-direction: column;
flex-grow: 1;
}
.heading {
font-size: 1rem;
margin-bottom: 5px;
}
.author {
margin-top: 5px;
/* font-weight: bold; */
}
.thumb {
width: auto;
height: 290px;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
border-radius: 6px;
position: relative;
}
.rating {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
background-color: var(--light);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
display: flex;
flex-direction: row;
align-items: center;
pointer-events: none;
}

View file

@ -1,34 +1,31 @@
import React from "react";
import React from 'react'
/**
* Sets default values for external links on an anchor tag.
* @returns
*/
function ExternalLink({
function ExternalLink ({
href,
rel = "nofollow noopener",
rel = 'nofollow noopener',
children,
target = "_blank",
target = '_blank'
}) {
return (
<>
<a href={href} rel={rel} target={target}>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
style={{ display: "inline", width: "1rem", marginRight: "0.25rem" }}
viewBox='0 0 24 24'
xmlns='http://www.w3.org/2000/svg'
style={{ display: 'inline', width: '1rem', marginRight: '0.25rem' }}
>
<g>
<path
fill="currentColor"
d="M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6zm11-3v8h-2V6.413l-7.793 7.794-1.414-1.414L17.585 5H13V3h8z"
/>
<path fill='currentColor' d='M10 6v2H5v11h11v-5h2v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6zm11-3v8h-2V6.413l-7.793 7.794-1.414-1.414L17.585 5H13V3h8z' />
</g>
</svg>
{children}
</a>
</>
);
)
}
export default ExternalLink;
export default ExternalLink

View file

@ -1,44 +1,50 @@
import React from "react";
import React from 'react'
import style from "./Footer.module.css";
import style from './Footer.module.css'
// @ts-ignore
import pck from "../../../package.json";
function Footer() {
function Footer () {
return (
<footer className={`${style.footer} hide-print`} data-testid="footer">
<hr />
<footer className={style.footer} data-testid='footer'>
<nav>
<div>
<span>
<a href="#">Back to top</a>
</span>
{", "}
<span>
<a href="/static/pgp.txt">PGP key</a>
</span>
{", "}
<span>
<a
href="https://git.aaronjy.me/aaron/aaronjy-me"
target="_blank"
rel="nofollow noopener noreferrer"
>
View site src
</a>
</span>
</div>
<ul>
<li>
<a href='#'>Back to top</a>
</li>
<li>
<a href='/static/pgp.txt'>pgp key</a>
</li>
<li>
<a href='mailto:me@aaronjy.me'>Send me an email</a>
</li>
</ul>
</nav>
<div>
<small>
2025 Aaron Yarborough,{" "}
<span title="major.minior.patch.content">v{pck.version}</span>
</small>
</div>
<nav>
<ul>
<li>
<small>
2024 Aaron Yarborough, made with{' '}
<a
target='_blank'
rel='nofollow noopener noreferrer'
href='https://nextjs.org/'
>
Next.js
</a>{' '}
and{' '}
<a
target='_blank'
rel='nofollow noopener noreferrer'
href='https://yegor256.github.io/tacit/'
>
Tacit
</a>
</small>
</li>
</ul>
</nav>
</footer>
);
)
}
export default Footer;
export default Footer

View file

@ -0,0 +1,7 @@
.footer nav:first-child a {
text-transform: lowercase;
}
.footer nav li {
margin-bottom: 0;
}

View file

@ -1,5 +1,11 @@
import style from "./Grid.module.css";
import React from 'react'
export default function Grid({ columns, children }) {
return <div className={style.grid}>{children}</div>;
import style from './Grid.module.css'
function Grid ({ children }) {
return (
<div className={style.grid}>{children}</div>
)
}
export default Grid

View file

@ -1,5 +1,5 @@
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem;
}
grid-template-columns: repeat(3, 1fr);
gap: 32px;
}

View file

@ -1,26 +1,18 @@
import Link from "next/link";
import React from "react";
import Link from 'next/link'
import React from 'react'
import styles from "./Header.module.css";
import styles from './Header.module.css'
function Header() {
function Header () {
return (
<header className={`${styles.header} hide-print`} data-testid="header">
<header className={styles.header} data-testid='header'>
<nav>
<Link href="/">Home</Link>
{", "}
<Link href="/writing">Writing</Link>
{", "}
<Link href="/tags">Tags</Link>
{", "}
<Link href="/cv">CV</Link>
{", "}
<Link href="/library">Library</Link>
{", "}
<Link href="/about">About</Link>
<Link href='/'>Home</Link>
<Link href='/writing'>Writing</Link>
<Link href='/cv'>CV</Link>
</nav>
</header>
);
)
}
export default Header;
export default Header

View file

@ -1,3 +1,9 @@
.header {
margin-top: 20px;
.header nav {
display: flex;
justify-content: center;
gap: 20px;
}
.header a {
text-transform: lowercase;
}

View file

@ -1,3 +0,0 @@
export default function Loading() {
return <p>Loading...</p>;
}

View file

@ -1,144 +1,81 @@
import React from "react";
import React from 'react'
import style from "./Resume.module.css";
import { markdownToHtml } from "@/services/content-service";
import { MDXClient } from "next-mdx-remote-client/csr";
import Link from "next/link";
import style from './Resume.module.css'
function Resume({
introMdxSource,
function Resume ({
competencies,
education,
certifications,
languages,
experience,
experience
}) {
return (
<div className={style.cv}>
<ol className="hide-print">
<li>
<a href="#experience">Professional experience</a>
</li>
<li>
<a href="#competencies">Competencies</a>
</li>
<li>
<a href="#competencies">Certifications</a>
</li>
<li>
<a href="#languages">Languages</a>
</li>
<li>
<a href="#education">Education</a>
</li>
</ol>
<div>
<div className={style.about}>
<MDXClient {...introMdxSource} />
<span className="print-only">
See more at <a href="https://aaronjy.me/cv">aaronjy.me/cv</a>
</span>
</div>
</div>
<div className={style.cvContent}>
<div className={style.experience}>
<h2 id="experience">Professional experience</h2>
{experience?.map((exp, i) => (
<div key={i}>
<WorkExperience
employer={exp.employer}
position={exp.position}
start={exp.start}
end={exp.end}
skills={exp.skills}
>
{markdownToHtml(exp.description)}
</WorkExperience>
</div>
<h2>Core competencies</h2>
<ul>
{competencies.sort().map((c, i) => (
<li key={i}>{c}</li>
))}
</div>
<div className={style.sidebar}>
<h2 id="competencies">Competencies</h2>
<ul>
{competencies
?.sort((a, b) => a.name - b.name)
.map((c, i) => (
<li key={i}>{c.name}</li>
))}
</ul>
</ul>
<h2 id="certifications">Certifications</h2>
<ul>
{certifications
?.sort((a, b) => a.name > b.name)
.map((c, i) => (
<li key={i}>{c.name}</li>
))}
</ul>
<h2>Certifications</h2>
<ul>
{certifications.sort().map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
<h2 className="languages">Languages</h2>
<ul>
{languages?.sort().map((c, i) => (
<li key={i}>
<strong>{c.name}</strong> - {c.proficiency}
</li>
))}
</ul>
<h2>Languages</h2>
<ul>
{languages.sort().map((c, i) => (
<li key={i}>
{c.name} - {c.proficiency}
</li>
))}
</ul>
<h2 className="education">Education</h2>
<ul>
{education
?.sort((a, b) => a.name - b.name)
.map((c, i) => (
<li key={i}>{c.name}</li>
))}
</ul>
</div>
<h2>Education history</h2>
<p>{education}</p>
</div>
<div>
<h2>Professional experience</h2>
{experience.map((exp, i) => (
<WorkExperience
key={i}
employer={exp.employer}
position={exp.position}
start={exp.start}
end={exp.end}
>
{exp.desc}
</WorkExperience>
))}
</div>
</div>
);
)
}
export default Resume;
export default Resume
function WorkExperience({ position, employer, start, end, skills, children }) {
function WorkExperience ({ position, employer, start, end, children }) {
return (
<>
<table className={style.experienceTable}>
<tbody>
<tr>
<td>
<span id={position} className={style.position}>
{position}
</span>
<br />
<span className={style.employer}>{employer}</span>
</td>
<td className="text-right">
<time>{start}</time> - <time>{end}</time>
</td>
</tr>
<tr>
<td colSpan={2}>
<div
data-test="children"
dangerouslySetInnerHTML={{ __html: children }}
className={style.experienceContent}
/>
{!!skills?.length && (
<div className={style.skillList}>
{skills.sort().map((skill) => (
<span key={skill}>{skill}</span>
))}
</div>
)}
</td>
</tr>
</tbody>
</table>
<hr />
</>
);
<div className={style['work-experience']}>
<div>
<h3>
{position}
<br />
<small>{employer}</small>
</h3>
<small>
<time>{start}</time> - <time>{end}</time>
</small>
</div>
<div
data-test='children'
dangerouslySetInnerHTML={{ __html: children }}
/>
</div>
)
}

View file

@ -1,74 +1,25 @@
.experienceTable {
width: 100%;
}
.position {
font-weight: 700;
}
.cvContent {
.cv {
display: flex;
flex-direction: row-reverse;
gap: 0.5em;
position: relative;
flex-direction: row;
gap: 25px;
}
.cvContent .experience {
flex-basis: 66.66%;
.cv > div:first-child {
flex: 1;
}
.cvContent .sidebar {
flex-basis: 33.33%;
position: sticky;
top: 10px;
left: 0px;
height: 1px;
/* Needed to make sticky work */
.cv > div:last-child {
flex: 2;
}
.about {
margin-bottom: 0;
}
.cvContent ol,
.cvContent ul {
padding: 0 1rem;
}
.skillList {
font-size: 0.8em;
.cv .work-experience >div:first-child {
display: flex;
flex-wrap: wrap;
gap: 0.2em;
align-items: center;
justify-content: space-between;
}
.skillList span {
background-color: var(--dark);
color: var(--light);
padding: 0 0.5em;
}
.experienceContent > p {
margin-bottom: 0;
}
@media screen and (max-width: 768px) {
.cvContent {
display: block;
@media (max-width: 768px) {
.cv {
flex-direction: column;
}
.cvContent .sidebar {
height: auto;
position: relative;
}
}
@media print {
.cvContent .experience {
flex-basis: 80%;
}
.cvContent .sidebar {
flex-basis: 20%;
}
}
}

View file

@ -1,17 +0,0 @@
import { formatDate } from "@/lib/helpers";
import Link from "next/link";
export default function StaticContentList({ entries, urlPrefix, max = 0 }) {
return (
<div>
{entries
.map((e) => (
<p key={e.slug}>
<Link href={`${urlPrefix}${e.slug}`}>{e.title}</Link>{" "}
{!!e.datePublished && <i>{`on ${formatDate(e.datePublished)}`}</i>}
</p>
))
.slice(0, max > 0 ? max : entries.length)}
</div>
);
}

View file

@ -1,56 +0,0 @@
// Posts
export class FailedFetchPostsError extends Error {
constructor(msg) {
super(`Failed to fetch posts: ${msg}`);
this.name = "FailedFetchPostsError";
}
}
export class FailedFetchPostError extends Error {
constructor(slug, msg) {
super(`Failed to fetch post '${slug}: ${msg}`);
this.name = "FailedFetchPostError";
}
}
// Book reviews
export class FailedFetchBookReviewsError extends Error {
constructor(msg) {
super(`Failed to fetch book reviews: ${msg}`);
this.name = "FailedFetchBookReviewsError";
}
}
export class FailedFetchBookReviewError extends Error {
constructor(slug, msg) {
super(`Failed to fetch book review '${slug}: ${msg}`);
this.name = "FailedFetchBookReviewError";
}
}
// Basic pages
export class FailedFetchBasicPagesError extends Error {
constructor(msg) {
super(`Failed to fetch basic pages: ${msg}`);
this.name = "FailedFetchBasicPagesError";
}
}
export class FailedFetchBasicPageError extends Error {
constructor(slug, msg) {
super(`Failed to fetch basic page '${slug}: ${msg}`);
this.name = "FailedFetchBasicPageError";
}
}
// CV
export class FailedFetchCVError extends Error {
constructor(msg) {
super(`Failed to fetch basic pages: ${msg}`);
this.name = "FailedFetchCVError";
}
}

View file

@ -1,17 +1,17 @@
import React from "react";
import React from 'react'
import style from "./DefaultLayout.module.css";
import Header from "@/components/Header/Header";
import Footer from "@/components/Footer/Footer";
import style from './DefaultLayout.module.css'
import Header from '@/components/Header/Header'
import Footer from '@/components/Footer/Footer'
function DefaultLayout({ children }) {
function DefaultLayout ({ children }) {
return (
<main className={`${style.layout}`}>
<Header />
<>{children}</>
<article>{children}</article>
<Footer />
</main>
);
)
}
export default DefaultLayout;
export default DefaultLayout

View file

@ -0,0 +1,9 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
.layout main {
flex-grow: 1;
}

62
src/lib/content.js Normal file
View file

@ -0,0 +1,62 @@
import fs from 'fs'
import fm from 'front-matter'
import showdown from 'showdown'
import { toSlug } from './helpers'
export function getMarkdownEntry (path) {
const fileContents = fs.readFileSync(path, {
encoding: 'utf-8'
})
const { attributes, body } = fm(fileContents)
const converter = new showdown.Converter({
tables: true,
tablesHeaderId: true
})
const html = converter.makeHtml(body)
const slug = toSlug(path.substring(path.lastIndexOf('/') + 1))
return {
attributes: {
...attributes,
pubdate: attributes.pubdate?.toUTCString() ?? null
},
html,
slug
}
}
export function getStaticEntryPaths (contentPath) {
const entries = fs.readdirSync(contentPath, { withFileTypes: true })
const paths = entries.map((dirent) => ({
params: {
slug: toSlug(dirent.name)
}
}))
return {
fallback: false,
paths
}
}
export function getStaticEntryProps (contentPath, { params }) {
const path = `${contentPath}/${params.slug}.md`
const entry = getMarkdownEntry(path)
const { attributes } = entry
return { props: { ...entry, attributes } }
}
export function getStaticEntryListProps (contentPath, urlPrefix) {
const fun = fs.readdirSync(contentPath, { withFileTypes: true })
const entries = fun.map((dirent) =>
getMarkdownEntry(`${dirent.path}/${dirent.name}`)
).sort((a, b) => new Date(b.attributes.pubdate) - new Date(a.attributes.pubdate))
return { props: { entries, urlPrefix } }
}

View file

@ -1,27 +1,9 @@
import * as dateFns from "date-fns";
import * as dateFns from 'date-fns'
export function filenameToSlug(input) {
return stringToSlug(input.substring(0, input.indexOf(".")));
export function toSlug (input) {
return input.substring(0, input.indexOf('.')).trim()
}
export function stringToSlug(str) {
return str
.trim()
.toLowerCase()
.replace(/[\W_]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function formatDate(date) {
return dateFns.format(Date.parse(date), "PPP");
}
/**
* Silliness to make sure dates don't get passed to the
* page function below as [object Object]
* @param {*} obj
* @returns
*/
export function stringifyAndParse(obj) {
return JSON.parse(JSON.stringify(obj));
export function formatDate (date) {
return dateFns.format(Date.parse(date), 'PPP')
}

View file

@ -1,44 +0,0 @@
import StaticContentList from "@/components/StaticContentList/StaticContentList";
import { fetchPosts } from "@/services/content-service";
import { useEffect, useState, useTransition } from "react";
export const mdxComponents = {
StaticContentList: ({ type, urlPrefix, max = 0 }) => {
const [items, setItems] = useState([]);
const [isLoading, setLoading] = useState(false);
useEffect(
function () {
if (!urlPrefix || max <= 0) return;
switch (type) {
case "posts":
(async function () {
setLoading(true);
const res = await fetchPosts([]);
const json = await res.json();
setItems(
json.data.sort((a, b) => a.datePublished < b.datePublished) ??
[],
);
setLoading(false);
})();
break;
default:
throw `Could not render StaticContentList: content type ${type} not supported.`;
}
},
[type, urlPrefix, max],
);
return (
<>
{isLoading && <p>Loading...</p>}
{!isLoading && (
<StaticContentList entries={items} urlPrefix={urlPrefix} max={max} />
)}
</>
);
},
};

View file

@ -1,94 +0,0 @@
import Loading from "@/components/Loading/Loading";
import { FailedFetchBasicPagesError } from "@/errors";
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import { mdxComponents } from "@/lib/mdx-components";
import { fetchBasicPages } from "@/services/content-service";
import { MDXClient } from "next-mdx-remote-client";
import { serialize } from "next-mdx-remote-client/serialize";
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
export async function getStaticPaths() {
const res = await fetchBasicPages(["path"]);
if (!res.ok) {
throw new FailedFetchBasicPagesError(await res.text());
}
const pages = (await res.json()).data;
return {
paths: pages.map((page) => ({
params: {
path: [page.path ?? ""],
},
})),
fallback: true,
};
}
export async function getStaticProps({ params }) {
const { path } = params;
const res = await fetchBasicPages([], {
path: {
_eq: path?.join("/") ?? null,
},
});
if (!res.ok) {
throw new FailedFetchBasicPageError(path, await res.text());
}
const page = (await res.json()).data.at(0);
if (!page) {
return {
notFound: true,
revalidate: 60,
};
}
const { content, title } = page;
const mdxSource = await serialize({ source: content });
return {
props: {
title,
mdxSource,
},
revalidate: 60,
};
}
export default function BasicPage({ title, mdxSource }) {
const { isFallback } = useRouter();
if (isFallback) {
return (
<DefaultLayout>
<Loading />
</DefaultLayout>
);
}
if (!mdxSource || "error" in mdxSource) {
return <p>Something went wrong: {mdxSource?.error ?? "???"}</p>;
}
return (
<DefaultLayout>
<NextSeo
title={title}
description={undefined}
openGraph={{
title,
description: undefined,
}}
/>
<div>
<MDXClient {...mdxSource} components={mdxComponents} />
</div>
</DefaultLayout>
);
}

View file

@ -1,14 +1,12 @@
import { DefaultSeo } from "next-seo";
import "@/styles/globals.css";
import '@/styles/globals.css'
export default function App({ Component, pageProps }) {
import { DefaultSeo } from 'next-seo'
export default function App ({ Component, pageProps }) {
return (
<>
<DefaultSeo
defaultTitle="Aaron Yarborough"
titleTemplate="%s | Aaron Yarborough"
/>
<DefaultSeo defaultTitle='Aaron Yarborough' titleTemplate='%s | Aaron Yarborough' />
<Component {...pageProps} />
</>
);
)
}

View file

@ -1,20 +1,15 @@
import { Html, Head, Main, NextScript } from "next/document";
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
export default function Document () {
return (
<Html lang="en">
<Html lang='en'>
<Head>
<link rel="stylesheet" href="https://neat.joeldare.com/neat.css" />
<script
defer
data-domain="aaronjy.me"
src="https://analytics.aaronjy.me/js/script.js"
/>
<link rel='stylesheet' href='https://cdn.jsdelivr.net/gh/yegor256/tacit@gh-pages/tacit-css-1.7.1.min.css' />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
)
}

View file

@ -1,74 +1,37 @@
import Resume from "@/components/Resume/Resume";
import { FailedFetchCVError } from "@/errors";
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import { fetchCV } from "@/services/content-service";
import { NextSeo } from "next-seo";
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
import React from 'react'
import yaml from 'js-yaml'
import fs from 'fs'
import showdown from 'showdown'
import { NextSeo } from 'next-seo'
import Resume from '@/components/Resume/Resume'
import style from "./cv.module.css";
import { serialize } from "next-mdx-remote-client/serialize";
export const Title = 'CV'
export const Description = 'Read about my professional experience as a software engineer, core competencies, and certifications.'
export const Title = "CV";
export async function getStaticProps() {
const res = await fetchCV([]);
if (!res.ok) {
throw new FailedFetchCVError(await res.text());
}
const cv = (await res.json()).data;
if (!cv) {
return {
notFound: true,
revalidate: 60,
};
}
const {
intro,
competencies,
education,
languages,
certifications,
experience,
} = cv;
const introMdxSource = await serialize({ source: intro });
return {
props: {
introMdxSource,
competencies,
education,
languages,
certifications,
experience,
},
revalidate: 60,
};
}
export default function ResumePage({
introMdxSource,
function ResumePage ({
competencies,
education,
certifications,
languages,
experience,
experience
}) {
return (
<DefaultLayout>
<NextSeo
title={Title}
openGraph={{
title: Title,
}}
title={Title} description={Description} openGraph={
{
Title,
Description
}
}
/>
<h1 className="hide-print">{Title}</h1>
<h1 className={`${style.printHeading} print-only`}>Aaron Yarborough</h1>
<section>
<h1>{Title} 💼</h1>
<p>{Description}</p>
</section>
<section>
<Resume
introMdxSource={introMdxSource}
competencies={competencies}
education={education}
certifications={certifications}
@ -77,5 +40,26 @@ export default function ResumePage({
/>
</section>
</DefaultLayout>
);
)
}
export function getStaticProps () {
const content = fs.readFileSync('./content/pages/cv.yml', {
encoding: 'utf-8'
})
const data = yaml.load(content)
const MDConverter = new showdown.Converter()
data.experience = data.experience.map((exp) => ({
...exp,
desc: MDConverter.makeHtml(exp.desc)
}))
return {
props: { ...data }
}
}
export default ResumePage

115
src/pages/index.js Normal file
View file

@ -0,0 +1,115 @@
import Head from 'next/head'
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
import ExternalLink from '@/components/ExternalLink/ExternalLink'
import Link from 'next/link'
import { Title as WritingTitle, Description as WritingDescription } from './writing'
import { Title as CvTitle, Description as CvDescription } from './cv'
export default function Home () {
return (
<DefaultLayout>
<Head>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<link rel='icon' href='/favicon.ico' />
</Head>
<section>
<h1>Hello! 👋🏻</h1>
<p>
I&apos;m Aaron, a Brit living in Newcastle-upon-tyne, UK. I
work professionally as a Software Engineer, and study
languages, history and philosophy in my spare time.
</p>
<p>
I current work as a Lead Consultant at Hippo Digital, working on public sector project for the Department of Education. You can find out more about my work history <Link href='/cv'>on my CV</Link>.
</p>
<div className='row'>
<div className='box'>
<Link href='/writing' className='box-title'>{WritingTitle}</Link>
<p className='box-text'>{WritingDescription}</p>
<Link href='/writing' className='box-link'>Read more...</Link>
</div>
<div className='box'>
<Link href='/cv' className='box-title'>{CvTitle}</Link>
<p className='box-text'>{CvDescription}</p>
<Link href='/cv' className='box-link'>Read more...</Link>
</div>
</div>
</section>
<section>
<h2>Tech I Like</h2>
<ul>
<li>
<strong>Web Development:</strong> I primarily use Node.js with TypeScript
(or JavaScript for smaller projects) alongside Next.js to build websites
and applications.
</li>
<li>
<strong>Scripting:</strong> My preferred scripting languages are Python
and JavaScript, as I&apos;m well-versed in them and they offer extensive
libraries that typically cover my needs.
</li>
<li>
<strong>API and Backend Development:</strong> For more robust API or backend
architecture, I often choose .NET Core with C# and ASP.NET. The strongly-typed
nature of C# and the structured framework of ASP.NET help maintain clean and
organised code.
</li>
<li>
<strong>Cloud Hosting:</strong> When possible, I opt for hosting on a
DigitalOcean droplet. If more extensive cloud services are required, I usually
opt for Google Cloud Platform (GCP), which I find more user-friendly than Azure
or AWS. I also self-host services on shared server hosting running Ubuntu Server, typically with Hetzner.
</li>
</ul>
</section>
<section>
<h2>Where to find me</h2>
<ul>
<li>
<strong>
<ExternalLink href='https://letterboxd.com/aaronyarbz/'>
Letterboxd
</ExternalLink>
</strong>{' '}
is a social platform for film lovers to rate, review, and discover
movies, akin to &quot;Goodreads for film.&quot;
</li>
<li>
<strong>
<ExternalLink href='https://github.com/AaronJY'>
GitHub
</ExternalLink>
</strong>{' '}
is a web-based platform for version control and collaboration on
software development projects. Find out what I&apos;ve been working
on here!
</li>
<li>
<strong>
<ExternalLink href='https://www.linkedin.com/in/aaronjyarborough/'>
LinkedIn
</ExternalLink>
</strong>
, unfortunately. A social network for professionals.
</li>
</ul>
</section>
<section>
<h2>About this site</h2>
<p>www.aaronjy.me is a static site (i.e. a bunch of HTML, JS, CSS and image files) written in JavaScript using Next.js. Tacit is being used as a micro CSS framework, and various smaller bits of custom CSS have been applied on top.</p>
<p>The site is hosted inside a Google Cloud Storage bucket with a load balancer sat in front of it. The load balancer is required as Cloud Storage doesn&apos;t support a) custom domains, b) HTTPS out of the box or c) a global CDN solution.</p>
<p>One of the biggest benefits of a website made of simple static files and assets is that I can deploy it easily, almost anywhere, and for very little money.</p>
</section>
</DefaultLayout>
)
}

View file

@ -1,74 +0,0 @@
import React from "react";
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import BookReview from "@/components/Book/BookReview";
import { fetchBookReviews, markdownToHtml } from "@/services/content-service";
import {
FailedFetchBookReviewError,
FailedFetchBookReviewsError,
} from "@/errors";
import { useRouter } from "next/router";
import Loading from "@/components/Loading/Loading";
export async function getStaticPaths() {
const res = await fetchBookReviews(["slug"], {
status: "published",
});
if (!res.ok) {
throw new FailedFetchBookReviewsError(await res.text());
}
const reviews = (await res.json()).data;
return {
paths: reviews.map((post) => ({
params: {
slug: post.slug,
},
})),
fallback: true,
};
}
export const getStaticProps = async ({ params }) => {
const { slug } = params;
const res = await fetchBookReviews([], {
slug,
status: "published",
});
if (!res.ok) {
throw new FailedFetchBookReviewError(slug, await res.text());
}
const review = (await res.json()).data.at(0);
if (!review) {
return {
notFound: true,
revalidate: 60,
};
}
const content = review.review;
const html = markdownToHtml(content);
return {
props: {
review,
html,
},
revalidate: 60,
};
};
export default function LibrarySingle({ review, html }) {
const { isFallback } = useRouter();
return (
<DefaultLayout>
{!isFallback && <BookReview review={review} html={html} />}
{isFallback && <Loading />}
</DefaultLayout>
);
}

View file

@ -1,55 +0,0 @@
import BookReviewItem from "@/components/BookReviewItem/BookReviewItem";
import Grid from "@/components/Grid/Grid";
import { FailedFetchBookReviewsError } from "@/errors";
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import { stringifyAndParse } from "@/lib/helpers";
import { fetchBookReviews } from "@/services/content-service";
import { NextSeo } from "next-seo";
export const Title = "Library";
export async function getStaticProps() {
const res = await fetchBookReviews();
if (!res.ok) {
throw new FailedFetchBookReviewsError(await res.text());
}
const reviews = (await res.json()).data.sort(
(a, b) => new Date(b.readDate).getTime() - new Date(a.readDate).getTime(),
);
return {
props: {
reviews: stringifyAndParse(reviews),
},
revalidate: 60,
};
}
export default function Library({ reviews }) {
return (
<DefaultLayout>
<NextSeo
title={Title}
openGraph={{
title: Title,
}}
/>
<h1>{Title}</h1>
<section>
<Grid columns={5}>
{reviews.map((review, _) => (
<BookReviewItem
key={review.title}
{...review}
href={`/library/${review.slug}`}
/>
))}
</Grid>
</section>
</DefaultLayout>
);
}

View file

@ -1,29 +0,0 @@
import {
fetchBasicPages,
fetchBookReviews,
fetchPosts,
} from "@/services/content-service";
import { getServerSideSitemapIndexLegacy } from "next-sitemap";
const siteUrl = process.env.SITE_URL || "https://www.aaronjy.me";
export const getServerSideProps = async (ctx) => {
const [basicPagesResp, bookReviewsResp, postsResp] = await Promise.all([
fetchBasicPages().then((res) => res.json()),
fetchBookReviews().then((res) => res.json()),
fetchPosts().then((res) => res.json()),
]);
const urls = [
...basicPagesResp.data.map((entry) => `${siteUrl}/${entry.path ?? ""}`),
...bookReviewsResp.data.map(
(entry) => `${siteUrl}/library/${entry.slug ?? ""}`,
),
...postsResp.data.map((entry) => `${siteUrl}/writing/${entry.slug ?? ""}`),
];
return getServerSideSitemapIndexLegacy(ctx, [...urls]);
};
// Default export to prevent next.js errors
export default function SitemapIndex() {}

View file

@ -1,73 +0,0 @@
import { FailedFetchPostsError } from "@/errors";
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import {
fetchItems,
fetchPosts,
getTagsFromPosts,
} from "@/services/content-service";
import { NextSeo } from "next-seo";
import Link from "next/link";
import React from "react";
export async function getStaticProps() {
const res = await fetchPosts(["title", "date_published", "tags", "slug"], {
status: "published",
});
if (!res.ok) {
throw new FailedFetchPostsError(await res.text());
}
const posts = (await res.json()).data;
const tags = getTagsFromPosts(posts);
return {
props: {
tags,
posts,
},
revalidate: 60,
};
}
export const Title = "Tags";
export default function Tags({ tags, posts }) {
return (
<DefaultLayout>
<NextSeo
title={Title}
openGraph={{
title: Title,
}}
/>
<h1>{Title}</h1>
<section>
{Object.keys(tags)
.sort()
.map((tag) => {
const tagPosts = posts
.filter((p) => p.tags.includes(tag))
.sort((a, b) => b.title > -a.title);
return (
<React.Fragment key={tag}>
<h2>{tag}</h2>
<ul>
{tagPosts.map((post, _) => {
return (
<li key={post.slug}>
<Link href={`/writing/${post.slug}`}>{post.title}</Link>
</li>
);
})}
</ul>
</React.Fragment>
);
})}
</section>
</DefaultLayout>
);
}

View file

@ -1,70 +1,15 @@
import React from "react";
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import Article from "@/components/Article/Article";
import { fetchPosts, markdownToHtml } from "@/services/content-service";
import { FailedFetchPostError, FailedFetchPostsError } from "@/errors";
import { useRouter } from "next/router";
import Loading from "@/components/Loading/Loading";
import React from 'react'
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
import { getStaticEntryPaths, getStaticEntryProps } from '@/lib/content'
import Article from '@/components/Article/Article'
export async function getStaticPaths() {
const res = await fetchPosts(["slug"], {
status: "published",
});
if (!res.ok) {
throw new FailedFetchPostsError(await res.text());
}
const posts = (await res.json()).data;
return {
paths: posts.map((post) => ({
params: {
slug: post.slug,
},
})),
fallback: true,
};
}
export const getStaticProps = async ({ params }) => {
const { slug } = params;
const res = await fetchPosts([], {
slug,
status: "published",
});
if (!res.ok) {
throw new FailedFetchPostError(slug, await res.text());
}
const post = (await res.json()).data.at(0);
if (!post) {
return {
notFound: true,
revalidate: 60,
};
}
const { content } = post;
const html = markdownToHtml(content);
return {
props: {
post,
html,
},
revalidate: 60,
};
};
export default function WritingSingle({ post, html }) {
const { isFallback } = useRouter();
export const getStaticPaths = () => getStaticEntryPaths('./content/writing')
export const getStaticProps = (ctx) => getStaticEntryProps('./content/writing', ctx)
export default function WritingSingle ({ attributes, html }) {
return (
<DefaultLayout>
{!isFallback && <Article {...post} html={html} />}
{isFallback && <Loading />}
<Article attributes={attributes} html={html} />
</DefaultLayout>
);
)
}

View file

@ -1,50 +1,46 @@
import DefaultLayout from "@/layouts/DefaultLayout/DefaultLayout";
import React from "react";
import { NextSeo } from "next-seo";
import StaticContentList from "@/components/StaticContentList/StaticContentList";
import { fetchPosts } from "@/services/content-service";
import { FailedFetchPostsError } from "@/errors";
import DefaultLayout from '@/layouts/DefaultLayout/DefaultLayout'
import React from 'react'
import Link from 'next/link'
import { getStaticEntryListProps } from '@/lib/content'
import { NextSeo } from 'next-seo'
import { formatDate } from '@/lib/helpers'
export const getStaticProps = async () => {
const res = await fetchPosts(["title", "date_published", "slug"], {
status: "published",
});
export const getStaticProps = () => getStaticEntryListProps('./content/writing', '/writing/')
if (!res.ok) {
throw new FailedFetchPostsError(await res.text());
}
export const Title = 'Writing'
export const Description = 'A collection of writing and musings on various topics that interest me, as well as technical writing.'
const json = await res.json();
const posts = json.data.sort(
(a, b) =>
new Date(b.datePublished).getTime() - new Date(a.datePublished).getTime(),
);
return {
props: {
posts,
},
revalidate: 60,
};
};
export const Title = "Writing";
export default function Writing({ posts }) {
export default function Writing ({ entries, urlPrefix }) {
return (
<DefaultLayout>
<NextSeo
title={Title}
openGraph={{
title: Title,
}}
description={Description}
openGraph={
{
Title,
Description
}
}
/>
<h1>{Title}</h1>
<section>
<h1>{Title} 🏻</h1>
<p>{Description}</p>
</section>
<section>
<StaticContentList entries={posts} urlPrefix="writing/" />
{entries.map((e) => (
<div key={e.attributes.title}>
<h2>
<Link href={`${urlPrefix}${e.slug}`}>{e.attributes.title}</Link>
</h2>
{!!e.attributes.pubdate && <p>{formatDate(e.attributes.pubdate)}</p>}
<p>{e.attributes.desc}</p>
<Link href={`${urlPrefix}${e.slug}`}>Read more</Link>
</div>
))}
</section>
</DefaultLayout>
);
)
}

View file

@ -1,72 +0,0 @@
import showdown from "showdown";
import camelcaseKeys from "camelcase-keys";
const baseUrl = process.env.NEXT_PUBLIC_CONTENT_API_BASE_URL;
// @ts-ignore
export const fetchPosts = async (...args) => fetchItems("post", ...args);
export const fetchBookReviews = async (...args) =>
fetchItems("book_review", ...args);
export const fetchBasicPages = async (...args) =>
fetchItems("basic_pages", ...args);
export const fetchCV = async (...args) => fetchItems("cv", ...args);
const originalFetch = globalThis.fetch;
globalThis.fetch = async (...args) => {
const response = await originalFetch(...args);
const originalJson = response.json;
response.json = async function () {
const data = await originalJson.call(this);
return camelcaseKeys(data, { deep: true });
};
return response;
};
export async function fetchItems(type, fields = undefined, filter = undefined) {
const url = new URL(`${baseUrl}/items/${type}`);
if (fields?.length) {
url.searchParams.append("fields", fields.join(","));
}
if (filter) {
url.searchParams.append("filter", JSON.stringify(filter));
}
return await apiFetch(url.toString());
}
export function getTagsFromPosts(posts) {
const allTags = {};
for (const post of posts) {
if (!post.tags) {
continue;
}
for (const tag of post.tags) {
allTags[tag] = !allTags[tag] ? 1 : allTags[tag] + 1;
}
}
return allTags;
}
export function markdownToHtml(content) {
const converter = new showdown.Converter({
tables: true,
tablesHeaderId: true,
});
const html = converter.makeHtml(content);
return html;
}
async function apiFetch(...args) {
// @ts-ignore
const res = await fetch(...args);
return res;
}

View file

@ -1,52 +1,118 @@
:root {
--color-default: #1f3470;
--color-primary: #5172cf;
--color-tertiary: grey;
--color-bg: #fbfbfb;
}
html, body {
margin: 0;
}
body {
position: relative;
background-color: var(--color-bg);
color: var(--color-default);
}
body::after {
content: '';
position: absolute;
bottom: 2vh;
right: 0;
width: 100vw;
height: 100%;
opacity: 0.2;
background-repeat: no-repeat;
background-position: bottom;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='1440' height='360' preserveAspectRatio='none' viewBox='0 0 1440 360'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1038%26quot%3b)' fill='none'%3e%3cpath d='M -47.398109710771735%2c191 C 48.6%2c178 240.6%2c108.8 432.60189028922827%2c126 C 624.6%2c143.2 720.6%2c282.4 912.6018902892283%2c277 C 1104.6%2c271.6 1200.6%2c93.6 1392.6018902892283%2c99 C 1584.6%2c104.4 1863.12%2c292.6 1872.6018902892283%2c304 C 1882.08%2c315.4 1526.52%2c185.6 1440%2c156' stroke='rgba(122%2c 122%2c 122%2c 0.58)' stroke-width='2'%3e%3c/path%3e%3cpath d='M -191.9102853036337%2c280 C -95.91%2c256.8 96.09%2c173.2 288.0897146963663%2c164 C 480.09%2c154.8 576.09%2c246.8 768.0897146963663%2c234 C 960.09%2c221.2 1056.09%2c108.2 1248.0897146963662%2c100 C 1440.09%2c91.8 1689.71%2c189.6 1728.0897146963662%2c193 C 1766.47%2c196.4 1497.62%2c132.2 1440%2c117' stroke='rgba(122%2c 122%2c 122%2c 0.58)' stroke-width='2'%3e%3c/path%3e%3cpath d='M -851.6383122526047%2c117 C -755.64%2c157.8 -563.64%2c331.2 -371.63831225260463%2c321 C -179.64%2c310.8 -83.64%2c67.8 108.36168774739537%2c66 C 300.36%2c64.2 396.36%2c296.4 588.3616877473953%2c312 C 780.36%2c327.6 898.03%2c143.4 1068.3616877473953%2c144 C 1238.69%2c144.6 1365.67%2c280.8 1440%2c315' stroke='rgba(122%2c 122%2c 122%2c 0.58)' stroke-width='2'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1038'%3e%3crect width='1440' height='360' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
z-index: -1;
}
h1, h2, h3 {
font-weight: 600;
color: var(--color-default)
}
a {
color: var(--color-primary)
}
ul {
margin-left: 0;
list-style: inside;
list-style-type: circle;
}
ul li:not(:last-child) {
margin-bottom: 10px;
}
article {
border: none;
background: none;
}
article img {
border: 1px solid var(--color-default);
}
pre {
padding: 0;
background-color: var(--color-default);
border-radius: 5px;
padding: 18px;
}
tbody {
vertical-align: top;
pre code {
color: var(--color-bg);
}
tr {
text-align: left;
code:not(pre > code) {
background-color: var(--color-default);
color: var(--color-bg)
}
td:first-child {
padding-right: 20px;
.row {
display: flex;
align-items: center;
}
.form-group {
margin: 20px 0;
.icon {
display: inline-flex;
}
.bold {
font-weight: 700;
.icon-left {
margin-right: 5px;
}
.text-right {
text-align: right;
.row {
display: flex;
flex-direction: row;
gap: 1.5rem;
}
.print-only {
display: none;
.box {
flex-grow: 1;
/* border: 1px solid var(--color-default); */
/* padding: 1.2rem; */
display: flex;
align-items: left;
justify-content: center;
flex-shrink: 0;
flex-basis: 0;
flex-direction: column;
}
@media print {
html {
padding-top: 1rem;
.box-title {
font-weight: 600;
}
.box-text {
margin: 0.8rem 0;
}
@media only screen and (max-width: 640px) {
.row {
flex-direction: column;
}
body {
font-size: 11.5px;
line-height: 1.3;
padding: 0;
}
.hide-print {
display: none;
}
.print-only {
display: initial;
}
}
}

View file

View file

@ -1,33 +0,0 @@
import { readdirSync, readFileSync } from "fs";
import path from "path";
import fm from "front-matter";
const dirPath = "./content/books";
const output = [];
const files = readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const content = readFileSync(filePath, {
encoding: "utf-8",
});
const { attributes, body } = fm(content, {
allowUnsafe: true,
});
const entry = {
title: attributes.title,
author: attributes.author,
read_date: attributes.readDate,
rating: Math.round(attributes.stars * 2),
// "image": attributes.thumbnailUrl,
tags: attributes.tags.split(", "),
review: body,
url: attributes.url,
};
output.push(entry);
}
console.log(JSON.stringify(output));

View file

@ -1,32 +0,0 @@
import { readdirSync, readFileSync } from "fs";
import path from "path";
import fm from "front-matter";
import { stringToSlug } from "../src/lib/helpers.js";
const dirPath = "./content/writing";
const output = [];
const files = readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const content = readFileSync(filePath, {
encoding: "utf-8",
});
const { attributes, body } = fm(content, {
allowUnsafe: true,
});
const entry = {
slug: stringToSlug(attributes.title),
title: attributes.title,
excerpt: attributes.desc,
date_published: attributes.pubdate,
tags: attributes.tags || [],
content: body,
};
output.push(entry);
}
console.log(JSON.stringify(output));