Switch the site to Astro

This commit is contained in:
Andras Schmelczer 2026-05-25 13:10:58 +01:00
parent a5f64a3ff8
commit 2e02e52661
14 changed files with 8633 additions and 17018 deletions

View file

@ -1 +0,0 @@
**/*.js

View file

@ -1,29 +0,0 @@
{
"root": true,
"env": {
"browser": true,
"es2020": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 11,
"sourceType": "module"
},
"plugins": ["unused-imports", "@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": "error",
"no-unused-vars": "off",
"unused-imports/no-unused-imports-ts": "error",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/ban-ts-comment": "off"
}
}

View file

@ -1,11 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"

2
.gitignore vendored
View file

@ -1,4 +1,4 @@
node_modules
dist
target
.astro
.DS_Store

View file

@ -4,7 +4,13 @@
"tabWidth": 2,
"singleQuote": true,
"endOfLine": "lf",
"importOrder": ["^[./]", ".*", ".scss$"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
}

View file

@ -37,6 +37,6 @@
"files.exclude": {
"node_modules": true
},
"editor.rulers": [120],
"editor.rulers": [90],
"editor.wordWrap": "on"
}

2
.vscode/tasks.json vendored
View file

@ -2,7 +2,7 @@
"version": "2.0.0",
"tasks": [
{
"label": "Format and lint",
"label": "Lint",
"type": "shell",
"command": "npm run lint",
"group": "test",

View file

@ -1,21 +1,35 @@
# Portfolio
# schmelczer.dev
> An easy-to-configure timeline for your projects.
A static personal blog for Andras Schmelczer, built with Astro.
[Check out the live version.](https://schmelczer.dev)
The site is article-first: articles live in `src/content/posts`, project index entries
live in `src/content/projects`, and normal pages are rendered as static HTML with no
required client JavaScript.
## Configuration
## Setup
- The actual content is in the [data](src/data) folder, starting with [portfolio.ts](src/data/portfolio.ts)
- The assets referenced should be located in [data/media](src/data/media)
```sh
npm ci
npx playwright install --with-deps chromium # required before `npm run qa:overflow`
```
## Build
## Commands
1. `npm install`
2. `npm run build`
3. You can find the results in the [dist](dist) folder
```sh
npm run dev
npm run lint
npm run build
npm run preview
npm run qa
```
## Info
## Structure
- All images are converted to `WebP` after being imported into any file.
> Except for the og-image, and SVGs.
- `src/content/posts`: Markdown articles
- `src/content/projects`: project index entries
- `src/pages`: static routes
- `src/layouts`: page and post layouts
- `src/components`: reusable UI pieces
- `src/styles/global.css`: the visual system
- `public/media/downloads`: CV and thesis PDFs
- `public/media/video`: project videos

136
astro.config.mjs Normal file
View file

@ -0,0 +1,136 @@
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import sitemap from '@astrojs/sitemap';
import { defineConfig } from 'astro/config';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeSlug from 'rehype-slug';
import { visit } from 'unist-util-visit';
// Build a lookup of post slugs to their last modification dates so the sitemap
// can advertise accurate <lastmod> values to crawlers. astro:content isn't
// available inside the config, so we read post frontmatter directly. Our posts
// always use single-line scalar `date:` / `updated:` keys, so a small regex
// extraction is sufficient and intentional.
const postsDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'src/content/posts'
);
function extractScalar(frontmatter, key) {
const match = frontmatter.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'));
return match?.[1]?.replace(/^['"]|['"]$/g, '');
}
const postLastmodLookup = new Map(
readdirSync(postsDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
.map((entry) => {
const raw = readFileSync(path.join(postsDir, entry.name), 'utf8');
const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
const rawDate =
extractScalar(frontmatter, 'updated') ?? extractScalar(frontmatter, 'date');
const parsed = rawDate ? new Date(rawDate) : null;
const valid = parsed && !Number.isNaN(parsed.valueOf()) ? parsed : null;
return [entry.name.replace(/\.md$/, ''), valid];
})
.filter(([, date]) => date !== null)
);
export default defineConfig({
site: 'https://schmelczer.dev',
trailingSlash: 'ignore',
integrations: [
sitemap({
filter: (page) => {
const path = new URL(page).pathname;
return !/^\/tags\/[^/]+\/?$/.test(path) && path !== '/404/';
},
serialize(item) {
const url = new URL(item.url);
const match = url.pathname.match(/^\/articles\/([^/]+)\/?$/);
let lastmod = item.lastmod;
if (match) {
const date = postLastmodLookup.get(match[1]);
if (date instanceof Date && !Number.isNaN(date.valueOf())) {
lastmod = date.toISOString();
}
}
return { ...item, changefreq: 'monthly', ...(lastmod ? { lastmod } : {}) };
},
}),
],
image: {
service: { entrypoint: 'astro/assets/services/sharp' },
},
vite: {
server: {
watch: {
// Avoid inotify instance limits in dev containers and mounted volumes.
usePolling: true,
},
},
},
markdown: {
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
wrap: false,
},
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: 'append',
properties: {
className: ['heading-anchor'],
},
// Glyph rendered via CSS ::before so it doesn't leak into the TOC
// when astro:content extracts heading.text from the rendered HTML.
content: [],
},
],
// Make scrollable code blocks and tables reachable via keyboard (WCAG
// 2.1.1): without tabindex, a keyboard user cannot focus a horizontally
// overflowing <pre> or <table> to scroll it. tabindex=0 is sufficient
// on its own; role=region would require a meaningful per-block label,
// which we don't have at markdown level.
function rehypeFocusableScrollables() {
const SCROLLABLE = new Set(['pre', 'table']);
return (tree) => {
visit(tree, 'element', (node) => {
if (!SCROLLABLE.has(node.tagName)) return;
node.properties.tabindex = '0';
});
};
},
function rehypeLabelHeadingPermalinks() {
function textOf(node) {
if (!node) return '';
if (node.type === 'text') return node.value ?? '';
return (node.children ?? []).map(textOf).join('');
}
return (tree) => {
visit(tree, 'element', (node) => {
if (!/^h[2-6]$/.test(node.tagName)) return;
const headingText = textOf(node).trim();
if (!headingText) return;
for (const child of node.children ?? []) {
const className = child.properties?.className;
const classes = Array.isArray(className) ? className : [className];
if (child.tagName === 'a' && classes.includes('heading-anchor')) {
child.properties.ariaLabel = `Permalink to ${headingText}`;
}
}
});
};
},
],
},
});

39
custom.d.ts vendored
View file

@ -1,39 +0,0 @@
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.jpg' {
import { ResponsiveImage } from 'src/types/responsive-image';
const content: ResponsiveImage;
export default content;
}
declare module '*.png' {
import { ResponsiveImage } from 'src/types/responsive-image';
const content: ResponsiveImage;
export default content;
}
declare module '*.mp4' {
import { url } from 'src/types/url';
const content: url;
export default content;
}
declare module '*.webm' {
import { url } from 'src/types/url';
const content: url;
export default content;
}
declare module '*.pdf' {
import { url } from 'src/types/url';
const content: url;
export default content;
}
declare module '*.html' {
const content: string;
export default content;
}

25045
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,60 +1,56 @@
{
"name": "portfolio",
"description": "An easily configurable timeline of projects.",
"name": "schmelczer-dev",
"description": "A static personal blog for Andras Schmelczer.",
"private": true,
"type": "module",
"packageManager": "npm@10.9.2",
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"start": "webpack serve --open --mode development",
"lint": "eslint --fix \"src/**/*.ts\" && prettier --write \"src/**/*.(ts|scss|json|html)\"",
"build": "webpack --mode production",
"update": "ncu"
"dev": "astro dev",
"start": "astro dev",
"typecheck": "astro check",
"lint": "prettier --check \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"format": "prettier --write \"astro.config.mjs\" \"src/**/*.{astro,ts,md,css}\" \"scripts/*.mjs\" \"*.md\" \"*.json\"",
"build": "astro build",
"preview": "astro preview",
"qa:links": "node scripts/check-links.mjs",
"qa:no-js": "node scripts/check-no-js.mjs",
"qa:overflow": "node scripts/install-playwright-deps.mjs && node scripts/check-overflow.mjs",
"qa": "npm run typecheck && npm run lint && npm run build && npm run qa:links && npm run qa:no-js && npm run qa:overflow"
},
"repository": {
"type": "git",
"url": "git+https://github.com/schmelczer/schmelczer.github.io.git"
},
"keywords": [
"CV",
"curriculum",
"vitae",
"portfolio",
"resumé"
"blog",
"software engineering",
"computer science",
"portfolio"
],
"author": "Andras Schmelczer",
"license": "GPL-3.0-or-later",
"bugs": {
"url": "https://github.com/schmelczer/schmelczer.github.io/issues"
},
"browserslist": [
"defaults"
],
"homepage": "https://github.com/schmelczer/schmelczer.github.io#readme",
"devDependencies": {
"@plausible-analytics/tracker": "^0.4.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"css-loader": "^6.8.1",
"eslint": "^8.50.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-unused-imports": "^3.0.0",
"html-webpack-plugin": "^5.5.3",
"inline-source-webpack-plugin": "^3.0.1",
"mini-css-extract-plugin": "^2.7.6",
"npm-check-updates": "^16.14.4",
"prettier": "^3.0.3",
"resolve-url-loader": "^5.0.0",
"responsive-loader": "^3.1.2",
"sass": "^1.68.0",
"sass-loader": "^13.3.2",
"sharp": "^0.32.6",
"sitemap-webpack-plugin": "^1.1.1",
"string-replace-loader": "^3.1.0",
"svg-inline-loader": "^0.8.2",
"terser-webpack-plugin": "^5.3.9",
"ts-loader": "^9.4.4",
"typescript": "^5.2.2",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
"@astrojs/check": "^0.9.9",
"@astrojs/rss": "^4.0.18",
"@astrojs/sitemap": "^3.7.2",
"astro": "^6.3.1",
"playwright": "^1.59.1",
"prettier": "^3.8.3",
"prettier-plugin-astro": "^0.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"typescript": "^5.9.3",
"unist-util-visit": "^5.1.0",
"sharp": "^0.34.5"
},
"overrides": {
"yaml": "^2.9.0"
}
}

View file

@ -1,15 +1,6 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"outDir": "./dist/",
"noImplicitAny": false,
"module": "es6",
"target": "es5",
"sourceMap": true,
"moduleResolution": "node",
"strict": true,
"allowJs": true,
"lib": [
"dom"
]
"types": ["astro/client"]
}
}

View file

@ -1,145 +0,0 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const InlineSourceWebpackPlugin = require('inline-source-webpack-plugin');
const SitemapPlugin = require('sitemap-webpack-plugin').default;
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const domain = 'schmelczer.dev';
module.exports = (env, argv) => ({
devtool: argv.mode === 'development' ? 'inline-source-map' : false,
entry: {
index: './src/index.ts',
},
devServer: {
allowedHosts: 'all',
},
watchOptions: {
ignored: '**/node_modules',
},
optimization: {
minimizer: [
new TerserPlugin({
terserOptions: {
module: true,
},
}),
],
},
performance: {
assetFilter: (f) => !/\.(webm|mp4|pdf)$/.test(f),
maxEntrypointSize: 100000,
maxAssetSize: 512000,
},
plugins: [
new SitemapPlugin({
base: `https://${domain}`,
paths: [
{
path: '/',
priority: 1,
changefreq: 'daily',
},
],
}),
new HtmlWebpackPlugin({
template: './src/index.html',
}),
new MiniCssExtractPlugin(),
argv.mode === 'production'
? new InlineSourceWebpackPlugin({
compress: true,
})
: null,
new (require('webpack').DefinePlugin)({
__CURRENT_DATE__: Date.now(),
}),
].filter(Boolean),
module: {
rules: [
{
test: /\.(jpe?g|png)$/i,
exclude: /no-change/i,
loader: 'responsive-loader',
options: {
adapter: require('responsive-loader/sharp'),
sizes: [200, 500, 900, 1400, 1920],
placeholder: true,
placeholderSize: 64,
quality: 85,
format: 'webp',
progressive: true,
name: '[hash:8].[ext]',
},
},
{
test: /\.(webm|mp4|woff2?)$/i,
type: 'asset/resource',
generator: {
filename: '[hash:8][ext]',
},
},
{
test: /\.svg$/i,
use: 'svg-inline-loader',
},
{
test: /\/no-change\//i,
type: 'asset/resource',
generator: {
filename: '[name][ext]',
},
},
{
test: /.pdf$/i,
type: 'asset/resource',
generator: {
filename: 'static/[name][ext]',
},
},
{
test: /\.scss$/i,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'resolve-url-loader',
{
loader: 'sass-loader',
options: {
sourceMap: true, // required by resolve-url-loader
},
},
],
},
{
test: /\.ts$/,
use: [
argv.mode === 'production'
? {
// for removing whitespace (mainly from template strings) which are not part of comments
loader: 'string-replace-loader',
options: {
search: /(?<!\/\/[^\n]*)(\\n|\s)+/gs,
replace: ' ',
},
}
: null,
'ts-loader',
].filter(Boolean),
},
],
},
resolve: {
extensions: [
'.ts',
'.js', // required for development
],
},
output: {
clean: true,
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '',
},
});